From e8b4dbbaca4ab1ba7a57004ad5a8667f410bb623 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 16:46:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=BB=9F=E8=AE=A1=E5=88=86?= =?UTF-8?q?=E6=9E=90=E9=A1=B5=EF=BC=9A=E6=B1=87=E6=80=BB=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E3=80=81=E5=88=86=E9=A1=B9=E4=B8=8B=E6=8B=89=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 stats_engine 与 stats_cache,提供 API 自动加载 8 种统计维度;交易与复盘变更时自动刷新缓存。 Co-authored-by: Cursor --- app.py | 119 +++++++---------- static/js/stats.js | 173 ++++++++++++++++++++++++ stats_engine.py | 310 +++++++++++++++++++++++++++++++++++++++++++ templates/stats.html | 158 +++++++--------------- 4 files changed, 581 insertions(+), 179 deletions(-) create mode 100644 static/js/stats.js create mode 100644 stats_engine.py 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 %} -
-
总交易
{{ total }}
-
止盈
{{ win }}
-
止损
{{ loss }}
-
胜率
{{ rate }}%
+
+ 正在加载统计… +
-
-
累计手续费
{{ total_fee }} 元
-
毛盈亏合计
{{ total_gross }} 元
-
净盈亏合计
{{ total_net }} 元
-
计费笔数
{{ fee_count }}
+
+
总交易次数
-
+
胜率
-
+
平均盈利
-
+
平均亏损
-
+
盈亏比
-
+
连续亏损次数
-
+
最大回撤
-
+
最大亏损金额
-
+
最大亏损占比
-
+
最大盈利金额
-
+
最大盈利占比
-
+
累计手续费
-
+
情绪单数量
-
+
情绪单占比
-
-

按品种统计

- - - - {% for s in by_symbol %} - - - - - - - {% else %} - - {% endfor %} - -
品种交易次数止盈次数胜率
{{ s.symbol_name or s.symbol }}{{ s.cnt }}{{ s.wins }}{{ round(s.wins / s.cnt * 100, 2) if s.cnt else 0 }}%
暂无数据
+
+

分项统计

+
+ + +
+
+
+ + + + + +
加载中…
+
-
-

手续费按品种(交易记录)

- - - - {% for f in fee_by_symbol %} - - - - - - {% else %} - - {% endfor %} - -
品种笔数累计手续费(元)
{{ f.symbol_name or f.symbol }}{{ f.cnt }}{{ round(f.total_fee, 2) }}
暂无手续费数据
-
- -
-

按类型统计

- - - - {% for t in by_type %} - - - - - - - {% else %} - - {% endfor %} - -
类型交易次数止盈次数胜率
{{ t.monitor_type }}{{ t.cnt }}{{ t.wins }}{{ round(t.wins / t.cnt * 100, 2) if t.cnt else 0 }}%
暂无数据
-
- -
-

按方向统计

- - - - {% for d in by_direction %} - - - - - - - {% else %} - - {% endfor %} - -
方向交易次数止盈次数胜率
{{ '做多' if d.direction == 'long' else '做空' }}{{ d.cnt }}{{ d.wins }}{{ round(d.wins / d.cnt * 100, 2) if d.cnt else 0 }}%
暂无数据
-
- -
-

最近 10 笔交易记录

- - - - {% for r in recent %} - - - - - - - - - - {% else %} - - {% endfor %} - -
品种方向毛盈亏手续费净盈亏结果时间
{{ 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 '') }}
暂无数据
-
+ +{% endblock %} + +{% block extra_js %} + {% endblock %}