diff --git a/app.py b/app.py
index 2a3b599..4f240d2 100644
--- a/app.py
+++ b/app.py
@@ -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")
diff --git a/static/js/stats.js b/static/js/stats.js
new file mode 100644
index 0000000..9045cfe
--- /dev/null
+++ b/static/js/stats.js
@@ -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();
+ });
+})();
diff --git a/stats_engine.py b/stats_engine.py
new file mode 100644
index 0000000..246a562
--- /dev/null
+++ b/stats_engine.py
@@ -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
diff --git a/templates/stats.html b/templates/stats.html
index 6c59c43..debcab1 100644
--- a/templates/stats.html
+++ b/templates/stats.html
@@ -2,122 +2,58 @@
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
{% block content %}
-
-
-
-
-
+
+ 正在加载统计…
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
按品种统计
-
- | 品种 | 交易次数 | 止盈次数 | 胜率 |
-
- {% for s in by_symbol %}
-
- | {{ s.symbol_name or s.symbol }} |
- {{ s.cnt }} |
- {{ s.wins }} |
- {{ round(s.wins / s.cnt * 100, 2) if s.cnt else 0 }}% |
-
- {% else %}
- | 暂无数据 |
- {% endfor %}
-
-
+
+
-
-
手续费按品种(交易记录)
-
- | 品种 | 笔数 | 累计手续费(元) |
-
- {% for f in fee_by_symbol %}
-
- | {{ f.symbol_name or f.symbol }} |
- {{ f.cnt }} |
- {{ round(f.total_fee, 2) }} |
-
- {% else %}
- | 暂无手续费数据 |
- {% endfor %}
-
-
-
-
-
-
按类型统计
-
- | 类型 | 交易次数 | 止盈次数 | 胜率 |
-
- {% for t in by_type %}
-
- | {{ t.monitor_type }} |
- {{ t.cnt }} |
- {{ t.wins }} |
- {{ round(t.wins / t.cnt * 100, 2) if t.cnt else 0 }}% |
-
- {% else %}
- | 暂无数据 |
- {% endfor %}
-
-
-
-
-
-
按方向统计
-
- | 方向 | 交易次数 | 止盈次数 | 胜率 |
-
- {% for d in by_direction %}
-
- | {{ '做多' if d.direction == 'long' else '做空' }} |
- {{ d.cnt }} |
- {{ d.wins }} |
- {{ round(d.wins / d.cnt * 100, 2) if d.cnt else 0 }}% |
-
- {% else %}
- | 暂无数据 |
- {% endfor %}
-
-
-
-
-
-
最近 10 笔交易记录
-
- | 品种 | 方向 | 毛盈亏 | 手续费 | 净盈亏 | 结果 | 时间 |
-
- {% for r in recent %}
-
- | {{ r.symbol_name or r.symbol }} |
- {{ '做多' if r.direction == 'long' else '做空' }} |
- {{ r.pnl if r.pnl is not none else '-' }} |
- {{ r.fee if r.fee is not none else '-' }} |
-
- {% if r.pnl_net and r.pnl_net > 0 %}{{ r.pnl_net }}
- {% elif r.pnl_net and r.pnl_net < 0 %}{{ r.pnl_net }}
- {% else %}-{% endif %}
- |
-
- {% if r.result == '止盈' %}止盈
- {% elif r.result == '止损' %}止损
- {% else %}{{ r.result or '-' }}{% endif %}
- |
- {{ r.close_time[:16] if r.close_time else (r.created_at[:16] if r.created_at else '') }} |
-
- {% else %}
- | 暂无数据 |
- {% endfor %}
-
-
-
+
+{% endblock %}
+
+{% block extra_js %}
+
{% endblock %}