diff --git a/hub_volume_rank_lib.py b/hub_volume_rank_lib.py index 19500ea..7df0ac1 100644 --- a/hub_volume_rank_lib.py +++ b/hub_volume_rank_lib.py @@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo from hub_trades_lib import trading_day_from_dt TOP_N_DEFAULT = 20 -CACHE_VERSION = 2 +CACHE_VERSION = 3 def volume_rank_reset_hour() -> int: @@ -471,16 +471,40 @@ def merge_exchange_rank( return out -def cache_needs_refresh(cache: dict[str, Any], *, expected_rank_date: str | None = None) -> bool: +def _exchange_rank_row_stale(row: dict[str, Any] | None) -> bool: + if not row: + return True + items = row.get("items") or [] + if len(items) < TOP_N_DEFAULT: + return True + total = int(row.get("total_symbols") or 0) + if total > 0 and total < TOP_N_DEFAULT: + return True + return False + + +def cache_needs_refresh( + cache: dict[str, Any], + *, + expected_rank_date: str | None = None, + required_keys: list[str] | None = None, +) -> bool: expected = expected_rank_date or rank_date_label() if int(cache.get("version") or 0) < CACHE_VERSION: return True - if not cache.get("exchanges"): + exchanges = cache.get("exchanges") or {} + if not exchanges: return True if str(cache.get("rank_date") or "") != expected: return True - for _key, row in (cache.get("exchanges") or {}).items(): - if not row or not row.get("items"): + keys = required_keys or list(exchanges.keys()) + if not keys: + return True + for key in keys: + ex_k = str(key or "").strip().lower() + if not ex_k: + continue + if _exchange_rank_row_stale(exchanges.get(ex_k)): return True return False @@ -494,12 +518,16 @@ def get_cached_rank( 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))] + stale = _exchange_rank_row_stale(ex_data) 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, + "item_count": len(items), + "expected_count": int(top_n), "total_symbols": int(ex_data.get("total_symbols") or 0), - "stale": False, + "stale": stale, + "error": ex_data.get("error"), } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 3566c26..92df1b2 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -26,6 +26,7 @@ from hub_ohlcv_lib import ( ) from hub_volume_rank_lib import ( TOP_N_DEFAULT, + _exchange_rank_row_stale, cache_needs_refresh, format_volume_quote, get_cached_rank, @@ -343,7 +344,7 @@ def _fetch_instance_volume_rank_sync(ex: dict, *, top_n: int = TOP_N_DEFAULT) -> 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: + with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 120.0)) as client: r = client.get(url, headers=_hub_headers()) if r.status_code >= 400: parsed = _parse_http_json_body(r) @@ -367,14 +368,21 @@ 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): + targets = enabled_exchanges(load_settings()) + required_keys = [ + str(ex.get("key") or "").strip().lower() + for ex in targets + if ex.get("enabled") and str(ex.get("key") or "").strip() + ] + if not force and not cache_needs_refresh( + cache, expected_rank_date=expected, required_keys=required_keys + ): 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() @@ -782,10 +790,22 @@ def api_chart_volume_rank(exchange_key: str = "", refresh: str = ""): 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() + targets = enabled_exchanges(load_settings()) + required_keys = [ + str(ex.get("key") or "").strip().lower() + for ex in targets + if ex.get("enabled") and str(ex.get("key") or "").strip() + ] + need_keys = [ex_k] if ex_k else required_keys + if cache_needs_refresh(cache, required_keys=need_keys): + _refresh_volume_ranks(force=True) + cache = _get_volume_rank_cache() + elif ex_k: + row = (cache.get("exchanges") or {}).get(ex_k) or {} + if _exchange_rank_row_stale(row): + _refresh_volume_ranks(force=True) + cache = _get_volume_rank_cache() if ex_k: ex = _find_exchange_by_key(ex_k) if not ex: diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 8131cb5..8845222 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -2629,15 +2629,24 @@ const rankDate = data.rank_date || "—"; const updated = data.updated_at || "—"; const total = data.total_symbols != null ? data.total_symbols : ""; - elVolRankMeta.textContent = - "昨日成交 Top20 · 交易日 " + + const count = data.items.length; + const expect = data.expected_count != null ? data.expected_count : 20; + let meta = + "昨日成交 Top" + + expect + + " · 交易日 " + rankDate + " · 每早 " + resetHour + - ":00 更新" + - (total ? " · 全市场 " + total + " 个" : "") + - " · " + - updated; + ":00 更新 · 显示 " + + count + + "/" + + expect + + " 条"; + if (total) meta += " · 全市场 " + total + " 个"; + if (data.stale) meta += " · 数据不完整,正在重拉…"; + meta += " · " + updated; + elVolRankMeta.textContent = meta; const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; data.items.forEach(function (row) { const li = document.createElement("li"); @@ -2667,21 +2676,24 @@ }); } - async function loadVolumeRank() { + async function loadVolumeRank(forceRefresh) { 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" } - ); + let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey); + if (forceRefresh) url += "&refresh=1"; + const r = await fetch(url, { credentials: "same-origin" }); const data = await r.json(); if (!r.ok) { throw new Error((data && data.detail) || (data && data.msg) || "加载失败"); } renderVolumeRank(data); + const expect = data.expected_count != null ? data.expected_count : 20; + if (!forceRefresh && data.ok && data.items && data.items.length < expect) { + void loadVolumeRank(true); + } } catch (e) { renderVolumeRank({ ok: false, msg: String(e.message || e) }); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 690129d..0f5f456 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -395,7 +395,7 @@
- + diff --git a/tests/test_hub_volume_rank_lib.py b/tests/test_hub_volume_rank_lib.py index 8272352..cf03577 100644 --- a/tests/test_hub_volume_rank_lib.py +++ b/tests/test_hub_volume_rank_lib.py @@ -2,6 +2,8 @@ from datetime import datetime from hub_volume_rank_lib import ( CACHE_VERSION, + TOP_N_DEFAULT, + _exchange_rank_row_stale, _okx_turnover_usdt, cache_needs_refresh, format_volume_quote, @@ -53,3 +55,11 @@ def test_cache_needs_refresh_and_merge(): def test_stale_cache_version_forces_refresh(): cache = {"version": CACHE_VERSION - 1, "rank_date": "2026-06-07", "exchanges": {"okx": {"items": [{}]}}} assert cache_needs_refresh(cache) is True + + +def test_short_item_list_is_stale(): + items = [{"rank": i, "symbol": f"S{i}/USDT"} for i in range(1, 13)] + row = {"items": items, "total_symbols": 12} + assert _exchange_rank_row_stale(row) is True + full = {"items": items + [{"rank": i, "symbol": f"X{i}/USDT"} for i in range(13, TOP_N_DEFAULT + 1)], "total_symbols": 300} + assert _exchange_rank_row_stale(full) is False