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 %}