Fix ghost open positions: dedupe tickets and reconcile against MT5.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 18:03:48 +08:00
parent e2847c90df
commit 391851a2b0
4 changed files with 71 additions and 33 deletions
+19 -6
View File
@@ -305,18 +305,31 @@ def list_trades(
open_only: bool = False, open_only: bool = False,
) -> list[dict]: ) -> list[dict]:
clause, params = _trades_where(account_id, machine_id, open_only=open_only) clause, params = _trades_where(account_id, machine_id, open_only=open_only)
order = ( if open_only:
"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""" sql = f"""
SELECT t.*, a.name AS account_name, m.name AS machine_name SELECT t.*, a.name AS account_name, m.name AS machine_name
FROM trades t FROM trades t
LEFT JOIN accounts a ON a.id = t.account_id LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN machines m ON m.id = t.machine_id LEFT JOIN machines m ON m.id = t.machine_id
{clause} {clause}
{order} 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 ? LIMIT ? OFFSET ?
""" """
params.extend([limit, max(0, int(offset))]) params.extend([limit, max(0, int(offset))])
+32 -12
View File
@@ -780,37 +780,58 @@ def register_routes(app: Flask) -> None:
database.now(), 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}) return jsonify({"ok": True, "id": tid})
@app.post("/api/v1/trades/reconcile") @app.post("/api/v1/trades/reconcile")
def api_trades_reconcile(): def api_trades_reconcile():
"""客户端上报当前仍持仓的 ticket;库中其它未平仓记录结束「持仓中」。""" """客户端上报当前仍持仓的 ticket;库中其它未平仓记录结束「持仓中」。
按账户对账(不按品种过滤),避免 XAUUSD/XAUUSDm 不一致导致幽灵持仓。
同一 ticket 多条未平仓时只保留 id 最大的一条。
"""
token = request.headers.get("X-Device-Token") or "" token = request.headers.get("X-Device-Token") or ""
data = request.get_json(silent=True) 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()} 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: with database.get_db() as conn:
act = database.auth_token(conn, token) act = database.auth_token(conn, token)
if not act: if not act:
return jsonify({"ok": False, "error": "未激活"}), 401 return jsonify({"ok": False, "error": "未激活"}), 401
if not act.get("account_id"): if not act.get("account_id"):
return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400 return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400
sql = """ rows = conn.execute(
"""
SELECT id, ticket, profit, snapshot_json SELECT id, ticket, profit, snapshot_json
FROM trades FROM trades
WHERE account_id=? WHERE account_id=?
AND (close_time IS NULL OR close_time='') AND (close_time IS NULL OR close_time='')
""" ORDER BY id DESC
params: list = [act["account_id"]] """,
if symbol: (act["account_id"],),
sql += " AND UPPER(symbol)=?" ).fetchall()
params.append(symbol)
rows = conn.execute(sql, tuple(params)).fetchall()
closed_n = 0 closed_n = 0
now_ts = database.now() now_ts = database.now()
seen_open: set[str] = set()
for r in rows: 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 continue
try: try:
snap = json.loads(r["snapshot_json"] or "{}") snap = json.loads(r["snapshot_json"] or "{}")
@@ -818,7 +839,6 @@ 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
# 若无盈亏则置 0,避免一直「—」;有历史补报时会再 upsert 覆盖
profit = r["profit"] profit = r["profit"]
if profit is None: if profit is None:
profit = 0.0 profit = 0.0
+8 -3
View File
@@ -49,7 +49,8 @@ TODAY_STATS_MS = 2500 # 今日战绩(含 history)节流
ATR_CACHE_MS = 2000 ATR_CACHE_MS = 2000
DAY_OHLC_CACHE_MS = 1500 DAY_OHLC_CACHE_MS = 1500
HUB_FLOAT_MS = 3000 # 浮盈上报间隔 HUB_FLOAT_MS = 3000 # 浮盈上报间隔
HUB_RECONCILE_MS = 45000 # 历史回填/对账间隔(重) HUB_RECONCILE_MS = 8000 # 对账间隔:清掉中控幽灵持仓
HUB_HISTORY_MS = 60000 # 历史平仓回填(较重)
BALANCE_HIGH = 10000.0 BALANCE_HIGH = 10000.0
BALANCE_LOW = 1000.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._day_ohlc_cache: Tuple[float, Any] = (0.0, None) # (ts, dict)
self._last_float_sync = 0.0 self._last_float_sync = 0.0
self._last_reconcile_sync = 0.0 self._last_reconcile_sync = 0.0
self._last_history_sync = 0.0
self._hub_sync_busy = False self._hub_sync_busy = False
self._cached_today_stats: Optional[dict] = None self._cached_today_stats: Optional[dict] = None
self._build_ui() self._build_ui()
@@ -1713,8 +1715,11 @@ class OrderApp(tk.Tk):
return return
self._last_float_sync = now self._last_float_sync = now
do_reconcile = (now - float(self._last_reconcile_sync or 0)) >= HUB_RECONCILE_MS / 1000.0 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: if do_reconcile:
self._last_reconcile_sync = now self._last_reconcile_sync = now
if do_history:
self._last_history_sync = now
# 持仓快照在 UI 线程取,避免与刷新抢 MT5 # 持仓快照在 UI 线程取,避免与刷新抢 MT5
try: try:
poss = self.bridge.positions(symbol) poss = self.bridge.positions(symbol)
@@ -1742,7 +1747,7 @@ class OrderApp(tk.Tk):
def run() -> None: def run() -> None:
try: try:
reports = list(float_reports) reports = list(float_reports)
if do_reconcile: if do_history:
try: try:
reports[0:0] = self.bridge.recent_close_reports(symbol, hours=48) reports[0:0] = self.bridge.recent_close_reports(symbol, hours=48)
except Exception: except Exception:
@@ -1752,7 +1757,7 @@ class OrderApp(tk.Tk):
hub_client.report_trade(item) hub_client.report_trade(item)
except Exception: except Exception:
pass pass
if do_reconcile: # 对账:清掉中控多出来的幽灵持仓(与 MT5 实际持仓对齐)
try: try:
hub_client.reconcile_trades(open_tickets, symbol) hub_client.reconcile_trades(open_tickets, symbol)
except Exception: except Exception:
Binary file not shown.