/** * 中控币种档案:列表筛选、交易时间线、永久 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 elTrades = document.getElementById("archive-trades"); 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; 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 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 anchorMsForTrade(tr) { if (!tr) return null; const mode = (elViewMode && elViewMode.value) || "hold"; if (mode === "entry") { return tr.opened_at_ms || null; } return tr.closed_at_ms || tr.opened_at_ms || null; } 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" }, timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, }); 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(); 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)); 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 || []; 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", }; }) ); if (candles.length > 10) { chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); } setStatus("K 线 " + candles.length + " 根 · " + timeframe); } function renderTrades() { if (!elTrades) return; if (!trades.length) { elTrades.innerHTML = '

该币种暂无已平仓记录。

'; return; } elTrades.innerHTML = '' + "" + "" + trades .map(function (t) { const tid = t.trade_id || t.id; const active = String(tid) === String(selectedTradeId) ? " is-active" : ""; const tag = t.behavior_tag || ""; return ( '' + "" + "" + "" + '" + '" + '' + "" ); }) .join("") + "
平仓方向结果盈亏标签备注
" + (t.closed_at || "—") + "" + (t.direction || "—") + "" + (t.result || "—") + "' + fmtPnl(t.pnl_amount) + "
"; elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { row.addEventListener("click", function (ev) { if (ev.target.closest("select") || ev.target.closest("input")) return; selectedTradeId = row.getAttribute("data-id"); renderTrades(); loadChart(); }); }); elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) { sel.addEventListener("change", function () { saveOverlay(sel.getAttribute("data-id"), sel.value, null); }); }); elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) { inp.addEventListener("change", function () { const row = inp.closest(".archive-trade-row"); const tagSel = row && row.querySelector(".archive-tag-select"); saveOverlay(inp.getAttribute("data-id"), tagSel ? tagSel.value : "", inp.value); }); }); } async function saveOverlay(tradeId, tag, note) { if (!selected) return; const body = { behavior_tag: tag || "", note: note != null ? note : undefined }; if (note == null) { const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]'); const inp = row && row.querySelector(".archive-note-input"); body.note = inp ? inp.value : ""; } await apiFetch("/api/archive/trade/" + selected.exchange_key + "/" + tradeId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const tr = trades.find(function (t) { return String(t.trade_id || t.id) === String(tradeId); }); if (tr) { tr.behavior_tag = body.behavior_tag; tr.note = body.note; } } async function openDetail(exchangeKey, symbol) { selected = { exchange_key: exchangeKey, symbol: symbol }; if (elDetailPanel) elDetailPanel.classList.remove("hidden"); const row = listRows.find(function (r) { return r.exchange_key === exchangeKey && r.symbol === symbol; }); if (elDetailTitle) { elDetailTitle.textContent = symbol + " · " + exchangeKey; } if (elDetailStats && row) { elDetailStats.textContent = row.trade_count + " 笔 · 胜 " + row.win_count + " / 负 " + row.loss_count + " · 合计 " + fmtPnl(row.total_pnl) + " U"; } renderList(); setStatus("加载交易明细…"); const r = await apiFetch( "/api/archive/detail?exchange_key=" + encodeURIComponent(exchangeKey) + "&symbol=" + encodeURIComponent(symbol) ); const j = await r.json(); trades = j.trades || []; selectedTradeId = trades.length ? String(trades[0].trade_id || trades[0].id) : null; renderTrades(); await loadChart(); } async function loadList() { setStatus("加载列表…"); const r = await apiFetch("/api/archive/list?" + queryListParams()); const j = await r.json(); listRows = j.rows || []; renderList(); setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString()); } async function loadMeta() { const r = await apiFetch("/api/archive/meta"); meta = await r.json(); timeframe = (meta && meta.default_timeframe) || "15m"; if (meta && meta.last_sync && elStatus && !elStatus.textContent) { setStatus(formatSyncSummary(meta.last_sync)); } renderExchangeOptions(); if (elTfTabs) { elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) { btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe); }); } } function formatSyncSummary(j) { const results = j.results || []; const okN = results.filter(function (x) { return x.ok !== false; }).length; const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"]; results.forEach(function (row) { const label = row.exchange_key || row.name || "?"; if (row.ok === false) { parts.push(label + " 失败: " + (row.msg || "未知错误")); } else { parts.push(label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"); } }); return parts.join(" · "); } async function syncAll() { setStatus("同步中(可能需数分钟)…"); elBtnSync && (elBtnSync.disabled = true); try { const r = await apiFetch("/api/archive/sync", { method: "POST" }); const j = await r.json(); setStatus(formatSyncSummary(j)); await loadList(); if (selected) await openDetail(selected.exchange_key, selected.symbol); } catch (e) { setStatus(String(e)); } finally { elBtnSync && (elBtnSync.disabled = false); } } function bindEvents() { if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList); if (elBtnSync) elBtnSync.addEventListener("click", syncAll); if (elExchange) elExchange.addEventListener("change", loadList); [elFilterProfit, elFilterLoss, elFilterSick, elFilterEmotion].forEach(function (el) { if (el) el.addEventListener("change", loadList); }); if (elTfTabs) { elTfTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-tf-btn"); if (!btn) return; timeframe = btn.getAttribute("data-tf") || "15m"; elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (b) { b.classList.toggle("is-active", b === btn); }); loadChart(); }); } if (elViewMode) elViewMode.addEventListener("change", loadChart); if (elBtnReloadChart) elBtnReloadChart.addEventListener("click", loadChart); if (elBtnJump) { elBtnJump.addEventListener("click", function () { loadChart(); }); } } async function init() { if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) { return; } if (!inited) { bindEvents(); inited = true; } await loadMeta(); await loadList(); } function destroy() { destroyChart(); } window.hubArchivePage = { init: init, destroy: destroy }; })();