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
+1 -1
View File
@@ -17,7 +17,7 @@
| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m |
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
| 展示周期 | Tab**5m / 15m / 1h / 4h**,默认 **15m** |
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) |
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** |
| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 |
| 图片 | **不上传** |
+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,
+2
View File
@@ -1734,6 +1734,7 @@ def api_archive_ohlcv(
anchor_ms: str = "",
opened_ms: str = "",
closed_ms: str = "",
range: str = "",
at: str = "",
bars: str = "",
):
@@ -1758,6 +1759,7 @@ def api_archive_ohlcv(
closed_ms=close_ms,
mode=mode,
bars=bar_n,
range_mode=(range or "").strip().lower() or "window",
)
if not result.get("ok"):
raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线")
+48 -16
View File
@@ -252,16 +252,18 @@
});
}
function focusHoldRange(candles, tr, tf) {
/** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */
function focusInitialTradeView(candles, tr, tf) {
if (!chart || !candles.length || !tr) return;
const mode = (elViewMode && elViewMode.value) || "hold";
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
let fromIdx = 0;
let toIdx = candles.length - 1;
let openIdx = 0;
let closeIdx = candles.length - 1;
if (openSec != null) {
for (let i = 0; i < candles.length; i++) {
if (candles[i].time >= openSec) {
fromIdx = Math.max(0, i - 12);
openIdx = i;
break;
}
}
@@ -269,11 +271,21 @@
if (closeSec != null) {
for (let i = candles.length - 1; i >= 0; i--) {
if (candles[i].time <= closeSec) {
toIdx = Math.min(candles.length - 1, i + 12);
closeIdx = i;
break;
}
}
}
const span = Math.max(24, closeIdx - openIdx + 20);
let fromIdx;
let toIdx;
if (mode === "entry") {
fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35));
toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65));
} else {
fromIdx = Math.max(0, openIdx - 10);
toIdx = Math.min(candles.length - 1, closeIdx + 14);
}
if (toIdx <= fromIdx) {
toIdx = Math.min(candles.length - 1, fromIdx + 80);
}
@@ -303,9 +315,24 @@
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
},
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" },
timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true },
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
timeScale: {
borderColor: isDark ? "#2a3348" : "#d0d7e2",
timeVisible: true,
secondsVisible: false,
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
horzTouchDrag: true,
vertTouchDrag: false,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
});
candleSeries = chart.addCandlestickSeries({
upColor: "#22c55e",
@@ -335,19 +362,23 @@
const tr = pickAnchorTrade();
const anchor = anchorMsForTrade(tr);
const jump = (elJumpAt && elJumpAt.value || "").trim();
const openMs = tradeOpenMs(tr);
const closeMs = tradeCloseMs(tr);
const params = new URLSearchParams({
exchange_key: selected.exchange_key,
symbol: selected.symbol,
timeframe: timeframe,
mode: (elViewMode && elViewMode.value) || "hold",
bars: "200",
});
if (jump) params.set("at", jump);
else if (anchor) params.set("anchor_ms", String(anchor));
const openMs = tradeOpenMs(tr);
const closeMs = tradeCloseMs(tr);
if (openMs) params.set("opened_ms", String(openMs));
if (closeMs) params.set("closed_ms", String(closeMs));
if (openMs && closeMs) {
params.set("range", "history");
params.set("opened_ms", String(openMs));
params.set("closed_ms", String(closeMs));
} else {
params.set("bars", "200");
if (jump) params.set("at", jump);
else if (anchor) params.set("anchor_ms", String(anchor));
}
setStatus("加载 K 线…");
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
const j = await r.json();
@@ -375,11 +406,12 @@
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
}
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
focusHoldRange(candles, tr, timeframe);
focusInitialTradeView(candles, tr, timeframe);
} else if (candles.length > 10) {
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
}
setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : ""));
const histHint = openMs && closeMs ? " · 可拖动/滚轮缩放查看建仓前走势" : "";
setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : "") + histHint);
}
function renderTrades() {
+2 -2
View File
@@ -189,7 +189,7 @@
<div id="page-archive" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">ARC</span> 币种档案</h1>
<p class="page-desc">一所一币一行 · 交易时间线 · 永久 5m K 线(15m/1h/4h 聚合</p>
<p class="page-desc">一所一币一行 · 交易时间线 · 建档 30 天 K 线可拖动缩放(默认聚焦持仓段,不含拉到「现在」</p>
</div>
<div class="archive-toolbar toolbar">
<label class="archive-field">
@@ -349,7 +349,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
<script src="/assets/archive.js?v=20260607-hub-archive-v3"></script>
<script src="/assets/archive.js?v=20260607-hub-archive-v4"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
</body>
+5 -3
View File
@@ -111,7 +111,7 @@ def test_fill_missing_bars_continuity():
assert any(b.get("filled") for b in filled)
def test_resolve_archive_chart_hold_window():
def test_resolve_archive_chart_history_range():
with tempfile.TemporaryDirectory() as td:
db = Path(td) / "archive.db"
init_db(db)
@@ -125,11 +125,13 @@ def test_resolve_archive_chart_hold_window():
opened_ms=open_ms,
closed_ms=close_ms,
mode="hold",
range_mode="history",
db_path=db,
)
assert out["ok"] is True
assert out.get("opened_ms") == open_ms
assert len(out["candles"]) >= 10
assert out.get("range_mode") == "history"
assert out.get("window_end_ms") <= close_ms + 4 * 3600_000
assert len(out["candles"]) >= 40
def test_list_with_overlay_filters():