|
#!/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) |