4bf0c2363f
Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
9.0 KiB
Python
286 lines
9.0 KiB
Python
"""行情区:各交易所 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,
|
|
}
|