From 4bf0c2363f165ad2b90b5153ab8a6663d8ff9fde Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 15:46:36 +0800 Subject: [PATCH] feat: daily volume top20 rank per exchange in market page Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 11 ++ crypto_monitor_gate/app.py | 11 ++ crypto_monitor_gate_bot/app.py | 11 ++ crypto_monitor_okx/app.py | 11 ++ hub_bridge.py | 20 ++ hub_volume_rank_lib.py | 285 +++++++++++++++++++++++++++ manual_trading_hub/hub.py | 180 ++++++++++++++++- manual_trading_hub/static/app.css | 111 +++++++++++ manual_trading_hub/static/chart.js | 67 +++++++ manual_trading_hub/static/index.html | 19 +- tests/test_hub_volume_rank_lib.py | 43 ++++ 11 files changed, 763 insertions(+), 6 deletions(-) create mode 100644 hub_volume_rank_lib.py create mode 100644 tests/test_hub_volume_rank_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index bf9ae95..32d454d 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -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}") diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 9b4917f..c0ef891 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -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}") diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 593f251..323a2c8 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -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 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 04939da..9aba218 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -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}") diff --git a/hub_bridge.py b/hub_bridge.py index db1255c..9c336fd 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -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(): diff --git a/hub_volume_rank_lib.py b/hub_volume_rank_lib.py new file mode 100644 index 0000000..a05a1d1 --- /dev/null +++ b/hub_volume_rank_lib.py @@ -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, + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 48d6292..32d34d2 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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 = "", diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 7ee6c21..cf8dc89 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index c83c89d..98185ff 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -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 = + '' + + (row.rank || "") + + '' + + (row.symbol || "") + + '' + + (row.volume_label || "") + + ""; + 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); }); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 4535d45..690129d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -80,9 +80,18 @@ 交易所 -