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
+2 -32
View File
@@ -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]:
+2 -36
View File
@@ -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/<int:trade_id>", methods=["DELETE"])
@lr
+103
View File
@@ -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),
}
+78 -19
View File
@@ -93,26 +93,85 @@
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
<div class="options-stats-grid">
<div class="options-stat-item">
<span class="k">胜率</span>
<span class="v" id="opt-stats-winrate"></span>
<div class="options-stats-panel">
<div class="options-stats-charts">
<div class="opt-stats-chart opt-stats-chart--ring">
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
<span class="opt-stats-ring-label" id="opt-stats-ring-label"></span>
</div>
<span class="opt-stats-chart-caption">胜率</span>
</div>
<div class="opt-stats-chart opt-stats-chart--pnl">
<div class="opt-stats-bar-row">
<span class="k">盈利</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
</div>
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label"></span>
</div>
<div class="opt-stats-bar-row">
<span class="k">亏损</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
</div>
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label"></span>
</div>
</div>
</div>
<div class="options-stat-item">
<span class="k">盈亏</span>
<span class="v" id="opt-stats-plr"></span>
<div class="opt-stats-chart opt-stats-chart--hold">
<div class="opt-stats-chart-title">持仓时长对</div>
<div class="opt-stats-bar-row">
<span class="k">盈单</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
</div>
<span class="v" id="opt-stats-win-hold-label"></span>
</div>
<div class="opt-stats-bar-row">
<span class="k">亏单</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
</div>
<span class="v" id="opt-stats-loss-hold-label"></span>
</div>
</div>
<div class="options-stat-item">
<span class="k">已平笔数</span>
<span class="v" id="opt-stats-closed"></span>
</div>
<div class="options-stat-item">
<span class="k">盈利</span>
<span class="v pos-pnl-profit" id="opt-stats-profit"></span>
</div>
<div class="options-stat-item">
<span class="k">亏损</span>
<span class="v pos-pnl-loss" id="opt-stats-loss"></span>
<div class="options-stats-grid">
<div class="options-stat-item">
<span class="k">胜率</span>
<span class="v" id="opt-stats-winrate"></span>
</div>
<div class="options-stat-item">
<span class="k">盈亏比</span>
<span class="v" id="opt-stats-plr"></span>
</div>
<div class="options-stat-item">
<span class="k">已平笔数</span>
<span class="v" id="opt-stats-closed"></span>
</div>
<div class="options-stat-item">
<span class="k">盈利</span>
<span class="v pos-pnl-profit" id="opt-stats-profit"></span>
</div>
<div class="options-stat-item">
<span class="k">亏损</span>
<span class="v pos-pnl-loss" id="opt-stats-loss"></span>
</div>
<div class="options-stat-item">
<span class="k">均持仓</span>
<span class="v" id="opt-stats-avg-hold"></span>
</div>
<div class="options-stat-item">
<span class="k">盈单持仓</span>
<span class="v" id="opt-stats-win-hold"></span>
</div>
<div class="options-stat-item">
<span class="k">亏单持仓</span>
<span class="v" id="opt-stats-loss-hold"></span>
</div>
<div class="options-stat-item">
<span class="k">持仓中</span>
<span class="v" id="opt-stats-open-hold"></span>
</div>
</div>
</div>
</div>
@@ -141,4 +200,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=19"></script>
<script src="/static/options_panel.js?v=20"></script>