feat(hub): add open/close arrows on archive chart with continuous klines

Span chart window across hold period, fill 5m gaps for smooth aggregation, and mark entry/exit with lightweight-charts arrows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-07 23:14:40 +08:00
parent 92ff945d72
commit 54c0b169c7
5 changed files with 282 additions and 15 deletions
+127 -4
View File
@@ -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() {