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` 的笔数及占开仓比例 |
|
| 犯病次数 / 占比 | `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
@@ -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'))}"
|
||||||
|
|||||||
@@ -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>";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user