重构统计分析页:汇总指标、分项下拉与后台缓存

新增 stats_engine 与 stats_cache,提供 API 自动加载 8 种统计维度;交易与复盘变更时自动刷新缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 16:46:06 +08:00
parent 0e385b057d
commit e8b4dbbaca
4 changed files with 581 additions and 179 deletions
+51 -68
View File
@@ -29,6 +29,7 @@ from fee_specs import (
)
from fee_sync import sync_fees_from_akshare
from contract_profile import get_contract_profile
from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache
from kline_chart import generate_review_kline_chart
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
@@ -174,6 +175,26 @@ def set_setting(key: str, value: str):
conn.close()
def touch_stats_cache():
try:
conn = get_db()
capital = float(get_setting("live_capital", "0") or 0)
refresh_stats_cache(conn, capital)
conn.close()
except Exception as exc:
app.logger.warning("stats cache refresh failed: %s", exc)
def get_stats_data() -> dict:
conn = get_db()
capital = float(get_setting("live_capital", "0") or 0)
data = load_stats_cache(conn)
if not data:
data = refresh_stats_cache(conn, capital)
conn.close()
return data
def init_db():
conn = get_db()
c = conn.cursor()
@@ -278,6 +299,10 @@ def init_db():
close_today_fixed REAL DEFAULT 0,
close_today_ratio REAL DEFAULT 0,
updated_at TEXT)''')
c.execute('''CREATE TABLE IF NOT EXISTS stats_cache
(key TEXT PRIMARY KEY,
data_json TEXT NOT NULL,
updated_at TEXT NOT NULL)''')
conn.commit()
conn.close()
@@ -904,6 +929,7 @@ def close_position(pid):
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
conn.commit()
conn.close()
touch_stats_cache()
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
return redirect(url_for("positions"))
@@ -946,6 +972,7 @@ def update_trade(tid):
)
conn.commit()
conn.close()
touch_stats_cache()
flash("交易记录已核对保存")
return redirect(url_for("records"))
@@ -957,6 +984,7 @@ def del_trade(tid):
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@@ -1179,6 +1207,7 @@ def add_review():
)
conn.commit()
conn.close()
touch_stats_cache()
flash("复盘记录已保存")
return redirect(url_for("records"))
@@ -1195,6 +1224,7 @@ def del_review(rid):
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@@ -1220,76 +1250,29 @@ def del_record(rid):
@app.route("/stats")
@login_required
def stats():
return render_template("stats.html")
@app.route("/api/stats")
@login_required
def api_stats():
return jsonify(get_stats_data())
@app.route("/api/stats/views")
@login_required
def api_stats_views():
return jsonify({"views": STATS_VIEWS})
@app.route("/api/stats/refresh", methods=["POST"])
@login_required
def api_stats_refresh():
conn = get_db()
total = conn.execute(
"SELECT COUNT(*) FROM trade_records WHERE result IN ('止盈','止损')"
).fetchone()[0]
win = conn.execute(
"SELECT COUNT(*) FROM trade_records WHERE result='止盈'"
).fetchone()[0]
loss = conn.execute(
"SELECT COUNT(*) FROM trade_records WHERE result='止损'"
).fetchone()[0]
rate = round(win / total * 100, 2) if total else 0
by_symbol = conn.execute(
"""SELECT symbol_name, symbol, COUNT(*) as cnt,
SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins
FROM trade_records WHERE result IN ('止盈','止损')
GROUP BY symbol ORDER BY cnt DESC"""
).fetchall()
by_type = conn.execute(
"""SELECT monitor_type, COUNT(*) as cnt,
SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins
FROM trade_records WHERE result IN ('止盈','止损')
GROUP BY monitor_type ORDER BY cnt DESC"""
).fetchall()
by_direction = conn.execute(
"""SELECT direction, COUNT(*) as cnt,
SUM(CASE WHEN result='止盈' THEN 1 ELSE 0 END) as wins
FROM trade_records WHERE result IN ('止盈','止损')
GROUP BY direction"""
).fetchall()
recent = conn.execute(
"SELECT * FROM trade_logs ORDER BY id DESC LIMIT 10"
).fetchall()
fee_trade = conn.execute(
"SELECT COALESCE(SUM(fee),0), COALESCE(SUM(pnl),0), COALESCE(SUM(pnl_net),0), COUNT(*) "
"FROM trade_logs WHERE fee IS NOT NULL"
).fetchone()
fee_review = conn.execute(
"SELECT COALESCE(SUM(fee),0), COALESCE(SUM(pnl),0), COALESCE(SUM(pnl_net),0), COUNT(*) "
"FROM review_records WHERE fee IS NOT NULL"
).fetchone()
total_fee = round((fee_trade[0] or 0) + (fee_review[0] or 0), 2)
total_gross = round((fee_trade[1] or 0) + (fee_review[1] or 0), 2)
total_net = round((fee_trade[2] or 0) + (fee_review[2] or 0), 2)
fee_count = (fee_trade[3] or 0) + (fee_review[3] or 0)
fee_by_symbol = conn.execute(
"""SELECT symbol_name, symbol,
COALESCE(SUM(fee),0) as total_fee,
COUNT(*) as cnt
FROM trade_logs WHERE fee IS NOT NULL
GROUP BY symbol ORDER BY total_fee DESC LIMIT 20"""
).fetchall()
capital = float(get_setting("live_capital", "0") or 0)
data = refresh_stats_cache(conn, capital)
conn.close()
return render_template(
"stats.html",
total=total, win=win, loss=loss, rate=rate,
by_symbol=by_symbol, by_type=by_type, by_direction=by_direction,
recent=recent,
total_fee=total_fee,
total_gross=total_gross,
total_net=total_net,
fee_count=fee_count,
fee_by_symbol=fee_by_symbol,
)
return jsonify(data)
@app.route("/contract")
+173
View File
@@ -0,0 +1,173 @@
(function () {
var cache = null;
function fmtNum(v, suffix) {
if (v === null || v === undefined || v === '') return '-';
var n = Number(v);
if (isNaN(n)) return String(v);
var s = Number.isInteger(n) ? String(n) : n.toFixed(2);
return suffix ? s + suffix : s;
}
function fmtMoney(v) {
if (v === null || v === undefined) return '-';
return fmtNum(v) + ' 元';
}
function fmtPct(v) {
if (v === null || v === undefined) return '-';
return fmtNum(v) + '%';
}
function setSummary(s) {
var map = {
total_trades: function () { return fmtNum(s.total_trades); },
win_rate: function () { return fmtPct(s.win_rate); },
avg_profit: function () { return fmtMoney(s.avg_profit); },
avg_loss: function () { return fmtMoney(s.avg_loss); },
profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); },
consecutive_losses: function () { return fmtNum(s.consecutive_losses); },
max_drawdown: function () {
var amt = fmtMoney(s.max_drawdown);
var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : '';
return amt + pct;
},
max_loss_amount: function () { return fmtMoney(s.max_loss_amount); },
max_loss_pct: function () { return fmtPct(s.max_loss_pct); },
max_profit_amount: function () { return fmtMoney(s.max_profit_amount); },
max_profit_pct: function () { return fmtPct(s.max_profit_pct); },
total_fee: function () { return fmtMoney(s.total_fee); },
emotion_count: function () { return fmtNum(s.emotion_count); },
emotion_ratio: function () { return fmtPct(s.emotion_ratio); },
};
document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) {
var key = el.getAttribute('data-k');
el.textContent = map[key] ? map[key]() : '-';
});
}
function fillViewSelect(views, selected) {
var sel = document.getElementById('stats-view-select');
if (!sel) return;
sel.innerHTML = '';
views.forEach(function (v) {
var opt = document.createElement('option');
opt.value = v.key;
opt.textContent = v.label;
if (v.key === selected) opt.selected = true;
sel.appendChild(opt);
});
}
function cellClass(key, val) {
if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') {
if (val > 0) return 'text-profit';
if (val < 0) return 'text-loss';
}
if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') {
return 'text-loss';
}
return '';
}
function renderBreakdown(key) {
if (!cache || !cache.breakdowns) return;
var block = cache.breakdowns[key];
var head = document.getElementById('stats-breakdown-head');
var body = document.getElementById('stats-breakdown-body');
if (!block || !head || !body) return;
head.innerHTML = '';
block.columns.forEach(function (col) {
var th = document.createElement('th');
th.textContent = col.label;
head.appendChild(th);
});
body.innerHTML = '';
if (!block.rows || !block.rows.length) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = block.columns.length;
td.className = 'text-muted';
td.textContent = '暂无数据';
tr.appendChild(td);
body.appendChild(tr);
return;
}
block.rows.forEach(function (row) {
var tr = document.createElement('tr');
block.columns.forEach(function (col) {
var td = document.createElement('td');
var val = row[col.key];
if (col.key === 'win_rate') {
td.textContent = fmtPct(val);
} else if (col.key === 'label') {
td.textContent = val || '-';
} else if (typeof val === 'number') {
td.textContent = fmtNum(val);
td.className = cellClass(col.key, val);
} else {
td.textContent = val != null ? val : '-';
}
tr.appendChild(td);
});
body.appendChild(tr);
});
}
function applyData(data) {
cache = data;
setSummary(data.summary || {});
var views = data.views || [];
var sel = document.getElementById('stats-view-select');
var current = sel && sel.value ? sel.value : (views[0] && views[0].key);
fillViewSelect(views, current);
renderBreakdown(current);
var updated = document.getElementById('stats-updated');
if (updated) {
updated.textContent = data.updated_at
? '统计更新于 ' + data.updated_at.replace('T', ' ')
: '统计已加载';
}
}
function loadStats() {
fetch('/api/stats')
.then(function (r) { return r.json(); })
.then(applyData)
.catch(function () {
var updated = document.getElementById('stats-updated');
if (updated) updated.textContent = '加载失败,请刷新页面';
});
}
document.addEventListener('DOMContentLoaded', function () {
var viewSel = document.getElementById('stats-view-select');
var refreshBtn = document.getElementById('stats-refresh-btn');
if (viewSel) {
viewSel.addEventListener('change', function () {
renderBreakdown(this.value);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
var btn = this;
btn.disabled = true;
btn.textContent = '计算中…';
fetch('/api/stats/refresh', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(applyData)
.catch(function () {
alert('重新计算失败');
})
.finally(function () {
btn.disabled = false;
btn.textContent = '重新计算';
});
});
}
loadStats();
});
})();
+310
View File
@@ -0,0 +1,310 @@
"""交易统计计算与缓存结构。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
STATS_VIEWS = [
{"key": "by_time", "label": "按时间统计"},
{"key": "by_week", "label": "周统计"},
{"key": "by_month", "label": "月统计"},
{"key": "by_symbol", "label": "按品种统计"},
{"key": "by_fee", "label": "按手续费统计"},
{"key": "by_direction", "label": "按方向统计"},
{"key": "by_trade_type", "label": "按交易类型统计"},
{"key": "by_emotion", "label": "情绪单统计"},
]
BREAKDOWN_COLUMNS = [
{"key": "label", "label": "维度"},
{"key": "count", "label": "交易次数"},
{"key": "wins", "label": "盈利笔数"},
{"key": "losses", "label": "亏损笔数"},
{"key": "win_rate", "label": "胜率(%)"},
{"key": "avg_profit", "label": "平均盈利"},
{"key": "avg_loss", "label": "平均亏损"},
{"key": "profit_loss_ratio", "label": "盈亏比"},
{"key": "total_fee", "label": "累计手续费"},
{"key": "total_net", "label": "净盈亏合计"},
{"key": "max_loss", "label": "最大亏损"},
{"key": "max_profit", "label": "最大盈利"},
]
def _parse_dt(value: str) -> Optional[datetime]:
if not value:
return None
text = value.strip().replace(" ", "T")
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _row_dict(row) -> dict:
return dict(row) if row is not None else {}
def _net_pnl(row: dict) -> float:
if row.get("pnl_net") is not None:
return float(row["pnl_net"])
pnl = float(row.get("pnl") or 0)
fee = float(row.get("fee") or 0)
return round(pnl - fee, 2)
def _fee(row: dict) -> float:
return float(row.get("fee") or 0)
def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]:
if margin and margin > 0:
return round(pnl_net / margin * 100, 2)
return None
def _agg_group(rows: list[dict], key_fn) -> list[dict]:
groups: dict[str, list[dict]] = {}
for row in rows:
key = key_fn(row) or "未知"
groups.setdefault(key, []).append(row)
result = []
for label, items in sorted(groups.items(), key=lambda x: x[0]):
result.append(_agg_metrics(label, items))
return result
def _agg_metrics(label: str, items: list[dict]) -> dict:
nets = [_net_pnl(r) for r in items]
wins = [n for n in nets if n > 0]
losses = [n for n in nets if n < 0]
count = len(items)
win_cnt = len(wins)
loss_cnt = len(losses)
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
total_fee = round(sum(_fee(r) for r in items), 2)
total_net = round(sum(nets), 2)
max_loss = round(min(nets), 2) if nets else 0.0
max_profit = round(max(nets), 2) if nets else 0.0
win_rate = round(win_cnt / count * 100, 2) if count else 0.0
return {
"label": label,
"count": count,
"wins": win_cnt,
"losses": loss_cnt,
"win_rate": win_rate,
"avg_profit": avg_profit,
"avg_loss": avg_loss,
"profit_loss_ratio": pl_ratio,
"total_fee": total_fee,
"total_net": total_net,
"max_loss": max_loss,
"max_profit": max_profit,
}
def _max_consecutive_losses(nets: list[float]) -> int:
streak = 0
best = 0
for n in nets:
if n < 0:
streak += 1
best = max(best, streak)
else:
streak = 0
return best
def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]:
equity = initial_capital
peak = initial_capital
max_dd = 0.0
max_dd_pct = 0.0
for n in nets:
equity += n
if equity > peak:
peak = equity
dd = peak - equity
if dd > max_dd:
max_dd = dd
if peak > 0:
pct = dd / peak * 100
if pct > max_dd_pct:
max_dd_pct = pct
return round(max_dd, 2), round(max_dd_pct, 2)
def fetch_trade_rows(conn) -> list[dict]:
rows = conn.execute(
"SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC"
).fetchall()
return [_row_dict(r) for r in rows]
def fetch_review_rows(conn) -> list[dict]:
rows = conn.execute(
"SELECT * FROM review_records ORDER BY close_time ASC, id ASC"
).fetchall()
return [_row_dict(r) for r in rows]
def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict:
nets = [_net_pnl(t) for t in trades]
count = len(trades)
wins = [n for n in nets if n > 0]
losses = [n for n in nets if n < 0]
win_cnt = len(wins)
loss_cnt = len(losses)
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2)
max_loss_amt = round(min(nets), 2) if nets else 0.0
max_profit_amt = round(max(nets), 2) if nets else 0.0
margins_loss = [
_margin_pct(_net_pnl(t), t.get("margin"))
for t in trades
if _net_pnl(t) < 0 and t.get("margin")
]
margins_profit = [
_margin_pct(_net_pnl(t), t.get("margin"))
for t in trades
if _net_pnl(t) > 0 and t.get("margin")
]
max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0
max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0
consec_loss = _max_consecutive_losses(nets)
max_dd, max_dd_pct = _max_drawdown(nets, live_capital)
emotion_cnt = sum(1 for r in reviews if r.get("is_emotion"))
review_cnt = len(reviews)
denom = count if count else review_cnt
emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0
return {
"total_trades": count,
"win_rate": round(win_cnt / count * 100, 2) if count else 0.0,
"avg_profit": avg_profit,
"avg_loss": avg_loss,
"profit_loss_ratio": pl_ratio,
"consecutive_losses": consec_loss,
"max_drawdown": max_dd,
"max_drawdown_pct": max_dd_pct,
"max_loss_amount": max_loss_amt,
"max_loss_pct": max_loss_pct,
"max_profit_amount": max_profit_amt,
"max_profit_pct": max_profit_pct,
"total_fee": total_fee,
"emotion_count": emotion_cnt,
"emotion_ratio": emotion_ratio,
"review_count": review_cnt,
"win_count": win_cnt,
"loss_count": loss_cnt,
}
def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]:
def day_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.date().isoformat() if dt else "未知"
def week_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
if not dt:
return "未知"
iso = dt.isocalendar()
return f"{iso.year}-W{iso.week:02d}"
def month_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.strftime("%Y-%m") if dt else "未知"
def symbol_key(row: dict) -> str:
return row.get("symbol_name") or row.get("symbol") or "未知"
def direction_key(row: dict) -> str:
d = row.get("direction") or ""
return "做多" if d == "long" else ("做空" if d == "short" else d or "未知")
def type_key(row: dict) -> str:
return row.get("monitor_type") or "未知"
by_fee_rows = []
fee_groups = {}
for t in trades:
key = symbol_key(t)
fee_groups.setdefault(key, []).append(t)
for label, items in sorted(fee_groups.items()):
row = _agg_metrics(label, items)
row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0
by_fee_rows.append(row)
emotion_trades = [r for r in reviews if r.get("is_emotion")]
non_emotion = [r for r in reviews if not r.get("is_emotion")]
emotion_rows = [
_agg_metrics("情绪单", emotion_trades),
_agg_metrics("非情绪单", non_emotion),
]
fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}]
return {
"by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)},
"by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)},
"by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)},
"by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)},
"by_fee": {"columns": fee_columns, "rows": by_fee_rows},
"by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)},
"by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)},
"by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows},
}
def build_all_stats(conn, live_capital: float = 0.0) -> dict:
trades = fetch_trade_rows(conn)
reviews = fetch_review_rows(conn)
summary = compute_summary(trades, reviews, live_capital)
breakdowns = compute_breakdowns(trades, reviews)
return {
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
"summary": summary,
"views": STATS_VIEWS,
"breakdowns": breakdowns,
}
def save_stats_cache(conn, data: dict) -> None:
conn.execute(
"""INSERT INTO stats_cache (key, data_json, updated_at)
VALUES ('all', ?, ?)
ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""",
(json.dumps(data, ensure_ascii=False), data["updated_at"]),
)
conn.commit()
def load_stats_cache(conn) -> Optional[dict]:
row = conn.execute(
"SELECT data_json FROM stats_cache WHERE key='all'"
).fetchone()
if not row:
return None
try:
return json.loads(row["data_json"])
except json.JSONDecodeError:
return None
def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
data = build_all_stats(conn, live_capital)
save_stats_cache(conn, data)
return data
+47 -111
View File
@@ -2,122 +2,58 @@
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
{% block content %}
<div class="stat-grid">
<div class="stat-item"><div class="label">总交易</div><div class="value">{{ total }}</div></div>
<div class="stat-item"><div class="label">止盈</div><div class="value text-profit">{{ win }}</div></div>
<div class="stat-item"><div class="label">止损</div><div class="value text-loss">{{ loss }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value">{{ rate }}%</div></div>
<div class="stats-toolbar">
<span id="stats-updated" class="hint">正在加载统计…</span>
<button type="button" class="btn-secondary" id="stats-refresh-btn">重新计算</button>
</div>
<div class="stat-grid">
<div class="stat-item"><div class="label">累计手续费</div><div class="value text-loss">{{ total_fee }} 元</div></div>
<div class="stat-item"><div class="label">毛盈亏合计</div><div class="value">{{ total_gross }} 元</div></div>
<div class="stat-item"><div class="label">净盈亏合计</div><div class="value {% if total_net > 0 %}text-profit{% elif total_net < 0 %}text-loss{% endif %}">{{ total_net }} 元</div></div>
<div class="stat-item"><div class="label">计费笔数</div><div class="value">{{ fee_count }}</div></div>
<div class="stat-grid stat-grid-summary" id="stats-summary">
<div class="stat-item"><div class="label">总交易次数</div><div class="value" data-k="total_trades">-</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value" data-k="win_rate">-</div></div>
<div class="stat-item"><div class="label">平均盈利</div><div class="value text-profit" data-k="avg_profit">-</div></div>
<div class="stat-item"><div class="label">平均亏损</div><div class="value text-loss" data-k="avg_loss">-</div></div>
<div class="stat-item"><div class="label">盈亏比</div><div class="value" data-k="profit_loss_ratio">-</div></div>
<div class="stat-item"><div class="label">连续亏损次数</div><div class="value" data-k="consecutive_losses">-</div></div>
<div class="stat-item"><div class="label">最大回撤</div><div class="value" data-k="max_drawdown">-</div></div>
<div class="stat-item"><div class="label">最大亏损金额</div><div class="value text-loss" data-k="max_loss_amount">-</div></div>
<div class="stat-item"><div class="label">最大亏损占比</div><div class="value text-loss" data-k="max_loss_pct">-</div></div>
<div class="stat-item"><div class="label">最大盈利金额</div><div class="value text-profit" data-k="max_profit_amount">-</div></div>
<div class="stat-item"><div class="label">最大盈利占比</div><div class="value text-profit" data-k="max_profit_pct">-</div></div>
<div class="stat-item"><div class="label">累计手续费</div><div class="value text-loss" data-k="total_fee">-</div></div>
<div class="stat-item"><div class="label">情绪单数量</div><div class="value" data-k="emotion_count">-</div></div>
<div class="stat-item"><div class="label">情绪单占比</div><div class="value" data-k="emotion_ratio">-</div></div>
</div>
<div class="card">
<h2>按品种统计</h2>
<table>
<thead><tr><th>品种</th><th>交易次数</th><th>止盈次数</th><th>胜率</th></tr></thead>
<tbody>
{% for s in by_symbol %}
<tr>
<td>{{ s.symbol_name or s.symbol }}</td>
<td>{{ s.cnt }}</td>
<td>{{ s.wins }}</td>
<td>{{ round(s.wins / s.cnt * 100, 2) if s.cnt else 0 }}%</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
<div class="stats-card-head">
<h2>分项统计</h2>
<div class="field stats-view-field">
<label for="stats-view-select">统计维度</label>
<select id="stats-view-select"></select>
</div>
</div>
<div class="card-scroll">
<table id="stats-breakdown-table">
<thead><tr id="stats-breakdown-head"></tr></thead>
<tbody id="stats-breakdown-body">
<tr><td colspan="12" class="text-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>手续费按品种(交易记录)</h2>
<table>
<thead><tr><th>品种</th><th>笔数</th><th>累计手续费(元)</th></tr></thead>
<tbody>
{% for f in fee_by_symbol %}
<tr>
<td>{{ f.symbol_name or f.symbol }}</td>
<td>{{ f.cnt }}</td>
<td class="text-loss">{{ round(f.total_fee, 2) }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted">暂无手续费数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>按类型统计</h2>
<table>
<thead><tr><th>类型</th><th>交易次数</th><th>止盈次数</th><th>胜率</th></tr></thead>
<tbody>
{% for t in by_type %}
<tr>
<td>{{ t.monitor_type }}</td>
<td>{{ t.cnt }}</td>
<td>{{ t.wins }}</td>
<td>{{ round(t.wins / t.cnt * 100, 2) if t.cnt else 0 }}%</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>按方向统计</h2>
<table>
<thead><tr><th>方向</th><th>交易次数</th><th>止盈次数</th><th>胜率</th></tr></thead>
<tbody>
{% for d in by_direction %}
<tr>
<td><span class="badge dir">{{ '做多' if d.direction == 'long' else '做空' }}</span></td>
<td>{{ d.cnt }}</td>
<td>{{ d.wins }}</td>
<td>{{ round(d.wins / d.cnt * 100, 2) if d.cnt else 0 }}%</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>最近 10 笔交易记录</h2>
<table>
<thead><tr><th>品种</th><th>方向</th><th>毛盈亏</th><th>手续费</th><th>净盈亏</th><th>结果</th><th>时间</th></tr></thead>
<tbody>
{% for r in recent %}
<tr>
<td>{{ r.symbol_name or r.symbol }}</td>
<td><span class="badge dir">{{ '做多' if r.direction == 'long' else '做空' }}</span></td>
<td>{{ r.pnl if r.pnl is not none else '-' }}</td>
<td class="text-muted">{{ r.fee if r.fee is not none else '-' }}</td>
<td>
{% if r.pnl_net and r.pnl_net > 0 %}<span class="badge profit">{{ r.pnl_net }}</span>
{% elif r.pnl_net and r.pnl_net < 0 %}<span class="badge loss">{{ r.pnl_net }}</span>
{% else %}-{% endif %}
</td>
<td>
{% if r.result == '止盈' %}<span class="badge profit">止盈</span>
{% elif r.result == '止损' %}<span class="badge loss">止损</span>
{% else %}{{ r.result or '-' }}{% endif %}
</td>
<td>{{ r.close_time[:16] if r.close_time else (r.created_at[:16] if r.created_at else '') }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-muted">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<style>
.stats-toolbar{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
.stats-toolbar .btn-secondary{width:auto;padding:.5rem 1rem;border-radius:8px;border:1px solid var(--input-border);background:var(--toggle-bg);color:var(--text-primary);cursor:pointer;font-size:.85rem}
.stats-toolbar .btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
.stat-grid-summary{grid-template-columns:repeat(auto-fill,minmax(128px,1fr))}
.stats-card-head{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}
.stats-card-head h2{margin-bottom:0}
.stats-view-field{width:auto;min-width:200px}
.stats-view-field select{width:100%;min-width:180px}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/stats.js') }}"></script>
{% endblock %}