"""行情区:各交易所 USDT 永续昨日成交额 Top N(每日 8:00 快照)。""" from __future__ import annotations import json import os import time from datetime import datetime, timedelta from pathlib import Path from typing import Any, Callable from zoneinfo import ZoneInfo from hub_trades_lib import trading_day_from_dt TOP_N_DEFAULT = 20 CACHE_VERSION = 1 def volume_rank_reset_hour() -> int: try: return max(0, min(23, int(os.getenv("HUB_VOLUME_RANK_RESET_HOUR", "8")))) except ValueError: return 8 def volume_rank_timezone() -> ZoneInfo: name = (os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" try: return ZoneInfo(name) except Exception: return ZoneInfo("Asia/Shanghai") def rank_date_label(*, now: datetime | None = None, reset_hour: int | None = None) -> str: """8 点更新后展示的「昨日」交易日(与 TRADING_DAY_RESET_HOUR 口径一致)。""" rh = volume_rank_reset_hour() if reset_hour is None else reset_hour tz = volume_rank_timezone() dt = now.astimezone(tz) if now else datetime.now(tz) cur_td = trading_day_from_dt(dt.replace(tzinfo=None), rh) cur = datetime.strptime(cur_td, "%Y-%m-%d").date() return (cur - timedelta(days=1)).isoformat() def seconds_until_next_reset( *, now: datetime | None = None, reset_hour: int | None = None, ) -> float: rh = volume_rank_reset_hour() if reset_hour is None else reset_hour tz = volume_rank_timezone() dt = now.astimezone(tz) if now else datetime.now(tz) nxt = dt.replace(hour=rh, minute=0, second=0, microsecond=0) if dt >= nxt: nxt += timedelta(days=1) return max(1.0, (nxt - dt).total_seconds()) def default_cache_path() -> Path: raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip() if raw: return Path(raw) hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data" hub_dir.mkdir(parents=True, exist_ok=True) return hub_dir / "hub_volume_rank.json" def _safe_float(v: Any) -> float | None: try: n = float(v) return n if n == n else None except (TypeError, ValueError): return None def _ticker_base(sym_text: str) -> str: s = str(sym_text or "").upper().strip() if ":" in s: s = s.split(":", 1)[0] if "/" in s: return s.split("/", 1)[0].strip() if "-" in s: return s.split("-", 1)[0].strip() if s.endswith("USDT"): return s[:-4].strip() return s def _hub_symbol_from_market(market: dict | None, fallback_symbol: str) -> str: if market: base = str(market.get("base") or "").strip().upper() quote = str(market.get("quote") or "USDT").strip().upper() if base: return f"{base}/{quote}" fb = str(fallback_symbol or "").upper().strip() if ":" in fb: fb = fb.split(":", 1)[0] if "/" in fb: return fb base = _ticker_base(fb) return f"{base}/USDT" if base else fb def _quote_volume_from_ticker(ticker: dict | None, market: dict | None) -> float | None: t = ticker or {} qv = _safe_float(t.get("quoteVolume")) if qv is not None and qv > 0: return qv info = t.get("info") if isinstance(t.get("info"), dict) else {} for key in ("quoteVolume", "volCcy24h", "vol24h", "turnover24h", "amount24"): qv = _safe_float(info.get(key)) if qv is not None and qv > 0: return qv bv = _safe_float(t.get("baseVolume")) lp = _safe_float(t.get("last")) or _safe_float(t.get("close")) if bv is not None and lp is not None and bv > 0 and lp > 0: return bv * lp if info: bv = _safe_float(info.get("volCcy24h") or info.get("vol24h")) lp = _safe_float(info.get("last") or info.get("lastPx")) if bv is not None and lp is not None and bv > 0 and lp > 0: return bv * lp if market and lp: bv = _safe_float(t.get("baseVolume")) if bv is not None and bv > 0: return bv * lp return None def _is_usdt_linear_swap(market: dict | None, symbol: str) -> bool: if not market: su = str(symbol or "").upper() return "USDT" in su and (":USDT" in su or "/USDT" in su or su.endswith("USDT")) if not market.get("swap"): return False if str(market.get("quote") or "").upper() != "USDT": return False if market.get("linear") is False: return False if market.get("active") is False: return False return True def fetch_usdt_swap_volume_rank( exchange, ensure_markets_loaded: Callable[[], None], *, top_n: int = TOP_N_DEFAULT, rank_date: str | None = None, ) -> dict[str, Any]: """从 ccxt 拉全市场 USDT 永续 ticker,按 24h 成交额(USDT) 取 Top N。""" top_n = max(1, min(int(top_n or TOP_N_DEFAULT), 100)) ensure_markets_loaded() scored: list[tuple[str, str, float]] = [] seen_bases: set[str] = set() try: tickers = exchange.fetch_tickers() except Exception as e: return {"ok": False, "msg": str(e)} markets = getattr(exchange, "markets", None) or {} for sym, ticker in (tickers or {}).items(): try: mk = markets.get(sym) if markets else None if not _is_usdt_linear_swap(mk, sym): continue qv = _quote_volume_from_ticker(ticker, mk) if qv is None or qv <= 0: continue hub_sym = _hub_symbol_from_market(mk, sym) base = _ticker_base(hub_sym) if not base or base in seen_bases: continue seen_bases.add(base) scored.append((hub_sym, base, float(qv))) except Exception: continue scored.sort(key=lambda x: x[2], reverse=True) items = [] for idx, (hub_sym, base, qv) in enumerate(scored[:top_n], 1): items.append( { "rank": idx, "symbol": hub_sym, "base": base, "volume_quote": round(qv, 4), } ) return { "ok": True, "rank_date": rank_date or rank_date_label(), "items": items, "total_symbols": len(scored), "fetched_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"), } def format_volume_quote(value: float | None) -> str: n = _safe_float(value) if n is None or n <= 0: return "—" if n >= 1e9: return f"{n / 1e9:.2f}B" if n >= 1e6: return f"{n / 1e6:.2f}M" if n >= 1e3: return f"{n / 1e3:.2f}K" return f"{n:.0f}" def load_volume_rank_cache(path: Path | None = None) -> dict[str, Any]: p = path or default_cache_path() if not p.is_file(): return {"version": CACHE_VERSION, "exchanges": {}} try: data = json.loads(p.read_text(encoding="utf-8")) if not isinstance(data, dict): return {"version": CACHE_VERSION, "exchanges": {}} data.setdefault("version", CACHE_VERSION) data.setdefault("exchanges", {}) return data except Exception: return {"version": CACHE_VERSION, "exchanges": {}} def save_volume_rank_cache(data: dict[str, Any], path: Path | None = None) -> None: p = path or default_cache_path() p.parent.mkdir(parents=True, exist_ok=True) payload = dict(data) payload["version"] = CACHE_VERSION payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds") p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def merge_exchange_rank( cache: dict[str, Any], exchange_key: str, payload: dict[str, Any], ) -> dict[str, Any]: ex_k = str(exchange_key or "").strip().lower() if not ex_k or not payload.get("ok"): return cache exchanges = dict(cache.get("exchanges") or {}) exchanges[ex_k] = { "rank_date": payload.get("rank_date"), "items": payload.get("items") or [], "total_symbols": int(payload.get("total_symbols") or 0), "fetched_at": payload.get("fetched_at"), "error": None, } out = dict(cache) out["exchanges"] = exchanges out["rank_date"] = payload.get("rank_date") or cache.get("rank_date") return out def cache_needs_refresh(cache: dict[str, Any], *, expected_rank_date: str | None = None) -> bool: expected = expected_rank_date or rank_date_label() if not cache.get("exchanges"): return True if str(cache.get("rank_date") or "") != expected: return True return False def get_cached_rank( cache: dict[str, Any], exchange_key: str, *, top_n: int = TOP_N_DEFAULT, ) -> dict[str, Any]: ex_k = str(exchange_key or "").strip().lower() ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} items = list(ex_data.get("items") or [])[: max(1, int(top_n))] return { "ok": True, "exchange_key": ex_k, "rank_date": ex_data.get("rank_date") or cache.get("rank_date"), "updated_at": cache.get("updated_at"), "items": items, "total_symbols": int(ex_data.get("total_symbols") or 0), "stale": False, }