Add win/loss metrics to archive stats with symbol filter sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`。
|
||||
|
||||
实例侧:
|
||||
|
||||
|
||||
+67
-29
@@ -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()
|
||||
|
||||
@@ -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'))}"
|
||||
|
||||
@@ -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 @@
|
||||
"</td><td>" +
|
||||
openN +
|
||||
"</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 +
|
||||
"</td><td>" +
|
||||
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 =
|
||||
'<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>" +
|
||||
rows +
|
||||
"</tbody></table>";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user