/** * 中控币种档案:列表筛选、交易时间线、永久 K 线(lightweight-charts)。 */ (function () { const page = document.getElementById("page-archive"); if (!page) return; const elExchange = document.getElementById("archive-exchange"); const elFilterProfit = document.getElementById("archive-filter-profit"); const elFilterLoss = document.getElementById("archive-filter-loss"); const elFilterSick = document.getElementById("archive-filter-sick"); const elFilterEmotion = document.getElementById("archive-filter-emotion"); const elBtnRefresh = document.getElementById("archive-btn-refresh"); const elBtnSync = document.getElementById("archive-btn-sync"); const elStatus = document.getElementById("archive-status"); const elList = document.getElementById("archive-list"); const elDetailPanel = document.getElementById("archive-detail-panel"); const elDetailTitle = document.getElementById("archive-detail-title"); const elDetailStats = document.getElementById("archive-detail-stats"); const elTfTabs = document.getElementById("archive-tf-tabs"); const elViewMode = document.getElementById("archive-view-mode"); const elJumpAt = document.getElementById("archive-jump-at"); const elBtnJump = document.getElementById("archive-btn-jump"); const elBtnReloadChart = document.getElementById("archive-btn-reload-chart"); const elChartHost = document.getElementById("archive-chart"); const elMarkAuto = document.getElementById("archive-mark-auto"); const elTrades = document.getElementById("archive-trades"); const ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto"; const TF_MS = { "5m": 5 * 60_000, "15m": 15 * 60_000, "1h": 60 * 60_000, "4h": 4 * 60 * 60_000, }; let meta = null; let listRows = []; let selected = null; let trades = []; let selectedTradeId = null; let timeframe = "15m"; let chart = null; let candleSeries = null; let volumeSeries = null; let inited = false; let markAuto = true; let lastCandles = []; function loadMarkAutoPref() { try { const raw = localStorage.getItem(ARCHIVE_MARK_AUTO_KEY); if (raw === "0" || raw === "false") markAuto = false; else if (raw === "1" || raw === "true") markAuto = true; } catch (_) {} syncMarkAutoBtn(); } function syncMarkAutoBtn() { if (!elMarkAuto) return; elMarkAuto.classList.toggle("is-on", markAuto); elMarkAuto.setAttribute("aria-pressed", markAuto ? "true" : "false"); } function saveMarkAutoPref() { try { localStorage.setItem(ARCHIVE_MARK_AUTO_KEY, markAuto ? "1" : "0"); } catch (_) {} } function tradeHistoryBounds(tradeList) { let minOpen = null; let maxClose = null; (tradeList || []).forEach(function (tr) { const o = tradeOpenMs(tr); const c = tradeCloseMs(tr); if (o != null) minOpen = minOpen == null ? o : Math.min(minOpen, o); if (c != null) maxClose = maxClose == null ? c : Math.max(maxClose, c); }); return { minOpen: minOpen, maxClose: maxClose }; } function fmt(n, d) { if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; return Number(n).toFixed(d == null ? 2 : d); } function fmtPnl(v) { const n = Number(v); if (!Number.isFinite(n)) return "—"; const s = (n >= 0 ? "+" : "") + n.toFixed(2); return s; } function fmtDt(raw) { if (raw == null || raw === "") return "—"; return String(raw).replace("T", " ").slice(0, 16); } function fmtHoldMinutes(tr) { if (!tr) return "—"; const text = tr.hold_minutes_text; if (text) return text; const n = Number(tr.hold_minutes); if (!Number.isFinite(n) || n <= 0) return "0分钟"; const hours = Math.floor(n / 60); const mins = Math.floor(n % 60); if (hours) return hours + "小时" + mins + "分钟"; return mins + "分钟"; } const ENTRY_TYPE_LABELS = { trend_pullback: "趋势回调", roll: "顺势加仓", trend: "趋势回调", }; function fmtEntryType(tr) { if (!tr) return "—"; const raw = String( tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || "" ).trim(); if (raw) { return ENTRY_TYPE_LABELS[raw] || raw; } const mt = String(tr.monitor_type || "").trim(); if (mt && mt !== "下单监控") { return ENTRY_TYPE_LABELS[mt] || mt; } return "—"; } function reviewMark(tr) { return tr && tr.reviewed ? "复" : ""; } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return ""; return n > 0 ? "pos" : "neg"; } function setStatus(text) { if (elStatus) elStatus.textContent = text || ""; } async function apiFetch(url, opts) { const r = await fetch(url, opts); if (r.status === 401) { location.href = "/login?next=" + encodeURIComponent(location.pathname); throw new Error("未登录"); } return r; } function queryListParams() { const q = new URLSearchParams(); const ex = (elExchange && elExchange.value) || ""; if (ex) q.set("exchange_key", ex); if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1"); if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); if (elFilterEmotion && elFilterEmotion.checked) q.set("filter_emotion", "1"); return q.toString(); } function renderExchangeOptions() { if (!elExchange || !meta) return; const cur = elExchange.value; elExchange.innerHTML = ''; (meta.exchanges || []).forEach(function (ex) { const opt = document.createElement("option"); opt.value = ex.key || ""; opt.textContent = (ex.name || ex.key || "") + " (" + (ex.key || "") + ")"; elExchange.appendChild(opt); }); if (cur) elExchange.value = cur; } function renderList() { if (!elList) return; if (!listRows.length) { elList.innerHTML = '
暂无档案数据。点击「同步交易与 K 线」从四所拉取。
'; return; } elList.innerHTML = listRows .map(function (row) { const active = selected && selected.exchange_key === row.exchange_key && selected.symbol === row.symbol ? " is-active" : ""; const seed = row.seed_complete ? "已建档" : "待种子"; return ( '" ); }) .join(""); elList.querySelectorAll(".archive-row").forEach(function (btn) { btn.addEventListener("click", function () { openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym")); }); }); } function pickAnchorTrade() { if (!trades.length) return null; if (selectedTradeId != null) { const hit = trades.find(function (t) { return String(t.trade_id || t.id) === String(selectedTradeId); }); if (hit) return hit; } 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 tradeOpenMs(tr); } 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; } const OPEN_ARROW_LONG = "#22c55e"; const OPEN_ARROW_SHORT = "#ef4444"; const OPEN_ARROW_LONG_HI = "#4ade80"; const OPEN_ARROW_SHORT_HI = "#f87171"; function isLongDirection(dir) { const d = String(dir || "").trim().toLowerCase(); if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") { return false; } if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") { return true; } return true; } function openArrowColor(long, highlight) { if (long) return highlight ? OPEN_ARROW_LONG_HI : OPEN_ARROW_LONG; return highlight ? OPEN_ARROW_SHORT_HI : OPEN_ARROW_SHORT; } function buildTradeMarkers(tr, candles, tf, opts) { if (!tr || !candles.length) return []; const options = opts || {}; const suffix = options.labelSuffix ? String(options.labelSuffix) : ""; const highlight = !!options.highlight; const long = isLongDirection(tr.direction); const openMs = tradeOpenMs(tr); const closeMs = tradeCloseMs(tr); const openColor = openArrowColor(long, highlight); let closeColor = highlight ? "#fbbf24" : "#f59e0b"; const pnl = Number(tr.pnl_amount); if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) { closeColor = "#a855f7"; } const markers = []; if (openMs) { markers.push({ time: snapToCandleTime(msToBarTime(openMs, tf), candles), position: long ? "belowBar" : "aboveBar", color: openColor, shape: long ? "arrowUp" : "arrowDown", text: "开" + suffix, }); } if (closeMs) { markers.push({ time: snapToCandleTime(msToBarTime(closeMs, tf), candles), position: long ? "aboveBar" : "belowBar", color: closeColor, shape: long ? "arrowDown" : "arrowUp", text: "平" + suffix, }); } return markers; } function buildChartMarkers(candles, tf) { if (!candles.length) return []; const tr = pickAnchorTrade(); if (!markAuto || !trades.length) { return buildTradeMarkers(tr, candles, tf, { highlight: true }); } const sorted = trades.slice().sort(function (a, b) { return (tradeOpenMs(a) || 0) - (tradeOpenMs(b) || 0); }); const multi = sorted.length > 1; const out = []; sorted.forEach(function (row, idx) { const tid = String(row.trade_id || row.id); const parts = buildTradeMarkers(row, candles, tf, { labelSuffix: multi ? String(idx + 1) : "", highlight: tid === String(selectedTradeId), }); out.push.apply(out, parts); }); return out.sort(function (a, b) { return a.time > b.time ? 1 : a.time < b.time ? -1 : 0; }); } function applyChartMarkers() { if (!candleSeries || !candleSeries.setMarkers || !lastCandles.length) return; candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe)); } /** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */ 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 openIdx = 0; let closeIdx = candles.length - 1; if (openSec != null) { for (let i = 0; i < candles.length; i++) { if (candles[i].time >= openSec) { openIdx = i; break; } } } if (closeSec != null) { for (let i = candles.length - 1; i >= 0; i--) { if (candles[i].time <= closeSec) { 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); } chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 }); } function destroyChart() { if (chart) { chart.remove(); chart = null; candleSeries = null; volumeSeries = null; } if (elChartHost) elChartHost.innerHTML = ""; } function ensureChart() { if (!elChartHost || !window.LightweightCharts) return; if (chart) return; const isDark = document.documentElement.getAttribute("data-theme") !== "light"; chart = LightweightCharts.createChart(elChartHost, { layout: { background: { color: isDark ? "#0b0e18" : "#f8f9fc" }, textColor: isDark ? "#9aa4b8" : "#4a5568", }, grid: { vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, }, 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", downColor: "#ef4444", borderVisible: false, wickUpColor: "#22c55e", wickDownColor: "#ef4444", }); volumeSeries = chart.addHistogramSeries({ color: "#3b82f680", priceFormat: { type: "volume" }, priceScaleId: "", }); volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 }, }); new ResizeObserver(function () { if (chart && elChartHost) { chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); } }).observe(elChartHost); chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); } async function loadChart() { if (!selected) return; const tr = pickAnchorTrade(); const anchor = anchorMsForTrade(tr); const jump = (elJumpAt && elJumpAt.value || "").trim(); let openMs = null; let closeMs = null; if (markAuto && trades.length) { const bounds = tradeHistoryBounds(trades); openMs = bounds.minOpen; closeMs = bounds.maxClose; } else if (tr) { openMs = tradeOpenMs(tr); closeMs = tradeCloseMs(tr); } const params = new URLSearchParams({ exchange_key: selected.exchange_key, symbol: selected.symbol, timeframe: timeframe, mode: (elViewMode && elViewMode.value) || "hold", }); 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(); if (!r.ok) { setStatus(j.detail || "K 线加载失败"); return; } ensureChart(); const candles = j.candles || []; lastCandles = candles; candleSeries.setData( candles.map(function (c) { return { time: c.time, open: c.open, high: c.high, low: c.low, close: c.close }; }) ); volumeSeries.setData( candles.map(function (c) { return { time: c.time, value: c.volume || 0, color: c.close >= c.open ? "#22c55e55" : "#ef444455", }; }) ); applyChartMarkers(); if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) { focusInitialTradeView(candles, tr, timeframe); } else if (candles.length > 10) { chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); } const markHint = markAuto && trades.length > 1 ? " · 自动标注 " + trades.length + " 笔" : tr ? " · 已标注开/平" : ""; const histHint = openMs && closeMs ? " · 可拖动/滚轮缩放查看建仓前走势" : ""; setStatus("K 线 " + candles.length + " 根 · " + timeframe + markHint + histHint); } function renderTrades() { if (!elTrades) return; if (!trades.length) { elTrades.innerHTML = '该币种暂无已平仓记录。
'; return; } elTrades.innerHTML = '| 开仓类型 | 开仓时间 | 平仓时间 | 持仓时长 | " + "方向 | 结果 | 盈亏 | 标签 | 备注 | 操作 | " + "
|---|---|---|---|---|---|---|---|---|---|
| " + (rev ? '' + rev + "" : "") + fmtEntryType(t) + " | " + '" + (rev ? '' + rev + "" : "") + fmtDt(t.opened_at) + " | " + '" + (rev ? '' + rev + "" : "") + fmtDt(t.closed_at) + " | " + '" + (rev ? '' + rev + "" : "") + fmtHoldMinutes(t) + " | " + "" + (t.direction || "—") + " | " + "" + (t.result || "—") + " | " + '' + fmtPnl(t.pnl_amount) + " | " + '" + ' | ' + ' | ' + " |