fix: invalidate stale 12-item volume rank cache and force full top20 refresh
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+34
-6
@@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from hub_trades_lib import trading_day_from_dt
|
from hub_trades_lib import trading_day_from_dt
|
||||||
|
|
||||||
TOP_N_DEFAULT = 20
|
TOP_N_DEFAULT = 20
|
||||||
CACHE_VERSION = 2
|
CACHE_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
def volume_rank_reset_hour() -> int:
|
def volume_rank_reset_hour() -> int:
|
||||||
@@ -471,16 +471,40 @@ def merge_exchange_rank(
|
|||||||
return out
|
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()
|
expected = expected_rank_date or rank_date_label()
|
||||||
if int(cache.get("version") or 0) < CACHE_VERSION:
|
if int(cache.get("version") or 0) < CACHE_VERSION:
|
||||||
return True
|
return True
|
||||||
if not cache.get("exchanges"):
|
exchanges = cache.get("exchanges") or {}
|
||||||
|
if not exchanges:
|
||||||
return True
|
return True
|
||||||
if str(cache.get("rank_date") or "") != expected:
|
if str(cache.get("rank_date") or "") != expected:
|
||||||
return True
|
return True
|
||||||
for _key, row in (cache.get("exchanges") or {}).items():
|
keys = required_keys or list(exchanges.keys())
|
||||||
if not row or not row.get("items"):
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -494,12 +518,16 @@ def get_cached_rank(
|
|||||||
ex_k = str(exchange_key or "").strip().lower()
|
ex_k = str(exchange_key or "").strip().lower()
|
||||||
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||||
items = list(ex_data.get("items") or [])[: max(1, int(top_n))]
|
items = list(ex_data.get("items") or [])[: max(1, int(top_n))]
|
||||||
|
stale = _exchange_rank_row_stale(ex_data)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"exchange_key": ex_k,
|
"exchange_key": ex_k,
|
||||||
"rank_date": ex_data.get("rank_date") or cache.get("rank_date"),
|
"rank_date": ex_data.get("rank_date") or cache.get("rank_date"),
|
||||||
"updated_at": cache.get("updated_at"),
|
"updated_at": cache.get("updated_at"),
|
||||||
"items": items,
|
"items": items,
|
||||||
|
"item_count": len(items),
|
||||||
|
"expected_count": int(top_n),
|
||||||
"total_symbols": int(ex_data.get("total_symbols") or 0),
|
"total_symbols": int(ex_data.get("total_symbols") or 0),
|
||||||
"stale": False,
|
"stale": stale,
|
||||||
|
"error": ex_data.get("error"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from hub_ohlcv_lib import (
|
|||||||
)
|
)
|
||||||
from hub_volume_rank_lib import (
|
from hub_volume_rank_lib import (
|
||||||
TOP_N_DEFAULT,
|
TOP_N_DEFAULT,
|
||||||
|
_exchange_rank_row_stale,
|
||||||
cache_needs_refresh,
|
cache_needs_refresh,
|
||||||
format_volume_quote,
|
format_volume_quote,
|
||||||
get_cached_rank,
|
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))}
|
params = {"top": str(int(top_n))}
|
||||||
url = f"{base}/api/hub/volume-rank?{urlencode(params)}"
|
url = f"{base}/api/hub/volume-rank?{urlencode(params)}"
|
||||||
try:
|
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())
|
r = client.get(url, headers=_hub_headers())
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
parsed = _parse_http_json_body(r)
|
parsed = _parse_http_json_body(r)
|
||||||
@@ -367,14 +368,21 @@ def _refresh_volume_ranks(*, force: bool = False) -> dict:
|
|||||||
global _volume_rank_cache
|
global _volume_rank_cache
|
||||||
expected = rank_date_label()
|
expected = rank_date_label()
|
||||||
cache = _get_volume_rank_cache()
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"skipped": True,
|
"skipped": True,
|
||||||
"rank_date": cache.get("rank_date"),
|
"rank_date": cache.get("rank_date"),
|
||||||
"updated_at": cache.get("updated_at"),
|
"updated_at": cache.get("updated_at"),
|
||||||
}
|
}
|
||||||
targets = enabled_exchanges(load_settings())
|
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for ex in targets:
|
for ex in targets:
|
||||||
ex_key = str(ex.get("key") or "").strip().lower()
|
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"):
|
if not result.get("ok"):
|
||||||
raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败")
|
raise HTTPException(status_code=502, detail=result.get("msg") or "刷新失败")
|
||||||
cache = _get_volume_rank_cache()
|
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()
|
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:
|
if ex_k:
|
||||||
ex = _find_exchange_by_key(ex_k)
|
ex = _find_exchange_by_key(ex_k)
|
||||||
if not ex:
|
if not ex:
|
||||||
|
|||||||
@@ -2629,15 +2629,24 @@
|
|||||||
const rankDate = data.rank_date || "—";
|
const rankDate = data.rank_date || "—";
|
||||||
const updated = data.updated_at || "—";
|
const updated = data.updated_at || "—";
|
||||||
const total = data.total_symbols != null ? data.total_symbols : "";
|
const total = data.total_symbols != null ? data.total_symbols : "";
|
||||||
elVolRankMeta.textContent =
|
const count = data.items.length;
|
||||||
"昨日成交 Top20 · 交易日 " +
|
const expect = data.expected_count != null ? data.expected_count : 20;
|
||||||
|
let meta =
|
||||||
|
"昨日成交 Top" +
|
||||||
|
expect +
|
||||||
|
" · 交易日 " +
|
||||||
rankDate +
|
rankDate +
|
||||||
" · 每早 " +
|
" · 每早 " +
|
||||||
resetHour +
|
resetHour +
|
||||||
":00 更新" +
|
":00 更新 · 显示 " +
|
||||||
(total ? " · 全市场 " + total + " 个" : "") +
|
count +
|
||||||
" · " +
|
"/" +
|
||||||
updated;
|
expect +
|
||||||
|
" 条";
|
||||||
|
if (total) meta += " · 全市场 " + total + " 个";
|
||||||
|
if (data.stale) meta += " · 数据不完整,正在重拉…";
|
||||||
|
meta += " · " + updated;
|
||||||
|
elVolRankMeta.textContent = meta;
|
||||||
const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||||
data.items.forEach(function (row) {
|
data.items.forEach(function (row) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
@@ -2667,21 +2676,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadVolumeRank() {
|
async function loadVolumeRank(forceRefresh) {
|
||||||
const exKey = (elExchange && elExchange.value) || "";
|
const exKey = (elExchange && elExchange.value) || "";
|
||||||
if (!exKey || !elVolRankMeta) return;
|
if (!exKey || !elVolRankMeta) return;
|
||||||
elVolRankMeta.textContent = "加载排名…";
|
elVolRankMeta.textContent = "加载排名…";
|
||||||
if (elVolRankList) elVolRankList.innerHTML = "";
|
if (elVolRankList) elVolRankList.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const r = await fetch(
|
let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey);
|
||||||
"/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey),
|
if (forceRefresh) url += "&refresh=1";
|
||||||
{ credentials: "same-origin" }
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
);
|
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
|
throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
|
||||||
}
|
}
|
||||||
renderVolumeRank(data);
|
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) {
|
} catch (e) {
|
||||||
renderVolumeRank({ ok: false, msg: String(e.message || e) });
|
renderVolumeRank({ ok: false, msg: String(e.message || e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,7 @@
|
|||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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-vol-rank"></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/chart.js?v=20260608-market-vol-rank-v2"></script>
|
||||||
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></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/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
from hub_volume_rank_lib import (
|
from hub_volume_rank_lib import (
|
||||||
CACHE_VERSION,
|
CACHE_VERSION,
|
||||||
|
TOP_N_DEFAULT,
|
||||||
|
_exchange_rank_row_stale,
|
||||||
_okx_turnover_usdt,
|
_okx_turnover_usdt,
|
||||||
cache_needs_refresh,
|
cache_needs_refresh,
|
||||||
format_volume_quote,
|
format_volume_quote,
|
||||||
@@ -53,3 +55,11 @@ def test_cache_needs_refresh_and_merge():
|
|||||||
def test_stale_cache_version_forces_refresh():
|
def test_stale_cache_version_forces_refresh():
|
||||||
cache = {"version": CACHE_VERSION - 1, "rank_date": "2026-06-07", "exchanges": {"okx": {"items": [{}]}}}
|
cache = {"version": CACHE_VERSION - 1, "rank_date": "2026-06-07", "exchanges": {"okx": {"items": [{}]}}}
|
||||||
assert cache_needs_refresh(cache) is True
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user