diff --git a/app/routes.py b/app/routes.py index 131e00a..21dfa3a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -683,12 +683,15 @@ def register_routes(app: Flask) -> None: existing = None if ticket: + # 优先更新仍未平仓的那条,避免历史补报插到已平仓副本上 existing = conn.execute( """ SELECT id, entry_style, review_note, open_time, close_time, close_price FROM trades WHERE account_id=? AND ticket=? - ORDER BY id DESC LIMIT 1 + ORDER BY CASE WHEN close_time IS NULL OR close_time='' THEN 0 ELSE 1 END, + id DESC + LIMIT 1 """, (act["account_id"], ticket), ).fetchone() @@ -765,6 +768,56 @@ def register_routes(app: Flask) -> None: tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] return jsonify({"ok": True, "id": tid}) + @app.post("/api/v1/trades/reconcile") + def api_trades_reconcile(): + """客户端上报当前仍持仓的 ticket;库中其它未平仓记录结束「持仓中」。""" + 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 = """ + 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() + closed_n = 0 + now_ts = database.now() + for r in rows: + if str(r["ticket"] or "") in open_tickets: + continue + try: + snap = json.loads(r["snapshot_json"] or "{}") + except json.JSONDecodeError: + snap = {} + snap["event"] = "reconcile_closed" + snap["reconciled_at"] = now_ts + # 若无盈亏则置 0,避免一直「—」;有历史补报时会再 upsert 覆盖 + profit = r["profit"] + if profit is None: + profit = 0.0 + conn.execute( + """ + UPDATE trades + SET close_time=?, profit=?, snapshot_json=? + WHERE id=? + """, + (now_ts, profit, json.dumps(snap, ensure_ascii=False), r["id"]), + ) + closed_n += 1 + return jsonify({"ok": True, "closed": closed_n}) + @app.post("/api/v1/bars") def api_bars_upload(): token = request.headers.get("X-Device-Token") or "" diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index ff994c1..22254a1 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -504,21 +504,38 @@ class Mt5Bridge: result = self._send_deal(request) except TradeError as exc: raise TradeError(f"下单失败:{exc}") from exc + # 持仓单号可能与 order 不同,优先用持仓 ticket,避免平仓对不上仍显示「持仓中」 + pos_ticket = int(result.order) + open_px = float(result.price) + open_ts = time.time() + for _ in range(10): + time.sleep(0.05) + for p in self.positions(symbol): + if int(p.ticket) == int(result.order) or abs(float(p.price_open) - open_px) < 0.05: + pos_ticket = int(p.ticket) + open_px = float(p.price_open) + open_ts = float(p.time) + sl = float(p.sl) if float(p.sl) > 0 else sl + tp = float(p.tp) if float(p.tp) > 0 else tp + break + else: + continue + break side = "买入" if order_type == mt5.ORDER_TYPE_BUY else "卖出" - msg = f"{side}成功 手数={lots:.2f} 成交价={result.price} 单号={result.order}" + msg = f"{side}成功 手数={lots:.2f} 成交价={open_px} 单号={pos_ticket}" report = { - "ticket": str(result.order), + "ticket": str(pos_ticket), "symbol": symbol, "side": "buy" if order_type == mt5.ORDER_TYPE_BUY else "sell", "volume": lots, - "open_price": float(result.price), + "open_price": open_px, "close_price": None, - "open_time": time.time(), + "open_time": open_ts, "close_time": None, "profit": 0.0, "sl": sl, "tp": tp, - "snapshot": {"event": "open"}, + "snapshot": {"event": "open", "order": int(result.order)}, } return msg, report @@ -609,6 +626,63 @@ class Mt5Bridge: if kind == "profit": raise TradeError(f"当日盈利已达 {pct:.2f}%(上限 +{DAY_PROFIT_LOCK*100:.0f}%),禁止开仓") + def recent_close_reports(self, symbol: str, hours: float = 72) -> list: + """从 MT5 历史成交还原已平仓,用于补报/纠错仍显示「持仓中」的记录。""" + self.ensure_symbol(symbol) + now = datetime.now() + deals = mt5.history_deals_get(now - timedelta(hours=hours), now + timedelta(hours=1)) + if not deals: + return [] + out_entries = { + mt5.DEAL_ENTRY_OUT, + mt5.DEAL_ENTRY_OUT_BY, + mt5.DEAL_ENTRY_INOUT, + } + by_pos: dict = {} + for d in deals: + if str(d.symbol) != symbol: + continue + if int(d.entry) not in out_entries: + continue + pid = int(d.position_id or 0) + if pid <= 0: + continue + g = by_pos.get(pid) + if g is None: + # 平仓方向:卖出平仓 → 原多单 + side = "buy" if int(d.type) == mt5.DEAL_TYPE_SELL else "sell" + g = { + "ticket": str(pid), + "symbol": symbol, + "side": side, + "volume": 0.0, + "open_price": 0.0, + "close_price": float(d.price), + "open_time": None, + "close_time": float(d.time), + "profit": 0.0, + "sl": None, + "tp": None, + "snapshot": {"event": "history_close"}, + } + by_pos[pid] = g + g["profit"] += float(d.profit) + float(d.swap) + float(d.commission) + g["volume"] += float(d.volume) + g["close_price"] = float(d.price) + g["close_time"] = max(float(g["close_time"]), float(d.time)) + for d in deals: + if str(d.symbol) != symbol: + continue + pid = int(d.position_id or 0) + if pid not in by_pos: + continue + if int(d.entry) in (mt5.DEAL_ENTRY_IN, mt5.DEAL_ENTRY_INOUT): + by_pos[pid]["open_price"] = float(d.price) + prev = by_pos[pid].get("open_time") + ts = float(d.time) + by_pos[pid]["open_time"] = ts if prev is None else min(float(prev), ts) + return list(by_pos.values()) + def close_ticket(self, ticket: int) -> str: p = self.get_position(ticket) symbol = str(p.symbol) @@ -1522,7 +1596,7 @@ class OrderApp(tk.Tk): threading.Thread(target=run, daemon=True).start() def _hub_sync_floating(self, symbol: str) -> None: - """定期把持仓浮盈 upsert 到中控,总览盈亏才有数。""" + """浮盈上报 + 历史平仓回填 + 对账清掉已不在仓的「假持仓中」。""" if hub_client is None or not is_activated(): return now = time.time() @@ -1534,9 +1608,14 @@ class OrderApp(tk.Tk): poss = self.bridge.positions(symbol) except Exception: return - if not poss: - return - reports = [] + open_tickets = [str(p.ticket) for p in poss] + reports: list = [] + # 1) 用成交历史补平仓(修复旧数据 / 漏报) + try: + reports.extend(self.bridge.recent_close_reports(symbol, hours=72)) + except Exception: + pass + # 2) 当前持仓浮盈 for p in poss: reports.append({ "ticket": str(p.ticket), @@ -1552,7 +1631,13 @@ class OrderApp(tk.Tk): "tp": float(p.tp), "snapshot": {"event": "float"}, }) - self._hub_report(reports, with_bars=False) + if reports: + self._hub_report(reports, with_bars=False) + # 3) 告诉服务器:不在 open_tickets 里的未平仓记录应结束「持仓中」 + try: + hub_client.reconcile_trades(open_tickets, symbol) + except Exception: + pass def _run_async(self, work) -> None: if self._busy: diff --git a/desktop/hub_client.py b/desktop/hub_client.py index 1bf5f34..b48b1d5 100644 --- a/desktop/hub_client.py +++ b/desktop/hub_client.py @@ -105,6 +105,30 @@ def report_trade(payload: dict[str, Any]) -> Optional[int]: return None +def reconcile_trades(open_tickets: list, symbol: str = "") -> int: + """告知中控当前仍持仓的单号;其余未平仓记录将被结束「持仓中」。""" + d = load_device() + if not d.get("token") or not d.get("server"): + return 0 + try: + r = _request( + "POST", + d["server"], + "/api/v1/trades/reconcile", + body={ + "open_tickets": [str(t) for t in (open_tickets or [])], + "symbol": symbol, + }, + token=d["token"], + timeout=15, + ) + if r.get("ok"): + return int(r.get("closed") or 0) + except HubError: + return 0 + return 0 + + def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int: d = load_device() if not d.get("token") or not d.get("server") or not bars: diff --git a/releases/黄金市价辅助.exe b/releases/黄金市价辅助.exe index d621006..9e7ce72 100644 Binary files a/releases/黄金市价辅助.exe and b/releases/黄金市价辅助.exe differ