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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user