diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index 74ce901..4463c6a 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -29,17 +29,20 @@ ## 区间统计(统计栏) -基于所选日期区间内 **全部开仓**(不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效): +基于当前 **列表筛选结果**(含盈利/亏损/犯病勾选、合约搜索;交易所下拉仍限定数据源): | 指标 | 说明 | |------|------| | 总开仓次数 | 区间内开仓笔数 | +| 盈利单 / 亏损单 | 盈亏 > 0 / < 0 的笔数(持平不计) | +| 平均盈利 / 平均亏损 | 盈利单、亏损单各自的均值(U) | +| 最大盈利 / 最大亏损 | 单笔最大盈利、最大亏损(U) | | 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 | | 盈亏 | 区间内全部已平仓盈亏合计 | | 剔除犯病盈亏 | 排除犯病单后的盈亏合计 | -| 各交易所 | 每所:开仓、犯病、盈亏、剔除犯病盈亏 | +| 各交易所 | 每所同上分项 | -表格列表仍可按盈利单 / 亏损单 / 犯病 / 搜索进一步过滤。 +在搜索框输入币种(如 `BTC`)后,统计栏与下方列表同步按该条件收窄。 ## 数据约定 @@ -87,10 +90,10 @@ | `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` | | `date_from` / `date_to` | 区间模式起止日 | | `exchange_key` | 可选,按交易所筛选 | -| `filter_profit` / `filter_loss` / `filter_sick` | 仅过滤表格列表 | -| `search` | 合约 / 交易所 / 备注搜索(仅列表) | +| `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 | +| `search` | 合约 / 交易所 / 备注搜索(同步过滤列表与统计) | -返回 `stats` 含 `open_count`、`sick_count`、`sick_pct`、`pnl_total`、`pnl_ex_sick`、`by_exchange`。 +返回 `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`。 实例侧: diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 6861b8a..893848f 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -1249,46 +1249,86 @@ def resolve_period_bounds( return start_ms, end_ms, d, d, f"本日 {d}" +def _pnl_side(pnl: float) -> str: + if pnl > 0.0001: + return "win" + if pnl < -0.0001: + return "loss" + return "flat" + + +def _empty_pnl_bucket() -> dict[str, Any]: + return { + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + "win_count": 0, + "loss_count": 0, + "avg_win": None, + "avg_loss": None, + "max_win": None, + "max_loss": None, + } + + +def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None: + wins = bucket.pop("_wins", []) + losses = bucket.pop("_losses", []) + bucket["win_count"] = len(wins) + 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 + 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) + + +def _accumulate_trade_stat(bucket: dict[str, Any], *, pnl: float, is_sick: bool) -> None: + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + if is_sick: + bucket["sick_count"] += 1 + else: + bucket["pnl_ex_sick"] += pnl + side = _pnl_side(pnl) + if side == "win": + bucket.setdefault("_wins", []).append(pnl) + elif side == "loss": + bucket.setdefault("_losses", []).append(pnl) + + def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: - total = len(trade_rows) - sick = 0 - pnl_all = 0.0 - pnl_ex = 0.0 + total_bucket = _empty_pnl_bucket() by_ex: dict[str, dict[str, Any]] = {} for td_row in trade_rows: ex = str(td_row.get("exchange_key") or "?") pnl = float(td_row.get("pnl_amount") or 0) tag = str(td_row.get("behavior_tag") or "") is_sick = tag == "sick" - if is_sick: - sick += 1 - pnl_all += pnl - if not is_sick: - pnl_ex += pnl + _accumulate_trade_stat(total_bucket, pnl=pnl, is_sick=is_sick) if ex not in by_ex: - by_ex[ex] = { - "open_count": 0, - "sick_count": 0, - "pnl_total": 0.0, - "pnl_ex_sick": 0.0, - } - bucket = by_ex[ex] - bucket["open_count"] += 1 - bucket["pnl_total"] += pnl - if is_sick: - bucket["sick_count"] += 1 - else: - bucket["pnl_ex_sick"] += pnl + by_ex[ex] = _empty_pnl_bucket() + _accumulate_trade_stat(by_ex[ex], pnl=pnl, is_sick=is_sick) + _finalize_pnl_bucket(total_bucket) for ex in by_ex: - by_ex[ex]["pnl_total"] = round(by_ex[ex]["pnl_total"], 4) - by_ex[ex]["pnl_ex_sick"] = round(by_ex[ex]["pnl_ex_sick"], 4) + _finalize_pnl_bucket(by_ex[ex]) + total = int(total_bucket["open_count"] or 0) + sick = int(total_bucket["sick_count"] or 0) sick_pct = round(sick / total * 100, 1) if total else 0.0 return { "open_count": total, "sick_count": sick, "sick_pct": sick_pct, - "pnl_total": round(pnl_all, 4), - "pnl_ex_sick": round(pnl_ex, 4), + "pnl_total": total_bucket["pnl_total"], + "pnl_ex_sick": total_bucket["pnl_ex_sick"], + "win_count": total_bucket["win_count"], + "loss_count": total_bucket["loss_count"], + "avg_win": total_bucket["avg_win"], + "avg_loss": total_bucket["avg_loss"], + "max_win": total_bucket["max_win"], + "max_loss": total_bucket["max_loss"], "by_exchange": by_ex, } @@ -1444,7 +1484,6 @@ def list_daily_trades( params, ).fetchall() overlays_by_ex: dict[str, dict[int, dict]] = {} - all_rows: list[dict[str, Any]] = [] trades: list[dict[str, Any]] = [] q = (search or "").strip().lower() for r in rows: @@ -1452,7 +1491,6 @@ def list_daily_trades( if ex_k not in overlays_by_ex: overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) - all_rows.append(td_row) pnl = float(td_row.get("pnl_amount") or 0) tag = td_row.get("behavior_tag") or "" if filter_profit and pnl <= 0.0001: @@ -1484,7 +1522,7 @@ def list_daily_trades( "date_from": df, "date_to": dt, "trades": trades, - "stats": _compute_period_stats(all_rows), + "stats": _compute_period_stats(trades), } finally: conn.close() diff --git a/manual_trading_hub/hub_ai/archive_quote.py b/manual_trading_hub/hub_ai/archive_quote.py index 69131c5..d610844 100644 --- a/manual_trading_hub/hub_ai/archive_quote.py +++ b/manual_trading_hub/hub_ai/archive_quote.py @@ -48,6 +48,9 @@ def format_archive_trades_for_ai(payload: dict[str, Any]) -> str: lines = [ ( 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_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'))}," f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}" diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 41874fe..cf4e241 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -422,6 +422,11 @@ if (cur) elExchange.value = cur; } + function fmtPnlStatOptional(v) { + if (v == null || v === "") return "—"; + return fmtPnlStat(v); + } + function renderStatsRow(label, e, isTotal) { const openN = e.open_count || 0; const sickN = e.sick_count || 0; @@ -435,6 +440,18 @@ "" + openN + "" + + (e.win_count || 0) + + "" + + (e.loss_count || 0) + + "" + + fmtPnlStatOptional(e.avg_win) + + "" + + fmtPnlStatOptional(e.avg_loss) + + "" + + fmtPnlStatOptional(e.max_win) + + "" + + fmtPnlStatOptional(e.max_loss) + + "" + sickN + "" + sickShare + @@ -461,6 +478,12 @@ sick_pct: st.sick_pct, pnl_total: st.pnl_total, pnl_ex_sick: st.pnl_ex_sick, + win_count: st.win_count, + loss_count: st.loss_count, + avg_win: st.avg_win, + avg_loss: st.avg_loss, + max_win: st.max_win, + max_loss: st.max_loss, }, true ) + @@ -471,7 +494,7 @@ .join(""); elStats.innerHTML = '' + - "" + + "" + "" + rows + "
范围开仓犯病犯病占比盈亏剔除犯病盈亏范围开仓盈利单亏损单平均盈利平均亏损最大盈利最大亏损犯病犯病占比盈亏剔除犯病盈亏
"; diff --git a/tests/test_hub_symbol_archive_lib.py b/tests/test_hub_symbol_archive_lib.py index 6ce7516..7546594 100644 --- a/tests/test_hub_symbol_archive_lib.py +++ b/tests/test_hub_symbol_archive_lib.py @@ -11,12 +11,15 @@ from zoneinfo import ZoneInfo from hub_symbol_archive_lib import ( CHART_DISPLAY_TZ, + _compute_period_stats, _fill_missing_bars, init_db, + list_daily_trades, load_symbol_trades, ms_to_wall_clock_str, parse_wall_clock_ms, resolve_archive_chart, + trading_day_bounds_ms, upsert_bars_5m, upsert_trade_overlay, list_symbol_rows, @@ -271,3 +274,71 @@ def test_upsert_forces_sync_exchange_key(): assert len(rows) == 1 assert rows[0]["exchange_key"] == "gate_bot" assert "account_exchange_key" not in rows[0] + + +def test_compute_period_stats_win_loss_metrics(): + rows = [ + {"exchange_key": "binance", "pnl_amount": 10.0, "behavior_tag": ""}, + {"exchange_key": "binance", "pnl_amount": 4.0, "behavior_tag": ""}, + {"exchange_key": "okx", "pnl_amount": -3.0, "behavior_tag": "sick"}, + {"exchange_key": "okx", "pnl_amount": -6.0, "behavior_tag": ""}, + ] + st = _compute_period_stats(rows) + assert st["open_count"] == 4 + assert st["win_count"] == 2 + assert st["loss_count"] == 2 + assert st["avg_win"] == 7.0 + assert st["avg_loss"] == -4.5 + assert st["max_win"] == 10.0 + assert st["max_loss"] == -6.0 + 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 + + +def test_list_daily_trades_search_filters_stats(): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "archive.db" + init_db(db) + day = "2023-11-15" + start_ms, _ = trading_day_bounds_ms(day) + btc_close = start_ms + 3_600_000 + eth_close = start_ms + 7_200_000 + upsert_trades_cache( + "gate", + [ + { + "id": 1, + "symbol": "BTC/USDT", + "result": "止盈", + "pnl_amount": 5.0, + "opened_at_ms": start_ms, + "closed_at_ms": btc_close, + }, + { + "id": 2, + "symbol": "ETH/USDT", + "result": "止损", + "pnl_amount": -2.0, + "opened_at_ms": btc_close, + "closed_at_ms": eth_close, + }, + ], + db_path=db, + ) + payload = list_daily_trades( + period="range", + date_from=day, + date_to=day, + search="btc", + db_path=db, + ) + assert len(payload["trades"]) == 1 + assert payload["trades"][0]["symbol"] == "BTC/USDT" + st = payload["stats"] + assert st["open_count"] == 1 + assert st["win_count"] == 1 + assert st["loss_count"] == 0 + assert st["max_win"] == 5.0 + assert st["pnl_total"] == 5.0