feat: daily volume top20 rank per exchange in market page
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8174,6 +8174,16 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||
)
|
||||
|
||||
|
||||
def _hub_fetch_volume_rank(top_n=20):
|
||||
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
|
||||
|
||||
return fetch_usdt_swap_volume_rank(
|
||||
exchange=exchange,
|
||||
ensure_markets_loaded=ensure_markets_loaded,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -8194,6 +8204,7 @@ try:
|
||||
account_fn=_hub_account_bundle,
|
||||
views={"add_order": add_order, "add_key": add_key},
|
||||
ohlcv_fn=_hub_fetch_ohlcv,
|
||||
volume_rank_fn=_hub_fetch_volume_rank,
|
||||
)
|
||||
except Exception as _hub_err:
|
||||
print(f"[hub_bridge] binance: {_hub_err}")
|
||||
|
||||
@@ -8234,6 +8234,16 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||
)
|
||||
|
||||
|
||||
def _hub_fetch_volume_rank(top_n=20):
|
||||
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
|
||||
|
||||
return fetch_usdt_swap_volume_rank(
|
||||
exchange=exchange,
|
||||
ensure_markets_loaded=ensure_markets_loaded,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -8254,6 +8264,7 @@ try:
|
||||
account_fn=_hub_account_bundle,
|
||||
views={"add_order": add_order, "add_key": add_key},
|
||||
ohlcv_fn=_hub_fetch_ohlcv,
|
||||
volume_rank_fn=_hub_fetch_volume_rank,
|
||||
)
|
||||
except Exception as _hub_err:
|
||||
print(f"[hub_bridge] gate: {_hub_err}")
|
||||
|
||||
@@ -7930,6 +7930,16 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||
)
|
||||
|
||||
|
||||
def _hub_fetch_volume_rank(top_n=20):
|
||||
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
|
||||
|
||||
return fetch_usdt_swap_volume_rank(
|
||||
exchange=exchange,
|
||||
ensure_markets_loaded=ensure_markets_loaded,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -7957,6 +7967,7 @@ try:
|
||||
"trend_pullback_breakeven": trend_pullback_breakeven,
|
||||
},
|
||||
ohlcv_fn=_hub_fetch_ohlcv,
|
||||
volume_rank_fn=_hub_fetch_volume_rank,
|
||||
)
|
||||
from strategy_trend_register import build_trend_config, patch_trend_hub_enrich
|
||||
|
||||
|
||||
@@ -7857,6 +7857,16 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||
)
|
||||
|
||||
|
||||
def _hub_fetch_volume_rank(top_n=20):
|
||||
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
|
||||
|
||||
return fetch_usdt_swap_volume_rank(
|
||||
exchange=exchange,
|
||||
ensure_markets_loaded=ensure_markets_loaded,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -7877,6 +7887,7 @@ try:
|
||||
account_fn=_hub_account_bundle,
|
||||
views={"add_order": add_order, "add_key": add_key},
|
||||
ohlcv_fn=_hub_fetch_ohlcv,
|
||||
volume_rank_fn=_hub_fetch_volume_rank,
|
||||
)
|
||||
except Exception as _hub_err:
|
||||
print(f"[hub_bridge] okx: {_hub_err}")
|
||||
|
||||
@@ -206,6 +206,7 @@ def install_on_app(
|
||||
views: dict,
|
||||
ohlcv_fn=None,
|
||||
account_fn=None,
|
||||
volume_rank_fn=None,
|
||||
):
|
||||
app.config["HUB_CTX"] = {
|
||||
"exchange": exchange,
|
||||
@@ -217,6 +218,7 @@ def install_on_app(
|
||||
"account_fn": account_fn,
|
||||
"views": views,
|
||||
"ohlcv_fn": ohlcv_fn,
|
||||
"volume_rank_fn": volume_rank_fn,
|
||||
}
|
||||
install_hub_embed_headers(app)
|
||||
configure_hub_embed_session(app)
|
||||
@@ -507,6 +509,24 @@ def register_hub_routes(app):
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/hub/volume-rank")
|
||||
@_hub_auth_required
|
||||
def api_hub_volume_rank():
|
||||
fn = _ctx().get("volume_rank_fn")
|
||||
if not callable(fn):
|
||||
return jsonify({"ok": False, "msg": "该实例未配置成交量排名接口"}), 501
|
||||
top_raw = (request.args.get("top") or "").strip()
|
||||
top_n = 20
|
||||
if top_raw.isdigit():
|
||||
top_n = int(top_raw)
|
||||
try:
|
||||
result = fn(top_n=top_n)
|
||||
if isinstance(result, dict):
|
||||
return jsonify(result)
|
||||
return jsonify({"ok": False, "msg": "成交量排名返回格式无效"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
@app.route("/api/hub/ohlcv")
|
||||
@_hub_auth_required
|
||||
def api_hub_ohlcv():
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""行情区:各交易所 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,
|
||||
}
|
||||
+179
-1
@@ -24,6 +24,18 @@ from hub_ohlcv_lib import (
|
||||
chart_memory_cap,
|
||||
retention_policy_meta,
|
||||
)
|
||||
from hub_volume_rank_lib import (
|
||||
TOP_N_DEFAULT,
|
||||
cache_needs_refresh,
|
||||
format_volume_quote,
|
||||
get_cached_rank,
|
||||
load_volume_rank_cache,
|
||||
merge_exchange_rank,
|
||||
rank_date_label,
|
||||
save_volume_rank_cache,
|
||||
seconds_until_next_reset,
|
||||
volume_rank_reset_hour,
|
||||
)
|
||||
from hub_symbol_archive_lib import (
|
||||
ARCHIVE_DEFAULT_TIMEFRAME,
|
||||
ARCHIVE_SEED_LOOKBACK_DAYS,
|
||||
@@ -104,6 +116,9 @@ HUB_BUILD = "20260607-hub-archive"
|
||||
_archive_sync_stop: asyncio.Event | None = None
|
||||
_archive_sync_task: asyncio.Task | None = None
|
||||
_last_archive_sync: dict | None = None
|
||||
_volume_rank_stop: asyncio.Event | None = None
|
||||
_volume_rank_task: asyncio.Task | None = None
|
||||
_volume_rank_cache: dict | None = None
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||
@@ -321,6 +336,99 @@ async def _run_archive_sync_once() -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_instance_volume_rank_sync(ex: dict, *, top_n: int = TOP_N_DEFAULT) -> dict:
|
||||
base = (ex.get("flask_url") or "").rstrip("/")
|
||||
if not base:
|
||||
return {"ok": False, "msg": "未配置 flask_url"}
|
||||
params = {"top": str(int(top_n))}
|
||||
url = f"{base}/api/hub/volume-rank?{urlencode(params)}"
|
||||
try:
|
||||
with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 60.0)) as client:
|
||||
r = client.get(url, headers=_hub_headers())
|
||||
if r.status_code >= 400:
|
||||
parsed = _parse_http_json_body(r)
|
||||
parsed.setdefault("ok", False)
|
||||
parsed.setdefault("status", r.status_code)
|
||||
return parsed
|
||||
data = r.json() if r.content else {}
|
||||
return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
def _get_volume_rank_cache() -> dict:
|
||||
global _volume_rank_cache
|
||||
if _volume_rank_cache is None:
|
||||
_volume_rank_cache = load_volume_rank_cache()
|
||||
return _volume_rank_cache
|
||||
|
||||
|
||||
def _refresh_volume_ranks(*, force: bool = False) -> dict:
|
||||
global _volume_rank_cache
|
||||
expected = rank_date_label()
|
||||
cache = _get_volume_rank_cache()
|
||||
if not force and not cache_needs_refresh(cache, expected_rank_date=expected):
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": True,
|
||||
"rank_date": cache.get("rank_date"),
|
||||
"updated_at": cache.get("updated_at"),
|
||||
}
|
||||
targets = enabled_exchanges(load_settings())
|
||||
errors: list[str] = []
|
||||
for ex in targets:
|
||||
ex_key = str(ex.get("key") or "").strip().lower()
|
||||
if not ex_key or not ex.get("enabled"):
|
||||
continue
|
||||
resp = _fetch_instance_volume_rank_sync(ex, top_n=TOP_N_DEFAULT)
|
||||
if resp.get("ok"):
|
||||
cache = merge_exchange_rank(cache, ex_key, resp)
|
||||
else:
|
||||
msg = str(resp.get("msg") or resp.get("error") or "拉取失败")
|
||||
errors.append(f"{ex_key}:{msg}")
|
||||
exchanges = dict(cache.get("exchanges") or {})
|
||||
prev = dict(exchanges.get(ex_key) or {})
|
||||
prev["error"] = msg
|
||||
exchanges[ex_key] = prev
|
||||
cache["exchanges"] = exchanges
|
||||
cache["rank_date"] = expected
|
||||
save_volume_rank_cache(cache)
|
||||
_volume_rank_cache = cache
|
||||
out: dict = {
|
||||
"ok": True,
|
||||
"rank_date": expected,
|
||||
"exchanges": len(targets),
|
||||
"updated_at": cache.get("updated_at"),
|
||||
}
|
||||
if errors:
|
||||
out["errors"] = errors[:8]
|
||||
return out
|
||||
|
||||
|
||||
async def _volume_rank_loop() -> None:
|
||||
global _volume_rank_stop
|
||||
stop = _volume_rank_stop
|
||||
if stop is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_volume_ranks, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
while not stop.is_set():
|
||||
try:
|
||||
wait_sec = seconds_until_next_reset()
|
||||
await asyncio.wait_for(stop.wait(), timeout=wait_sec)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_volume_ranks, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _archive_sync_loop() -> None:
|
||||
global _archive_sync_stop
|
||||
stop = _archive_sync_stop
|
||||
@@ -340,11 +448,13 @@ async def _archive_sync_loop() -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _hub_lifespan(_app: FastAPI):
|
||||
global _archive_sync_stop, _archive_sync_task
|
||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||
await board_store.start(_run_board_aggregate)
|
||||
await chart_poll_store.start(_run_chart_poll)
|
||||
_archive_sync_stop = asyncio.Event()
|
||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||
_volume_rank_stop = asyncio.Event()
|
||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -358,6 +468,16 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
pass
|
||||
_archive_sync_task = None
|
||||
_archive_sync_stop = None
|
||||
if _volume_rank_stop:
|
||||
_volume_rank_stop.set()
|
||||
if _volume_rank_task:
|
||||
_volume_rank_task.cancel()
|
||||
try:
|
||||
await _volume_rank_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_volume_rank_task = None
|
||||
_volume_rank_stop = None
|
||||
await chart_poll_store.stop()
|
||||
await board_store.stop()
|
||||
|
||||
@@ -645,9 +765,67 @@ def api_chart_meta():
|
||||
"chunk_limits": {tf: chart_chunk_limit(tf) for tf in tfs if tf in CHART_TIMEFRAMES},
|
||||
"memory_caps": {tf: chart_memory_cap(tf) for tf in tfs if tf in CHART_TIMEFRAMES},
|
||||
"exchanges": exchanges,
|
||||
"volume_rank_top_n": TOP_N_DEFAULT,
|
||||
"volume_rank_reset_hour": volume_rank_reset_hour(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/chart/volume-rank")
|
||||
def api_chart_volume_rank(exchange_key: str = "", refresh: str = ""):
|
||||
force = (refresh or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
if force:
|
||||
result = _refresh_volume_ranks(force=True)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败")
|
||||
cache = _get_volume_rank_cache()
|
||||
if cache_needs_refresh(cache):
|
||||
_refresh_volume_ranks(force=False)
|
||||
cache = _get_volume_rank_cache()
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if ex_k:
|
||||
ex = _find_exchange_by_key(ex_k)
|
||||
if not ex:
|
||||
raise HTTPException(status_code=400, detail="交易所不存在")
|
||||
payload = get_cached_rank(cache, ex_k, top_n=TOP_N_DEFAULT)
|
||||
payload["items"] = [
|
||||
{**row, "volume_label": format_volume_quote(row.get("volume_quote"))}
|
||||
for row in payload.get("items") or []
|
||||
]
|
||||
payload["reset_hour"] = volume_rank_reset_hour()
|
||||
err = ((cache.get("exchanges") or {}).get(ex_k) or {}).get("error")
|
||||
if err and not payload.get("items"):
|
||||
payload["ok"] = False
|
||||
payload["msg"] = err
|
||||
return payload
|
||||
exchanges_out = {}
|
||||
for ex in enabled_exchanges(load_settings()):
|
||||
key = str(ex.get("key") or "").strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
row = get_cached_rank(cache, key, top_n=TOP_N_DEFAULT)
|
||||
row["name"] = ex.get("name")
|
||||
row["items"] = [
|
||||
{**item, "volume_label": format_volume_quote(item.get("volume_quote"))}
|
||||
for item in row.get("items") or []
|
||||
]
|
||||
exchanges_out[key] = row
|
||||
return {
|
||||
"ok": True,
|
||||
"rank_date": cache.get("rank_date"),
|
||||
"updated_at": cache.get("updated_at"),
|
||||
"reset_hour": volume_rank_reset_hour(),
|
||||
"exchanges": exchanges_out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/chart/volume-rank/refresh")
|
||||
async def api_chart_volume_rank_refresh():
|
||||
result = await asyncio.to_thread(_refresh_volume_ranks, force=True)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/chart/ohlcv")
|
||||
def api_chart_ohlcv(
|
||||
exchange_key: str = "",
|
||||
|
||||
@@ -2928,6 +2928,117 @@ body.login-page {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.market-field-symbol .market-symbol-wrap {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-field-symbol .market-symbol-wrap > input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.market-vol-rank {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.market-vol-rank > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
background: var(--inset-surface);
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market-vol-rank > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.market-vol-rank[open] > summary {
|
||||
border-color: rgba(0, 255, 157, 0.35);
|
||||
background: rgba(0, 255, 157, 0.08);
|
||||
}
|
||||
|
||||
.market-vol-rank-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
width: min(320px, 72vw);
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg, #1a1f2e);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 0 6px;
|
||||
}
|
||||
|
||||
.market-vol-rank-meta {
|
||||
padding: 0 10px 6px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.market-vol-rank-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.market-vol-rank-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-vol-rank-item:hover {
|
||||
background: var(--inset-surface);
|
||||
}
|
||||
|
||||
.market-vol-rank-item.is-active {
|
||||
background: rgba(0, 255, 157, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.market-vol-rank-no {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.market-vol-rank-sym {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.market-vol-rank-vol {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.market-draw-menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
|
||||
const elExchange = document.getElementById("market-exchange");
|
||||
const elSymbol = document.getElementById("market-symbol");
|
||||
const elVolRankMeta = document.getElementById("market-vol-rank-meta");
|
||||
const elVolRankList = document.getElementById("market-vol-rank-list");
|
||||
const elVolRankDetails = document.getElementById("market-vol-rank");
|
||||
const elTf = document.getElementById("market-timeframe");
|
||||
const elRefresh = document.getElementById("market-refresh");
|
||||
const elStatus = document.getElementById("market-status");
|
||||
@@ -2613,6 +2616,68 @@
|
||||
void postChartUnwatch();
|
||||
}
|
||||
|
||||
function renderVolumeRank(data) {
|
||||
if (!elVolRankMeta || !elVolRankList) return;
|
||||
elVolRankList.innerHTML = "";
|
||||
if (!data || !data.ok || !data.items || !data.items.length) {
|
||||
elVolRankMeta.textContent =
|
||||
(data && data.msg) || "暂无排名数据(请稍后或检查实例 /api/hub/volume-rank)";
|
||||
return;
|
||||
}
|
||||
const resetHour = data.reset_hour != null ? data.reset_hour : 8;
|
||||
const rankDate = data.rank_date || "—";
|
||||
const updated = data.updated_at || "—";
|
||||
elVolRankMeta.textContent =
|
||||
"昨日成交 Top20 · 交易日 " + rankDate + " · 每早 " + resetHour + ":00 更新 · " + updated;
|
||||
const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
data.items.forEach(function (row) {
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "market-vol-rank-item";
|
||||
if (row.symbol && row.symbol.toUpperCase() === curSym) {
|
||||
btn.classList.add("is-active");
|
||||
}
|
||||
btn.dataset.symbol = row.symbol || "";
|
||||
btn.innerHTML =
|
||||
'<span class="market-vol-rank-no">' +
|
||||
(row.rank || "") +
|
||||
'</span><span class="market-vol-rank-sym">' +
|
||||
(row.symbol || "") +
|
||||
'</span><span class="market-vol-rank-vol">' +
|
||||
(row.volume_label || "") +
|
||||
"</span>";
|
||||
btn.addEventListener("click", function () {
|
||||
if (!elSymbol || !row.symbol) return;
|
||||
elSymbol.value = row.symbol;
|
||||
if (elVolRankDetails) elVolRankDetails.open = false;
|
||||
loadChart(false);
|
||||
});
|
||||
li.appendChild(btn);
|
||||
elVolRankList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVolumeRank() {
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
if (!exKey || !elVolRankMeta) return;
|
||||
elVolRankMeta.textContent = "加载排名…";
|
||||
if (elVolRankList) elVolRankList.innerHTML = "";
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey),
|
||||
{ credentials: "same-origin" }
|
||||
);
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
|
||||
}
|
||||
renderVolumeRank(data);
|
||||
} catch (e) {
|
||||
renderVolumeRank({ ok: false, msg: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
|
||||
chartMeta = await r.json();
|
||||
@@ -2628,6 +2693,7 @@
|
||||
readQuery();
|
||||
applyDefaults();
|
||||
updateExchangeDisplay();
|
||||
void loadVolumeRank();
|
||||
}
|
||||
|
||||
async function loadChart(force, options) {
|
||||
@@ -2767,6 +2833,7 @@
|
||||
updateExchangeDisplay();
|
||||
syncFsToolbarFromMain();
|
||||
lastViewKey = "";
|
||||
void loadVolumeRank();
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v6" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-vol-rank" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -80,9 +80,18 @@
|
||||
<span>交易所</span>
|
||||
<select id="market-exchange"></select>
|
||||
</label>
|
||||
<label class="market-field">
|
||||
<label class="market-field market-field-symbol">
|
||||
<span>币种</span>
|
||||
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
||||
<div class="market-symbol-wrap">
|
||||
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
||||
<details id="market-vol-rank" class="market-vol-rank">
|
||||
<summary title="昨日成交额 Top20(每早8点更新)">Top20</summary>
|
||||
<div class="market-vol-rank-panel">
|
||||
<div id="market-vol-rank-meta" class="market-vol-rank-meta">加载中…</div>
|
||||
<ol id="market-vol-rank-list" class="market-vol-rank-list"></ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</label>
|
||||
<label class="market-field">
|
||||
<span>周期</span>
|
||||
@@ -385,8 +394,8 @@
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/assets/chart_draw.js?v=20260608-market-draw-v6"></script>
|
||||
<script src="/assets/chart.js?v=20260608-market-draw-v6"></script>
|
||||
<script src="/assets/chart_draw.js?v=20260608-market-vol-rank"></script>
|
||||
<script src="/assets/chart.js?v=20260608-market-vol-rank"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
|
||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
|
||||
from hub_volume_rank_lib import (
|
||||
cache_needs_refresh,
|
||||
format_volume_quote,
|
||||
merge_exchange_rank,
|
||||
rank_date_label,
|
||||
)
|
||||
|
||||
|
||||
def test_rank_date_label_after_reset():
|
||||
# 2026-06-08 09:00 北京时间 → 昨日交易日 2026-06-07
|
||||
dt = datetime(2026, 6, 8, 9, 0, 0)
|
||||
assert rank_date_label(now=dt, reset_hour=8) == "2026-06-07"
|
||||
|
||||
|
||||
def test_rank_date_label_before_reset():
|
||||
# 2026-06-08 07:00 → 当前交易日仍算 2026-06-07,昨日为 2026-06-06
|
||||
dt = datetime(2026, 6, 8, 7, 0, 0)
|
||||
assert rank_date_label(now=dt, reset_hour=8) == "2026-06-06"
|
||||
|
||||
|
||||
def test_format_volume_quote():
|
||||
assert format_volume_quote(1_500_000_000) == "1.50B"
|
||||
assert format_volume_quote(2_300_000) == "2.30M"
|
||||
assert format_volume_quote(4500) == "4.50K"
|
||||
|
||||
|
||||
def test_cache_needs_refresh_and_merge():
|
||||
cache = {"rank_date": "2026-06-05", "exchanges": {}}
|
||||
assert cache_needs_refresh(cache, expected_rank_date="2026-06-07") is True
|
||||
merged = merge_exchange_rank(
|
||||
cache,
|
||||
"binance",
|
||||
{
|
||||
"ok": True,
|
||||
"rank_date": "2026-06-07",
|
||||
"items": [{"rank": 1, "symbol": "BTC/USDT", "volume_quote": 1.0}],
|
||||
"total_symbols": 100,
|
||||
},
|
||||
)
|
||||
assert merged["exchanges"]["binance"]["items"][0]["symbol"] == "BTC/USDT"
|
||||
assert merged["rank_date"] == "2026-06-07"
|
||||
Reference in New Issue
Block a user