Files
crypto_monitor/manual_trading_hub/static/archive.js
T
2026-07-10 12:14:17 +08:00

1951 lines
66 KiB
JavaScript

/**
* 内照明心:复盘语录 + 当日交易记录 + 按需 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 elChartSection = document.getElementById("archive-chart-section");
const elChartTitle = document.getElementById("archive-chart-title");
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 ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto";
const TRADES_VISIBLE_ROWS_CHART_OPEN = 10;
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 dailyTrades = [];
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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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.open);
}
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;
elChartSection.open = !!on;
if (elBtnChartToggle) {
elBtnChartToggle.classList.toggle("is-active", !!on);
}
syncTradesLayout();
if (!on) {
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 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 (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 '<span class="' + cls + '">' + text + "</span>";
}
function renderExchangeOptions() {
if (!elExchange || !meta) return;
const cur = elExchange.value;
elExchange.innerHTML = '<option value="">全部</option>';
(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 (
"<tr" +
rowCls +
"><td>" +
(isTotal ? "<strong>" + esc(label) + "</strong>" : esc(label)) +
"</td><td>" +
openN +
"</td><td>" +
(e.win_count || 0) +
"</td><td>" +
(e.loss_count || 0) +
"</td><td>" +
fmtWinRate(e.win_rate, openN, e.win_count) +
"</td><td>" +
fmtPnlStatOptional(e.avg_win) +
"</td><td>" +
fmtPnlStatOptional(e.avg_loss) +
"</td><td>" +
fmtProfitLossRatio(e.profit_loss_ratio) +
"</td><td>" +
fmtPnlStatOptional(e.max_win) +
"</td><td>" +
fmtPnlStatOptional(e.max_loss) +
"</td><td>" +
sickN +
"</td><td>" +
sickShare +
"%</td><td>" +
fmtPnlStat(e.pnl_total) +
"</td><td>" +
fmtPnlStat(e.pnl_ex_sick) +
"</td><td>" +
fmtVolStat(e.turnover_total) +
"</td><td>" +
fmtFeeStat(e.commission_total) +
"</td></tr>"
);
}
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("/api/archive/calendar?" + 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 (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 =
'<table class="archive-stats-table"><thead><tr>' +
"<th>范围</th><th>开仓</th><th>盈利单</th><th>亏损单</th><th>胜率</th><th>平均盈利</th><th>平均亏损</th><th>盈亏比</th><th>最大盈利</th><th>最大亏损</th><th>犯病</th><th>犯病占比</th><th>盈亏</th><th>剔除犯病盈亏</th><th>成交额</th><th>手续费</th>" +
"</tr></thead><tbody>" +
rows +
"</tbody></table>";
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 '<p class="archive-viz-empty muted">当前区间暂无平仓数据</p>';
}
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 '<line x1="' + padL + '" y1="' + y + '" x2="' + (w - padR) + '" y2="' + y + '" class="' + cls + '" />';
})
.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 (
'<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="' + r + '" class="archive-cum-dot ' + dotCls + (isLast ? " archive-cum-dot--last" : "") + '">' +
'<title>' + esc(p.day.slice(5)) + " 当日" + dayPnl + "U · 累计" + cumPnl + "U</title>" +
"</circle>"
);
})
.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 (
'<div class="archive-cum-wrap">' +
'<div class="archive-cum-head">' +
'<span class="archive-viz-block-title">累计盈亏</span>' +
'<span class="archive-cum-end ' + lastCls + '">' + sign + last.cum.toFixed(2) + "U</span>" +
"</div>" +
'<div class="archive-cum-body">' +
'<div class="archive-cum-yaxis" aria-hidden="true">' +
'<span>' + esc(yTop) + "</span>" +
'<span>' + esc(yMid) + "</span>" +
'<span>' + esc(yBot) + "</span>" +
"</div>" +
'<div class="archive-cum-plot">' +
'<svg class="archive-cum-chart" viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" aria-hidden="true">' +
gridLines +
'<polyline points="' + linePts + '" class="archive-cum-line ' + lineCls + '" vector-effect="non-scaling-stroke" />' +
dots +
"</svg>" +
'<div class="archive-cum-xaxis" aria-hidden="true">' +
"<span>" + xStart + "</span>" +
(series.length > 1 ? "<span>" + xEnd + "</span>" : "") +
"</div>" +
"</div>" +
"</div>" +
'<div class="archive-cum-foot muted">' +
dayCount + " 个交易日 · 区间高 " + esc(peak) + " · 低 " + esc(trough) +
"</div>" +
"</div>"
);
}
function barRow(label, valueLabel, pct, fillCls) {
const w = Math.max(0, Math.min(100, pct));
return (
'<div class="archive-viz-bar-row">' +
'<span class="k" title="' + esc(label) + '">' + esc(label) + "</span>" +
'<div class="archive-viz-bar-track"><div class="archive-viz-bar-fill ' + fillCls + '" style="width:' + w.toFixed(1) + '%"></div></div>' +
'<span class="v">' + esc(valueLabel) + "</span>" +
"</div>"
);
}
function stackedPnlBar(profit, loss) {
const total = profit + loss;
if (total <= 0) {
return '<p class="archive-viz-empty muted">暂无盈亏数据</p>';
}
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 (
'<div class="archive-viz-stacked">' +
'<div class="archive-viz-stacked-seg archive-viz-stacked-seg--profit" style="width:' + profitPct.toFixed(1) + '%" title="总盈利 ' + profit.toFixed(2) + 'U">' +
(profitPct >= 18 ? profit.toFixed(2) + "U" : "") +
"</div>" +
'<div class="archive-viz-stacked-seg archive-viz-stacked-seg--loss" style="width:' + lossPct.toFixed(1) + '%" title="总亏损 ' + loss.toFixed(2) + 'U">' +
(lossPct >= 18 ? loss.toFixed(2) + "U" : "") +
"</div>" +
"</div>" +
'<div class="archive-viz-stacked-meta">' +
'<span class="archive-viz-legend archive-viz-legend--profit">盈利 ' + profit.toFixed(2) + "U</span>" +
'<span class="archive-viz-legend archive-viz-legend--loss">亏损 ' + loss.toFixed(2) + "U</span>" +
'<span class="archive-viz-legend archive-viz-legend--net ' + netCls + '">净 ' + netSign + net.toFixed(2) + "U</span>" +
"</div>"
);
}
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 (
'<div class="archive-viz-div-row">' +
'<span class="k" title="' + esc(label) + '">' + esc(label) + "</span>" +
'<div class="archive-viz-div-track"><div class="archive-viz-div-mid"></div><div class="archive-viz-div-fill ' + cls + '" style="' + style + '"></div></div>' +
'<span class="v ' + vCls + '">' + sign + pnl.toFixed(2) + "U</span>" +
"</div>"
);
}
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 = '<p class="archive-viz-empty muted">当前区间暂无平仓数据</p>';
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 = '<p class="archive-viz-empty muted">暂无分策略数据</p>';
}
const cumSeries = buildCumulativeSeries(dailyTrades);
elStatsCharts.innerHTML =
'<div class="archive-viz-kpis">' +
'<div class="archive-viz-kpi archive-viz-kpi--pnl">' +
'<span class="archive-viz-kpi-val ' + netCls + '">' + netSign + netPnl.toFixed(2) + "U</span>" +
'<span class="archive-viz-kpi-lbl">净盈亏</span>' +
"</div>" +
'<div class="archive-viz-kpi archive-viz-kpi--win">' +
'<div class="archive-viz-ring" style="--win-pct:' + winRate.toFixed(1) + '">' +
'<span class="archive-viz-ring-label">' + winRate.toFixed(0) + "%</span>" +
"</div>" +
'<span class="archive-viz-kpi-lbl">' + winN + "胜 " + lossN + "负</span>" +
"</div>" +
'<div class="archive-viz-kpi archive-viz-kpi--sick">' +
'<span class="archive-viz-kpi-val">' + sickN + " 笔</span>" +
'<span class="archive-viz-kpi-lbl">犯病 ' + sickPct.toFixed(0) + '%</span>' +
"</div>" +
"</div>" +
'<div class="archive-viz-block">' +
'<div class="archive-viz-block-title">盈亏构成</div>' +
(pnlTotal > 0 ? stackedPnlBar(sides.profit, sides.loss) : '<p class="archive-viz-empty muted">暂无盈亏数据</p>') +
"</div>" +
'<div class="archive-viz-block archive-viz-block--exchange">' +
'<div class="archive-viz-block-title">分策略盈亏</div>' +
exBars +
"</div>" +
(holdMax > 0
? '<div class="archive-viz-block archive-viz-block--hold">' +
'<div class="archive-viz-block-title">持仓时长对比</div>' +
'<div class="archive-viz-hold-grid">' +
'<div class="archive-viz-hold-card archive-viz-hold-card--win">' +
'<span class="archive-viz-hold-val">' + esc(fmtDurationMinutes(winHold)) + "</span>" +
'<span class="archive-viz-hold-lbl">盈单均持仓</span>' +
'<div class="archive-viz-bar-track"><div class="archive-viz-bar-fill archive-viz-bar-fill--profit" style="width:' + (((winHold || 0) / holdMax) * 100).toFixed(1) + '%"></div></div>' +
"</div>" +
'<div class="archive-viz-hold-card archive-viz-hold-card--loss">' +
'<span class="archive-viz-hold-val">' + esc(fmtDurationMinutes(lossHold)) + "</span>" +
'<span class="archive-viz-hold-lbl">亏单均持仓</span>' +
'<div class="archive-viz-bar-track"><div class="archive-viz-bar-fill archive-viz-bar-fill--loss" style="width:' + (((lossHold || 0) / holdMax) * 100).toFixed(1) + '%"></div></div>' +
"</div>" +
"</div></div>"
: "") +
'<div class="archive-viz-block archive-viz-block--cum">' +
renderCumulativeChart(cumSeries) +
"</div>";
}
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 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();
}
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 = '<p class="archive-empty">暂无复盘语录,可在上方添加.</p>';
return;
}
elQuotesList.innerHTML = quotes
.map(function (q) {
const selected = String(q.id) === String(selectedQuoteId);
return (
'<div class="archive-quote-block' +
(selected ? " is-open" : "") +
'">' +
'<button type="button" class="archive-quote-item' +
(selected ? " is-selected" : "") +
'" data-id="' +
q.id +
'">' +
'<span class="archive-quote-date">' +
esc(q.quote_date) +
"</span>" +
'<span class="archive-quote-preview">' +
esc(quotePreview(q.content)) +
"</span>" +
(selected ? "" : '<span class="archive-quote-open-hint">查看</span>') +
"</button>" +
(selected
? '<div class="archive-quote-detail">' +
'<div class="archive-quote-full">' +
esc(q.content || "(空)") +
"</div>" +
'<div class="archive-quote-actions">' +
'<button type="button" class="ghost archive-quote-edit-btn" data-id="' +
q.id +
'">修改</button>' +
'<button type="button" class="archive-del-btn archive-quote-del-btn" data-id="' +
q.id +
'">删除</button>' +
'<button type="button" class="ghost archive-quote-ai-btn" data-id="' +
q.id +
'">AI对话</button>' +
"</div></div>"
: "") +
"</div>"
);
})
.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")
);
}
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;
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 renderTrades() {
if (!elTrades) return;
if (!dailyTrades.length) {
elTrades.innerHTML =
'<p class="archive-empty">该日暂无交易记录.可调整日期或点击「同步」拉取数据.</p>';
return;
}
elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' +
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
"<th>方向</th><th>结果</th><th>盈亏</th><th>成交额</th><th>手续费</th><th>标签</th><th>备注</th><th>操作</th>" +
"</tr></thead><tbody>" +
dailyTrades
.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 (
'<tr class="archive-trade-row' +
active +
(sick ? " archive-trade-sick" : "") +
'" data-key="' +
esc(rowKey) +
'" data-id="' +
tid +
'" data-ex="' +
esc(exKey) +
'" data-sym="' +
esc(t.symbol || "") +
'">' +
"<td>" +
esc(tradeRowExchange(t)) +
"</td>" +
'<td class="archive-symbol">' +
esc(t.symbol || "—") +
"</td>" +
"<td>" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
esc(fmtEntryType(t)) +
"</td>" +
'<td class="archive-dt">' +
fmtDt(t.opened_at) +
"</td>" +
'<td class="archive-dt">' +
fmtDt(t.closed_at) +
"</td>" +
'<td class="archive-hold">' +
fmtHoldMinutes(t) +
"</td>" +
"<td>" +
esc(t.direction || "—") +
"</td>" +
"<td>" +
esc(t.result || "—") +
"</td>" +
'<td class="' +
pnlClass(t.pnl_amount) +
'">' +
fmtPnl(t.pnl_amount) +
"</td>" +
"<td>" +
fmtVolStat(t.exchange_turnover_usdt) +
"</td>" +
"<td>" +
fmtFeeStat(t.exchange_commission_usdt) +
"</td>" +
(journalSick
? '<td><span class="archive-tag-fixed is-tag-sick" title="实例复盘已勾选情绪标签">犯病</span></td>'
: '<td><select class="archive-tag-select" data-id="' +
tid +
'" data-ex="' +
esc(exKey) +
'">' +
'<option value=""' +
(tag === "" ? " selected" : "") +
">—</option>" +
'<option value="sick"' +
(tag === "sick" ? " selected" : "") +
">犯病</option>" +
'<option value="emotion"' +
(tag === "emotion" ? " selected" : "") +
">情绪</option>" +
"</select></td>") +
'<td><input class="archive-note-input" data-id="' +
tid +
'" data-ex="' +
esc(exKey) +
'" value="' +
esc(t.note || "") +
'" placeholder="备注" /></td>' +
'<td class="archive-actions-cell">' +
'<button type="button" class="ghost archive-chart-btn" data-id="' +
tid +
'">图表</button>' +
'<button type="button" class="archive-del-btn" data-id="' +
tid +
'">删除</button>' +
"</td></tr>"
);
})
.join("") +
"</tbody></table>";
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("/api/archive/daily-trades?" + 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 || [];
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
if (periodMode === "today" && tradingDay) {
selectedCalendarDay = tradingDay;
if (calendarWidget) calendarWidget.selectedDay = tradingDay;
}
renderStats();
renderTrades();
void loadCalendar();
setStatus(
(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 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);
}
});
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 (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", 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 (elChartSection) {
elChartSection.addEventListener("toggle", async function () {
if (elBtnChartToggle) elBtnChartToggle.classList.toggle("is-active", elChartSection.open);
syncTradesLayout();
if (elChartSection.open) {
await ensureChartSelection();
void loadChart();
} else {
destroyChart();
}
});
}
if (elQuoteForm) elQuoteForm.addEventListener("submit", submitQuoteForm);
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();
inited = true;
}
await loadMeta();
await loadQuotes();
await loadDailyTrades();
}
function destroy() {
destroyChart();
}
window.hubArchivePage = { init: init, destroy: destroy };
})();