diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index dac8dfd..2eb49cc 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -7211,6 +7211,149 @@ body.funds-fullscreen-open { .archive-stats-table tr.archive-stats-total td { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.archive-stats-table .pnl-neg { + color: var(--loss); +} +.archive-stats-viz-section { + margin-top: 10px; +} +.archive-stats-charts { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 4px; +} +.archive-viz-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 14px; + align-items: center; +} +.archive-viz-ring-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.archive-viz-ring { + --win-pct: 0; + width: 68px; + height: 68px; + border-radius: 50%; + background: conic-gradient( + var(--profit) 0 calc(var(--win-pct) * 1%), + var(--loss) calc(var(--win-pct) * 1%) 100% + ); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.archive-viz-ring::before { + content: ""; + position: absolute; + inset: 8px; + border-radius: 50%; + background: var(--inset-surface); +} +.archive-viz-ring-label { + position: relative; + z-index: 1; + font-size: 0.82rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.archive-viz-caption, +.archive-viz-block-title { + font-size: 0.66rem; + color: var(--muted); +} +.archive-viz-block-title { + margin-bottom: 4px; +} +.archive-viz-bars, +.archive-viz-block { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.archive-viz-bar-row { + display: grid; + grid-template-columns: 2.4em 1fr auto; + gap: 6px; + align-items: center; + font-size: 0.72rem; +} +.archive-viz-bar-row .k { + opacity: 0.85; +} +.archive-viz-bar-row .v { + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} +.archive-viz-bar-track { + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} +.archive-viz-bar-fill { + height: 100%; + width: 0; + border-radius: 999px; + transition: width 0.25s ease; +} +.archive-viz-bar-fill--profit { + background: linear-gradient(90deg, #2f9f62, var(--profit)); +} +.archive-viz-bar-fill--loss { + background: linear-gradient(90deg, #c44a4a, var(--loss)); +} +.archive-viz-bar-fill--sick { + background: linear-gradient(90deg, #9b59b6, #c084fc); +} +.archive-viz-empty { + margin: 0; + font-size: 0.72rem; +} +.archive-cum-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.archive-cum-end { + font-size: 0.78rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.archive-cum-chart { + width: 100%; + height: 88px; + display: block; + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); +} +.archive-cum-zero { + stroke: rgba(255, 255, 255, 0.12); + stroke-width: 1; +} +.archive-cum-line { + fill: none; + stroke: #5b8cff; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.archive-calendar-section { + margin-bottom: 10px; +} +.archive-calendar-section .trade-cal-wrap { + margin-top: 8px; +} .archive-stats-table .pnl-pos { color: #22c55e; } diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 9c02588..20e1087 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -20,6 +20,9 @@ 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"); @@ -552,6 +555,11 @@ 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() { @@ -593,6 +601,185 @@ "" + 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 = 360; + const h = 88; + const padX = 12; + const padY = 10; + const vals = series.map(function (s) { return s.cum; }); + const minV = Math.min(0, ...vals); + const maxV = Math.max(0, ...vals); + const range = maxV - minV || 1; + const innerW = w - padX * 2; + const innerH = h - padY * 2; + const zeroY = padY + innerH - ((0 - minV) / range) * innerH; + const pts = series + .map(function (s, i) { + const x = padX + (i / Math.max(series.length - 1, 1)) * innerW; + const y = padY + innerH - ((s.cum - minV) / range) * innerH; + return x.toFixed(1) + "," + y.toFixed(1); + }) + .join(" "); + const last = series[series.length - 1]; + const lastCls = last.cum >= 0 ? "pnl-pos" : "pnl-neg"; + const sign = last.cum > 0 ? "+" : ""; + return ( + '
' + + '累计盈亏' + + '' + sign + last.cum.toFixed(2) + "U" + + "
" + + '" + ); + } + + function barRow(label, valueLabel, pct, fillCls) { + return ( + '
' + + '' + esc(label) + "" + + '
' + + '' + esc(valueLabel) + "" + + "
" + ); + } + + function renderStatsCharts() { + if (!elStatsCharts) return; + const st = dailyStats || { open_count: 0, by_exchange: {} }; + const openN = st.open_count || 0; + const winRate = openN ? Number(st.win_rate) || 0 : 0; + const sides = sumTradePnlSides(dailyTrades); + const pnlTotal = sides.profit + sides.loss; + 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 pnl = Number(st.pnl_total) || 0; + const sign = pnl > 0 ? "+" : ""; + elStatsVizSub.textContent = openN + ? "胜率 " + winRate.toFixed(0) + "% · " + sign + pnl.toFixed(2) + "U" + : "暂无平仓"; + } + + 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; + const pct = (Math.abs(pnl) / maxAbs) * 100; + const cls = pnl >= 0 ? "archive-viz-bar-fill--profit" : "archive-viz-bar-fill--loss"; + const sign = pnl > 0 ? "+" : ""; + return barRow(exchangeLabel(ex), sign + pnl.toFixed(2) + "U", pct, cls); + }) + .join(""); + } else { + exBars = '

暂无分交易所数据

'; + } + + const cumSeries = buildCumulativeSeries(dailyTrades); + elStatsCharts.innerHTML = + '
' + + '
' + + '
' + + '' + (openN ? winRate.toFixed(0) + "%" : "—") + "" + + "
" + + '胜率' + + "
" + + '
' + + (pnlTotal > 0 + ? barRow("盈利", sides.profit.toFixed(2) + "U", (sides.profit / pnlTotal) * 100, "archive-viz-bar-fill--profit") + + barRow("亏损", sides.loss.toFixed(2) + "U", (sides.loss / pnlTotal) * 100, "archive-viz-bar-fill--loss") + : barRow("盈利", "—", 0, "archive-viz-bar-fill--profit") + + barRow("亏损", "—", 0, "archive-viz-bar-fill--loss")) + + barRow("犯病", sickN + " 笔 · " + sickPct.toFixed(1) + "%", sickPct, "archive-viz-bar-fill--sick") + + "
" + + "
" + + '
' + + '
分交易所盈亏
' + + exBars + + "
" + + (holdMax > 0 + ? '
' + + '
持仓时长对比
' + + barRow("盈单", fmtDurationMinutes(winHold), ((winHold || 0) / holdMax) * 100, "archive-viz-bar-fill--profit") + + barRow("亏单", fmtDurationMinutes(lossHold), ((lossHold || 0) / holdMax) * 100, "archive-viz-bar-fill--loss") + + "
" + : "") + + '
' + + renderCumulativeChart(cumSeries) + + "
"; } function quotePreview(text) { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 0628f6f..75b463a 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -470,6 +470,10 @@

统计分析

+
+ 图表概览 +
+
-
-
- - - +
+ 交易日历 +
+
+ + + +
+
-
-
+
K 线图表
@@ -1222,7 +1229,7 @@ - +