diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index 2ce61ce..478d5d5 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -17,7 +17,7 @@ | 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m | | 增量同步 | 默认每 **4 小时** 补新 5m 至当前 | | 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** | -| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) | +| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** | | 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | | 图片 | **不上传** | diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 37b0eb0..edfb796 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -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, diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 6cc7d2f..ac33262 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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 线") diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index a0fd07f..00d76e6 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -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() { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 1b95e0e..532288e 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -189,7 +189,7 @@