/** * 内照明心:复盘语录 + 当日交易记录 + 按需 K 线. */ (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 elPeriodTabs = document.getElementById("archive-period-tabs"); const elTradingDay = document.getElementById("archive-trading-day"); const elPeriodRangeWrap = document.getElementById("archive-period-range-wrap"); const elDateFrom = document.getElementById("archive-date-from"); const elDateTo = document.getElementById("archive-date-to"); const elSearch = document.getElementById("archive-search"); const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle"); const elBtnRefresh = document.getElementById("archive-btn-refresh"); const elBtnSync = document.getElementById("archive-btn-sync"); const elStatus = document.getElementById("archive-status"); const elStats = document.getElementById("archive-stats"); const elStatsCharts = document.getElementById("archive-stats-charts"); const elStatsVizSub = document.getElementById("archive-stats-viz-sub"); const elCalSummarySub = document.getElementById("archive-cal-summary-sub"); const elCalendarWrap = document.getElementById("archive-calendar-wrap"); const elCalendar = document.getElementById("archive-calendar"); const elCalTitle = document.getElementById("archive-cal-title"); const elCalPrev = document.getElementById("archive-cal-prev"); const elCalNext = document.getElementById("archive-cal-next"); const elQuotesList = document.getElementById("archive-quotes-list"); const elQuotesCount = document.getElementById("archive-quotes-count"); const elQuoteForm = document.getElementById("archive-quote-form"); const elQuoteDate = document.getElementById("archive-quote-date"); const elQuoteContent = document.getElementById("archive-quote-content"); const elQuoteSubmit = document.getElementById("archive-quote-submit"); const elContentTabs = document.getElementById("archive-content-tabs"); const elProductTabs = document.getElementById("archive-product-tabs"); const elPanelViz = document.getElementById("archive-panel-viz"); const elPanelCalendar = document.getElementById("archive-panel-calendar"); const elPanelTrades = document.getElementById("archive-panel-trades"); const elPanelQuotes = document.getElementById("archive-panel-quotes"); const elQuoteDayTradesMeta = document.getElementById("archive-quote-day-trades-meta"); const elQuoteDayTradesBody = document.getElementById("archive-quote-day-trades-body"); const elChartSection = document.getElementById("archive-chart-section"); const elChartTitle = document.getElementById("archive-chart-title"); const elBtnChartClose = document.getElementById("archive-btn-chart-close"); const elCalDayTradesMeta = document.getElementById("archive-calendar-day-trades-meta"); const elCalDayTradesBody = document.getElementById("archive-calendar-day-trades-body"); 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 elTradesSection = document.getElementById("archive-trades-section"); const elTradesPager = document.getElementById("archive-trades-pager"); const elTradesPrev = document.getElementById("archive-trades-prev"); const elTradesNext = document.getElementById("archive-trades-next"); const elTradesPageLabel = document.getElementById("archive-trades-page-label"); const ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto"; const TRADES_PAGE_SIZE = 5; const TRADES_VISIBLE_ROWS_CHART_OPEN = 5; const TF_MS = { "5m": 5 * 60_000, "15m": 15 * 60_000, "1h": 60 * 60_000, "4h": 4 * 60 * 60_000, }; const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; let meta = null; let quotes = []; let selectedQuoteId = null; let editingQuoteId = null; let archiveContentTab = "trades"; let archiveProduct = "perp"; let quoteDayTrades = []; let quoteDayTradesDay = ""; let quoteDayTradesReq = 0; let dailyTrades = []; let tradesPage = 0; let dailyStats = { open_count: 0, by_exchange: {} }; let periodMode = "today"; let periodLabel = ""; let dateFrom = ""; let dateTo = ""; let tradingDay = ""; let selected = null; let trades = []; let selectedTradeKey = null; let timeframe = "15m"; let chart = null; let candleSeries = null; let volumeSeries = null; let inited = false; let markAuto = true; let lastCandles = []; let chartExchangeSymbol = ""; let chartMarketType = "swap"; let searchTimer = null; let calendarWidget = null; let selectedCalendarDay = ""; function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } 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 "—"; return (n >= 0 ? "+" : "") + n.toFixed(2); } function pad2(n) { return n < 10 ? "0" + n : String(n); } function utcSecToBjDate(utcSec) { return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); } function formatChartTimeBj(utcSec, withDate) { const d = utcSecToBjDate(utcSec); const h = pad2(d.getUTCHours()); const mi = pad2(d.getUTCMinutes()); if (!withDate) return h + ":" + mi; return ( d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate()) + " " + h + ":" + mi ); } function chartLocalizationBj() { return { locale: "zh-CN", dateFormat: "yyyy-MM-dd", timeFormatter: function (time) { if (typeof time === "number") return formatChartTimeBj(time, true); if (time && typeof time === "object" && time.year) { return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); } return ""; }, tickMarkFormatter: function (time, tickMarkType) { if (typeof time !== "number") { if (time && typeof time === "object" && time.year) { return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); } return ""; } const d = utcSecToBjDate(time); if (tickMarkType === 0) return String(d.getUTCFullYear()); if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); if (tickMarkType === 2) return pad2(d.getUTCDate()); return formatChartTimeBj(time, false); }, }; } 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 mt || "—"; } 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 || ""; } function tradeRowExchange(tr) { if (!tr) return "—"; const exKey = String(tr.exchange_key || "").toLowerCase(); return exKey ? exchangeLabel(exKey) : "—"; } function tradeRowKey(tr) { if (!tr) return ""; const exKey = String(tr.exchange_key || "").toLowerCase(); const tid = tr.trade_id != null ? tr.trade_id : tr.id; if (!exKey || tid == null || tid === "") return ""; return exKey + ":" + String(tid); } function findTradeByKey(key) { if (!key) return null; return ( dailyTrades.find(function (t) { return tradeRowKey(t) === String(key); }) || null ); } function applyTagSelectStyle(sel) { if (!sel) return; const v = sel.value || ""; sel.classList.remove("is-tag-empty", "is-tag-sick", "is-tag-emotion"); if (v === "sick") sel.classList.add("is-tag-sick"); else if (v === "emotion") sel.classList.add("is-tag-emotion"); else sel.classList.add("is-tag-empty"); } function exchangeLabel(exKey) { const key = String(exKey || "").toLowerCase(); if (!key) return "—"; const hit = (meta && meta.exchanges || []).find(function (ex) { return String(ex.key || "").toLowerCase() === key; }); return hit ? hit.name || hit.key : exKey; } function scheduleChartResize() { requestAnimationFrame(function () { if (chart && elChartHost) { const w = elChartHost.clientWidth; const h = elChartHost.clientHeight; if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h }); } requestAnimationFrame(function () { if (chart && elChartHost) { const w = elChartHost.clientWidth; const h = elChartHost.clientHeight; if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h }); } }); }); } async function ensureChartSelection() { if (selected && selected.exchange_key && selected.symbol) return; if (!dailyTrades.length) return; const tr = dailyTrades.find(function (t) { return t.exchange_key && t.symbol; }); if (!tr) return; selected = { exchange_key: tr.exchange_key, symbol: tr.symbol }; selectedTradeKey = tradeRowKey(tr); await loadSymbolTradesForChart(tr.exchange_key, tr.symbol); } function isChartOpen() { return !!(elChartSection && !elChartSection.hidden); } function syncTradesLayout() { const open = isChartOpen(); if (page) page.classList.toggle("is-chart-open", open); if (elTradesSection) elTradesSection.classList.toggle("is-chart-open", open); if (!elTrades) return; if (open) { const head = elTrades.querySelector("thead tr"); const row = elTrades.querySelector("tbody tr"); if (head && row) { const h = head.offsetHeight + row.offsetHeight * TRADES_VISIBLE_ROWS_CHART_OPEN; elTrades.style.maxHeight = h + "px"; } } else { elTrades.style.maxHeight = ""; } } function setChartOpen(on) { if (!elChartSection) return; const want = !!on; elChartSection.hidden = !want; if (elBtnChartToggle) { elBtnChartToggle.classList.toggle("is-active", want); } if (want && archiveContentTab !== "trades") { setArchiveContentTab("trades"); } syncTradesLayout(); if (!want) { destroyChart(); return; } scheduleChartResize(); } function formatChartContractLabel(sym, exchangeSymbol, marketType) { const base = String(sym || "—"); const mt = String(marketType || "").toLowerCase(); if (mt === "swap" || (exchangeSymbol && String(exchangeSymbol).indexOf(":") >= 0)) { return base + " 永续"; } return base; } function updateChartTitle() { if (!elChartTitle) return; if (!selected) { elChartTitle.textContent = "—"; return; } const label = formatChartContractLabel( selected.symbol, chartExchangeSymbol, chartMarketType ); elChartTitle.textContent = label + " · " + exchangeLabel(selected.exchange_key); } 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 syncPeriodUI() { if (elPeriodTabs) { elPeriodTabs.querySelectorAll(".archive-period-btn").forEach(function (btn) { btn.classList.toggle("is-active", btn.getAttribute("data-period") === periodMode); }); } if (elTradingDay) { elTradingDay.classList.toggle("hidden", periodMode !== "today"); } if (elPeriodRangeWrap) { elPeriodRangeWrap.classList.toggle("hidden", periodMode !== "range"); } } function setPeriodMode(mode) { periodMode = mode || "today"; syncPeriodUI(); } function isOptionsProduct() { return archiveProduct === "options"; } function syncProductUI() { document.body.classList.toggle("archive-product-options", isOptionsProduct()); if (elProductTabs) { elProductTabs.querySelectorAll(".archive-product-tab").forEach(function (btn) { const on = btn.getAttribute("data-archive-product") === archiveProduct; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); } if (isOptionsProduct()) { setChartOpen(false); if (archiveContentTab === "viz") setArchiveContentTab("trades"); } } function setArchiveProduct(product) { const next = product === "options" ? "options" : "perp"; if (next === archiveProduct) return; archiveProduct = next; selected = null; selectedTradeKey = null; syncProductUI(); void loadDailyTrades(); void loadCalendar(); } function dailyTradesApiPath() { return isOptionsProduct() ? "/api/archive/options/daily-trades" : "/api/archive/daily-trades"; } function calendarApiPath() { return isOptionsProduct() ? "/api/archive/options/calendar" : "/api/archive/calendar"; } function queryDailyParams() { const q = new URLSearchParams(); q.set("period", periodMode); if (periodMode === "today" && elTradingDay && elTradingDay.value) { q.set("trading_day", elTradingDay.value); } if (periodMode === "range") { if (elDateFrom && elDateFrom.value) q.set("date_from", elDateFrom.value); if (elDateTo && elDateTo.value) q.set("date_to", elDateTo.value); } 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 (!isOptionsProduct() && elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim()); return q.toString(); } function fmtVolStat(v) { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return "—"; if (n >= 10000) return (n / 1000).toFixed(1) + "k"; return n.toFixed(0) + "U"; } function fmtFeeStat(v) { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return "—"; return n.toFixed(2) + "U"; } function fmtPnlStat(v) { const n = Number(v); if (!Number.isFinite(n)) return "—"; const cls = n >= 0 ? "pnl-pos" : "pnl-neg"; const text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; return '' + text + ""; } 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 fmtPnlStatOptional(v) { if (v == null || v === "") return "—"; return fmtPnlStat(v); } function fmtWinRate(v, openN, winN) { if (v != null && v !== "") return Number(v).toFixed(1) + "%"; if (openN) return (Math.round(((winN || 0) / openN) * 1000) / 10) + "%"; return "—"; } function fmtProfitLossRatio(v) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return n.toFixed(2) + ":1"; } function renderStatsRow(label, e, isTotal) { const openN = e.open_count || 0; const sickN = e.sick_count || 0; const sickShare = e.sick_pct != null ? e.sick_pct : openN ? Math.round((sickN / openN) * 1000) / 10 : 0; const rowCls = isTotal ? ' class="archive-stats-total"' : ""; return ( "" + (isTotal ? "" + esc(label) + "" : esc(label)) + "" + openN + "" + (e.win_count || 0) + "" + (e.loss_count || 0) + "" + fmtWinRate(e.win_rate, openN, e.win_count) + "" + fmtPnlStatOptional(e.avg_win) + "" + fmtPnlStatOptional(e.avg_loss) + "" + fmtProfitLossRatio(e.profit_loss_ratio) + "" + fmtPnlStatOptional(e.max_win) + "" + fmtPnlStatOptional(e.max_loss) + "" + sickN + "" + sickShare + "%" + fmtPnlStat(e.pnl_total) + "" + fmtPnlStat(e.pnl_ex_sick) + "" + fmtVolStat(e.turnover_total) + "" + fmtFeeStat(e.commission_total) + "" ); } function calendarRefDate() { let ref = tradingDay || (elTradingDay && elTradingDay.value) || ""; if (!ref && dateFrom) ref = dateFrom; return ref || new Date(); } function ensureCalendarWidget() { if (calendarWidget || !window.TradeStatsCalendar || !elCalendar) return calendarWidget; calendarWidget = new TradeStatsCalendar({ gridEl: elCalendar, titleEl: elCalTitle, prevBtn: elCalPrev, nextBtn: elCalNext, showSick: true, buildQuery: function (year, month) { const q = new URLSearchParams(); q.set("year", String(year)); q.set("month", String(month)); const ex = (elExchange && elExchange.value) || ""; if (ex) q.set("exchange_key", ex); return q; }, fetchFn: async function (q) { const r = await apiFetch(calendarApiPath() + "?" + q.toString()); return r.json(); }, parseResponse: function (data) { if (!data || !data.ok) return {}; return data.days || {}; }, onDayClick: function (day) { selectedCalendarDay = day; setPeriodMode("today"); if (elTradingDay) elTradingDay.value = day; if (elQuoteDate) elQuoteDate.value = day; if (elFilterSick) elFilterSick.checked = false; syncPeriodUI(); void loadDailyTrades(); }, }); calendarWidget.ensureMonth(calendarRefDate()); return calendarWidget; } async function loadCalendar() { const cal = ensureCalendarWidget(); if (!cal) return; cal.selectedDay = selectedCalendarDay; await cal.load(); if (elCalSummarySub && cal.monthPnlTotal != null) { const pnl = Number(cal.monthPnlTotal) || 0; const sign = pnl > 0 ? "+" : ""; elCalSummarySub.textContent = cal.year + "年" + cal.month + "月 " + sign + pnl.toFixed(2) + "U"; } } function renderStats() { if (!elStats) return; const st = dailyStats || { open_count: 0, by_exchange: {} }; const label = periodLabel || "本日"; const byEx = st.by_exchange || {}; const exKeys = Object.keys(byEx).sort(); let rows = renderStatsRow( label, { open_count: st.open_count, sick_count: st.sick_count, sick_pct: st.sick_pct, pnl_total: st.pnl_total, pnl_ex_sick: st.pnl_ex_sick, win_count: st.win_count, loss_count: st.loss_count, avg_win: st.avg_win, avg_loss: st.avg_loss, win_rate: st.win_rate, profit_loss_ratio: st.profit_loss_ratio, max_win: st.max_win, max_loss: st.max_loss, turnover_total: st.turnover_total, commission_total: st.commission_total, }, true ) + exKeys .map(function (ex) { return renderStatsRow(exchangeLabel(ex), byEx[ex] || {}, false); }) .join(""); elStats.innerHTML = '' + "" + "" + rows + "
范围开仓盈利单亏损单胜率平均盈利平均亏损盈亏比最大盈利最大亏损犯病犯病占比盈亏剔除犯病盈亏成交额手续费
"; renderStatsCharts(); } function fmtDurationMinutes(minutes) { if (minutes == null || minutes === "" || Number.isNaN(Number(minutes))) return "—"; let m = Math.max(0, Math.round(Number(minutes))); if (m < 60) return m + "分"; const h = Math.floor(m / 60); const rm = m % 60; if (h < 24) return rm ? h + "时" + rm + "分" : h + "时"; const d = Math.floor(h / 24); const rh = h % 24; return rh ? d + "天" + rh + "时" : d + "天"; } function sumTradePnlSides(trades) { let profit = 0; let loss = 0; (trades || []).forEach(function (t) { const pnl = Number(t.pnl_amount); if (!Number.isFinite(pnl)) return; if (pnl > 0.0001) profit += pnl; else if (pnl < -0.0001) loss += Math.abs(pnl); }); return { profit: profit, loss: loss }; } function avgHoldMinutes(trades, side) { const vals = []; (trades || []).forEach(function (t) { const pnl = Number(t.pnl_amount); const hold = Number(t.hold_minutes); if (!Number.isFinite(hold) || hold < 0) return; if (side === "win" && pnl > 0.0001) vals.push(hold); if (side === "loss" && pnl < -0.0001) vals.push(hold); }); if (!vals.length) return null; return Math.round(vals.reduce(function (a, b) { return a + b; }, 0) / vals.length); } function buildCumulativeSeries(trades) { const byDay = {}; (trades || []).forEach(function (t) { const raw = t.closed_at || ""; const day = String(raw).slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return; byDay[day] = (byDay[day] || 0) + Number(t.pnl_amount || 0); }); const days = Object.keys(byDay).sort(); let cum = 0; return days.map(function (day) { cum += byDay[day]; return { day: day, pnl: byDay[day], cum: cum }; }); } function renderCumulativeChart(series) { if (!series.length) { return '

当前区间暂无平仓数据

'; } const w = 320; const h = 96; const padL = 6; const padR = 6; const padT = 10; const padB = 10; const vals = series.map(function (s) { return s.cum; }); const rawMin = Math.min.apply(null, vals); const rawMax = Math.max.apply(null, vals); let minV = Math.min(0, rawMin); let maxV = Math.max(0, rawMax); const span = maxV - minV || Math.max(Math.abs(rawMax), Math.abs(rawMin), 1); const yPad = span * 0.14; minV -= yPad; maxV += yPad; const range = maxV - minV || 1; const innerW = w - padL - padR; const innerH = h - padT - padB; const yOf = function (v) { return padT + innerH - ((v - minV) / range) * innerH; }; const zeroY = yOf(0); const showZero = rawMin < -0.0001 || rawMax > 0.0001; const pts = series.map(function (s, i) { const x = padL + (i / Math.max(series.length - 1, 1)) * innerW; const y = yOf(s.cum); return { x: x, y: y, day: s.day, pnl: s.pnl, cum: s.cum }; }); const linePts = pts.map(function (p) { return p.x.toFixed(1) + "," + p.y.toFixed(1); }).join(" "); const last = series[series.length - 1]; const lastCls = last.cum >= 0 ? "pnl-pos" : "pnl-neg"; const lineCls = last.cum >= 0 ? "archive-cum-line--up" : "archive-cum-line--down"; const sign = last.cum > 0 ? "+" : ""; const fmtAxis = function (v) { const a = Math.abs(v); if (a >= 100) return (v > 0 ? "+" : "") + v.toFixed(0) + "U"; if (a >= 10) return (v > 0 ? "+" : "") + v.toFixed(1) + "U"; return (v > 0 ? "+" : "") + v.toFixed(2) + "U"; }; const yTop = fmtAxis(maxV); const yMid = showZero ? "0" : fmtAxis((maxV + minV) / 2); const yBot = fmtAxis(minV); const gridLines = [maxV, showZero ? 0 : null, minV] .filter(function (v, i, arr) { return v != null && arr.indexOf(v) === i; }) .map(function (v) { const y = yOf(v).toFixed(1); const cls = v === 0 ? "archive-cum-grid archive-cum-grid--zero" : "archive-cum-grid"; return ''; }) .join(""); const dots = pts .map(function (p, i) { const isLast = i === pts.length - 1; const dotCls = p.cum >= 0 ? "archive-cum-dot--up" : "archive-cum-dot--down"; const r = isLast ? 4.2 : 2.6; const dayPnl = p.pnl > 0 ? "+" + p.pnl.toFixed(2) : p.pnl.toFixed(2); const cumPnl = p.cum > 0 ? "+" + p.cum.toFixed(2) : p.cum.toFixed(2); return ( '' + '' + esc(p.day.slice(5)) + " 当日" + dayPnl + "U · 累计" + cumPnl + "U" + "" ); }) .join(""); const dayCount = series.length; const peak = fmtAxis(rawMax); const trough = fmtAxis(rawMin); const xStart = esc(series[0].day.slice(5)); const xEnd = esc(series[series.length - 1].day.slice(5)); return ( '
' + '
' + '累计盈亏' + '' + sign + last.cum.toFixed(2) + "U" + "
" + '
' + '" + '
' + '" + '" + "
" + "
" + '
' + dayCount + " 个交易日 · 区间高 " + esc(peak) + " · 低 " + esc(trough) + "
" + "
" ); } function barRow(label, valueLabel, pct, fillCls) { const w = Math.max(0, Math.min(100, pct)); return ( '
' + '' + esc(label) + "" + '
' + '' + esc(valueLabel) + "" + "
" ); } function stackedPnlBar(profit, loss) { const total = profit + loss; if (total <= 0) { return '

暂无盈亏数据

'; } const profitPct = (profit / total) * 100; const lossPct = 100 - profitPct; const net = profit - loss; const netCls = net >= 0 ? "pnl-pos" : "pnl-neg"; const netSign = net > 0 ? "+" : ""; return ( '
' + '
' + (profitPct >= 18 ? profit.toFixed(2) + "U" : "") + "
" + '
' + (lossPct >= 18 ? loss.toFixed(2) + "U" : "") + "
" + "
" + '
' + '盈利 ' + profit.toFixed(2) + "U" + '亏损 ' + loss.toFixed(2) + "U" + '净 ' + netSign + net.toFixed(2) + "U" + "
" ); } function divergingBarRow(label, pnl, maxAbs) { const absPct = (Math.abs(pnl) / maxAbs) * 50; const cls = pnl >= 0 ? "archive-viz-div-fill--profit" : "archive-viz-div-fill--loss"; const vCls = pnl >= 0 ? "pnl-pos" : "pnl-neg"; const sign = pnl > 0 ? "+" : ""; const style = pnl >= 0 ? "left:50%;width:" + absPct.toFixed(1) + "%" : "right:50%;width:" + absPct.toFixed(1) + "%"; return ( '
' + '' + esc(label) + "" + '
' + '' + sign + pnl.toFixed(2) + "U" + "
" ); } function renderStatsCharts() { if (!elStatsCharts) return; const st = dailyStats || { open_count: 0, by_exchange: {} }; const openN = st.open_count || 0; const winN = st.win_count || 0; const lossN = st.loss_count || 0; const winRate = openN ? Number(st.win_rate) || 0 : 0; const sides = sumTradePnlSides(dailyTrades); const pnlTotal = sides.profit + sides.loss; const netPnl = Number(st.pnl_total) || 0; const sickN = st.sick_count || 0; const sickPct = openN ? (sickN / openN) * 100 : 0; const winHold = avgHoldMinutes(dailyTrades, "win"); const lossHold = avgHoldMinutes(dailyTrades, "loss"); const holdMax = Math.max(winHold || 0, lossHold || 0); const byEx = st.by_exchange || {}; const exKeys = Object.keys(byEx).sort(); if (elStatsVizSub) { const sign = netPnl > 0 ? "+" : ""; elStatsVizSub.textContent = openN ? "胜率 " + winRate.toFixed(0) + "% · " + sign + netPnl.toFixed(2) + "U" : "暂无平仓"; } if (!openN) { elStatsCharts.innerHTML = '

当前区间暂无平仓数据

'; return; } const netCls = netPnl >= 0 ? "pnl-pos" : "pnl-neg"; const netSign = netPnl > 0 ? "+" : ""; let exBars = ""; if (exKeys.length) { const maxAbs = Math.max.apply( null, exKeys.map(function (ex) { return Math.abs(Number(byEx[ex].pnl_total) || 0); }).concat([0.0001]) ); exBars = exKeys .map(function (ex) { const pnl = Number(byEx[ex].pnl_total) || 0; return divergingBarRow(exchangeLabel(ex), pnl, maxAbs); }) .join(""); } else { exBars = '

暂无分策略数据

'; } const cumSeries = buildCumulativeSeries(dailyTrades); elStatsCharts.innerHTML = '
' + '
' + '' + netSign + netPnl.toFixed(2) + "U" + '净盈亏' + "
" + '
' + '
' + '' + winRate.toFixed(0) + "%" + "
" + '' + winN + "胜 " + lossN + "负" + "
" + '
' + '' + sickN + " 笔" + '犯病 ' + sickPct.toFixed(0) + '%' + "
" + "
" + '
' + '
盈亏构成
' + (pnlTotal > 0 ? stackedPnlBar(sides.profit, sides.loss) : '

暂无盈亏数据

') + "
" + '
' + '
分策略盈亏
' + exBars + "
" + (holdMax > 0 ? '
' + '
持仓时长对比
' + '
' + '
' + '' + esc(fmtDurationMinutes(winHold)) + "" + '盈单均持仓' + '
' + "
" + '
' + '' + esc(fmtDurationMinutes(lossHold)) + "" + '亏单均持仓' + '
' + "
" + "
" : "") + '
' + renderCumulativeChart(cumSeries) + "
"; } function quotePreview(text) { const s = String(text || "").replace(/\s+/g, " ").trim(); if (!s) return "(空)"; return s.length > 36 ? s.slice(0, 36) + "…" : s; } function findQuote(id) { if (id == null || id === "") return null; return ( quotes.find(function (q) { return String(q.id) === String(id); }) || null ); } function updateQuoteSubmitBtn() { if (!elQuoteSubmit) return; elQuoteSubmit.textContent = editingQuoteId ? "修改保存" : "添加语录"; } function syncQuoteDateFromTradingDay(force) { if (!elQuoteDate) return; const day = (elTradingDay && elTradingDay.value) || selectedCalendarDay || tradingDay || ""; if (!day) return; if (force || !elQuoteDate.value) { elQuoteDate.value = day; } } function setArchiveContentTab(tab) { const allowed = { viz: 1, calendar: 1, trades: 1, quotes: 1 }; const next = allowed[tab] ? tab : "trades"; archiveContentTab = next; if (elContentTabs) { elContentTabs.querySelectorAll(".archive-content-tab").forEach(function (btn) { const on = btn.getAttribute("data-archive-tab") === next; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); } const panels = [ [elPanelViz, "viz"], [elPanelCalendar, "calendar"], [elPanelTrades, "trades"], [elPanelQuotes, "quotes"], ]; panels.forEach(function (pair) { const el = pair[0]; const key = pair[1]; if (!el) return; const on = key === next; el.classList.toggle("is-active", on); el.hidden = !on; }); if (next === "quotes") { syncQuoteDateFromTradingDay(false); void loadQuoteDayTrades(); } else if (next === "calendar") { void loadCalendar(); renderCalendarDayTrades(); } else if (next === "trades") { requestAnimationFrame(syncTradesLayout); } } function quoteTagLabel(t) { if (!t) return "—"; if (t.behavior_tag_from_journal || t.behavior_tag === "sick") return "犯病"; if (t.behavior_tag === "emotion") return "情绪"; return "—"; } function renderQuoteDayTrades() { if (!elQuoteDayTradesBody) return; const day = quoteDayTradesDay || (elQuoteDate && elQuoteDate.value) || ""; if (elQuoteDayTradesMeta) { elQuoteDayTradesMeta.textContent = day ? day + " · " + quoteDayTrades.length + " 笔" : ""; } if (!day) { elQuoteDayTradesBody.innerHTML = '

选择日期后自动带上该日已平仓记录.

'; return; } if (!quoteDayTrades.length) { elQuoteDayTradesBody.innerHTML = '

该日暂无已平仓记录.

'; return; } elQuoteDayTradesBody.innerHTML = '' + "" + "" + quoteDayTrades .map(function (t) { return ( "" + "" + "" + "" + '" + '" + '" + "" ); }) .join("") + "
交易所合约方向开仓平仓盈亏标签
" + esc(tradeRowExchange(t)) + "" + esc(t.symbol || "—") + "" + esc(t.direction || "—") + "' + fmtDt(t.opened_at) + "' + fmtDt(t.closed_at) + "' + fmtPnl(t.pnl_amount) + "" + esc(quoteTagLabel(t)) + "
"; } async function loadQuoteDayTrades() { if (!elQuoteDayTradesBody) return; const day = elQuoteDate && String(elQuoteDate.value || "").trim(); if (!day) { quoteDayTrades = []; quoteDayTradesDay = ""; renderQuoteDayTrades(); return; } const req = ++quoteDayTradesReq; const q = new URLSearchParams(); q.set("period", "today"); q.set("trading_day", day); elQuoteDayTradesBody.innerHTML = '

加载当日已平仓…

'; if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day; try { const r = await apiFetch(dailyTradesApiPath() + "?" + q.toString()); const j = await r.json(); if (req !== quoteDayTradesReq) return; if (!r.ok) { quoteDayTrades = []; quoteDayTradesDay = day; elQuoteDayTradesBody.innerHTML = '

' + esc(j.detail || "加载失败") + "

"; return; } quoteDayTrades = j.trades || []; quoteDayTradesDay = day; renderQuoteDayTrades(); } catch (_) { if (req !== quoteDayTradesReq) return; quoteDayTrades = []; quoteDayTradesDay = day; elQuoteDayTradesBody.innerHTML = '

加载失败

'; } } function resetQuoteForm() { editingQuoteId = null; if (elQuoteContent) elQuoteContent.value = ""; updateQuoteSubmitBtn(); } function startEditQuote() { const q = findQuote(selectedQuoteId); if (!q) return; editingQuoteId = q.id; if (elQuoteDate) elQuoteDate.value = q.quote_date || ""; if (elQuoteContent) { elQuoteContent.value = q.content || ""; elQuoteContent.focus(); } updateQuoteSubmitBtn(); void loadQuoteDayTrades(); } function selectQuote(id) { const nextId = String(id); const same = selectedQuoteId != null && String(selectedQuoteId) === nextId; if (editingQuoteId != null && String(editingQuoteId) !== nextId) { resetQuoteForm(); } selectedQuoteId = same ? null : id; renderQuotes(); } function renderQuotes() { if (!elQuotesList) return; if (elQuotesCount) { elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : ""; } if (!quotes.length) { elQuotesList.innerHTML = '

暂无复盘语录,可在上方添加.

'; return; } elQuotesList.innerHTML = quotes .map(function (q) { const selected = String(q.id) === String(selectedQuoteId); return ( '
' + '" + (selected ? '
' + '
' + esc(q.content || "(空)") + "
" + '
' + '' + '' + '' + "
" : "") + "
" ); }) .join(""); elQuotesList.querySelectorAll(".archive-quote-item").forEach(function (btn) { btn.addEventListener("click", function () { selectQuote(btn.getAttribute("data-id")); }); }); elQuotesList.querySelectorAll(".archive-quote-edit-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); selectedQuoteId = btn.getAttribute("data-id"); startEditQuote(); }); }); elQuotesList.querySelectorAll(".archive-quote-del-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); void deleteQuote(btn.getAttribute("data-id")); }); }); elQuotesList.querySelectorAll(".archive-quote-ai-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); startQuoteAiChat(btn.getAttribute("data-id")); }); }); } async function loadQuotes() { const r = await apiFetch("/api/archive/quotes"); const j = await r.json(); quotes = j.quotes || []; if (!findQuote(selectedQuoteId)) { selectedQuoteId = null; } renderQuotes(); } async function submitQuoteForm(ev) { if (ev) ev.preventDefault(); const date = elQuoteDate && elQuoteDate.value; const content = elQuoteContent && elQuoteContent.value.trim(); if (!date || !content) return; if (editingQuoteId) { await saveQuote(editingQuoteId, date, content); return; } const r = await apiFetch("/api/archive/quotes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quote_date: date, content: content }), }); const j = await r.json(); if (!r.ok) { setStatus(j.detail || "添加失败"); return; } resetQuoteForm(); selectedQuoteId = null; await loadQuotes(); setStatus("语录已添加"); } async function saveQuote(id, quoteDate, content) { const r = await apiFetch("/api/archive/quotes/" + id, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quote_date: String(quoteDate || "").trim(), content: content }), }); const j = await r.json(); if (!r.ok) { setStatus(j.detail || "保存失败"); return; } resetQuoteForm(); selectedQuoteId = null; await loadQuotes(); setStatus("语录已保存"); } const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; function startQuoteAiChat(quoteId) { const q = findQuote(quoteId); const content = q && String(q.content || "").trim(); if (!q || !content) { setStatus("语录内容为空,无法发起 AI 对话"); return; } try { sessionStorage.setItem( ARCHIVE_QUOTE_AI_KEY, JSON.stringify({ quote_date: q.quote_date || "", content: content, }) ); } catch (_) { setStatus("无法保存跳转数据"); return; } if (typeof window.hubNavigateTo === "function") { window.hubNavigateTo("/ai"); return; } location.href = "/ai"; } async function deleteQuote(id) { if (!id || !window.confirm("确定删除这条复盘语录?")) return; const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" }); if (!r.ok) { const j = await r.json().catch(function () { return {}; }); setStatus(j.detail || "删除失败"); return; } if (String(id) === String(editingQuoteId)) resetQuoteForm(); if (String(id) === String(selectedQuoteId)) selectedQuoteId = null; await loadQuotes(); setStatus("语录已删除"); } function pickAnchorTrade() { if (!trades.length) return null; if (selectedTradeKey) { const hit = trades.find(function (t) { return tradeRowKey(t) === selectedTradeKey; }); 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 ms = Date.UTC( Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0) ) - CHART_TZ_OFFSET_SEC * 1000; 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 rowKey = tradeRowKey(row); const parts = buildTradeMarkers(row, candles, tf, { labelSuffix: multi ? String(idx + 1) : "", highlight: rowKey === selectedTradeKey, }); 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 }, localization: chartLocalizationBj(), 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 loadSymbolTradesForChart(exKey, sym) { const r = await apiFetch( "/api/archive/detail?exchange_key=" + encodeURIComponent(exKey) + "&symbol=" + encodeURIComponent(sym) ); const j = await r.json(); trades = j.trades || []; } async function loadChart() { if (!selected || !isChartOpen()) return; const tr = pickAnchorTrade(); const jump = (elJumpAt && elJumpAt.value) || ""; 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"); const anchor = anchorMsForTrade(tr); if (jump.trim()) params.set("at", jump.trim()); 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; } chartExchangeSymbol = j.exchange_symbol || ""; chartMarketType = j.market_type || "swap"; if (chart) { destroyChart(); } ensureChart(); scheduleChartResize(); 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 }); } updateChartTitle(); scheduleChartResize(); setStatus( "K 线 " + candles.length + " 根 · " + timeframe + " · " + formatChartContractLabel(selected.symbol, chartExchangeSymbol, chartMarketType) ); } function isTradeRowInteractiveTarget(el) { return !!( el && el.closest && el.closest("button, select, input, textarea, a, label, .archive-actions-cell") ); } function ensureTradePageVisible(tr) { if (!tr || !dailyTrades.length) return; const key = tradeRowKey(tr); const idx = dailyTrades.findIndex(function (t) { return tradeRowKey(t) === key; }); if (idx < 0) return; tradesPage = Math.floor(idx / TRADES_PAGE_SIZE); } async function switchToTrade(tr) { if (!tr) return; const exKey = String(tr.exchange_key || "").toLowerCase(); const sym = tr.symbol || ""; if (!exKey || !sym) { setStatus("该笔交易缺少交易所或合约,无法切换"); return; } const key = tradeRowKey(tr); const prevEx = selected && selected.exchange_key; const prevSym = selected && selected.symbol; if (key === selectedTradeKey && prevEx === exKey && prevSym === sym) return; selected = { exchange_key: exKey, symbol: sym }; selectedTradeKey = key; ensureTradePageVisible(tr); renderTrades(); const needSymbolReload = prevEx !== exKey || prevSym !== sym; if (needSymbolReload) { await loadSymbolTradesForChart(exKey, sym); } if (!isChartOpen()) return; if (needSymbolReload) { await loadChart(); return; } applyChartMarkers(); const anchor = pickAnchorTrade(); if (anchor && lastCandles.length) { focusInitialTradeView(lastCandles, anchor, timeframe); } updateChartTitle(); setStatus("已切换至 " + sym + " · " + exchangeLabel(exKey)); } async function openTradeChart(tr) { if (!tr) return; const exKey = String(tr.exchange_key || "").toLowerCase(); const sym = tr.symbol || ""; if (!exKey || !sym) { setStatus("该笔交易缺少交易所或合约,无法加载图表"); return; } setChartOpen(true); await switchToTrade(tr); } function renderCalendarDayTrades() { if (!elCalDayTradesBody) return; const day = selectedCalendarDay || (periodMode === "today" && tradingDay) || (elTradingDay && elTradingDay.value) || ""; if (elCalDayTradesMeta) { elCalDayTradesMeta.textContent = periodMode === "today" && day ? day + " · " + dailyTrades.length + " 笔" : ""; } if (periodMode !== "today" || !day) { elCalDayTradesBody.innerHTML = '

点击日历中的日期,在下方查看当日交易记录.

'; return; } if (!dailyTrades.length) { elCalDayTradesBody.innerHTML = '

该日暂无交易记录.

'; return; } elCalDayTradesBody.innerHTML = '' + "" + "" + dailyTrades .map(function (t) { const rowKey = tradeRowKey(t); const tag = quoteTagLabel(t); return ( "" + "" + "" + "" + '" + '" + '" + "" + '' ); }) .join("") + "
交易所合约方向开仓平仓盈亏标签
" + esc(tradeRowExchange(t)) + "" + esc(t.symbol || "—") + "" + esc(t.direction || "—") + "' + fmtDt(t.opened_at) + "' + fmtDt(t.closed_at) + "' + fmtPnl(t.pnl_amount) + "" + esc(tag) + "
"; elCalDayTradesBody.querySelectorAll(".archive-cal-chart-btn").forEach(function (btn) { btn.addEventListener("click", function () { const tr = findTradeByKey(btn.getAttribute("data-key")); if (tr) void openTradeChart(tr); }); }); } function tradesPageCount() { return Math.max(1, Math.ceil((dailyTrades.length || 0) / TRADES_PAGE_SIZE)); } function clampTradesPage() { const pages = tradesPageCount(); if (tradesPage >= pages) tradesPage = pages - 1; if (tradesPage < 0) tradesPage = 0; } function updateTradesPager() { clampTradesPage(); const pages = tradesPageCount(); const show = dailyTrades.length > TRADES_PAGE_SIZE; if (elTradesPager) elTradesPager.hidden = !show; if (elTradesPageLabel) { elTradesPageLabel.textContent = "第 " + (tradesPage + 1) + " / " + pages + " 页"; } if (elTradesPrev) elTradesPrev.disabled = tradesPage <= 0; if (elTradesNext) elTradesNext.disabled = tradesPage + 1 >= pages; } function pagedDailyTrades() { clampTradesPage(); const start = tradesPage * TRADES_PAGE_SIZE; return dailyTrades.slice(start, start + TRADES_PAGE_SIZE); } function renderTrades() { if (!elTrades) return; if (!dailyTrades.length) { elTrades.innerHTML = '

该日暂无交易记录.可调整日期或点击「同步」拉取数据.

'; updateTradesPager(); return; } const pageRows = pagedDailyTrades(); if (isOptionsProduct()) { elTrades.innerHTML = '' + "" + "" + "" + pageRows .map(function (t) { const rowKey = tradeRowKey(t); const active = rowKey && rowKey === selectedTradeKey ? " is-active" : ""; const holdMin = t.hold_minutes != null ? t.hold_minutes : t.hold_seconds != null ? Number(t.hold_seconds) / 60 : null; const optLabel = t.source_label || t.source_type || (t.opt_type === "C" || t.opt_type === "CALL" ? "Call" : t.opt_type === "P" || t.opt_type === "PUT" ? "Put" : "—"); const pnl = t.pnl_amount != null ? t.pnl_amount : t.realized_pnl_total; return ( '' + "" + '" + "" + '" + '" + '" + "" + "" + '" + "" + "" + "" ); }) .join("") + "
交易所标的合约/来源开仓时间平仓时间持仓类型策略盈亏权利金复盘
" + esc(tradeRowExchange(t)) + "' + esc(t.underlying || "—") + "" + esc(t.inst_id || t.source_label || "—") + "' + fmtDt(t.opened_at) + "' + fmtDt(t.closed_at) + "' + fmtDurationMinutes(holdMin) + "" + esc(optLabel) + "" + esc(t.strategy_tag || "—") + "' + fmtPnl(pnl) + "" + fmtVolStat(t.premium_total != null ? t.premium_total : t.premium_paid) + "" + (t.reviewed ? "已复盘" : "—") + "
"; updateTradesPager(); return; } elTrades.innerHTML = '' + "" + "" + "" + pageRows .map(function (t) { const tid = t.trade_id || t.id; const exKey = String(t.exchange_key || "").toLowerCase(); const rowKey = tradeRowKey(t); const journalSick = !!t.behavior_tag_from_journal; const tag = journalSick ? "sick" : (t.behavior_tag || ""); const sick = tag === "sick"; const active = rowKey && rowKey === selectedTradeKey ? " is-active" : ""; const rev = reviewMark(t); return ( '' + "" + '" + "" + '" + '" + '" + "" + "" + '" + "" + "" + (journalSick ? '' : '") + '' + '" ); }) .join("") + "
交易所合约开仓类型开仓时间平仓时间持仓时长方向结果盈亏成交额手续费标签备注操作
" + esc(tradeRowExchange(t)) + "' + esc(t.symbol || "—") + "" + (rev ? '' + rev + "" : "") + esc(fmtEntryType(t)) + "' + fmtDt(t.opened_at) + "' + fmtDt(t.closed_at) + "' + fmtHoldMinutes(t) + "" + esc(t.direction || "—") + "" + esc(t.result || "—") + "' + fmtPnl(t.pnl_amount) + "" + fmtVolStat(t.exchange_turnover_usdt) + "" + fmtFeeStat(t.exchange_commission_usdt) + "犯病' + '' + '' + "
"; updateTradesPager(); elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); const row = btn.closest(".archive-trade-row"); void deleteTrade(btn.getAttribute("data-id"), row && row.getAttribute("data-ex")); }); }); elTrades.querySelectorAll(".archive-chart-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); const row = btn.closest(".archive-trade-row"); const rowKey = row && row.getAttribute("data-key"); const tr = findTradeByKey(rowKey); if (tr) void openTradeChart(tr); else if (rowKey) { selectedTradeKey = rowKey; renderTrades(); } }); }); elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { row.addEventListener("click", function (ev) { if (!isChartOpen()) return; if (isTradeRowInteractiveTarget(ev.target)) return; const tr = findTradeByKey(row.getAttribute("data-key")); if (tr) void switchToTrade(tr); }); }); elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) { applyTagSelectStyle(sel); sel.addEventListener("mousedown", function (ev) { ev.stopPropagation(); }); sel.addEventListener("change", function () { applyTagSelectStyle(sel); saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), sel.value, null); }); }); elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) { inp.addEventListener("mousedown", function (ev) { ev.stopPropagation(); }); inp.addEventListener("click", function (ev) { ev.stopPropagation(); }); inp.addEventListener("change", function () { const row = inp.closest(".archive-trade-row"); const tagSel = row && row.querySelector(".archive-tag-select"); const tr = findTradeByKey(row && row.getAttribute("data-key")); const tag = tr && tr.behavior_tag_from_journal ? "sick" : tagSel ? tagSel.value : ""; saveOverlay( inp.getAttribute("data-id"), inp.getAttribute("data-ex"), tag, inp.value ); }); }); requestAnimationFrame(syncTradesLayout); } async function deleteTrade(tradeId, exchangeKey) { const exKey = exchangeKey || (selected && selected.exchange_key); if (!exKey || tradeId == null) return; if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return; const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" }); if (!r.ok) { const j = await r.json().catch(function () { return {}; }); setStatus(j.detail || j.msg || "删除失败"); return; } const deletedKey = String(exchangeKey || "").toLowerCase() + ":" + String(tradeId); if (selectedTradeKey === deletedKey) selectedTradeKey = null; await loadDailyTrades(); setStatus("已移除 1 笔档案记录"); } async function saveOverlay(tradeId, exchangeKey, tag, note) { const exKey = exchangeKey || (selected && selected.exchange_key); if (!exKey) return; const tr = dailyTrades.find(function (t) { return ( String(t.trade_id || t.id) === String(tradeId) && String(t.exchange_key || "").toLowerCase() === String(exKey).toLowerCase() ); }); if (tr && tr.behavior_tag_from_journal && tag != null && String(tag) !== "sick") { return; } const body = { behavior_tag: tr && tr.behavior_tag_from_journal ? "sick" : tag || "", note: note != null ? note : undefined, }; if (note == null) { const row = elTrades.querySelector( '.archive-trade-row[data-id="' + tradeId + '"][data-ex="' + exKey + '"]' ); const inp = row && row.querySelector(".archive-note-input"); body.note = inp ? inp.value : ""; } await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (tr) { tr.behavior_tag = body.behavior_tag; tr.note = body.note; } renderTrades(); } async function loadDailyTrades() { setStatus("加载交易记录…"); const r = await apiFetch(dailyTradesApiPath() + "?" + queryDailyParams()); const j = await r.json(); if (!r.ok) { setStatus(j.detail || "加载失败"); return; } periodMode = j.period || periodMode || "today"; periodLabel = j.period_label || periodLabel || ""; dateFrom = j.date_from || dateFrom || ""; dateTo = j.date_to || dateTo || ""; tradingDay = j.trading_day || tradingDay; if (elTradingDay && tradingDay) elTradingDay.value = tradingDay; if (elDateFrom && dateFrom) elDateFrom.value = dateFrom; if (elDateTo && dateTo) elDateTo.value = dateTo; if (elQuoteDate && tradingDay && !elQuoteDate.value) elQuoteDate.value = tradingDay; syncPeriodUI(); dailyTrades = j.trades || []; tradesPage = 0; dailyStats = j.stats || { open_count: 0, by_exchange: {} }; if (periodMode === "today" && tradingDay) { selectedCalendarDay = tradingDay; if (calendarWidget) calendarWidget.selectedDay = tradingDay; } renderStats(); renderTrades(); renderCalendarDayTrades(); void loadCalendar(); if (archiveContentTab === "quotes") void loadQuoteDayTrades(); setStatus( (isOptionsProduct() ? "期权 · " : "永续 · ") + (periodLabel || tradingDay || "当日") + " · 列表 " + dailyTrades.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 optResults = j.options_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 { let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"; if (row.trades_removed > 0) line += " 清" + row.trades_removed; parts.push(line); } }); optResults.forEach(function (row) { const label = (row.exchange_key || row.name || "?") + "期权"; if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误")); else { let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades_upserted || 0) + " 笔"; if (row.trades_removed > 0) line += " 清" + row.trades_removed; parts.push(line); } }); return parts.join(" · "); } async function syncAll() { setStatus("同步中(可能需数分钟)…"); if (elBtnSync) elBtnSync.disabled = true; try { const r = await apiFetch("/api/archive/sync", { method: "POST" }); const j = await r.json(); setStatus(formatSyncSummary(j)); await loadDailyTrades(); await loadCalendar(); await loadQuotes(); if (isChartOpen() && selected) await loadChart(); } catch (e) { setStatus(String(e)); } finally { if (elBtnSync) elBtnSync.disabled = false; } } function bindEvents() { if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades); if (elBtnSync) elBtnSync.addEventListener("click", syncAll); if (elTradesPrev) { elTradesPrev.addEventListener("click", function () { if (tradesPage <= 0) return; tradesPage -= 1; renderTrades(); }); } if (elTradesNext) { elTradesNext.addEventListener("click", function () { if (tradesPage + 1 >= tradesPageCount()) return; tradesPage += 1; renderTrades(); }); } if (elExchange) { elExchange.addEventListener("change", function () { void loadDailyTrades(); void loadCalendar(); }); } if (elPeriodTabs) { elPeriodTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-period-btn"); if (!btn) return; const next = btn.getAttribute("data-period") || "today"; if (next === periodMode) return; setPeriodMode(next); loadDailyTrades(); }); } if (elTradingDay) { elTradingDay.addEventListener("change", function () { if (elQuoteDate && elTradingDay.value) elQuoteDate.value = elTradingDay.value; void loadDailyTrades(); }); } if (elDateFrom) elDateFrom.addEventListener("change", loadDailyTrades); if (elDateTo) elDateTo.addEventListener("change", loadDailyTrades); [elFilterProfit, elFilterLoss, elFilterSick].forEach(function (el) { if (el) el.addEventListener("change", loadDailyTrades); }); if (elSearch) { elSearch.addEventListener("input", function () { clearTimeout(searchTimer); searchTimer = setTimeout(loadDailyTrades, 320); }); } if (elBtnChartToggle) { elBtnChartToggle.addEventListener("click", async function () { const next = !isChartOpen(); setChartOpen(next); if (next) { await ensureChartSelection(); void loadChart(); } }); } if (elBtnChartClose) { elBtnChartClose.addEventListener("click", function () { setChartOpen(false); }); } if (elQuoteForm) elQuoteForm.addEventListener("submit", submitQuoteForm); if (elQuoteDate) { elQuoteDate.addEventListener("change", function () { void loadQuoteDayTrades(); }); } if (elContentTabs) { elContentTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-content-tab"); if (!btn) return; setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades"); }); } if (elProductTabs) { elProductTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-product-tab"); if (!btn) return; setArchiveProduct(btn.getAttribute("data-archive-product") || "perp"); }); } 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 (elMarkAuto) { elMarkAuto.addEventListener("click", function () { markAuto = !markAuto; syncMarkAutoBtn(); saveMarkAutoPref(); loadChart(); }); } if (elBtnJump) elBtnJump.addEventListener("click", loadChart); } async function init() { if (!page || page.classList.contains("hidden")) return; if (!inited) { loadMarkAutoPref(); setChartOpen(false); syncPeriodUI(); syncTradesLayout(); bindEvents(); syncProductUI(); setArchiveContentTab("trades"); inited = true; } await loadMeta(); await loadQuotes(); await loadDailyTrades(); } function destroy() { destroyChart(); } window.hubArchivePage = { init: init, destroy: destroy }; })();