diff --git a/app/db.py b/app/db.py index b8398f0..b96383d 100644 --- a/app/db.py +++ b/app/db.py @@ -305,20 +305,33 @@ def list_trades( open_only: bool = False, ) -> list[dict]: clause, params = _trades_where(account_id, machine_id, open_only=open_only) - order = ( - "ORDER BY COALESCE(t.open_time, t.created_at) DESC" - if open_only - else "ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC" - ) - sql = f""" - SELECT t.*, a.name AS account_name, m.name AS machine_name - FROM trades t - LEFT JOIN accounts a ON a.id = t.account_id - LEFT JOIN machines m ON m.id = t.machine_id - {clause} - {order} - LIMIT ? OFFSET ? - """ + if open_only: + # 持仓列表:同一账户+单号只显示最新一条 + sql = f""" + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + {clause} + AND t.id = ( + SELECT MAX(t2.id) FROM trades t2 + WHERE t2.account_id = t.account_id + AND t2.ticket = t.ticket + AND (t2.close_time IS NULL OR t2.close_time='') + ) + ORDER BY COALESCE(t.open_time, t.created_at) DESC + LIMIT ? OFFSET ? + """ + else: + sql = f""" + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + {clause} + ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC + LIMIT ? OFFSET ? + """ params.extend([limit, max(0, int(offset))]) cur = conn.execute(sql, tuple(params)) rows = [] diff --git a/app/routes.py b/app/routes.py index 5878813..a853714 100644 --- a/app/routes.py +++ b/app/routes.py @@ -780,37 +780,58 @@ def register_routes(app: Flask) -> None: database.now(), ), ) - tid = 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, ""): + dup_snap = json.dumps( + {"event": "dedupe_closed", "kept_id": tid}, ensure_ascii=False + ) + conn.execute( + """ + UPDATE trades + SET close_time=?, 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), + ) return jsonify({"ok": True, "id": tid}) @app.post("/api/v1/trades/reconcile") def api_trades_reconcile(): - """客户端上报当前仍持仓的 ticket;库中其它未平仓记录结束「持仓中」。""" + """客户端上报当前仍持仓的 ticket;库中其它未平仓记录结束「持仓中」。 + + 按账户对账(不按品种过滤),避免 XAUUSD/XAUUSDm 不一致导致幽灵持仓。 + 同一 ticket 多条未平仓时只保留 id 最大的一条。 + """ token = request.headers.get("X-Device-Token") or "" data = request.get_json(silent=True) or {} open_tickets = {str(t).strip() for t in (data.get("open_tickets") or []) if str(t).strip()} - symbol = (data.get("symbol") or "").strip().upper() with database.get_db() as conn: act = database.auth_token(conn, token) if not act: return jsonify({"ok": False, "error": "未激活"}), 401 if not act.get("account_id"): return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400 - sql = """ + rows = conn.execute( + """ SELECT id, ticket, profit, snapshot_json FROM trades WHERE account_id=? AND (close_time IS NULL OR close_time='') - """ - params: list = [act["account_id"]] - if symbol: - sql += " AND UPPER(symbol)=?" - params.append(symbol) - rows = conn.execute(sql, tuple(params)).fetchall() + ORDER BY id DESC + """, + (act["account_id"],), + ).fetchall() closed_n = 0 now_ts = database.now() + seen_open: set[str] = set() for r in rows: - if str(r["ticket"] or "") in open_tickets: + tid = str(r["ticket"] or "") + keep = tid in open_tickets and tid not in seen_open + if keep: + seen_open.add(tid) continue try: snap = json.loads(r["snapshot_json"] or "{}") @@ -818,7 +839,6 @@ def register_routes(app: Flask) -> None: snap = {} snap["event"] = "reconcile_closed" snap["reconciled_at"] = now_ts - # 若无盈亏则置 0,避免一直「—」;有历史补报时会再 upsert 覆盖 profit = r["profit"] if profit is None: profit = 0.0 diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index f7426ad..1792b14 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -49,7 +49,8 @@ TODAY_STATS_MS = 2500 # 今日战绩(含 history)节流 ATR_CACHE_MS = 2000 DAY_OHLC_CACHE_MS = 1500 HUB_FLOAT_MS = 3000 # 浮盈上报间隔 -HUB_RECONCILE_MS = 45000 # 历史回填/对账间隔(重) +HUB_RECONCILE_MS = 8000 # 对账间隔:清掉中控幽灵持仓 +HUB_HISTORY_MS = 60000 # 历史平仓回填(较重) BALANCE_HIGH = 10000.0 BALANCE_LOW = 1000.0 @@ -1031,6 +1032,7 @@ class OrderApp(tk.Tk): self._day_ohlc_cache: Tuple[float, Any] = (0.0, None) # (ts, dict) self._last_float_sync = 0.0 self._last_reconcile_sync = 0.0 + self._last_history_sync = 0.0 self._hub_sync_busy = False self._cached_today_stats: Optional[dict] = None self._build_ui() @@ -1713,8 +1715,11 @@ class OrderApp(tk.Tk): return self._last_float_sync = now do_reconcile = (now - float(self._last_reconcile_sync or 0)) >= HUB_RECONCILE_MS / 1000.0 + do_history = (now - float(self._last_history_sync or 0)) >= HUB_HISTORY_MS / 1000.0 if do_reconcile: self._last_reconcile_sync = now + if do_history: + self._last_history_sync = now # 持仓快照在 UI 线程取,避免与刷新抢 MT5 try: poss = self.bridge.positions(symbol) @@ -1742,7 +1747,7 @@ class OrderApp(tk.Tk): def run() -> None: try: reports = list(float_reports) - if do_reconcile: + if do_history: try: reports[0:0] = self.bridge.recent_close_reports(symbol, hours=48) except Exception: @@ -1752,11 +1757,11 @@ class OrderApp(tk.Tk): hub_client.report_trade(item) except Exception: pass - if do_reconcile: - try: - hub_client.reconcile_trades(open_tickets, symbol) - except Exception: - pass + # 对账:清掉中控多出来的幽灵持仓(与 MT5 实际持仓对齐) + try: + hub_client.reconcile_trades(open_tickets, symbol) + except Exception: + pass except Exception: pass finally: diff --git a/releases/黄金市价辅助.exe b/releases/黄金市价辅助.exe index fcfedbf..e9589fa 100644 Binary files a/releases/黄金市价辅助.exe and b/releases/黄金市价辅助.exe differ