From c0f3606ecc56e2cea3fd3f1251840cce476fe1bb Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 21 Jun 2026 09:13:37 +0800 Subject: [PATCH] Add win rate and profit-loss ratio to archive stats. Co-authored-by: Cursor --- docs/hub-symbol-archive-kline.md | 2 +- hub_symbol_archive_lib.py | 15 +++++++++++++-- manual_trading_hub/hub_ai/archive_quote.py | 17 +++++++++++++++++ manual_trading_hub/static/archive.js | 21 ++++++++++++++++++++- tests/test_hub_symbol_archive_lib.py | 4 ++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index 4463c6a..959e021 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -93,7 +93,7 @@ | `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 | | `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`。 实例侧: diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 893848f..2745183 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -1275,14 +1275,23 @@ def _empty_pnl_bucket() -> dict[str, Any]: def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None: wins = bucket.pop("_wins", []) 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["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_loss"] = round(min(losses), 4) if losses else None 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["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: @@ -1329,6 +1338,8 @@ def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: "avg_loss": total_bucket["avg_loss"], "max_win": total_bucket["max_win"], "max_loss": total_bucket["max_loss"], + "win_rate": total_bucket["win_rate"], + "profit_loss_ratio": total_bucket["profit_loss_ratio"], "by_exchange": by_ex, } diff --git a/manual_trading_hub/hub_ai/archive_quote.py b/manual_trading_hub/hub_ai/archive_quote.py index d610844..6dac822 100644 --- a/manual_trading_hub/hub_ai/archive_quote.py +++ b/manual_trading_hub/hub_ai/archive_quote.py @@ -42,6 +42,22 @@ def _fmt_pnl(v: Any) -> str: 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: trades = payload.get("trades") 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('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_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"犯病 {int(stats.get('sick_count') or 0)} 笔," f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}," diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index cf4e241..9a4e690 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -427,6 +427,19 @@ 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) { const openN = e.open_count || 0; const sickN = e.sick_count || 0; @@ -444,10 +457,14 @@ "" + (e.loss_count || 0) + "" + + fmtWinRate(e.win_rate, openN, e.win_count) + + "" + fmtPnlStatOptional(e.avg_win) + "" + fmtPnlStatOptional(e.avg_loss) + "" + + fmtProfitLossRatio(e.profit_loss_ratio) + + "" + fmtPnlStatOptional(e.max_win) + "" + fmtPnlStatOptional(e.max_loss) + @@ -482,6 +499,8 @@ loss_count: st.loss_count, avg_win: st.avg_win, avg_loss: st.avg_loss, + win_rate: st.win_rate, + profit_loss_ratio: st.profit_loss_ratio, max_win: st.max_win, max_loss: st.max_loss, }, @@ -494,7 +513,7 @@ .join(""); elStats.innerHTML = '' + - "" + + "" + "" + rows + "
范围开仓盈利单亏损单平均盈利平均亏损最大盈利最大亏损犯病犯病占比盈亏剔除犯病盈亏范围开仓盈利单亏损单胜率平均盈利平均亏损盈亏比最大盈利最大亏损犯病犯病占比盈亏剔除犯病盈亏
"; diff --git a/tests/test_hub_symbol_archive_lib.py b/tests/test_hub_symbol_archive_lib.py index 7546594..7e71fe4 100644 --- a/tests/test_hub_symbol_archive_lib.py +++ b/tests/test_hub_symbol_archive_lib.py @@ -291,10 +291,14 @@ def test_compute_period_stats_win_loss_metrics(): assert st["avg_loss"] == -4.5 assert st["max_win"] == 10.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["pnl_total"] == 5.0 assert st["pnl_ex_sick"] == 8.0 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():