Add options stats holding time metrics and lightweight charts.

Show average hold duration for wins and losses, open positions, and CSS ring/bar visualizations in the stats tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-10 11:32:45 +08:00
parent 9f255f13dd
commit d1ab2d5172
7 changed files with 455 additions and 88 deletions
+106
View File
@@ -3254,6 +3254,102 @@ html[data-theme="light"] .options-estimate-row {
.options-pos-stats-card {
flex-shrink: 0;
}
.options-stats-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.options-stats-charts {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px 12px;
align-items: center;
}
.opt-stats-chart--ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.opt-stats-ring {
--win-pct: 0;
width: 68px;
height: 68px;
border-radius: 50%;
background: conic-gradient(
#4cd97f 0 calc(var(--win-pct) * 1%),
#ff6b6b calc(var(--win-pct) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.opt-stats-ring::before {
content: "";
position: absolute;
inset: 8px;
border-radius: 50%;
background: #141923;
}
.opt-stats-ring-label {
position: relative;
z-index: 1;
font-size: 0.82rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.opt-stats-chart-caption,
.opt-stats-chart-title {
font-size: 0.66rem;
color: #9aa3bf;
text-align: center;
}
.opt-stats-chart-title {
margin-bottom: 4px;
text-align: left;
}
.opt-stats-chart--pnl,
.opt-stats-chart--hold {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.opt-stats-bar-row {
display: grid;
grid-template-columns: 2.2em 1fr auto;
gap: 6px;
align-items: center;
font-size: 0.72rem;
}
.opt-stats-bar-row .k {
opacity: 0.8;
}
.opt-stats-bar-row .v {
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.opt-stats-bar-track {
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.opt-stats-bar-fill {
height: 100%;
width: 0;
border-radius: 999px;
transition: width 0.25s ease;
}
.opt-stats-bar-fill--profit {
background: linear-gradient(90deg, #2f9f62, #4cd97f);
}
.opt-stats-bar-fill--loss {
background: linear-gradient(90deg, #c44a4a, #ff6b6b);
}
.options-stats-grid {
display: flex;
flex-wrap: wrap;
@@ -3690,6 +3786,16 @@ html[data-theme="light"] .opt-pos-tab.active {
box-shadow: inset 0 0 0 1px rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .opt-stats-ring::before {
background: #f4f7fb;
}
html[data-theme="light"] .opt-stats-bar-track {
background: rgba(20, 34, 50, 0.08);
}
html[data-theme="light"] .opt-stats-chart-caption,
html[data-theme="light"] .opt-stats-chart-title {
color: #5a6a80 !important;
}
html[data-theme="light"] .opt-moneyness-itm {
color: #087a50 !important;
background: rgba(8, 122, 80, 0.12) !important;
+97 -1
View File
@@ -692,10 +692,16 @@
const closedEl = document.getElementById("opt-stats-closed");
const profitEl = document.getElementById("opt-stats-profit");
const lossEl = document.getElementById("opt-stats-loss");
const avgHoldEl = document.getElementById("opt-stats-avg-hold");
const winHoldEl = document.getElementById("opt-stats-win-hold");
const lossHoldEl = document.getElementById("opt-stats-loss-hold");
const openHoldEl = document.getElementById("opt-stats-open-hold");
const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl];
if (!d.ok) {
[winEl, plrEl, closedEl, profitEl, lossEl].forEach(function (el) {
statEls.forEach(function (el) {
if (el) el.textContent = "—";
});
paintStatsCharts(null);
return;
}
if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%";
@@ -711,6 +717,96 @@
lossEl.textContent = d.total_loss != null && d.total_loss > 0
? fmt(d.total_loss, 2) + " USDC" : (d.total_closed ? "0 USDC" : "—");
}
if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec);
if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec);
if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec);
if (openHoldEl) {
const cnt = Number(d.open_count) || 0;
if (!cnt) {
openHoldEl.textContent = "0 笔";
} else {
openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec);
}
}
paintStatsCharts(d);
}
function fmtDuration(sec) {
if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.round(Number(sec)));
if (s < 60) return s + "秒";
const m = Math.floor(s / 60);
if (m < 60) {
const rs = s % 60;
return rs ? m + "分" + rs + "秒" : 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 setBarFill(el, pct) {
if (!el) return;
const n = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = n + "%";
}
function paintStatsCharts(d) {
const ring = document.getElementById("opt-stats-ring");
const ringLabel = document.getElementById("opt-stats-ring-label");
const profitBar = document.getElementById("opt-stats-bar-profit");
const lossBar = document.getElementById("opt-stats-bar-loss");
const profitBarLabel = document.getElementById("opt-stats-bar-profit-label");
const lossBarLabel = document.getElementById("opt-stats-bar-loss-label");
const winHoldBar = document.getElementById("opt-stats-bar-win-hold");
const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold");
const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label");
const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label");
if (!d || !d.ok) {
if (ring) ring.style.setProperty("--win-pct", "0");
if (ringLabel) ringLabel.textContent = "—";
[profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); });
[profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) {
if (el) el.textContent = "—";
});
return;
}
const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0;
if (ring) ring.style.setProperty("--win-pct", String(winRate));
if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%";
const profit = Math.max(0, Number(d.total_profit) || 0);
const loss = Math.max(0, Number(d.total_loss) || 0);
const pnlTotal = profit + loss;
if (pnlTotal > 0) {
setBarFill(profitBar, (profit / pnlTotal) * 100);
setBarFill(lossBar, (loss / pnlTotal) * 100);
if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC";
if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC";
} else {
setBarFill(profitBar, 0);
setBarFill(lossBar, 0);
if (profitBarLabel) profitBarLabel.textContent = d.total_closed ? "0 USDC" : "—";
if (lossBarLabel) lossBarLabel.textContent = d.total_closed ? "0 USDC" : "—";
}
const winHold = Number(d.avg_win_hold_sec) || 0;
const lossHold = Number(d.avg_loss_hold_sec) || 0;
const holdMax = Math.max(winHold, lossHold);
if (holdMax > 0) {
setBarFill(winHoldBar, (winHold / holdMax) * 100);
setBarFill(lossHoldBar, (lossHold / holdMax) * 100);
if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec);
if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec);
} else {
setBarFill(winHoldBar, 0);
setBarFill(lossHoldBar, 0);
if (winHoldBarLabel) winHoldBarLabel.textContent = "—";
if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—";
}
}
async function deleteHistoryRow(id, status) {