Add trade stats board: win rate, hold buckets, by machine/side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-17 22:31:17 +08:00
parent 6b705a60ca
commit ae1b91fcc5
5 changed files with 375 additions and 7 deletions
+190
View File
@@ -304,6 +304,196 @@ def list_trades(
return rows
def _hold_seconds(open_time: Any, close_time: Any, now_ts: Optional[float] = None) -> Optional[float]:
if open_time in (None, ""):
return None
try:
start = float(open_time)
end = float(close_time) if close_time not in (None, "") else float(now_ts or now())
except (TypeError, ValueError):
return None
return max(0.0, end - start)
def _fmt_hold_secs(secs: Optional[float]) -> str:
if secs is None:
return "—"
s = int(secs)
days, rem = divmod(s, 86400)
hours, rem = divmod(rem, 3600)
mins, sec = divmod(rem, 60)
if days > 0:
return f"{days}天{hours}小时{mins}分"
if hours > 0:
return f"{hours}小时{mins}分"
if mins > 0:
return f"{mins}分{sec}秒"
return f"{sec}秒"
def _bucket_key(secs: Optional[float]) -> str:
if secs is None:
return "unknown"
if secs < 300:
return "lt_5m"
if secs < 1800:
return "m5_30"
if secs < 7200:
return "m30_2h"
return "ge_2h"
def _empty_group(label: str) -> dict[str, Any]:
return {
"label": label,
"count": 0,
"wins": 0,
"losses": 0,
"breakeven": 0,
"profit": 0.0,
"hold_sum": 0.0,
"hold_n": 0,
}
def _finalize_group(g: dict[str, Any]) -> dict[str, Any]:
n = g["count"]
decided = g["wins"] + g["losses"]
g["win_rate"] = (g["wins"] / decided * 100.0) if decided else 0.0
g["avg_profit"] = (g["profit"] / n) if n else 0.0
g["avg_hold_secs"] = (g["hold_sum"] / g["hold_n"]) if g["hold_n"] else None
g["avg_hold"] = _fmt_hold_secs(g["avg_hold_secs"])
return g
def _accumulate(g: dict[str, Any], profit: Optional[float], hold: Optional[float]) -> None:
g["count"] += 1
p = float(profit) if profit is not None else 0.0
g["profit"] += p
if profit is not None:
if p > 1e-9:
g["wins"] += 1
elif p < -1e-9:
g["losses"] += 1
else:
g["breakeven"] += 1
if hold is not None:
g["hold_sum"] += hold
g["hold_n"] += 1
def trade_stats(
conn: sqlite3.Connection,
account_id: Optional[int] = None,
machine_id: Optional[int] = None,
) -> dict[str, Any]:
"""程序统计看板:盈亏/胜率/持仓时长分段/按机器与方向。"""
clause, params = _trades_where(account_id, machine_id)
sql = f"""
SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time,
t.volume, t.machine_id, t.entry_style, m.name AS machine_name
FROM trades t
LEFT JOIN machines m ON m.id = t.machine_id
{clause}
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
"""
rows = [row_to_dict(r) for r in conn.execute(sql, tuple(params)).fetchall()]
now_ts = now()
bucket_labels = {
"lt_5m": "< 5 分钟",
"m5_30": "5–30 分钟",
"m30_2h": "30 分钟–2 小时",
"ge_2h": "≥ 2 小时",
"unknown": "时长未知",
}
by_hold = {k: _empty_group(v) for k, v in bucket_labels.items()}
by_machine: dict[str, dict[str, Any]] = {}
by_side: dict[str, dict[str, Any]] = {}
total = len(rows)
closed = 0
open_pos = 0
wins = losses = breakeven = 0
profit_sum = 0.0
gross_win = 0.0
gross_loss = 0.0
hold_sum = 0.0
hold_n = 0
annotated = 0
for t in rows:
profit = t.get("profit")
hold = _hold_seconds(t.get("open_time"), t.get("close_time"), now_ts)
closed_flag = t.get("close_time") not in (None, "")
if closed_flag:
closed += 1
else:
open_pos += 1
if profit is not None:
p = float(profit)
profit_sum += p
if p > 1e-9:
wins += 1
gross_win += p
elif p < -1e-9:
losses += 1
gross_loss += abs(p)
else:
breakeven += 1
if hold is not None:
hold_sum += hold
hold_n += 1
if (t.get("entry_style") or "").strip():
annotated += 1
bk = _bucket_key(hold)
_accumulate(by_hold[bk], profit, hold)
mid = t.get("machine_id")
mkey = str(mid) if mid is not None else "none"
if mkey not in by_machine:
name = t.get("machine_name") or ("未绑定" if mid is None else f"#{mid}")
by_machine[mkey] = _empty_group(name)
_accumulate(by_machine[mkey], profit, hold)
side = (t.get("side") or "—").lower()
if side not in by_side:
by_side[side] = _empty_group("买入" if side == "buy" else ("卖出" if side == "sell" else side))
_accumulate(by_side[side], profit, hold)
decided = wins + losses
avg_hold = (hold_sum / hold_n) if hold_n else None
return {
"total": total,
"closed": closed,
"open_pos": open_pos,
"wins": wins,
"losses": losses,
"breakeven": breakeven,
"win_rate": (wins / decided * 100.0) if decided else 0.0,
"profit": profit_sum,
"avg_profit": (profit_sum / total) if total else 0.0,
"gross_win": gross_win,
"gross_loss": gross_loss,
"profit_factor": (gross_win / gross_loss) if gross_loss > 1e-9 else (None if gross_win <= 0 else 999.0),
"avg_hold_secs": avg_hold,
"avg_hold": _fmt_hold_secs(avg_hold),
"annotated": annotated,
"unannotated": total - annotated,
"by_hold": [_finalize_group(by_hold[k]) for k in ("lt_5m", "m5_30", "m30_2h", "ge_2h", "unknown") if by_hold[k]["count"] or k != "unknown"],
"by_machine": sorted(
[_finalize_group(g) for g in by_machine.values()],
key=lambda x: x["profit"],
reverse=True,
),
"by_side": [_finalize_group(g) for g in by_side.values()],
}
def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
r = conn.execute(
"""