diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 0a99abc..37b0eb0 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -537,12 +537,71 @@ def _to_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: return out +def _snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int: + step = max(1, int(step_ms)) + origin = int(origin_ms) + if ts_ms <= origin: + return origin + idx = (int(ts_ms) - origin + step - 1) // step + return origin + idx * step + + +def _fill_missing_bars( + bars: list[dict[str, Any]], + period_ms: int, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """5m 缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续。""" + by_ts: dict[int, dict[str, Any]] = {} + for b in bars or []: + try: + by_ts[int(b["open_time_ms"])] = b + except (KeyError, TypeError, ValueError): + continue + if not by_ts: + return [] + keys = sorted(by_ts.keys()) + step_ms = max(1, int(period_ms)) + origin = keys[0] + aligned_start = _snap_to_bar_grid(int(start_ms), origin, step_ms) + aligned_end = max(int(end_ms), keys[-1]) + out: list[dict[str, Any]] = [] + last: dict[str, Any] | None = None + for ts_key in keys: + if ts_key <= aligned_start: + last = by_ts[ts_key] + ts = aligned_start + while ts <= aligned_end: + cur = by_ts.get(ts) + if cur is not None: + last = cur + out.append(cur) + elif last is not None: + c = float(last["close"]) + out.append( + { + "open_time_ms": ts, + "open": c, + "high": c, + "low": c, + "close": c, + "volume": 0.0, + "filled": True, + } + ) + ts += step_ms + return out + + def resolve_archive_chart( exchange_key: str, symbol: str, timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, *, anchor_ms: int | None = None, + opened_ms: int | None = None, + closed_ms: int | None = None, mode: str = "hold", bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT, db_path: Path | None = None, @@ -557,20 +616,40 @@ def resolve_archive_chart( return {"ok": False, "msg": "缺少 exchange_key 或 symbol"} period = TIMEFRAME_MS[tf] - visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500)) - anchor = int(anchor_ms) if anchor_ms else _now_ms() - half = visible // 2 - start_ms = max(0, anchor - half * period) - end_ms = anchor + half * period + 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: + 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)) + anchor = int(anchor_ms) if anchor_ms else _now_ms() + half = visible // 2 + start_ms = max(0, anchor - half * period) + end_ms = anchor + half * period - raw_5m = load_bars_5m_range(ex_k, sym, start_ms - period * 3, end_ms + period * 3, db_path=db_path) + raw_5m = load_bars_5m_range( + ex_k, + sym, + start_ms - period_5m * 6, + end_ms + period_5m * 6, + db_path=db_path, + ) if not raw_5m: return {"ok": False, "msg": "档案库暂无 K 线,请等待同步或手动刷新"} + filled_5m = _fill_missing_bars(raw_5m, period_5m, start_ms - period_5m * 2, end_ms + period_5m * 2) + if tf == "5m": - merged = [b for b in raw_5m if start_ms <= int(b["open_time_ms"]) <= end_ms] + merged = [b for b in filled_5m if start_ms <= int(b["open_time_ms"]) <= end_ms] else: - agg = aggregate_ohlcv_bars(raw_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] candles = _to_candles(merged) @@ -584,10 +663,13 @@ def resolve_archive_chart( "timeframe": tf, "mode": (mode or "hold").strip().lower(), "anchor_ms": anchor, + "opened_ms": hold_open, + "closed_ms": hold_close, "window_start_ms": start_ms, "window_end_ms": end_ms, "candles": candles, "bar_count": len(candles), + "gaps_filled": sum(1 for b in filled_5m if b.get("filled")), } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 32bb8ca..6cc7d2f 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1732,6 +1732,8 @@ def api_archive_ohlcv( timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, mode: str = "hold", anchor_ms: str = "", + opened_ms: str = "", + closed_ms: str = "", at: str = "", bars: str = "", ): @@ -1741,6 +1743,8 @@ def api_archive_ohlcv( raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") init_archive_db() anchor = _parse_anchor_ms(at, anchor_ms) + open_ms = _parse_anchor_ms("", opened_ms) + close_ms = _parse_anchor_ms("", closed_ms) try: bar_n = int(bars) if (bars or "").strip().isdigit() else ARCHIVE_VISIBLE_BARS_DEFAULT except ValueError: @@ -1750,6 +1754,8 @@ def api_archive_ohlcv( sym, timeframe, anchor_ms=anchor, + opened_ms=open_ms, + closed_ms=close_ms, mode=mode, bars=bar_n, ) diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 13bd70d..a0fd07f 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -157,13 +157,127 @@ return trades[0]; } + function parseTimeMs(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "number" && Number.isFinite(raw)) { + const v = Math.trunc(raw); + return v > 1e12 ? v : v * 1000; + } + const s = String(raw).trim().replace("Z", "").replace("T", " "); + if (!s) return null; + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/); + if (!m) return null; + const dt = new Date( + Number(m[1]), + Number(m[2]) - 1, + Number(m[3]), + Number(m[4] || 0), + Number(m[5] || 0), + Number(m[6] || 0) + ); + const ms = dt.getTime(); + return Number.isFinite(ms) ? ms : null; + } + + function tradeOpenMs(tr) { + if (!tr) return null; + return tr.opened_at_ms || parseTimeMs(tr.opened_at); + } + + function tradeCloseMs(tr) { + if (!tr) return null; + return tr.closed_at_ms || parseTimeMs(tr.closed_at); + } + function anchorMsForTrade(tr) { if (!tr) return null; const mode = (elViewMode && elViewMode.value) || "hold"; if (mode === "entry") { - return tr.opened_at_ms || null; + return tradeOpenMs(tr); } - return tr.closed_at_ms || tr.opened_at_ms || null; + return tradeCloseMs(tr) || tradeOpenMs(tr); + } + + function msToBarTime(ms, tf) { + const period = TF_MS[tf] || TF_MS["15m"]; + const aligned = Math.floor(Number(ms) / period) * period; + return Math.floor(aligned / 1000); + } + + function snapToCandleTime(targetSec, candles) { + if (!candles || !candles.length) return targetSec; + let best = candles[0].time; + let bestDiff = Math.abs(candles[0].time - targetSec); + for (let i = 0; i < candles.length; i++) { + const d = Math.abs(candles[i].time - targetSec); + if (d < bestDiff) { + bestDiff = d; + best = candles[i].time; + } + } + return best; + } + + function isLongDirection(dir) { + const d = String(dir || "").trim().toLowerCase(); + return d === "long" || d === "多" || d === "buy"; + } + + function buildTradeMarkers(tr, candles, tf) { + if (!tr || !candles.length) return []; + const long = isLongDirection(tr.direction); + const openMs = tradeOpenMs(tr); + const closeMs = tradeCloseMs(tr); + const markers = []; + if (openMs) { + markers.push({ + time: snapToCandleTime(msToBarTime(openMs, tf), candles), + position: long ? "belowBar" : "aboveBar", + color: long ? "#22c55e" : "#ef4444", + shape: long ? "arrowUp" : "arrowDown", + text: "开", + }); + } + if (closeMs) { + markers.push({ + time: snapToCandleTime(msToBarTime(closeMs, tf), candles), + position: long ? "aboveBar" : "belowBar", + color: "#f59e0b", + shape: long ? "arrowDown" : "arrowUp", + text: "平", + }); + } + return markers.sort(function (a, b) { + return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; + }); + } + + function focusHoldRange(candles, tr, tf) { + if (!chart || !candles.length || !tr) return; + 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; + if (openSec != null) { + for (let i = 0; i < candles.length; i++) { + if (candles[i].time >= openSec) { + fromIdx = Math.max(0, i - 12); + break; + } + } + } + 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); + break; + } + } + } + if (toIdx <= fromIdx) { + toIdx = Math.min(candles.length - 1, fromIdx + 80); + } + chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 }); } function destroyChart() { @@ -230,6 +344,10 @@ }); 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)); setStatus("加载 K 线…"); const r = await apiFetch("/api/archive/ohlcv?" + params.toString()); const j = await r.json(); @@ -253,10 +371,15 @@ }; }) ); - if (candles.length > 10) { + if (candleSeries.setMarkers) { + candleSeries.setMarkers(buildTradeMarkers(tr, candles, timeframe)); + } + if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) { + focusHoldRange(candles, tr, timeframe); + } else if (candles.length > 10) { chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); } - setStatus("K 线 " + candles.length + " 根 · " + timeframe); + setStatus("K 线 " + candles.length + " 根 · " + timeframe + (tr ? " · 已标注开/平" : "")); } function renderTrades() { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index fdc860f..1b95e0e 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -349,7 +349,7 @@
- +