Reconcile stale open trades; report position ticket and history closes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+54
-1
@@ -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 ""
|
||||
|
||||
@@ -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"},
|
||||
})
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user