Add win/loss metrics to archive stats with symbol filter sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-21 09:03:21 +08:00
parent 073a382d41
commit c05afbbedf
5 changed files with 174 additions and 36 deletions
+9 -6
View File
@@ -29,17 +29,20 @@
## 区间统计(统计栏) ## 区间统计(统计栏)
基于所选日期区间内 **全部开仓**不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效): 基于当前 **列表筛选结果**盈利/亏损/犯病勾选、合约搜索;交易所下拉仍限定数据源):
| 指标 | 说明 | | 指标 | 说明 |
|------|------| |------|------|
| 总开仓次数 | 区间内开仓笔数 | | 总开仓次数 | 区间内开仓笔数 |
| 盈利单 / 亏损单 | 盈亏 &gt; 0 / &lt; 0 的笔数(持平不计) |
| 平均盈利 / 平均亏损 | 盈利单、亏损单各自的均值(U) |
| 最大盈利 / 最大亏损 | 单笔最大盈利、最大亏损(U) |
| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 | | 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 |
| 盈亏 | 区间内全部已平仓盈亏合计 | | 盈亏 | 区间内全部已平仓盈亏合计 |
| 剔除犯病盈亏 | 排除犯病单后的盈亏合计 | | 剔除犯病盈亏 | 排除犯病单后的盈亏合计 |
| 各交易所 | 每所:开仓、犯病、盈亏、剔除犯病盈亏 | | 各交易所 | 每所同上分项 |
表格列表仍可按盈利单 / 亏损单 / 犯病 / 搜索进一步过滤 在搜索框输入币种(如 `BTC`)后,统计栏与下方列表同步按该条件收窄
## 数据约定 ## 数据约定
@@ -87,10 +90,10 @@
| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` | | `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` |
| `date_from` / `date_to` | 区间模式起止日 | | `date_from` / `date_to` | 区间模式起止日 |
| `exchange_key` | 可选,按交易所筛选 | | `exchange_key` | 可选,按交易所筛选 |
| `filter_profit` / `filter_loss` / `filter_sick` | 过滤表格列表 | | `filter_profit` / `filter_loss` / `filter_sick` | 过滤列表与统计 |
| `search` | 合约 / 交易所 / 备注搜索(仅列表 | | `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`
实例侧: 实例侧:
+64 -26
View File
@@ -1249,46 +1249,86 @@ def resolve_period_bounds(
return start_ms, end_ms, d, d, f"本日 {d}" return start_ms, end_ms, d, d, f"本日 {d}"
def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: def _pnl_side(pnl: float) -> str:
total = len(trade_rows) if pnl > 0.0001:
sick = 0 return "win"
pnl_all = 0.0 if pnl < -0.0001:
pnl_ex = 0.0 return "loss"
by_ex: dict[str, dict[str, Any]] = {} return "flat"
for td_row in trade_rows:
ex = str(td_row.get("exchange_key") or "?")
pnl = float(td_row.get("pnl_amount") or 0) def _empty_pnl_bucket() -> dict[str, Any]:
tag = str(td_row.get("behavior_tag") or "") return {
is_sick = tag == "sick"
if is_sick:
sick += 1
pnl_all += pnl
if not is_sick:
pnl_ex += pnl
if ex not in by_ex:
by_ex[ex] = {
"open_count": 0, "open_count": 0,
"sick_count": 0, "sick_count": 0,
"pnl_total": 0.0, "pnl_total": 0.0,
"pnl_ex_sick": 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,
} }
bucket = by_ex[ex]
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["open_count"] += 1
bucket["pnl_total"] += pnl bucket["pnl_total"] += pnl
if is_sick: if is_sick:
bucket["sick_count"] += 1 bucket["sick_count"] += 1
else: else:
bucket["pnl_ex_sick"] += pnl 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_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"
_accumulate_trade_stat(total_bucket, pnl=pnl, is_sick=is_sick)
if ex not in by_ex:
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: for ex in by_ex:
by_ex[ex]["pnl_total"] = round(by_ex[ex]["pnl_total"], 4) _finalize_pnl_bucket(by_ex[ex])
by_ex[ex]["pnl_ex_sick"] = round(by_ex[ex]["pnl_ex_sick"], 4) 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 sick_pct = round(sick / total * 100, 1) if total else 0.0
return { return {
"open_count": total, "open_count": total,
"sick_count": sick, "sick_count": sick,
"sick_pct": sick_pct, "sick_pct": sick_pct,
"pnl_total": round(pnl_all, 4), "pnl_total": total_bucket["pnl_total"],
"pnl_ex_sick": round(pnl_ex, 4), "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, "by_exchange": by_ex,
} }
@@ -1444,7 +1484,6 @@ def list_daily_trades(
params, params,
).fetchall() ).fetchall()
overlays_by_ex: dict[str, dict[int, dict]] = {} overlays_by_ex: dict[str, dict[int, dict]] = {}
all_rows: list[dict[str, Any]] = []
trades: list[dict[str, Any]] = [] trades: list[dict[str, Any]] = []
q = (search or "").strip().lower() q = (search or "").strip().lower()
for r in rows: for r in rows:
@@ -1452,7 +1491,6 @@ def list_daily_trades(
if ex_k not in overlays_by_ex: if ex_k not in overlays_by_ex:
overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) 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"]))) 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) pnl = float(td_row.get("pnl_amount") or 0)
tag = td_row.get("behavior_tag") or "" tag = td_row.get("behavior_tag") or ""
if filter_profit and pnl <= 0.0001: if filter_profit and pnl <= 0.0001:
@@ -1484,7 +1522,7 @@ def list_daily_trades(
"date_from": df, "date_from": df,
"date_to": dt, "date_to": dt,
"trades": trades, "trades": trades,
"stats": _compute_period_stats(all_rows), "stats": _compute_period_stats(trades),
} }
finally: finally:
conn.close() conn.close()
@@ -48,6 +48,9 @@ def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
lines = [ lines = [
( (
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"平均盈利 {_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"犯病 {int(stats.get('sick_count') or 0)} 笔,"
f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}" f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}"
f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}" f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}"
+24 -1
View File
@@ -422,6 +422,11 @@
if (cur) elExchange.value = cur; if (cur) elExchange.value = cur;
} }
function fmtPnlStatOptional(v) {
if (v == null || v === "") return "—";
return fmtPnlStat(v);
}
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;
@@ -435,6 +440,18 @@
"</td><td>" + "</td><td>" +
openN + openN +
"</td><td>" + "</td><td>" +
(e.win_count || 0) +
"</td><td>" +
(e.loss_count || 0) +
"</td><td>" +
fmtPnlStatOptional(e.avg_win) +
"</td><td>" +
fmtPnlStatOptional(e.avg_loss) +
"</td><td>" +
fmtPnlStatOptional(e.max_win) +
"</td><td>" +
fmtPnlStatOptional(e.max_loss) +
"</td><td>" +
sickN + sickN +
"</td><td>" + "</td><td>" +
sickShare + sickShare +
@@ -461,6 +478,12 @@
sick_pct: st.sick_pct, sick_pct: st.sick_pct,
pnl_total: st.pnl_total, pnl_total: st.pnl_total,
pnl_ex_sick: st.pnl_ex_sick, 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 true
) + ) +
@@ -471,7 +494,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>" +
"</tr></thead><tbody>" + "</tr></thead><tbody>" +
rows + rows +
"</tbody></table>"; "</tbody></table>";
+71
View File
@@ -11,12 +11,15 @@ from zoneinfo import ZoneInfo
from hub_symbol_archive_lib import ( from hub_symbol_archive_lib import (
CHART_DISPLAY_TZ, CHART_DISPLAY_TZ,
_compute_period_stats,
_fill_missing_bars, _fill_missing_bars,
init_db, init_db,
list_daily_trades,
load_symbol_trades, load_symbol_trades,
ms_to_wall_clock_str, ms_to_wall_clock_str,
parse_wall_clock_ms, parse_wall_clock_ms,
resolve_archive_chart, resolve_archive_chart,
trading_day_bounds_ms,
upsert_bars_5m, upsert_bars_5m,
upsert_trade_overlay, upsert_trade_overlay,
list_symbol_rows, list_symbol_rows,
@@ -271,3 +274,71 @@ def test_upsert_forces_sync_exchange_key():
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["exchange_key"] == "gate_bot" assert rows[0]["exchange_key"] == "gate_bot"
assert "account_exchange_key" not in rows[0] 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