Add win rate and profit-loss ratio to archive stats.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-21 09:13:37 +08:00
parent c05afbbedf
commit c0f3606ecc
5 changed files with 55 additions and 4 deletions
+1 -1
View File
@@ -93,7 +93,7 @@
| `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 | | `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 |
| `search` | 合约 / 交易所 / 备注搜索(同步过滤列表与统计) | | `search` | 合约 / 交易所 / 备注搜索(同步过滤列表与统计) |
返回 `stats``open_count``win_count``loss_count``avg_win``avg_loss``max_win``max_loss``sick_count``sick_pct``pnl_total``pnl_ex_sick``by_exchange` 返回 `stats``open_count``win_count``loss_count``win_rate``avg_win``avg_loss``profit_loss_ratio``max_win``max_loss``sick_count``sick_pct``pnl_total``pnl_ex_sick``by_exchange`
实例侧: 实例侧:
+13 -2
View File
@@ -1275,14 +1275,23 @@ def _empty_pnl_bucket() -> dict[str, Any]:
def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None: def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None:
wins = bucket.pop("_wins", []) wins = bucket.pop("_wins", [])
losses = bucket.pop("_losses", []) losses = bucket.pop("_losses", [])
bucket["win_count"] = len(wins) open_count = int(bucket.get("open_count") or 0)
win_count = len(wins)
bucket["win_count"] = win_count
bucket["loss_count"] = len(losses) bucket["loss_count"] = len(losses)
bucket["avg_win"] = round(sum(wins) / len(wins), 4) if wins else None bucket["avg_win"] = round(sum(wins) / len(wins), 4) if wins else None
bucket["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else None avg_loss = round(sum(losses) / len(losses), 4) if losses else None
bucket["avg_loss"] = avg_loss
bucket["max_win"] = round(max(wins), 4) if wins else None bucket["max_win"] = round(max(wins), 4) if wins else None
bucket["max_loss"] = round(min(losses), 4) if losses else None bucket["max_loss"] = round(min(losses), 4) if losses else None
bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4) bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4)
bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4) bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4)
bucket["win_rate"] = round(win_count / open_count * 100, 1) if open_count else None
avg_win = bucket["avg_win"]
if avg_win is not None and avg_loss is not None and avg_loss != 0:
bucket["profit_loss_ratio"] = round(avg_win / abs(avg_loss), 2)
else:
bucket["profit_loss_ratio"] = None
def _accumulate_trade_stat(bucket: dict[str, Any], *, pnl: float, is_sick: bool) -> None: def _accumulate_trade_stat(bucket: dict[str, Any], *, pnl: float, is_sick: bool) -> None:
@@ -1329,6 +1338,8 @@ def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
"avg_loss": total_bucket["avg_loss"], "avg_loss": total_bucket["avg_loss"],
"max_win": total_bucket["max_win"], "max_win": total_bucket["max_win"],
"max_loss": total_bucket["max_loss"], "max_loss": total_bucket["max_loss"],
"win_rate": total_bucket["win_rate"],
"profit_loss_ratio": total_bucket["profit_loss_ratio"],
"by_exchange": by_ex, "by_exchange": by_ex,
} }
@@ -42,6 +42,22 @@ def _fmt_pnl(v: Any) -> str:
return f"{sign}{n:.2f}U" return f"{sign}{n:.2f}U"
def _fmt_pct(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.1f}%"
def _fmt_rr(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.2f}:1"
def format_archive_trades_for_ai(payload: dict[str, Any]) -> str: def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
trades = payload.get("trades") or [] trades = payload.get("trades") or []
stats = payload.get("stats") or {} stats = payload.get("stats") or {}
@@ -50,6 +66,7 @@ def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
f"统计:开仓 {int(stats.get('open_count') or 0)} 笔," f"统计:开仓 {int(stats.get('open_count') or 0)} 笔,"
f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)}" f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)}"
f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))}" f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))}"
f"胜率 {_fmt_pct(stats.get('win_rate'))},盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))}"
f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))}" f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))}"
f"犯病 {int(stats.get('sick_count') or 0)} 笔," f"犯病 {int(stats.get('sick_count') or 0)} 笔,"
f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}" f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}"
+20 -1
View File
@@ -427,6 +427,19 @@
return fmtPnlStat(v); return fmtPnlStat(v);
} }
function fmtWinRate(v, openN, winN) {
if (v != null && v !== "") return Number(v).toFixed(1) + "%";
if (openN) return (Math.round(((winN || 0) / openN) * 1000) / 10) + "%";
return "—";
}
function fmtProfitLossRatio(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(2) + ":1";
}
function renderStatsRow(label, e, isTotal) { function renderStatsRow(label, e, isTotal) {
const openN = e.open_count || 0; const openN = e.open_count || 0;
const sickN = e.sick_count || 0; const sickN = e.sick_count || 0;
@@ -444,10 +457,14 @@
"</td><td>" + "</td><td>" +
(e.loss_count || 0) + (e.loss_count || 0) +
"</td><td>" + "</td><td>" +
fmtWinRate(e.win_rate, openN, e.win_count) +
"</td><td>" +
fmtPnlStatOptional(e.avg_win) + fmtPnlStatOptional(e.avg_win) +
"</td><td>" + "</td><td>" +
fmtPnlStatOptional(e.avg_loss) + fmtPnlStatOptional(e.avg_loss) +
"</td><td>" + "</td><td>" +
fmtProfitLossRatio(e.profit_loss_ratio) +
"</td><td>" +
fmtPnlStatOptional(e.max_win) + fmtPnlStatOptional(e.max_win) +
"</td><td>" + "</td><td>" +
fmtPnlStatOptional(e.max_loss) + fmtPnlStatOptional(e.max_loss) +
@@ -482,6 +499,8 @@
loss_count: st.loss_count, loss_count: st.loss_count,
avg_win: st.avg_win, avg_win: st.avg_win,
avg_loss: st.avg_loss, avg_loss: st.avg_loss,
win_rate: st.win_rate,
profit_loss_ratio: st.profit_loss_ratio,
max_win: st.max_win, max_win: st.max_win,
max_loss: st.max_loss, max_loss: st.max_loss,
}, },
@@ -494,7 +513,7 @@
.join(""); .join("");
elStats.innerHTML = elStats.innerHTML =
'<table class="archive-stats-table"><thead><tr>' + '<table class="archive-stats-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><th>剔除犯病盈亏</th>" + "<th>范围</th><th>开仓</th><th>盈利单</th><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>" + "</tr></thead><tbody>" +
rows + rows +
"</tbody></table>"; "</tbody></table>";
+4
View File
@@ -291,10 +291,14 @@ def test_compute_period_stats_win_loss_metrics():
assert st["avg_loss"] == -4.5 assert st["avg_loss"] == -4.5
assert st["max_win"] == 10.0 assert st["max_win"] == 10.0
assert st["max_loss"] == -6.0 assert st["max_loss"] == -6.0
assert st["win_rate"] == 50.0
assert st["profit_loss_ratio"] == round(7.0 / 4.5, 2)
assert st["sick_count"] == 1 assert st["sick_count"] == 1
assert st["pnl_total"] == 5.0 assert st["pnl_total"] == 5.0
assert st["pnl_ex_sick"] == 8.0 assert st["pnl_ex_sick"] == 8.0
assert st["by_exchange"]["binance"]["win_count"] == 2 assert st["by_exchange"]["binance"]["win_count"] == 2
assert st["by_exchange"]["binance"]["win_rate"] == 100.0
assert st["by_exchange"]["binance"]["profit_loss_ratio"] is None
def test_list_daily_trades_search_filters_stats(): def test_list_daily_trades_search_filters_stats():