diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 95c4f41..d0ce557 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -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; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 241353a..a4f264e 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -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) { diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index adb1f0d..6e6675f 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -4,41 +4,11 @@ from __future__ import annotations from typing import Any -from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages -from lib.options.options_db import init_options_tables +from lib.options.options_stats_lib import compute_options_stats def _compute_options_stats(get_db) -> dict[str, Any]: - conn = get_db() - try: - init_options_tables(conn) - rows = conn.execute( - """ - SELECT realized_pnl FROM options_trades - WHERE status = 'closed' AND realized_pnl IS NOT NULL - """ - ).fetchall() - finally: - conn.close() - wins: list[float] = [] - losses: list[float] = [] - for row in rows: - pnl = float(row["realized_pnl"]) - if pnl > 0: - wins.append(pnl) - elif pnl < 0: - losses.append(pnl) - total_closed = len(wins) + len(losses) - win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 - avg_win = sum(wins) / len(wins) if wins else None - avg_loss = sum(losses) / len(losses) if losses else None - return { - "total_closed": total_closed, - "win_rate": win_rate, - "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), - "total_profit": round(sum(wins), 4) if wins else 0.0, - "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, - } + return compute_options_stats(get_db) def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: diff --git a/lib/options/options_register.py b/lib/options/options_register.py index cc2939e..c2cea17 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -666,43 +666,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) - from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages + from lib.options.options_stats_lib import compute_options_stats - conn = cfg["get_db"]() - try: - init_options_tables(conn) - rows = conn.execute( - """ - SELECT realized_pnl FROM options_trades - WHERE status = 'closed' AND realized_pnl IS NOT NULL - """ - ).fetchall() - finally: - conn.close() - wins: list[float] = [] - losses: list[float] = [] - for row in rows: - pnl = float(row["realized_pnl"]) - if pnl > 0: - wins.append(pnl) - elif pnl < 0: - losses.append(pnl) - total_closed = len(wins) + len(losses) - win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 - avg_win = sum(wins) / len(wins) if wins else None - avg_loss = sum(losses) / len(losses) if losses else None - total_profit = round(sum(wins), 4) if wins else 0.0 - total_loss = round(abs(sum(losses)), 4) if losses else 0.0 - return jsonify( - { - "ok": True, - "total_closed": total_closed, - "win_rate": win_rate, - "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), - "total_profit": total_profit, - "total_loss": total_loss, - } - ) + return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])}) @app.route("/api/options/history/", methods=["DELETE"]) @lr diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py new file mode 100644 index 0000000..fd81e9c --- /dev/null +++ b/lib/options/options_stats_lib.py @@ -0,0 +1,103 @@ +"""期权本地交易统计(胜率 / 盈亏 / 持仓时长).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages +from lib.options.options_db import init_options_tables + + +def _parse_ts(raw: Any) -> datetime | None: + if raw is None or raw == "": + return None + s = str(raw).strip().replace(" ", "T", 1) + try: + return datetime.fromisoformat(s) + except (TypeError, ValueError): + return None + + +def _hold_seconds(created_at: Any, closed_at: Any) -> float | None: + start = _parse_ts(created_at) + end = _parse_ts(closed_at) + if start is None or end is None: + return None + sec = (end - start).total_seconds() + return sec if sec >= 0 else None + + +def _avg_seconds(values: list[float]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 1) + + +def compute_options_stats(get_db) -> dict[str, Any]: + conn = get_db() + try: + init_options_tables(conn) + closed_rows = conn.execute( + """ + SELECT realized_pnl, created_at, closed_at + FROM options_trades + WHERE status = 'closed' AND realized_pnl IS NOT NULL + """ + ).fetchall() + open_rows = conn.execute( + """ + SELECT created_at FROM options_trades WHERE status = 'open' + """ + ).fetchall() + finally: + conn.close() + + wins: list[float] = [] + losses: list[float] = [] + win_holds: list[float] = [] + loss_holds: list[float] = [] + all_holds: list[float] = [] + now = datetime.now() + + for row in closed_rows: + pnl = float(row["realized_pnl"]) + hold = _hold_seconds(row["created_at"], row["closed_at"]) + if hold is not None: + all_holds.append(hold) + if pnl > 0: + wins.append(pnl) + if hold is not None: + win_holds.append(hold) + elif pnl < 0: + losses.append(pnl) + if hold is not None: + loss_holds.append(hold) + + open_holds: list[float] = [] + for row in open_rows: + start = _parse_ts(row["created_at"]) + if start is None: + continue + sec = (now - start).total_seconds() + if sec >= 0: + open_holds.append(sec) + + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + + return { + "total_closed": total_closed, + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "total_profit": round(sum(wins), 4) if wins else 0.0, + "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, + "avg_hold_sec": _avg_seconds(all_holds), + "avg_win_hold_sec": _avg_seconds(win_holds), + "avg_loss_hold_sec": _avg_seconds(loss_holds), + "open_count": len(open_holds), + "avg_open_hold_sec": _avg_seconds(open_holds), + } diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 20f2998..2fcbcc1 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -93,26 +93,85 @@