Skip to content

Instantly share code, notes, and snippets.

@marsmensch
Last active September 27, 2025 21:21
Show Gist options
  • Select an option

  • Save marsmensch/f75a8d7f1d59cefdc6fe060d42229afc to your computer and use it in GitHub Desktop.

Select an option

Save marsmensch/f75a8d7f1d59cefdc6fe060d42229afc to your computer and use it in GitHub Desktop.
FLASH - A small Python script that finds the best **Binance USDT-M perpetuals** for scalping and exports them as Tradingview watchlist

FLASH — Fast Locator of Assets for Scalp Hits

A small Python script that finds the best Binance USDT-M perpetuals for scalping and exports:

  • a TradingView watchlist (BINANCE:SYMBOL.P)
  • a CSV with metrics and score

flash

No API key needed. Uses Binance public futures endpoints only.


Styles (pick one)

  • majors — deep books, tight spreads, clean fills (default)
  • balanced — liquidity × movement, active mid-tier
  • momentum — higher ATR%, faster tape (still tradable)

These are liquidity/throughput styles, not coin market-cap tiers.


How it works (quick)

  1. Get all USDT-M PERPETUAL pairs in TRADING status.
  2. Pull 24h stats and mark prices in bulk (fast).
  3. Seed candidates by 24h quote volume (style can skip top N).
  4. For candidates only:
    • Open Interest → convert to OI USD (OI × mark price)
    • 5m klines (~6h)6h quote volume + ATR% (14×5m)
  5. Normalize metrics and score: wL*LIQ + wV*VOL + wX*VOLA.
  6. Export TradingView list + CSV (UTC-stamped).

Install

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt   # requests, pandas

Run

# Majors (default), Top 25
python flash.py

# Balanced, Top 25
python flash.py --target balanced

# Momentum, Top 40
python flash.py --target momentum --limit 40

You’ll see a banner like:

FLASH — Fast Locator of Assets for Scalp Hits
Style: Majors (deep & tight) | Limit: 25 | Interval: 5m | Lookback: 6h
Floors: OI≥$100,000,000 | 6h Vol≥50,000,000

Output

  • top_binance_perps_YYYYMMDD_HHMMZ.txt — TradingView list (comma-separated BINANCE:SYMBOL.P)
    Import in TradingView: Watchlist → Import list…
  • top_binance_perps_YYYYMMDD_HHMMZ.csv — metrics + score

Notes

  • Universe = Binance USDT-M PERPETUAL pairs in TRADING at runtime.
  • Paces around ~3 requests/sec with progress + ETA.
  • If no symbols pass filters, try a different style (--target balanced/momentum) or tweak --limit.

Author: mars 2025 • MIT • not financial advice

#!/usr/bin/env python3
# ======================================================================
# FLASH — Fast Locator of Assets for Scalp Hits (Binance-only, public)
# Author: mars 2025
#
# What this script does
# ---------------------
# 1) Discover USDⓈ-M PERPETUAL USDT pairs from Binance (single call).
# 2) Fetch *bulk* 24h stats and *bulk* mark prices (two calls).
# 3) Form a small candidate set by 24h quote volume (with optional skips).
# 4) For candidates only, fetch:
# - Open Interest (per-symbol) → OI USD = OI * markPrice
# - 5m klines (~6h) → 6h quote volume + ATR%(14)
# 5) Score = Liquidity·wL + Volume·wV + Volatility·wX (style preset)
# 6) Output a TradingView list (BINANCE:SYMBOL.P) + CSV (timestamped).
#
# CLI
# ---
# --target : majors (default) | balanced | momentum (legacy: large|medium|low)
# --limit : Top-N to output (default: preset default)
# -v/--verbose : Show detailed per-symbol progress (off by default)
#
# License: MIT — use at your own risk.
# ======================================================================
from __future__ import annotations
import os
import sys
import time
import argparse
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
import requests
import pandas as pd
from argparse import RawTextHelpFormatter
API_BASE = "https://fapi.binance.com" # USDⓈ-M Futures base
# -------------------------------
# Fixed sampling parameters
# -------------------------------
LOOKBACK_BARS_5M = 72 # 6h on 5m bars
INTERVAL = "5m"
ATR_PERIOD = 14
# Candidate discovery pacing (Binance is generous; keep it simple)
TARGET_RPS = 3.0 # ~3 requests/sec in per-symbol loops
WEIGHT_SOFT_CAP = 1000 # if x-mbx-used-weight-1m exceeds → quick breather
WEIGHT_BACKOFF_SECS = 2.0
PROGRESS_EVERY = 15 # only used when --verbose
OUTFILE_BASENAME = "top_binance_perps"
ANCHOR_SYMBOLS = ("BTCUSDT", "ETHUSDT", "BNBUSDT") # sanity check
# -------------------------------
# Style presets — hardened defaults
# -------------------------------
# Knobs per style:
# SKIP_TOP_BY_24H : how many of the highest 24h-volume symbols to skip
# MAX_OI_USD : optional ceiling on OI USD (filters out mega-caps)
# MAX_24H_QUOTE : optional ceiling on 24h quote volume
PRESETS: Dict[str, Dict[str, Any]] = {
"Majors": {
"DISPLAY_NAME": "Majors (deep & tight)",
"MIN_OI_USD": 100_000_000, # ≥ $100M OI
"MIN_VOL_QUOTE_6H": 50_000_000, # ≥ $50M notional in ~6h
"WEIGHT_LIQ": 0.55,
"WEIGHT_VOL": 0.35,
"WEIGHT_VOLA": 0.10,
"MAX_CANDIDATES_HARD": 80,
"CANDIDATE_MULTIPLIER":4,
"CANDIDATE_FLOOR": 60,
"DEFAULT_LIMIT": 25,
# Hardened:
"SKIP_TOP_BY_24H": 2, # skip BTC/ETH by default
"MAX_OI_USD": None,
"MAX_24H_QUOTE": None,
"DESCRIPTION": "Deep books, tight spreads, clean fills.",
},
"Balanced": {
"DISPLAY_NAME": "Balanced (liquidity × movement)",
"MIN_OI_USD": 5_000_000,
"MIN_VOL_QUOTE_6H": 2_000_000,
"WEIGHT_LIQ": 0.45,
"WEIGHT_VOL": 0.35,
"WEIGHT_VOLA": 0.20,
"MAX_CANDIDATES_HARD": 120,
"CANDIDATE_MULTIPLIER":5,
"CANDIDATE_FLOOR": 80,
"DEFAULT_LIMIT": 25,
# Hardened:
"SKIP_TOP_BY_24H": 10, # skip top-10 by 24h vol
"MAX_OI_USD": 1_000_000_000, # ≤ $1B OI USD
"MAX_24H_QUOTE": 2_000_000_000, # ≤ $2B 24h quote vol
"DESCRIPTION": "Active mid-tier: good fills with more movement.",
},
"Momentum": {
"DISPLAY_NAME": "Momentum (higher ATR%)",
"MIN_OI_USD": 5_000_000, # modest floor to keep fills realistic
"MIN_VOL_QUOTE_6H": 2_000_000,
"WEIGHT_LIQ": 0.35,
"WEIGHT_VOL": 0.25,
"WEIGHT_VOLA": 0.40, # emphasize movers
"MAX_CANDIDATES_HARD": 140,
"CANDIDATE_MULTIPLIER":6,
"CANDIDATE_FLOOR": 100,
"DEFAULT_LIMIT": 30,
# Hardened:
"SKIP_TOP_BY_24H": 30,
"MAX_OI_USD": 500_000_000, # ≤ $500M OI USD
"MAX_24H_QUOTE": 1_000_000_000, # ≤ $1B 24h quote vol
"DESCRIPTION": "Smaller throughput, spicier tape; still tradable.",
},
}
# Backward-compatible aliases
TARGET_ALIASES: Dict[str, str] = {
"majors": "Majors",
"balanced": "Balanced",
"momentum": "Momentum",
# legacy
"large": "Majors",
"medium": "Balanced",
"mid": "Balanced",
"low": "Momentum",
"small": "Momentum",
}
# ------------- Pretty output -------------
def supports_color() -> bool:
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
def ccyan_bold(s: str) -> str:
return ("\033[96m\033[1m" + s + "\033[0m") if supports_color() else s
def cyellow_bold(s: str) -> str:
return ("\033[93m\033[1m" + s + "\033[0m") if supports_color() else s
def banner(style_label: str, display_name: str, limit: int, floors: Tuple[int,int]) -> None:
min_oi_usd, min_vol_6h = floors
print("="*64)
print(ccyan_bold("FLASH — Fast Locator of Assets for Scalp Hits"))
print(f"Style: {cyellow_bold(display_name)} | Limit: {limit} | Interval: {INTERVAL} | Lookback: 6h")
print(f"Floors: OI≥${min_oi_usd:,.0f} | 6h Vol≥{min_vol_6h:,.0f}")
print("="*64)
def step(msg: str) -> None:
print(f"\n▶ {msg}")
def note(msg: str) -> None:
print(f" - {msg}")
def warn(msg: str) -> None:
print(f" ! {msg}")
def format_duration(seconds: float) -> str:
seconds = int(max(0, round(seconds)))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h: return f"{h}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
# ------------- Friendly argparse -------------
class FriendlyParser(argparse.ArgumentParser):
def error(self, message):
self.print_usage(sys.stderr)
print(f"\nError: {message}\n", file=sys.stderr)
self.print_help(sys.stderr)
sys.exit(2)
# ------------- Simple pacing -------------
class SimplePacer:
"""Keep roughly TARGET_RPS; also watch x-mbx-used-weight-1m."""
def __init__(self, rps: float = TARGET_RPS):
self.rps = float(rps)
self._last_at = 0.0
def before_request(self):
if self._last_at <= 0: return
next_ok = self._last_at + (1.0 / max(self.rps, 0.1))
now = time.time()
if now < next_ok: time.sleep(next_ok - now)
def after_request(self, headers: Dict[str, str]):
self._last_at = time.time()
used = headers.get("x-mbx-used-weight-1m")
if used:
try:
if int(used) > WEIGHT_SOFT_CAP:
time.sleep(WEIGHT_BACKOFF_SECS)
except Exception:
pass
SESSION = requests.Session()
def bget(path: str, params: Dict[str, Any] = None, pacer: Optional[SimplePacer] = None, max_retries: int = 3):
"""GET helper with small retry + simple pacing."""
url = f"{API_BASE}{path}"
params = params or {}
attempt = 0
while True:
if pacer: pacer.before_request()
try:
r = SESSION.get(url, params=params, timeout=20)
except requests.RequestException as e:
warn(f"Network error {path}: {e}")
attempt += 1
if attempt > max_retries: raise
time.sleep(min(2**attempt, 6)); continue
if r.status_code in (418, 429):
warn(f"{r.status_code} rate limit on {path}. Backing off 3s.")
time.sleep(3.0); attempt += 1
if attempt > max_retries: r.raise_for_status(); continue
continue
if 500 <= r.status_code < 600:
backoff = min(2**attempt, 6)
warn(f"{r.status_code} on {path}. Backing off {backoff}s.")
time.sleep(backoff); attempt += 1
if attempt > max_retries: r.raise_for_status(); continue
continue
try:
data = r.json()
except Exception as e:
print("[ERROR] Bad JSON:", e, file=sys.stderr)
print("Response (first 300 chars):", r.text[:300], file=sys.stderr)
r.raise_for_status(); raise
if pacer: pacer.after_request(r.headers)
return data
# ------------- Helpers -------------
def build_tv_symbol(symbol: str) -> str:
return f"BINANCE:{symbol}.P"
def dated_filenames(base: str) -> Tuple[str, str]:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%MZ")
return f"{base}_{stamp}.csv", f"{base}_{stamp}.txt"
# ------------- Binance fetchers -------------
def fetch_universe(pacer: SimplePacer) -> pd.DataFrame:
"""Universe = USDⓈ-M PERPETUAL, USDT-quoted, TRADING. One fast call."""
ex = bget("/fapi/v1/exchangeInfo", pacer=pacer)
if not isinstance(ex, dict) or "symbols" not in ex:
print("[ERROR] Unexpected exchangeInfo payload.", file=sys.stderr); sys.exit(2)
rows = []
for s in ex.get("symbols", []):
if s.get("contractType") != "PERPETUAL": continue
if s.get("quoteAsset") != "USDT": continue
if s.get("status") != "TRADING": continue
rows.append({"symbol": s.get("symbol")})
df = pd.DataFrame(rows)
print(f"Detected Binance USDⓈ-M perps: {len(df)} symbols")
anchors_present = [a for a in ANCHOR_SYMBOLS if a in df['symbol'].values]
note(f"Anchor symbols present: {', '.join(anchors_present) if anchors_present else 'none'}")
return df
def fetch_bulk_24h(pacer: SimplePacer) -> Dict[str, Dict[str, Any]]:
data = bget("/fapi/v1/ticker/24hr", pacer=pacer)
out: Dict[str, Dict[str, Any]] = {}
if isinstance(data, list):
for d in data:
sym = d.get("symbol")
if sym: out[sym] = d
return out
def fetch_bulk_mark(pacer: SimplePacer) -> Dict[str, float]:
data = bget("/fapi/v1/premiumIndex", pacer=pacer)
out: Dict[str, float] = {}
if isinstance(data, list):
for d in data:
sym = d.get("symbol"); mp = d.get("markPrice")
try: out[sym] = float(mp)
except Exception: pass
return out
# ------------- Progress helpers -------------
def should_emit_progress(i: int, n: int, verbose: bool, t0: float) -> Optional[str]:
"""
Return a formatted progress line or None.
Default: print at 20/40/60/80/100%. Verbose: every PROGRESS_EVERY and final.
"""
if verbose:
emit = (i % PROGRESS_EVERY) == 0 or i == n
else:
# milestones: nearest integers for 20/40/60/80/100%
milestones = {max(1, int(round(n*p))) for p in (0.2, 0.4, 0.6, 0.8, 1.0)}
emit = i in milestones
if not emit:
return None
elapsed = time.time() - t0
eff = (i / elapsed) if elapsed > 0 else TARGET_RPS
eta = max(0.0, (n - i) / max(eff, 0.1))
return f" · Progress {i}/{n} | elapsed {format_duration(elapsed)} | eff rps≈{eff:.1f} | ETA ≈ {format_duration(eta)}"
# ------------- Per-symbol fetchers with sparse progress -------------
def fetch_open_interest_for(symbols: List[str], pacer: SimplePacer, verbose: bool=False) -> Dict[str, float]:
"""Per-symbol OI (base units)."""
res: Dict[str, float] = {}
step(f"Fetching Open Interest for candidates (symbols={len(symbols)} | target rps≈{TARGET_RPS:.1f})")
t0 = time.time()
n = len(symbols)
for i, sym in enumerate(symbols, 1):
d = bget("/fapi/v1/openInterest", {"symbol": sym}, pacer=pacer)
try:
res[sym] = float(d.get("openInterest", "0"))
except Exception:
res[sym] = 0.0
line = should_emit_progress(i, n, verbose, t0)
if line: print(line)
return res
def fetch_klines_5m(symbols: List[str], pacer: SimplePacer, verbose: bool=False) -> Dict[str, List[List[Any]]]:
"""Per-symbol klines (limit=LOOKBACK_BARS_5M)."""
out: Dict[str, List[List[Any]]] = {}
step(f"Fetching 5m klines (~{LOOKBACK_BARS_5M} bars) for candidates (symbols={len(symbols)} | target rps≈{TARGET_RPS:.1f})")
t0 = time.time()
n = len(symbols)
for i, sym in enumerate(symbols, 1):
data = bget("/fapi/v1/klines", {"symbol": sym, "interval": INTERVAL, "limit": LOOKBACK_BARS_5M}, pacer=pacer)
if isinstance(data, list):
out[sym] = data
line = should_emit_progress(i, n, verbose, t0)
if line: print(line)
return out
# ------------- Metrics -------------
def compute_from_klines(kl: List[List[Any]]) -> Tuple[float, float]:
"""Inputs: raw Binance 5m klines. Returns: (6h_quote_volume, atr_percent_14)."""
if not kl: return 0.0, 0.0
qvol_sum = 0.0
closes: List[float] = []
highs: List[float] = []
lows: List[float] = []
# kline idx: 0 openTime, 1 open, 2 high, 3 low, 4 close, 5 volume(base),
# 6 closeTime, 7 quoteVolume, 8 trades, 9 takerBuyBase, 10 takerBuyQuote, 11 ignore
for row in kl[-LOOKBACK_BARS_5M:]:
try:
qvol_sum += float(row[7])
closes.append(float(row[4]))
highs.append(float(row[2]))
lows.append(float(row[3]))
except Exception:
continue
if len(closes) < ATR_PERIOD + 1:
return qvol_sum, 0.0
# TR = max(H-L, |H-prevC|, |L-prevC|)
trs: List[float] = []
prev_c = closes[0]
for idx in range(1, len(closes)):
h = highs[idx]; l = lows[idx]; c_prev = prev_c
trs.append(max(h - l, abs(h - c_prev), abs(l - c_prev)))
prev_c = closes[idx]
if len(trs) < ATR_PERIOD:
return qvol_sum, 0.0
atr = sum(trs[-ATR_PERIOD:]) / ATR_PERIOD
last_close = closes[-1] if closes else 0.0
atr_pct = (atr / last_close) * 100.0 if last_close else 0.0
return qvol_sum, atr_pct
def normalize(vals: List[float]) -> List[float]:
if not vals: return []
vmin, vmax = min(vals), max(vals)
if vmax - vmin == 0: return [0.5 for _ in vals]
return [(v - vmin) / (vmax - vmin) for v in vals]
# ------------- Main -------------
def main() -> None:
parser = FriendlyParser(
prog="flash.py",
usage="flash.py [--target majors|balanced|momentum] [--limit N] [-v/--verbose]",
formatter_class=lambda prog: RawTextHelpFormatter(prog, max_help_position=26),
description=(
"FLASH — Fast Locator of Assets for Scalp Hits (BINANCE)\n"
"Ranks perps with OI USD (liquidity), 6h quote volume (5m), ATR%(14×5m).\n"
"Uses only public Binance endpoints. Styles via --target; default majors."
),
epilog=(
"Examples:\n"
" python flash.py # majors, Top 25\n"
" python flash.py --target balanced # balanced, Top 25\n"
" python flash.py --target momentum --limit 40\n\n"
"Files (UTC timestamped):\n"
" top_binance_perps_YYYYMMDD_HHMMZ.txt TradingView list (BINANCE:SYMBOL.P)\n"
" top_binance_perps_YYYYMMDD_HHMMZ.csv Metrics & score\n"
"\nAuthor: mars 2025"
),
)
parser.add_argument("--target", type=str.lower,
choices=["majors","balanced","momentum","large","medium","low","mid","small"],
default="majors",
help="Scalping style preset (default: majors).")
parser.add_argument("--limit", type=int, default=None,
help="How many tickers to output (default: preset's default).")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show detailed per-symbol progress")
args = parser.parse_args()
# Resolve preset label from alias
preset_label = TARGET_ALIASES.get(args.target, "Majors")
cfg = PRESETS[preset_label]
LIMIT = int(args.limit if args.limit is not None else cfg["DEFAULT_LIMIT"])
MIN_OI_USD = cfg["MIN_OI_USD"]
MIN_VOL_QUOTE_6H = cfg["MIN_VOL_QUOTE_6H"]
WEIGHT_LIQ = cfg["WEIGHT_LIQ"]
WEIGHT_VOL = cfg["WEIGHT_VOL"]
WEIGHT_VOLA = cfg["WEIGHT_VOLA"]
MAX_CANDIDATES_HARD = cfg["MAX_CANDIDATES_HARD"]
CANDIDATE_MULTIPLIER = cfg["CANDIDATE_MULTIPLIER"]
CANDIDATE_FLOOR = cfg["CANDIDATE_FLOOR"]
DISPLAY_NAME = cfg.get("DISPLAY_NAME", preset_label)
DESCRIPTION = cfg.get("DESCRIPTION", "")
banner(preset_label, DISPLAY_NAME, LIMIT, (MIN_OI_USD, MIN_VOL_QUOTE_6H))
if DESCRIPTION:
note(DESCRIPTION)
skip = cfg.get("SKIP_TOP_BY_24H", 0)
max_oi = cfg.get("MAX_OI_USD")
max_qv = cfg.get("MAX_24H_QUOTE")
note(f"Preset knobs → skip top by 24h vol: {skip}; ceilings: "
f"OI USD≤{f'${max_oi:,}' if max_oi else '—'}, 24h vol≤{f'{max_qv:,}' if max_qv else '—'}")
pacer = SimplePacer(TARGET_RPS)
overall_start = time.time()
# 1) Universe (single call)
step("Building Binance USDⓈ-M PERPETUAL universe")
t_uni = time.time()
uni = fetch_universe(pacer)
note(f"Universe built in {format_duration(time.time()-t_uni)}")
if uni.empty:
print("No Binance USDT-M perpetual markets found.", file=sys.stderr); sys.exit(2)
# 2) Bulk stats (two calls)
step("Fetching bulk 24h stats + mark prices (one shot each)")
t_bulk = time.time()
stats24 = fetch_bulk_24h(pacer)
mark = fetch_bulk_mark(pacer)
note(f"Bulk fetched in {format_duration(time.time()-t_bulk)}")
# Keep usable rows
syms = set(uni["symbol"].tolist())
def sfloat(x, d=0.0):
try: return float(x)
except: return d
pre_rows = []
for sym in syms:
s = stats24.get(sym)
if not s: continue
pre_rows.append({
"symbol": sym,
"quoteVolume24h": sfloat(s.get("quoteVolume")),
"lastPrice": sfloat(s.get("lastPrice")),
"markPrice": sfloat(mark.get(sym, sfloat(s.get("lastPrice"))))
})
pre_df = pd.DataFrame(pre_rows)
if pre_df.empty:
print("No 24h stats found for universe.", file=sys.stderr); sys.exit(3)
# 3) Candidates by 24h quote volume (with optional skip)
pre_df.sort_values("quoteVolume24h", ascending=False, inplace=True)
cap = min(MAX_CANDIDATES_HARD, max(LIMIT * CANDIDATE_MULTIPLIER, CANDIDATE_FLOOR))
skip = int(cfg.get("SKIP_TOP_BY_24H", 0))
sliced = pre_df.iloc[skip:skip+cap].copy()
candidates = sliced["symbol"].tolist()
note(f"Selected {len(candidates)} candidates by 24h quote volume (cap {cap}, skipped top {skip})")
# 4) Candidate OI + klines
t_oi = time.time()
oi_base = fetch_open_interest_for(candidates, pacer, verbose=args.verbose)
note(f"OI fetched in {format_duration(time.time()-t_oi)}")
t_kl = time.time()
kl_map = fetch_klines_5m(candidates, pacer, verbose=args.verbose)
note(f"OHLCV fetched in {format_duration(time.time()-t_kl)}")
# 5) Compute metrics
rows = []
mpx = dict(zip(pre_df["symbol"], pre_df["markPrice"]))
for sym in candidates:
base_oi = float(oi_base.get(sym, 0.0))
oi_usd = base_oi * float(mpx.get(sym, 0.0))
qv6h, atr_pct = compute_from_klines(kl_map.get(sym, []))
rows.append({"symbol": sym, "oi_usd": oi_usd, "vol_quote_6h": qv6h, "atr_pct": atr_pct})
df = pd.DataFrame(rows)
if df.empty:
print("No metrics computed; possibly rate-limited or no data.", file=sys.stderr); sys.exit(4)
# 6) Floors + ceilings + score
before = len(df)
df = df[(df["oi_usd"] >= MIN_OI_USD) & (df["vol_quote_6h"] >= MIN_VOL_QUOTE_6H)].copy()
pass_rate = (len(df)/before*100.0) if before else 0.0
note(f"After floors (OI≥${MIN_OI_USD:,}, 6h Vol≥{MIN_VOL_QUOTE_6H:,}): {len(df)}/{before} symbols ({pass_rate:.1f}%)")
max_oi = cfg.get("MAX_OI_USD")
max_qv = cfg.get("MAX_24H_QUOTE")
if max_oi is not None:
pre_len = len(df)
df = df[df["oi_usd"] <= float(max_oi)].copy()
note(f"Applied ceiling MAX_OI_USD≤${max_oi:,}: {len(df)}/{pre_len} remain")
if max_qv is not None:
vol_map = dict(zip(pre_df["symbol"], pre_df["quoteVolume24h"]))
df["quoteVolume24h"] = df["symbol"].map(vol_map)
pre_len2 = len(df)
df = df[df["quoteVolume24h"] <= float(max_qv)].copy()
note(f"Applied ceiling MAX_24H_QUOTE≤{max_qv:,}: {len(df)}/{pre_len2} remain")
if df.empty:
print("No symbols pass filters; try a different --target or tweak preset values.", file=sys.stderr); sys.exit(5)
df["liq_n"] = normalize(df["oi_usd"].tolist())
df["vol_n"] = normalize(df["vol_quote_6h"].tolist())
df["vola_n"] = normalize(df["atr_pct"].tolist())
print(f"Scoring weights → Liquidity={WEIGHT_LIQ}, Volume={WEIGHT_VOL}, Volatility={WEIGHT_VOLA}")
df["score"] = WEIGHT_LIQ*df["liq_n"] + WEIGHT_VOL*df["vol_n"] + WEIGHT_VOLA*df["vola_n"]
df.sort_values(["score", "oi_usd", "vol_quote_6h"], ascending=[False, False, False], inplace=True)
top_df = df.head(LIMIT).copy()
top_df["tv_symbol"] = top_df["symbol"].apply(build_tv_symbol)
top_df = top_df[~top_df["tv_symbol"].isna()]
# 7) Write files
csv_path, tv_path = dated_filenames(OUTFILE_BASENAME)
top_df.to_csv(csv_path, index=False)
with open(tv_path, "w", encoding="utf-8") as f:
f.write(",".join(top_df["tv_symbol"].tolist()))
# 8) Preview + totals
preview = top_df[['tv_symbol', 'oi_usd', 'vol_quote_6h', 'atr_pct']].head(10)
print("\nTop preview:")
print(preview.to_string(index=False))
print(f"\nWrote CSV: {csv_path} ({len(top_df)} rows)")
print(f"Wrote TradingView list: {tv_path}")
print(f"Total runtime: {format_duration(time.time()-overall_start)}")
print("Import in TradingView: Watchlist → 'Import list…' (file must be .txt and symbols comma-separated).")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nAborted by user (Ctrl+C).", file=sys.stderr)
sys.exit(130)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment