Dedupe duplicate ticket closes so stats match MT5 today PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 19:39:08 +08:00
parent 1d2d4bbaff
commit dcef9f1d61
3 changed files with 83 additions and 12 deletions
+57
View File
@@ -174,6 +174,45 @@ def scrub_synthetic_trade_profits(conn: sqlite3.Connection) -> int:
return n 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]: def row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
if row is None: if row is None:
return None return None
@@ -537,6 +576,16 @@ def trade_stats(
rows: list[dict] = [] rows: list[dict] = []
skipped_synthetic = 0 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: for t in raw_rows:
closed_flag = t.get("close_time") not in (None, "") closed_flag = t.get("close_time") not in (None, "")
if closed_flag and is_synthetic_close(t.get("snapshot_json")): if closed_flag and is_synthetic_close(t.get("snapshot_json")):
@@ -560,6 +609,13 @@ def trade_stats(
continue continue
if ots < start_ts or ots >= end_ts: if ots < start_ts or ots >= end_ts:
continue 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) rows.append(t)
total = len(rows) total = len(rows)
@@ -656,6 +712,7 @@ def trade_stats(
"annotated": annotated, "annotated": annotated,
"unannotated": total - annotated, "unannotated": total - annotated,
"skipped_synthetic": skipped_synthetic, "skipped_synthetic": skipped_synthetic,
"skipped_dup_ticket": skipped_dup_ticket,
"period": period, "period": period,
"day": day_label, "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"],
+16 -3
View File
@@ -782,11 +782,14 @@ 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, ""): # - 上报持仓:关掉其它未平仓副本
# - 上报平仓:其它同单号副本盈亏清空(防 history 重复插入双计)
if ticket:
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
) )
if close_time in (None, ""):
conn.execute( conn.execute(
""" """
UPDATE trades UPDATE trades
@@ -796,6 +799,15 @@ def register_routes(app: Flask) -> None:
""", """,
(database.now(), dup_snap, act["account_id"], ticket, tid), (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}) return jsonify({"ok": True, "id": tid})
@app.post("/api/v1/trades/reconcile") @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") 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:
# 清掉历史假平仓上误写的盈亏 # 清掉假平仓盈亏 + 同单号重复平仓双计
database.scrub_synthetic_trade_profits(conn) database.scrub_synthetic_trade_profits(conn)
database.scrub_duplicate_ticket_profits(conn)
stats = database.trade_stats( stats = database.trade_stats(
conn, conn,
account_id=account_id, account_id=account_id,
+1
View File
@@ -6,6 +6,7 @@
默认按北京时间<strong>今日真实平仓</strong>统计;对账/去重产生的假平仓已排除。 默认按北京时间<strong>今日真实平仓</strong>统计;对账/去重产生的假平仓已排除。
{% if selected_period == 'today' and stats.day %}当前:{{ stats.day }}{% endif %} {% if selected_period == 'today' and stats.day %}当前:{{ stats.day }}{% endif %}
{% if stats.skipped_synthetic %} · 已排除假平仓 {{ stats.skipped_synthetic }} 笔{% endif %} {% if stats.skipped_synthetic %} · 已排除假平仓 {{ stats.skipped_synthetic }} 笔{% endif %}
{% if stats.skipped_dup_ticket %} · 已排除重复单号 {{ stats.skipped_dup_ticket }} 笔{% endif %}
</p> </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">