fix(hub): load full pre-entry history for archive chart pan/zoom

range=history serves archive seed through close (not now). Default view focuses hold period; user can scroll/zoom left to see global morphology before entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-07 23:20:37 +08:00
parent 54c0b169c7
commit 5ceacd8077
6 changed files with 126 additions and 26 deletions
+68 -4
View File
@@ -20,6 +20,12 @@ ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"})
ARCHIVE_DEFAULT_TIMEFRAME = "15m"
ARCHIVE_SEED_LOOKBACK_DAYS = 30
ARCHIVE_VISIBLE_BARS_DEFAULT = 200
ARCHIVE_MAX_CANDLES: dict[str, int] = {
"5m": 4000,
"15m": 2500,
"1h": 1200,
"4h": 600,
}
ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600)))
ARCHIVE_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
@@ -594,6 +600,47 @@ def _fill_missing_bars(
return out
def _archive_earliest_bar_ms(
exchange_key: str,
symbol: str,
*,
db_path: Path | None = None,
) -> int | None:
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
conn = _connect(db_path)
try:
row = conn.execute(
"SELECT MIN(open_time_ms) AS mn FROM archive_bars_5m WHERE exchange_key=? AND symbol=?",
(ex_k, sym),
).fetchone()
if row and row["mn"] is not None:
return int(row["mn"])
finally:
conn.close()
return None
def _trim_bars_for_cap(
bars: list[dict[str, Any]],
*,
end_ms: int,
max_n: int,
) -> list[dict[str, Any]]:
"""超长时优先保留到平仓,再从最古老端截断。"""
if len(bars) <= max_n:
return bars
cut_end = len(bars)
for i in range(len(bars) - 1, -1, -1):
if int(bars[i]["open_time_ms"]) <= int(end_ms):
cut_end = i + 1
break
essential = bars[:cut_end]
if len(essential) <= max_n:
return essential
return essential[len(essential) - max_n :]
def resolve_archive_chart(
exchange_key: str,
symbol: str,
@@ -604,9 +651,13 @@ def resolve_archive_chart(
closed_ms: int | None = None,
mode: str = "hold",
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
range_mode: str = "window",
db_path: Path | None = None,
) -> dict[str, Any]:
"""从永久 5m 库聚合出档案 K 线视窗。"""
"""从永久 5m 库聚合出档案 K 线视窗。
range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态。
"""
tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME)
if tf not in ARCHIVE_TIMEFRAMES:
return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"}
@@ -619,13 +670,21 @@ def resolve_archive_chart(
period_5m = TIMEFRAME_MS["5m"]
hold_open = int(opened_ms) if opened_ms else None
hold_close = int(closed_ms) if closed_ms else None
if hold_open and hold_close and hold_close >= hold_open:
rm = (range_mode or "window").strip().lower()
if hold_open and hold_close and hold_close >= hold_open and rm == "history":
seed_back = max(0, hold_open - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000)
earliest = _archive_earliest_bar_ms(ex_k, sym, db_path=db_path)
if earliest is not None:
start_ms = min(earliest, seed_back)
else:
start_ms = seed_back
end_ms = hold_close + max(period * 16, period_5m * 8)
anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
elif hold_open and hold_close and hold_close >= hold_open:
hold_len = hold_close - hold_open
pad = max(period * 24, hold_len // 3, period_5m * 12)
start_ms = max(0, hold_open - pad)
end_ms = hold_close + pad
visible = int((end_ms - start_ms) / period) + 8
visible = max(50, min(visible, 500))
anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
else:
visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500))
@@ -652,6 +711,10 @@ def resolve_archive_chart(
agg = aggregate_ohlcv_bars(filled_5m, tf)
merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms]
max_n = ARCHIVE_MAX_CANDLES.get(tf, 2000)
if rm == "history" and merged:
merged = _trim_bars_for_cap(merged, end_ms=end_ms, max_n=max_n)
candles = _to_candles(merged)
if not candles:
return {"ok": False, "msg": "视窗内无 K 线"}
@@ -662,6 +725,7 @@ def resolve_archive_chart(
"symbol": sym,
"timeframe": tf,
"mode": (mode or "hold").strip().lower(),
"range_mode": rm,
"anchor_ms": anchor,
"opened_ms": hold_open,
"closed_ms": hold_close,