From dcef9f1d61743f1fb706123fb74c5b56e5d33d03 Mon Sep 17 00:00:00 2001
From: dekun
Date: Wed, 21 Oct 2026 19:39:08 +0800
Subject: [PATCH] Dedupe duplicate ticket closes so stats match MT5 today PnL.
Co-authored-by: Cursor
---
app/db.py | 57 ++++++++++++++++++++++++++++++++
app/routes.py | 37 ++++++++++++++-------
app/templates/records_stats.html | 1 +
3 files changed, 83 insertions(+), 12 deletions(-)
diff --git a/app/db.py b/app/db.py
index e35cf4b..c9ebd8b 100644
--- a/app/db.py
+++ b/app/db.py
@@ -174,6 +174,45 @@ def scrub_synthetic_trade_profits(conn: sqlite3.Connection) -> int:
return n
+def scrub_duplicate_ticket_profits(conn: sqlite3.Connection) -> int:
+ """同一账户+单号多条真实平仓时,只保留 id 最大的一条盈亏,其余标为去重。"""
+ rows = conn.execute(
+ """
+ SELECT id, account_id, ticket, profit, snapshot_json, close_time
+ FROM trades
+ WHERE ticket IS NOT NULL AND ticket != ''
+ AND close_time IS NOT NULL AND close_time != ''
+ ORDER BY account_id, ticket, id DESC
+ """
+ ).fetchall()
+ keep: set[tuple[Any, str]] = set()
+ n = 0
+ now_ts = now()
+ for r in rows:
+ if is_synthetic_close(r["snapshot_json"]):
+ continue
+ key = (r["account_id"], str(r["ticket"]))
+ if key not in keep:
+ keep.add(key)
+ continue
+ # 重复平仓副本:清空盈亏并标记
+ try:
+ snap = json.loads(r["snapshot_json"] or "{}")
+ except json.JSONDecodeError:
+ snap = {}
+ if not isinstance(snap, dict):
+ snap = {}
+ snap["event"] = "dedupe_closed"
+ snap["deduped_at"] = now_ts
+ snap["reason"] = "duplicate_ticket_close"
+ conn.execute(
+ "UPDATE trades SET profit=NULL, snapshot_json=? WHERE id=?",
+ (json.dumps(snap, ensure_ascii=False), r["id"]),
+ )
+ n += 1
+ return n
+
+
def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
if row is None:
return None
@@ -537,6 +576,16 @@ def trade_stats(
rows: list[dict] = []
skipped_synthetic = 0
+ skipped_dup_ticket = 0
+ seen_closed_ticket: set[str] = set()
+ # raw 已按时间 DESC;再按 id 大的优先,便于同 ticket 去重留最新
+ raw_rows.sort(
+ key=lambda t: (
+ float(t.get("close_time") or t.get("open_time") or t.get("created_at") or 0),
+ int(t.get("id") or 0),
+ ),
+ reverse=True,
+ )
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")):
@@ -560,6 +609,13 @@ def trade_stats(
continue
if ots < start_ts or ots >= end_ts:
continue
+ if closed_flag:
+ tk = str(t.get("ticket") or "").strip()
+ if tk:
+ if tk in seen_closed_ticket:
+ skipped_dup_ticket += 1
+ continue
+ seen_closed_ticket.add(tk)
rows.append(t)
total = len(rows)
@@ -656,6 +712,7 @@ def trade_stats(
"annotated": annotated,
"unannotated": total - annotated,
"skipped_synthetic": skipped_synthetic,
+ "skipped_dup_ticket": skipped_dup_ticket,
"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"],
diff --git a/app/routes.py b/app/routes.py
index ff5dfac..56de3d6 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -782,20 +782,32 @@ def register_routes(app: Flask) -> None:
)
tid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
- # 同一账户+单号只保留一条「持仓中」,其余重复未平仓直接关掉(不计盈亏)
- if ticket and close_time in (None, ""):
+ # 同一账户+单号只保留一条有效记录:
+ # - 上报持仓:关掉其它未平仓副本
+ # - 上报平仓:其它同单号副本盈亏清空(防 history 重复插入双计)
+ if ticket:
dup_snap = json.dumps(
{"event": "dedupe_closed", "kept_id": tid}, ensure_ascii=False
)
- conn.execute(
- """
- UPDATE trades
- SET close_time=?, profit=NULL, snapshot_json=?
- WHERE account_id=? AND ticket=? AND id!=?
- AND (close_time IS NULL OR close_time='')
- """,
- (database.now(), dup_snap, act["account_id"], ticket, tid),
- )
+ if close_time in (None, ""):
+ conn.execute(
+ """
+ UPDATE trades
+ SET close_time=?, profit=NULL, snapshot_json=?
+ WHERE account_id=? AND ticket=? AND id!=?
+ AND (close_time IS NULL OR close_time='')
+ """,
+ (database.now(), dup_snap, act["account_id"], ticket, tid),
+ )
+ else:
+ conn.execute(
+ """
+ UPDATE trades
+ SET profit=NULL, snapshot_json=?
+ WHERE account_id=? AND ticket=? AND id!=?
+ """,
+ (dup_snap, act["account_id"], ticket, tid),
+ )
return jsonify({"ok": True, "id": tid})
@app.post("/api/v1/trades/reconcile")
@@ -932,8 +944,9 @@ def register_routes(app: Flask) -> None:
account_id = None if u["role"] == "admin" else u.get("account_id")
machines, machine_id = _resolve_machine_filter(u, machine_raw)
with database.get_db() as conn:
- # 清掉历史假平仓上误写的盈亏
+ # 清掉假平仓盈亏 + 同单号重复平仓双计
database.scrub_synthetic_trade_profits(conn)
+ database.scrub_duplicate_ticket_profits(conn)
stats = database.trade_stats(
conn,
account_id=account_id,
diff --git a/app/templates/records_stats.html b/app/templates/records_stats.html
index 1e98678..4075d62 100644
--- a/app/templates/records_stats.html
+++ b/app/templates/records_stats.html
@@ -6,6 +6,7 @@
默认按北京时间今日真实平仓统计;对账/去重产生的假平仓已排除。
{% if selected_period == 'today' and stats.day %}当前:{{ stats.day }}{% endif %}
{% if stats.skipped_synthetic %} · 已排除假平仓 {{ stats.skipped_synthetic }} 笔{% endif %}
+ {% if stats.skipped_dup_ticket %} · 已排除重复单号 {{ stats.skipped_dup_ticket }} 笔{% endif %}