重构统计分析页:汇总指标、分项下拉与后台缓存
新增 stats_engine 与 stats_cache,提供 API 自动加载 8 种统计维度;交易与复盘变更时自动刷新缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,7 @@ from fee_specs import (
|
|||||||
)
|
)
|
||||||
from fee_sync import sync_fees_from_akshare
|
from fee_sync import sync_fees_from_akshare
|
||||||
from contract_profile import get_contract_profile
|
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 kline_chart import generate_review_kline_chart
|
||||||
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
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()
|
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():
|
def init_db():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -278,6 +299,10 @@ def init_db():
|
|||||||
close_today_fixed REAL DEFAULT 0,
|
close_today_fixed REAL DEFAULT 0,
|
||||||
close_today_ratio REAL DEFAULT 0,
|
close_today_ratio REAL DEFAULT 0,
|
||||||
updated_at TEXT)''')
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -904,6 +929,7 @@ def close_position(pid):
|
|||||||
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
|
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
touch_stats_cache()
|
||||||
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
|
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
|
||||||
return redirect(url_for("positions"))
|
return redirect(url_for("positions"))
|
||||||
|
|
||||||
@@ -946,6 +972,7 @@ def update_trade(tid):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
touch_stats_cache()
|
||||||
flash("交易记录已核对保存")
|
flash("交易记录已核对保存")
|
||||||
return redirect(url_for("records"))
|
return redirect(url_for("records"))
|
||||||
|
|
||||||
@@ -957,6 +984,7 @@ def del_trade(tid):
|
|||||||
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
|
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
touch_stats_cache()
|
||||||
flash("已删除")
|
flash("已删除")
|
||||||
return redirect(url_for("records"))
|
return redirect(url_for("records"))
|
||||||
|
|
||||||
@@ -1179,6 +1207,7 @@ def add_review():
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
touch_stats_cache()
|
||||||
flash("复盘记录已保存")
|
flash("复盘记录已保存")
|
||||||
return redirect(url_for("records"))
|
return redirect(url_for("records"))
|
||||||
|
|
||||||
@@ -1195,6 +1224,7 @@ def del_review(rid):
|
|||||||
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
|
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
touch_stats_cache()
|
||||||
flash("已删除")
|
flash("已删除")
|
||||||
return redirect(url_for("records"))
|
return redirect(url_for("records"))
|
||||||
|
|
||||||
@@ -1220,76 +1250,29 @@ def del_record(rid):
|
|||||||
@app.route("/stats")
|
@app.route("/stats")
|
||||||
@login_required
|
@login_required
|
||||||
def stats():
|
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()
|
conn = get_db()
|
||||||
total = conn.execute(
|
capital = float(get_setting("live_capital", "0") or 0)
|
||||||
"SELECT COUNT(*) FROM trade_records WHERE result IN ('止盈','止损')"
|
data = refresh_stats_cache(conn, capital)
|
||||||
).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()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return jsonify(data)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/contract")
|
@app.route("/contract")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -2,122 +2,58 @@
|
|||||||
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
|
{% block title %}统计分析 - 国内期货监控系统{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stats-toolbar">
|
||||||
<div class="stat-item"><div class="label">总交易</div><div class="value">{{ total }}</div></div>
|
<span id="stats-updated" class="hint">正在加载统计…</span>
|
||||||
<div class="stat-item"><div class="label">止盈</div><div class="value text-profit">{{ win }}</div></div>
|
<button type="button" class="btn-secondary" id="stats-refresh-btn">重新计算</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid stat-grid-summary" id="stats-summary">
|
||||||
<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" data-k="total_trades">-</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" data-k="win_rate">-</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 text-profit" data-k="avg_profit">-</div></div>
|
||||||
<div class="stat-item"><div class="label">计费笔数</div><div class="value">{{ fee_count }}</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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>按品种统计</h2>
|
<div class="stats-card-head">
|
||||||
<table>
|
<h2>分项统计</h2>
|
||||||
<thead><tr><th>品种</th><th>交易次数</th><th>止盈次数</th><th>胜率</th></tr></thead>
|
<div class="field stats-view-field">
|
||||||
<tbody>
|
<label for="stats-view-select">统计维度</label>
|
||||||
{% for s in by_symbol %}
|
<select id="stats-view-select"></select>
|
||||||
<tr>
|
</div>
|
||||||
<td>{{ s.symbol_name or s.symbol }}</td>
|
</div>
|
||||||
<td>{{ s.cnt }}</td>
|
<div class="card-scroll">
|
||||||
<td>{{ s.wins }}</td>
|
<table id="stats-breakdown-table">
|
||||||
<td>{{ round(s.wins / s.cnt * 100, 2) if s.cnt else 0 }}%</td>
|
<thead><tr id="stats-breakdown-head"></tr></thead>
|
||||||
</tr>
|
<tbody id="stats-breakdown-body">
|
||||||
{% else %}
|
<tr><td colspan="12" class="text-muted">加载中…</td></tr>
|
||||||
<tr><td colspan="4" class="text-muted">暂无数据</td></tr>
|
</tbody>
|
||||||
{% endfor %}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<style>
|
||||||
<h2>手续费按品种(交易记录)</h2>
|
.stats-toolbar{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
|
||||||
<table>
|
.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}
|
||||||
<thead><tr><th>品种</th><th>笔数</th><th>累计手续费(元)</th></tr></thead>
|
.stats-toolbar .btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
<tbody>
|
.stat-grid-summary{grid-template-columns:repeat(auto-fill,minmax(128px,1fr))}
|
||||||
{% for f in fee_by_symbol %}
|
.stats-card-head{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||||
<tr>
|
.stats-card-head h2{margin-bottom:0}
|
||||||
<td>{{ f.symbol_name or f.symbol }}</td>
|
.stats-view-field{width:auto;min-width:200px}
|
||||||
<td>{{ f.cnt }}</td>
|
.stats-view-field select{width:100%;min-width:180px}
|
||||||
<td class="text-loss">{{ round(f.total_fee, 2) }}</td>
|
</style>
|
||||||
</tr>
|
{% endblock %}
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="3" class="text-muted">暂无手续费数据</td></tr>
|
{% block extra_js %}
|
||||||
{% endfor %}
|
<script src="{{ url_for('static', filename='js/stats.js') }}"></script>
|
||||||
</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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user