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(
"""
+26 -5
View File
@@ -621,21 +621,26 @@ def register_routes(app: Flask) -> None:
bars = database.get_bars(conn, symbol, timeframe, start_ts, end_ts)
return jsonify({"ok": True, "bars": bars})
def _resolve_machine_filter(u: dict, machine_raw: str):
machine_id = int(machine_raw) if machine_raw.isdigit() else None
with database.get_db() as conn:
machines = database.list_machines_for_user(conn, u)
allowed_ids = {int(m["id"]) for m in machines}
if machine_id is not None and machine_id not in allowed_ids:
machine_id = None
return machines, machine_id
@app.get("/records")
@login_required
def records_page():
u = current_user()
machine_raw = (request.args.get("machine_id") or "").strip()
machine_id = int(machine_raw) if machine_raw.isdigit() else None
page_raw = (request.args.get("page") or "1").strip()
page = int(page_raw) if page_raw.isdigit() and int(page_raw) >= 1 else 1
per_page = 10
account_id = None if u["role"] == "admin" else u.get("account_id")
machines, machine_id = _resolve_machine_filter(u, machine_raw)
with database.get_db() as conn:
machines = database.list_machines_for_user(conn, u)
allowed_ids = {int(m["id"]) for m in machines}
if machine_id is not None and machine_id not in allowed_ids:
machine_id = None
total = database.count_trades(conn, account_id=account_id, machine_id=machine_id)
total_pages = max(1, (total + per_page - 1) // per_page)
if page > total_pages:
@@ -661,6 +666,22 @@ def register_routes(app: Flask) -> None:
total_pages=total_pages,
)
@app.get("/records/stats")
@login_required
def records_stats_page():
u = current_user()
machine_raw = (request.args.get("machine_id") or "").strip()
account_id = None if u["role"] == "admin" else u.get("account_id")
machines, machine_id = _resolve_machine_filter(u, machine_raw)
with database.get_db() as conn:
stats = database.trade_stats(conn, account_id=account_id, machine_id=machine_id)
return render_template(
"records_stats.html",
stats=stats,
machines=machines,
selected_machine_id=machine_id,
)
@app.get("/records/<int:tid>")
@login_required
def record_detail(tid: int):
+2 -1
View File
@@ -12,7 +12,8 @@
<div class="brand">{{ app_name }}</div>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('records_page') }}" class="{% if request.endpoint and request.endpoint.startswith('record') %}active{% endif %}">交易复盘</a>
<a href="{{ url_for('records_page') }}" class="{% if request.endpoint in ('records_page', 'record_detail', 'record_annotate') %}active{% endif %}">交易复盘</a>
<a href="{{ url_for('records_stats_page') }}" class="{% if request.endpoint=='records_stats_page' %}active{% endif %}">统计看板</a>
<a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a>
{% if is_admin %}
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">账户</a>
+6 -1
View File
@@ -2,7 +2,12 @@
{% block title %}交易复盘 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 6px">交易记录与复盘</h2>
<p class="muted" style="margin:0 0 14px">按机器筛选 · 每页 {{ per_page }} 条 · 时间均为北京时间</p>
<p class="muted" style="margin:0 0 10px">按机器筛选 · 每页 {{ per_page }} 条 · 时间均为北京时间</p>
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">
<a class="btn" href="{{ url_for('records_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_page') }}">交易列表</a>
<a class="btn secondary" href="{{ url_for('records_stats_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_stats_page') }}">统计看板</a>
</div>
<div class="card" style="margin-bottom:14px">
<form method="get" action="{{ url_for('records_page') }}" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}统计看板 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 6px">统计看板</h2>
<p class="muted" style="margin:0 0 10px">程序汇总成交数据;入场形式分类确定后再叠加维度。</p>
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">
<a class="btn secondary" href="{{ url_for('records_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_page') }}">交易列表</a>
<a class="btn" href="{{ url_for('records_stats_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_stats_page') }}">统计看板</a>
</div>
<div class="card" style="margin-bottom:14px">
<form method="get" action="{{ url_for('records_stats_page') }}" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="muted" for="machine_id" style="margin:0">机器</label>
<select name="machine_id" id="machine_id" style="min-width:200px;width:auto">
<option value="">全部机器</option>
{% for m in machines %}
<option value="{{ m.id }}" {% if selected_machine_id == m.id %}selected{% endif %}>
{{ m.name }} #{{ m.id }}{% if m.online %} · 在线{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn">筛选</button>
{% if selected_machine_id %}
<a class="btn secondary" href="{{ url_for('records_stats_page') }}">清除</a>
{% endif %}
</form>
</div>
<div class="grid cols-3">
<div class="card">
<div class="muted">成交笔数</div>
<div class="stat">{{ stats.total }}</div>
<div class="muted" style="margin-top:6px">已平 {{ stats.closed }} · 持仓中 {{ stats.open_pos }}</div>
</div>
<div class="card">
<div class="muted">胜率</div>
<div class="stat">{{ '%.1f'|format(stats.win_rate) }}%</div>
<div class="muted" style="margin-top:6px">盈 {{ stats.wins }} · 亏 {{ stats.losses }} · 平 {{ stats.breakeven }}</div>
</div>
<div class="card">
<div class="muted">合计盈亏</div>
<div class="stat" style="color:{% if stats.profit>0 %}var(--green){% elif stats.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ '%.2f'|format(stats.profit) }}
</div>
<div class="muted" style="margin-top:6px">笔均 {{ '%.2f'|format(stats.avg_profit) }}</div>
</div>
</div>
<div class="grid cols-3" style="margin-top:14px">
<div class="card">
<div class="muted">平均持仓时间</div>
<div class="stat" style="font-size:22px;color:var(--accent)">{{ stats.avg_hold }}</div>
<div class="muted" style="margin-top:6px">有开仓时间的样本参与计算</div>
</div>
<div class="card">
<div class="muted">盈亏因子</div>
<div class="stat" style="font-size:22px">
{% if stats.profit_factor is none %}—
{% elif stats.profit_factor >= 999 %}∞
{% else %}{{ '%.2f'|format(stats.profit_factor) }}{% endif %}
</div>
<div class="muted" style="margin-top:6px">毛利 {{ '%.2f'|format(stats.gross_win) }} / 毛亏 {{ '%.2f'|format(stats.gross_loss) }}</div>
</div>
<div class="card">
<div class="muted">入场形式标注</div>
<div class="stat" style="font-size:22px">{{ stats.annotated }} <span class="muted" style="font-size:14px">/ {{ stats.total }}</span></div>
<div class="muted" style="margin-top:6px">未标注 {{ stats.unannotated }}(分类确定后再细拆)</div>
</div>
</div>
<div class="grid cols-2" style="margin-top:14px">
<div class="card">
<h3>按持仓时间</h3>
<table>
<thead>
<tr>
<th>区间</th><th>笔数</th><th>胜率</th><th>盈亏</th><th>平均持仓</th>
</tr>
</thead>
<tbody>
{% for g in stats.by_hold %}
<tr>
<td>{{ g.label }}</td>
<td>{{ g.count }}</td>
<td>{{ '%.1f'|format(g.win_rate) }}%</td>
<td style="color:{% if g.profit>0 %}var(--green){% elif g.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ '%.2f'|format(g.profit) }}
</td>
<td class="muted">{{ g.avg_hold }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>按方向</h3>
<table>
<thead>
<tr>
<th>方向</th><th>笔数</th><th>胜率</th><th>盈亏</th><th>平均持仓</th>
</tr>
</thead>
<tbody>
{% for g in stats.by_side %}
<tr>
<td>{{ g.label }}</td>
<td>{{ g.count }}</td>
<td>{{ '%.1f'|format(g.win_rate) }}%</td>
<td style="color:{% if g.profit>0 %}var(--green){% elif g.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ '%.2f'|format(g.profit) }}
</td>
<td class="muted">{{ g.avg_hold }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>按机器</h3>
<table>
<thead>
<tr>
<th>机器</th><th>笔数</th><th>胜率</th><th>盈亏</th><th>笔均</th><th>平均持仓</th>
</tr>
</thead>
<tbody>
{% for g in stats.by_machine %}
<tr>
<td>{{ g.label }}</td>
<td>{{ g.count }}</td>
<td>{{ '%.1f'|format(g.win_rate) }}%</td>
<td style="color:{% if g.profit>0 %}var(--green){% elif g.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ '%.2f'|format(g.profit) }}
</td>
<td>{{ '%.2f'|format(g.avg_profit) }}</td>
<td class="muted">{{ g.avg_hold }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}