Add trade stats board: win rate, hold buckets, by machine/side.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
"""
|
||||
|
||||
+30
-9
@@ -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})
|
||||
|
||||
@app.get("/records")
|
||||
@login_required
|
||||
def records_page():
|
||||
u = current_user()
|
||||
machine_raw = (request.args.get("machine_id") or "").strip()
|
||||
def _resolve_machine_filter(u: dict, machine_raw: str):
|
||||
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")
|
||||
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()
|
||||
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:
|
||||
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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user