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 | | 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m |
| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 | | 增量同步 | 默认每 **4 小时** 补新 5m 至当前 |
| 展示周期 | Tab**5m / 15m / 1h / 4h**,默认 **15m** | | 展示周期 | Tab**5m / 15m / 1h / 4h**,默认 **15m** |
| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) | | 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** |
| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | | 时间跳转 | 上方输入 `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_DEFAULT_TIMEFRAME = "15m"
ARCHIVE_SEED_LOOKBACK_DAYS = 30 ARCHIVE_SEED_LOOKBACK_DAYS = 30
ARCHIVE_VISIBLE_BARS_DEFAULT = 200 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_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_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000")) ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
@@ -594,6 +600,47 @@ def _fill_missing_bars(
return out 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( def resolve_archive_chart(
exchange_key: str, exchange_key: str,
symbol: str, symbol: str,
@@ -604,9 +651,13 @@ def resolve_archive_chart(
closed_ms: int | None = None, closed_ms: int | None = None,
mode: str = "hold", mode: str = "hold",
bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT, bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT,
range_mode: str = "window",
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""从永久 5m 库聚合出档案 K 线视窗。""" """从永久 5m 库聚合出档案 K 线视窗。
range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态。
"""
tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME) tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME)
if tf not in ARCHIVE_TIMEFRAMES: if tf not in ARCHIVE_TIMEFRAMES:
return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"} return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"}
@@ -619,13 +670,21 @@ def resolve_archive_chart(
period_5m = TIMEFRAME_MS["5m"] period_5m = TIMEFRAME_MS["5m"]
hold_open = int(opened_ms) if opened_ms else None hold_open = int(opened_ms) if opened_ms else None
hold_close = int(closed_ms) if closed_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 hold_len = hold_close - hold_open
pad = max(period * 24, hold_len // 3, period_5m * 12) pad = max(period * 24, hold_len // 3, period_5m * 12)
start_ms = max(0, hold_open - pad) start_ms = max(0, hold_open - pad)
end_ms = hold_close + 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 anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open
else: else:
visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500)) 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) agg = aggregate_ohlcv_bars(filled_5m, tf)
merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms] 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) candles = _to_candles(merged)
if not candles: if not candles:
return {"ok": False, "msg": "视窗内无 K 线"} return {"ok": False, "msg": "视窗内无 K 线"}
@@ -662,6 +725,7 @@ def resolve_archive_chart(
"symbol": sym, "symbol": sym,
"timeframe": tf, "timeframe": tf,
"mode": (mode or "hold").strip().lower(), "mode": (mode or "hold").strip().lower(),
"range_mode": rm,
"anchor_ms": anchor, "anchor_ms": anchor,
"opened_ms": hold_open, "opened_ms": hold_open,
"closed_ms": hold_close, "closed_ms": hold_close,
+2
View File
@@ -1734,6 +1734,7 @@ def api_archive_ohlcv(
anchor_ms: str = "", anchor_ms: str = "",
opened_ms: str = "", opened_ms: str = "",
closed_ms: str = "", closed_ms: str = "",
range: str = "",
at: str = "", at: str = "",
bars: str = "", bars: str = "",
): ):
@@ -1758,6 +1759,7 @@ def api_archive_ohlcv(
closed_ms=close_ms, closed_ms=close_ms,
mode=mode, mode=mode,
bars=bar_n, bars=bar_n,
range_mode=(range or "").strip().lower() or "window",
) )
if not result.get("ok"): if not result.get("ok"):
raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线") 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; if (!chart || !candles.length || !tr) return;
const mode = (elViewMode && elViewMode.value) || "hold";
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null; const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null; const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
let fromIdx = 0; let openIdx = 0;
let toIdx = candles.length - 1; let closeIdx = candles.length - 1;
if (openSec != null) { if (openSec != null) {
for (let i = 0; i < candles.length; i++) { for (let i = 0; i < candles.length; i++) {
if (candles[i].time >= openSec) { if (candles[i].time >= openSec) {
fromIdx = Math.max(0, i - 12); openIdx = i;
break; break;
} }
} }
@@ -269,11 +271,21 @@
if (closeSec != null) { if (closeSec != null) {
for (let i = candles.length - 1; i >= 0; i--) { for (let i = candles.length - 1; i >= 0; i--) {
if (candles[i].time <= closeSec) { if (candles[i].time <= closeSec) {
toIdx = Math.min(candles.length - 1, i + 12); closeIdx = i;
break; 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) { if (toIdx <= fromIdx) {
toIdx = Math.min(candles.length - 1, fromIdx + 80); toIdx = Math.min(candles.length - 1, fromIdx + 80);
} }
@@ -303,9 +315,24 @@
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
}, },
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" }, rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true }, timeScale: {
borderColor: isDark ? "#2a3348" : "#d0d7e2",
timeVisible: true,
secondsVisible: false,
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
horzTouchDrag: true,
vertTouchDrag: false,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
}); });
candleSeries = chart.addCandlestickSeries({ candleSeries = chart.addCandlestickSeries({
upColor: "#22c55e", upColor: "#22c55e",
@@ -335,19 +362,23 @@
const tr = pickAnchorTrade(); const tr = pickAnchorTrade();
const anchor = anchorMsForTrade(tr); const anchor = anchorMsForTrade(tr);
const jump = (elJumpAt && elJumpAt.value || "").trim(); const jump = (elJumpAt && elJumpAt.value || "").trim();
const openMs = tradeOpenMs(tr);
const closeMs = tradeCloseMs(tr);
const params = new URLSearchParams({ const params = new URLSearchParams({
exchange_key: selected.exchange_key, exchange_key: selected.exchange_key,
symbol: selected.symbol, symbol: selected.symbol,
timeframe: timeframe, timeframe: timeframe,
mode: (elViewMode && elViewMode.value) || "hold", mode: (elViewMode && elViewMode.value) || "hold",
bars: "200",
}); });
if (jump) params.set("at", jump); if (openMs && closeMs) {
else if (anchor) params.set("anchor_ms", String(anchor)); params.set("range", "history");
const openMs = tradeOpenMs(tr); params.set("opened_ms", String(openMs));
const closeMs = tradeCloseMs(tr); params.set("closed_ms", String(closeMs));
if (openMs) params.set("opened_ms", String(openMs)); } else {
if (closeMs) params.set("closed_ms", String(closeMs)); params.set("bars", "200");
if (jump) params.set("at", jump);
else if (anchor) params.set("anchor_ms", String(anchor));
}
setStatus("加载 K 线…"); setStatus("加载 K 线…");
const r = await apiFetch("/api/archive/ohlcv?" + params.toString()); const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
const j = await r.json(); const j = await r.json();
@@ -375,11 +406,12 @@
candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe)); candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe));
} }
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) { if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
focusHoldRange(candles, tr, timeframe); focusInitialTradeView(candles, tr, timeframe);
} else if (candles.length > 10) { } else if (candles.length > 10) {
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); 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() { function renderTrades() {
+2 -2
View File
@@ -189,7 +189,7 @@
<div id="page-archive" class="page hidden"> <div id="page-archive" class="page hidden">
<div class="page-head"> <div class="page-head">
<h1><span class="head-tag">ARC</span> 币种档案</h1> <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>
<div class="archive-toolbar toolbar"> <div class="archive-toolbar toolbar">
<label class="archive-field"> <label class="archive-field">
@@ -349,7 +349,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.js?v=20260604-upnl-contracts"></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/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>
</body> </body>
+5 -3
View File
@@ -111,7 +111,7 @@ def test_fill_missing_bars_continuity():
assert any(b.get("filled") for b in filled) 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: with tempfile.TemporaryDirectory() as td:
db = Path(td) / "archive.db" db = Path(td) / "archive.db"
init_db(db) init_db(db)
@@ -125,11 +125,13 @@ def test_resolve_archive_chart_hold_window():
opened_ms=open_ms, opened_ms=open_ms,
closed_ms=close_ms, closed_ms=close_ms,
mode="hold", mode="hold",
range_mode="history",
db_path=db, db_path=db,
) )
assert out["ok"] is True assert out["ok"] is True
assert out.get("opened_ms") == open_ms assert out.get("range_mode") == "history"
assert len(out["candles"]) >= 10 assert out.get("window_end_ms") <= close_ms + 4 * 3600_000
assert len(out["candles"]) >= 40
def test_list_with_overlay_filters(): def test_list_with_overlay_filters():