Files
crypto_monitor/manual_trading_hub/static/archive.js
T
dekun 7b0b8996fe feat(hub): add period date range and trade stats to inner-light-mind
Support today/week/month/custom range selection with sick count, PnL, and per-exchange breakdown; update docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:09:39 +08:00

1233 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 内照明心:复盘语录 + 当日交易记录 + 按需 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 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 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 ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto";
const TF_MS = {
"5m": 5 * 60_000,
"15m": 15 * 60_000,
"1h": 60 * 60_000,
"4h": 4 * 60 * 60_000,
};
const CHART_TZ_OFFSET_SEC = 8 * 60 * 60;
let meta = null;
let quotes = [];
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 selectedTradeId = null;
let timeframe = "15m";
let chart = null;
let candleSeries = null;
let volumeSeries = null;
let inited = false;
let markAuto = true;
let lastCandles = [];
let searchTimer = null;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.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 = tr.exchange_key || tr.account_exchange_key || "";
if (exKey) return exchangeLabel(exKey);
const name = tr.account_name || tr.exchange_name || "";
return name || "—";
}
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 };
selectedTradeId = String(tr.trade_id || tr.id);
await loadSymbolTradesForChart(tr.exchange_key, tr.symbol);
}
function isChartOpen() {
return !!(elChartSection && elChartSection.open);
}
function setChartOpen(on) {
if (!elChartSection) return;
elChartSection.open = !!on;
if (elBtnChartToggle) {
elBtnChartToggle.classList.toggle("is-active", !!on);
}
if (!on) {
destroyChart();
return;
}
scheduleChartResize();
}
function updateChartTitle() {
if (!elChartTitle) return;
if (!selected) {
elChartTitle.textContent = "—";
return;
}
elChartTitle.textContent = selected.symbol + " · " + 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 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 renderStats() {
if (!elStats) return;
const st = dailyStats || { open_count: 0, by_exchange: {} };
const label = periodLabel || "本日";
const sickPct = st.sick_pct != null ? st.sick_pct : 0;
let html =
'<div class="archive-stats-line"><strong>' +
esc(label) +
"</strong> · 开仓 " +
(st.open_count || 0) +
" 次 · 犯病 " +
(st.sick_count || 0) +
" 次(" +
sickPct +
"% · 盈亏 " +
fmtPnlStat(st.pnl_total) +
" · 剔除犯病盈亏 " +
fmtPnlStat(st.pnl_ex_sick) +
"</div>";
const byEx = st.by_exchange || {};
const exKeys = Object.keys(byEx).sort();
if (exKeys.length) {
const exParts = exKeys.map(function (ex) {
const e = byEx[ex] || {};
const sickN = e.sick_count || 0;
const openN = e.open_count || 0;
const sickShare = openN ? Math.round((sickN / openN) * 1000) / 10 : 0;
return (
esc(exchangeLabel(ex)) +
" " +
openN +
"次/犯病" +
sickN +
"" +
sickShare +
"%/盈亏" +
fmtPnlStat(e.pnl_total) +
"/剔犯" +
fmtPnlStat(e.pnl_ex_sick)
);
});
html += '<div class="archive-stats-sub">' + exParts.join(" · ") + "</div>";
}
elStats.innerHTML = html;
}
function quotePreview(text) {
const s = String(text || "").replace(/\s+/g, " ").trim();
if (!s) return "(空)";
return s.length > 36 ? s.slice(0, 36) + "…" : s;
}
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) {
return (
'<details class="archive-quote-card">' +
'<summary class="archive-quote-summary">' +
'<span class="archive-quote-date">' +
esc(q.quote_date) +
"</span>" +
'<span class="archive-quote-preview">' +
esc(quotePreview(q.content)) +
"</span>" +
"</summary>" +
'<div class="archive-quote-body">' +
'<textarea class="archive-quote-edit" data-id="' +
q.id +
'" rows="4">' +
esc(q.content) +
"</textarea>" +
'<div class="archive-quote-actions">' +
'<button type="button" class="ghost archive-quote-save" data-id="' +
q.id +
'">保存</button>' +
'<button type="button" class="archive-del-btn archive-quote-del" data-id="' +
q.id +
'">删除</button>' +
"</div></div></details>"
);
})
.join("");
elQuotesList.querySelectorAll(".archive-quote-save").forEach(function (btn) {
btn.addEventListener("click", function () {
const id = btn.getAttribute("data-id");
const card = btn.closest(".archive-quote-card");
const ta = card && card.querySelector(".archive-quote-edit");
const dateEl = card && card.querySelector(".archive-quote-date");
if (!id || !ta) return;
void saveQuote(id, dateEl ? dateEl.textContent : "", ta.value, card);
});
});
elQuotesList.querySelectorAll(".archive-quote-del").forEach(function (btn) {
btn.addEventListener("click", function () {
void deleteQuote(btn.getAttribute("data-id"));
});
});
}
async function loadQuotes() {
const r = await apiFetch("/api/archive/quotes");
const j = await r.json();
quotes = j.quotes || [];
renderQuotes();
}
async function addQuote(ev) {
if (ev) ev.preventDefault();
const date = elQuoteDate && elQuoteDate.value;
const content = elQuoteContent && elQuoteContent.value.trim();
if (!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;
}
if (elQuoteContent) elQuoteContent.value = "";
await loadQuotes();
setStatus("语录已添加");
}
async function saveQuote(id, quoteDate, content, cardEl) {
let card = cardEl;
if (!card && elQuotesList) {
const ta = elQuotesList.querySelector('.archive-quote-edit[data-id="' + id + '"]');
card = ta ? ta.closest(".archive-quote-card") : null;
}
const date =
quoteDate ||
(card &&
card.querySelector(".archive-quote-date") &&
card.querySelector(".archive-quote-date").textContent) ||
"";
const r = await apiFetch("/api/archive/quotes/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quote_date: date.trim(), content: content }),
});
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "保存失败");
return;
}
if (card) card.open = false;
await loadQuotes();
setStatus("语录已保存");
}
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;
}
await loadQuotes();
setStatus("语录已删除");
}
function pickAnchorTrade() {
if (!trades.length) return null;
if (selectedTradeId != null) {
const hit = trades.find(function (t) {
return String(t.trade_id || t.id) === String(selectedTradeId);
});
if (hit) return hit;
}
return trades[0];
}
function parseTimeMs(raw) {
if (raw == null || raw === "") return null;
if (typeof raw === "number" && Number.isFinite(raw)) {
const v = Math.trunc(raw);
return v > 1e12 ? v : v * 1000;
}
const s = String(raw).trim().replace("Z", "").replace("T", " ");
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
if (!m) return null;
const 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 tid = String(row.trade_id || row.id);
const parts = buildTradeMarkers(row, candles, tf, {
labelSuffix: multi ? String(idx + 1) : "",
highlight: tid === String(selectedTradeId),
});
out.push.apply(out, parts);
});
return out.sort(function (a, b) {
return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
});
}
function applyChartMarkers() {
if (!candleSeries || !candleSeries.setMarkers || !lastCandles.length) return;
candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe));
}
function focusInitialTradeView(candles, tr, tf) {
if (!chart || !candles.length || !tr) return;
const mode = (elViewMode && elViewMode.value) || "hold";
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
let openIdx = 0;
let closeIdx = candles.length - 1;
if (openSec != null) {
for (let i = 0; i < candles.length; i++) {
if (candles[i].time >= openSec) {
openIdx = i;
break;
}
}
}
if (closeSec != null) {
for (let i = candles.length - 1; i >= 0; i--) {
if (candles[i].time <= closeSec) {
closeIdx = i;
break;
}
}
}
const span = Math.max(24, closeIdx - openIdx + 20);
let fromIdx;
let toIdx;
if (mode === "entry") {
fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35));
toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65));
} else {
fromIdx = Math.max(0, openIdx - 10);
toIdx = Math.min(candles.length - 1, closeIdx + 14);
}
if (toIdx <= fromIdx) toIdx = Math.min(candles.length - 1, fromIdx + 80);
chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 });
}
function destroyChart() {
if (chart) {
chart.remove();
chart = null;
candleSeries = null;
volumeSeries = null;
}
if (elChartHost) elChartHost.innerHTML = "";
}
function ensureChart() {
if (!elChartHost || !window.LightweightCharts) return;
if (chart) return;
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
chart = LightweightCharts.createChart(elChartHost, {
layout: {
background: { color: isDark ? "#0b0e18" : "#f8f9fc" },
textColor: isDark ? "#9aa4b8" : "#4a5568",
},
grid: {
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
},
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
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;
}
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);
}
async function openTradeChart(tr) {
if (!tr) return;
const exKey = tr.exchange_key || tr.account_exchange_key || "";
const sym = tr.symbol || "";
if (!exKey || !sym) {
setStatus("该笔交易缺少交易所或合约,无法加载图表");
return;
}
selected = { exchange_key: exKey, symbol: sym };
selectedTradeId = String(tr.trade_id || tr.id);
setChartOpen(true);
await loadSymbolTradesForChart(exKey, sym);
await loadChart();
}
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>" +
"</tr></thead><tbody>" +
dailyTrades
.map(function (t) {
const tid = t.trade_id || t.id;
const exKey = t.exchange_key || t.account_exchange_key || "";
const tag = t.behavior_tag || "";
const sick = tag === "sick";
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
const rev = reviewMark(t);
return (
'<tr class="archive-trade-row' +
active +
(sick ? " archive-trade-sick" : "") +
'" data-id="' +
tid +
'" data-ex="' +
esc(exKey) +
'" data-sym="' +
esc(t.symbol || "") +
'">' +
"<td>" +
esc(tradeRowExchange(t)) +
"</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><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 tid = btn.getAttribute("data-id");
const tr = dailyTrades.find(function (t) {
return String(t.trade_id || t.id) === String(tid);
});
if (tr) void openTradeChart(tr);
else if (row) {
selectedTradeId = tid;
renderTrades();
}
});
});
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
sel.addEventListener("change", function () {
saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), sel.value, null);
});
});
elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) {
inp.addEventListener("change", function () {
const row = inp.closest(".archive-trade-row");
const tagSel = row && row.querySelector(".archive-tag-select");
saveOverlay(
inp.getAttribute("data-id"),
inp.getAttribute("data-ex"),
tagSel ? tagSel.value : "",
inp.value
);
});
});
}
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;
}
if (String(selectedTradeId) === String(tradeId)) selectedTradeId = null;
await loadDailyTrades();
setStatus("已移除 1 笔档案记录");
}
async function saveOverlay(tradeId, exchangeKey, tag, note) {
const exKey = exchangeKey || (selected && selected.exchange_key);
if (!exKey) return;
const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
if (note == null) {
const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]');
const inp = row && row.querySelector(".archive-note-input");
body.note = inp ? inp.value : "";
}
await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const tr = dailyTrades.find(function (t) {
return String(t.trade_id || t.id) === String(tradeId);
});
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: {} };
renderStats();
renderTrades();
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 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", loadDailyTrades);
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);
if (elChartSection.open) {
await ensureChartSelection();
void loadChart();
} else {
destroyChart();
}
});
}
if (elQuoteForm) elQuoteForm.addEventListener("submit", addQuote);
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();
bindEvents();
inited = true;
}
await loadMeta();
await loadQuotes();
await loadDailyTrades();
}
function destroy() {
destroyChart();
}
window.hubArchivePage = { init: init, destroy: destroy };
})();