Fix stats dirty data: today filter, exclude synthetic closes, scrub frozen float PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 19:28:01 +08:00
parent 391851a2b0
commit 1d2d4bbaff
3 changed files with 153 additions and 37 deletions
+118 -23
View File
@@ -6,10 +6,15 @@ import secrets
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
from app.config import Config from app.config import Config
BJT = timezone(timedelta(hours=8))
# 对账/去重产生的假平仓,不计入盈亏统计
SYNTHETIC_CLOSE_EVENTS = frozenset({"reconcile_closed", "dedupe_closed"})
def _connect() -> sqlite3.Connection: def _connect() -> sqlite3.Connection:
Config.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) Config.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -125,6 +130,50 @@ def now() -> float:
return time.time() return time.time()
def day_bounds_bjt(day: Optional[str] = None) -> tuple[float, float, str]:
"""北京时间某日 [start, end);day=YYYY-MM-DD,默认今天。"""
if day:
d0 = datetime.strptime(day.strip(), "%Y-%m-%d").replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=BJT
)
else:
d0 = datetime.now(BJT).replace(hour=0, minute=0, second=0, microsecond=0)
d1 = d0 + timedelta(days=1)
return d0.timestamp(), d1.timestamp(), d0.strftime("%Y-%m-%d")
def _snapshot_event(snapshot_json: Any) -> str:
try:
snap = json.loads(snapshot_json or "{}")
except (TypeError, json.JSONDecodeError):
return ""
if not isinstance(snap, dict):
return ""
return str(snap.get("event") or "")
def is_synthetic_close(snapshot_json: Any) -> bool:
return _snapshot_event(snapshot_json) in SYNTHETIC_CLOSE_EVENTS
def scrub_synthetic_trade_profits(conn: sqlite3.Connection) -> int:
"""把历史假平仓上的「冻结浮盈」清掉,避免脏数据继续进统计。"""
rows = conn.execute(
"""
SELECT id, snapshot_json, profit FROM trades
WHERE close_time IS NOT NULL AND close_time != ''
AND profit IS NOT NULL
"""
).fetchall()
n = 0
for r in rows:
if not is_synthetic_close(r["snapshot_json"]):
continue
conn.execute("UPDATE trades SET profit=NULL WHERE id=?", (r["id"],))
n += 1
return n
def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]: def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
if row is None: if row is None:
return None return None
@@ -449,19 +498,27 @@ def trade_stats(
conn: sqlite3.Connection, conn: sqlite3.Connection,
account_id: Optional[int] = None, account_id: Optional[int] = None,
machine_id: Optional[int] = None, machine_id: Optional[int] = None,
day: Optional[str] = None,
period: str = "today",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""程序统计看板:盈亏/胜率/持仓时长分段/按机器与方向。""" """程序统计看板:默认北京时间「今日」真实平仓;排除对账/去重假平仓。"""
clause, params = _trades_where(account_id, machine_id) clause, params = _trades_where(account_id, machine_id)
sql = f""" sql = f"""
SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time, SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time,
t.volume, t.machine_id, t.entry_style, m.name AS machine_name t.volume, t.machine_id, t.entry_style, t.snapshot_json,
m.name AS machine_name
FROM trades t FROM trades t
LEFT JOIN machines m ON m.id = t.machine_id LEFT JOIN machines m ON m.id = t.machine_id
{clause} {clause}
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
""" """
rows = [row_to_dict(r) for r in conn.execute(sql, tuple(params)).fetchall()] raw_rows = [row_to_dict(r) for r in conn.execute(sql, tuple(params)).fetchall()]
now_ts = now() now_ts = now()
period = (period or "today").strip().lower()
day_label = ""
start_ts = end_ts = None
if period != "all":
start_ts, end_ts, day_label = day_bounds_bjt(day)
bucket_labels = { bucket_labels = {
"lt_5m": "< 5 分钟", "lt_5m": "< 5 分钟",
@@ -478,6 +535,33 @@ def trade_stats(
} }
by_style: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in ENTRY_STYLES} by_style: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in ENTRY_STYLES}
rows: list[dict] = []
skipped_synthetic = 0
for t in raw_rows:
closed_flag = t.get("close_time") not in (None, "")
if closed_flag and is_synthetic_close(t.get("snapshot_json")):
skipped_synthetic += 1
continue
if period != "all" and start_ts is not None and end_ts is not None:
# 今日:按平仓时间;仍持仓的按开仓时间计入「持仓中」但不进合计盈亏
if closed_flag:
ct = t.get("close_time")
try:
cts = float(ct)
except (TypeError, ValueError):
continue
if cts < start_ts or cts >= end_ts:
continue
else:
ot = t.get("open_time") or t.get("created_at")
try:
ots = float(ot)
except (TypeError, ValueError):
continue
if ots < start_ts or ots >= end_ts:
continue
rows.append(t)
total = len(rows) total = len(rows)
closed = 0 closed = 0
open_pos = 0 open_pos = 0
@@ -488,19 +572,23 @@ def trade_stats(
hold_sum = 0.0 hold_sum = 0.0
hold_n = 0 hold_n = 0
annotated = 0 annotated = 0
scored = 0 # 计入盈亏的已平仓笔数
for t in rows: for t in rows:
profit = t.get("profit")
hold = _hold_seconds(t.get("open_time"), t.get("close_time"), now_ts)
closed_flag = t.get("close_time") not in (None, "") closed_flag = t.get("close_time") not in (None, "")
hold = _hold_seconds(t.get("open_time"), t.get("close_time"), now_ts)
# 合计盈亏/胜率只统计真实已平仓;持仓浮盈不进合计
profit = t.get("profit") if closed_flag else None
if closed_flag: if closed_flag:
closed += 1 closed += 1
else: else:
open_pos += 1 open_pos += 1
if profit is not None: if closed_flag and profit is not None:
p = float(profit) p = float(profit)
profit_sum += p profit_sum += p
scored += 1
if p > 1e-9: if p > 1e-9:
wins += 1 wins += 1
gross_win += p gross_win += p
@@ -510,7 +598,7 @@ def trade_stats(
else: else:
breakeven += 1 breakeven += 1
if hold is not None: if closed_flag and hold is not None:
hold_sum += hold hold_sum += hold
hold_n += 1 hold_n += 1
@@ -518,30 +606,34 @@ def trade_stats(
if style: if style:
annotated += 1 annotated += 1
# 分组也只累加已平仓盈亏,避免浮盈干扰分类
cat = entry_category(style) cat = entry_category(style)
_accumulate(by_category[cat], profit, hold) _accumulate(by_category[cat], profit, hold if closed_flag else None)
if style in by_style: if style in by_style:
_accumulate(by_style[style], profit, hold) _accumulate(by_style[style], profit, hold if closed_flag else None)
elif style: elif style:
key = f"旧 · {style}" key = f"旧 · {style}"
if key not in by_style: if key not in by_style:
by_style[key] = _empty_group(key) by_style[key] = _empty_group(key)
_accumulate(by_style[key], profit, hold) _accumulate(by_style[key], profit, hold if closed_flag else None)
bk = _bucket_key(hold) if closed_flag:
_accumulate(by_hold[bk], profit, hold) bk = _bucket_key(hold)
_accumulate(by_hold[bk], profit, hold)
mid = t.get("machine_id") mid = t.get("machine_id")
mkey = str(mid) if mid is not None else "none" mkey = str(mid) if mid is not None else "none"
if mkey not in by_machine: if mkey not in by_machine:
name = t.get("machine_name") or ("未绑定" if mid is None else f"#{mid}") name = t.get("machine_name") or ("未绑定" if mid is None else f"#{mid}")
by_machine[mkey] = _empty_group(name) by_machine[mkey] = _empty_group(name)
_accumulate(by_machine[mkey], profit, hold) _accumulate(by_machine[mkey], profit, hold)
side = (t.get("side") or "—").lower() side = (t.get("side") or "—").lower()
if side not in by_side: if side not in by_side:
by_side[side] = _empty_group("买入" if side == "buy" else ("卖出" if side == "sell" else side)) by_side[side] = _empty_group(
_accumulate(by_side[side], profit, hold) "买入" if side == "buy" else ("卖出" if side == "sell" else side)
)
_accumulate(by_side[side], profit, hold)
decided = wins + losses decided = wins + losses
avg_hold = (hold_sum / hold_n) if hold_n else None avg_hold = (hold_sum / hold_n) if hold_n else None
@@ -555,7 +647,7 @@ def trade_stats(
"breakeven": breakeven, "breakeven": breakeven,
"win_rate": (wins / decided * 100.0) if decided else 0.0, "win_rate": (wins / decided * 100.0) if decided else 0.0,
"profit": profit_sum, "profit": profit_sum,
"avg_profit": (profit_sum / total) if total else 0.0, "avg_profit": (profit_sum / scored) if scored else 0.0,
"gross_win": gross_win, "gross_win": gross_win,
"gross_loss": gross_loss, "gross_loss": gross_loss,
"profit_factor": (gross_win / gross_loss) if gross_loss > 1e-9 else (None if gross_win <= 0 else 999.0), "profit_factor": (gross_win / gross_loss) if gross_loss > 1e-9 else (None if gross_win <= 0 else 999.0),
@@ -563,6 +655,9 @@ def trade_stats(
"avg_hold": _fmt_hold_secs(avg_hold), "avg_hold": _fmt_hold_secs(avg_hold),
"annotated": annotated, "annotated": annotated,
"unannotated": total - annotated, "unannotated": total - annotated,
"skipped_synthetic": skipped_synthetic,
"period": period,
"day": day_label,
"by_hold": [_finalize_group(by_hold[k]) for k in ("lt_5m", "m5_30", "m30_2h", "ge_2h", "unknown") if by_hold[k]["count"] or k != "unknown"], "by_hold": [_finalize_group(by_hold[k]) for k in ("lt_5m", "m5_30", "m30_2h", "ge_2h", "unknown") if by_hold[k]["count"] or k != "unknown"],
"by_machine": sorted( "by_machine": sorted(
[_finalize_group(g) for g in by_machine.values()], [_finalize_group(g) for g in by_machine.values()],
+20 -8
View File
@@ -782,7 +782,7 @@ def register_routes(app: Flask) -> None:
) )
tid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) tid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
# 同一账户+单号只保留一条「持仓中」,其余重复未平仓直接关掉 # 同一账户+单号只保留一条「持仓中」,其余重复未平仓直接关掉(不计盈亏)
if ticket and close_time in (None, ""): if ticket and close_time in (None, ""):
dup_snap = json.dumps( dup_snap = json.dumps(
{"event": "dedupe_closed", "kept_id": tid}, ensure_ascii=False {"event": "dedupe_closed", "kept_id": tid}, ensure_ascii=False
@@ -790,7 +790,7 @@ def register_routes(app: Flask) -> None:
conn.execute( conn.execute(
""" """
UPDATE trades UPDATE trades
SET close_time=?, snapshot_json=? SET close_time=?, profit=NULL, snapshot_json=?
WHERE account_id=? AND ticket=? AND id!=? WHERE account_id=? AND ticket=? AND id!=?
AND (close_time IS NULL OR close_time='') AND (close_time IS NULL OR close_time='')
""", """,
@@ -839,16 +839,14 @@ def register_routes(app: Flask) -> None:
snap = {} snap = {}
snap["event"] = "reconcile_closed" snap["event"] = "reconcile_closed"
snap["reconciled_at"] = now_ts snap["reconciled_at"] = now_ts
profit = r["profit"] # 假平仓不写入冻结浮盈,避免脏数据进统计
if profit is None:
profit = 0.0
conn.execute( conn.execute(
""" """
UPDATE trades UPDATE trades
SET close_time=?, profit=?, snapshot_json=? SET close_time=?, profit=NULL, snapshot_json=?
WHERE id=? WHERE id=?
""", """,
(now_ts, profit, json.dumps(snap, ensure_ascii=False), r["id"]), (now_ts, json.dumps(snap, ensure_ascii=False), r["id"]),
) )
closed_n += 1 closed_n += 1
return jsonify({"ok": True, "closed": closed_n}) return jsonify({"ok": True, "closed": closed_n})
@@ -927,15 +925,29 @@ def register_routes(app: Flask) -> None:
def records_stats_page(): def records_stats_page():
u = current_user() u = current_user()
machine_raw = (request.args.get("machine_id") or "").strip() machine_raw = (request.args.get("machine_id") or "").strip()
period = (request.args.get("period") or "today").strip().lower()
if period not in ("today", "all"):
period = "today"
day = (request.args.get("day") or "").strip() or None
account_id = None if u["role"] == "admin" else u.get("account_id") account_id = None if u["role"] == "admin" else u.get("account_id")
machines, machine_id = _resolve_machine_filter(u, machine_raw) machines, machine_id = _resolve_machine_filter(u, machine_raw)
with database.get_db() as conn: with database.get_db() as conn:
stats = database.trade_stats(conn, account_id=account_id, machine_id=machine_id) # 清掉历史假平仓上误写的盈亏
database.scrub_synthetic_trade_profits(conn)
stats = database.trade_stats(
conn,
account_id=account_id,
machine_id=machine_id,
day=day,
period=period,
)
return render_template( return render_template(
"records_stats.html", "records_stats.html",
stats=stats, stats=stats,
machines=machines, machines=machines,
selected_machine_id=machine_id, selected_machine_id=machine_id,
selected_period=period,
selected_day=stats.get("day") or day or "",
) )
@app.get("/records/<int:tid>") @app.get("/records/<int:tid>")
+15 -6
View File
@@ -2,15 +2,26 @@
{% block title %}统计看板 · {{ app_name }}{% endblock %} {% block title %}统计看板 · {{ app_name }}{% endblock %}
{% block content %} {% block content %}
<h2 style="margin:0 0 6px">统计看板</h2> <h2 style="margin:0 0 6px">统计看板</h2>
<p class="muted" style="margin:0 0 10px">程序汇总:顺势/反转 × 三种结构手法,其他=乱作。</p> <p class="muted" style="margin:0 0 10px">
默认按北京时间<strong>今日真实平仓</strong>统计;对账/去重产生的假平仓已排除。
{% if selected_period == 'today' and stats.day %}当前:{{ stats.day }}{% endif %}
{% if stats.skipped_synthetic %} · 已排除假平仓 {{ stats.skipped_synthetic }} 笔{% endif %}
</p>
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"> <div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">
<a class="btn secondary" href="{{ url_for('records_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_page') }}">交易列表</a> <a class="btn secondary" href="{{ url_for('records_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_page') }}">交易列表</a>
<a class="btn" href="{{ url_for('records_stats_page', machine_id=selected_machine_id) if selected_machine_id else url_for('records_stats_page') }}">统计看板</a> <a class="btn" href="{{ url_for('records_stats_page', machine_id=selected_machine_id, period=selected_period, day=selected_day) if selected_machine_id else url_for('records_stats_page', period=selected_period, day=selected_day) }}">统计看板</a>
</div> </div>
<div class="card" style="margin-bottom:14px"> <div class="card" style="margin-bottom:14px">
<form method="get" action="{{ url_for('records_stats_page') }}" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> <form method="get" action="{{ url_for('records_stats_page') }}" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="muted" for="period" style="margin:0">范围</label>
<select name="period" id="period" style="min-width:120px;width:auto">
<option value="today" {% if selected_period=='today' %}selected{% endif %}>今日</option>
<option value="all" {% if selected_period=='all' %}selected{% endif %}>全部</option>
</select>
<label class="muted" for="day" style="margin:0">日期</label>
<input type="date" name="day" id="day" value="{{ selected_day or '' }}" style="width:auto">
<label class="muted" for="machine_id" style="margin:0">机器</label> <label class="muted" for="machine_id" style="margin:0">机器</label>
<select name="machine_id" id="machine_id" style="min-width:200px;width:auto"> <select name="machine_id" id="machine_id" style="min-width:200px;width:auto">
<option value="">全部机器</option> <option value="">全部机器</option>
@@ -21,9 +32,7 @@
{% endfor %} {% endfor %}
</select> </select>
<button type="submit" class="btn">筛选</button> <button type="submit" class="btn">筛选</button>
{% if selected_machine_id %} <a class="btn secondary" href="{{ url_for('records_stats_page') }}">今日重置</a>
<a class="btn secondary" href="{{ url_for('records_stats_page') }}">清除</a>
{% endif %}
</form> </form>
</div> </div>
@@ -39,7 +48,7 @@
<div class="muted" style="margin-top:6px">盈 {{ stats.wins }} · 亏 {{ stats.losses }} · 平 {{ stats.breakeven }}</div> <div class="muted" style="margin-top:6px">盈 {{ stats.wins }} · 亏 {{ stats.losses }} · 平 {{ stats.breakeven }}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="muted">合计盈亏</div> <div class="muted">合计盈亏{% if selected_period=='today' %}(今日已平){% endif %}</div>
<div class="stat" style="color:{% if stats.profit>0 %}var(--green){% elif stats.profit<0 %}var(--red){% else %}inherit{% endif %}"> <div class="stat" style="color:{% if stats.profit>0 %}var(--green){% elif stats.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ '%.2f'|format(stats.profit) }} {{ '%.2f'|format(stats.profit) }}
</div> </div>