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
|
existing = None
|
||||||
if ticket:
|
if ticket:
|
||||||
|
# 优先更新仍未平仓的那条,避免历史补报插到已平仓副本上
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, entry_style, review_note, open_time, close_time, close_price
|
SELECT id, entry_style, review_note, open_time, close_time, close_price
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE account_id=? AND ticket=?
|
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),
|
(act["account_id"], ticket),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -765,6 +768,56 @@ def register_routes(app: Flask) -> None:
|
|||||||
tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
return jsonify({"ok": True, "id": tid})
|
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")
|
@app.post("/api/v1/bars")
|
||||||
def api_bars_upload():
|
def api_bars_upload():
|
||||||
token = request.headers.get("X-Device-Token") or ""
|
token = request.headers.get("X-Device-Token") or ""
|
||||||
|
|||||||
@@ -504,21 +504,38 @@ class Mt5Bridge:
|
|||||||
result = self._send_deal(request)
|
result = self._send_deal(request)
|
||||||
except TradeError as exc:
|
except TradeError as exc:
|
||||||
raise TradeError(f"下单失败:{exc}") from 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 "卖出"
|
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 = {
|
report = {
|
||||||
"ticket": str(result.order),
|
"ticket": str(pos_ticket),
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"side": "buy" if order_type == mt5.ORDER_TYPE_BUY else "sell",
|
"side": "buy" if order_type == mt5.ORDER_TYPE_BUY else "sell",
|
||||||
"volume": lots,
|
"volume": lots,
|
||||||
"open_price": float(result.price),
|
"open_price": open_px,
|
||||||
"close_price": None,
|
"close_price": None,
|
||||||
"open_time": time.time(),
|
"open_time": open_ts,
|
||||||
"close_time": None,
|
"close_time": None,
|
||||||
"profit": 0.0,
|
"profit": 0.0,
|
||||||
"sl": sl,
|
"sl": sl,
|
||||||
"tp": tp,
|
"tp": tp,
|
||||||
"snapshot": {"event": "open"},
|
"snapshot": {"event": "open", "order": int(result.order)},
|
||||||
}
|
}
|
||||||
return msg, report
|
return msg, report
|
||||||
|
|
||||||
@@ -609,6 +626,63 @@ class Mt5Bridge:
|
|||||||
if kind == "profit":
|
if kind == "profit":
|
||||||
raise TradeError(f"当日盈利已达 {pct:.2f}%(上限 +{DAY_PROFIT_LOCK*100:.0f}%),禁止开仓")
|
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:
|
def close_ticket(self, ticket: int) -> str:
|
||||||
p = self.get_position(ticket)
|
p = self.get_position(ticket)
|
||||||
symbol = str(p.symbol)
|
symbol = str(p.symbol)
|
||||||
@@ -1522,7 +1596,7 @@ class OrderApp(tk.Tk):
|
|||||||
threading.Thread(target=run, daemon=True).start()
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
def _hub_sync_floating(self, symbol: str) -> None:
|
def _hub_sync_floating(self, symbol: str) -> None:
|
||||||
"""定期把持仓浮盈 upsert 到中控,总览盈亏才有数。"""
|
"""浮盈上报 + 历史平仓回填 + 对账清掉已不在仓的「假持仓中」。"""
|
||||||
if hub_client is None or not is_activated():
|
if hub_client is None or not is_activated():
|
||||||
return
|
return
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1534,9 +1608,14 @@ class OrderApp(tk.Tk):
|
|||||||
poss = self.bridge.positions(symbol)
|
poss = self.bridge.positions(symbol)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
if not poss:
|
open_tickets = [str(p.ticket) for p in poss]
|
||||||
return
|
reports: list = []
|
||||||
reports = []
|
# 1) 用成交历史补平仓(修复旧数据 / 漏报)
|
||||||
|
try:
|
||||||
|
reports.extend(self.bridge.recent_close_reports(symbol, hours=72))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 2) 当前持仓浮盈
|
||||||
for p in poss:
|
for p in poss:
|
||||||
reports.append({
|
reports.append({
|
||||||
"ticket": str(p.ticket),
|
"ticket": str(p.ticket),
|
||||||
@@ -1552,7 +1631,13 @@ class OrderApp(tk.Tk):
|
|||||||
"tp": float(p.tp),
|
"tp": float(p.tp),
|
||||||
"snapshot": {"event": "float"},
|
"snapshot": {"event": "float"},
|
||||||
})
|
})
|
||||||
|
if reports:
|
||||||
self._hub_report(reports, with_bars=False)
|
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:
|
def _run_async(self, work) -> None:
|
||||||
if self._busy:
|
if self._busy:
|
||||||
|
|||||||
@@ -105,6 +105,30 @@ def report_trade(payload: dict[str, Any]) -> Optional[int]:
|
|||||||
return None
|
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:
|
def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int:
|
||||||
d = load_device()
|
d = load_device()
|
||||||
if not d.get("token") or not d.get("server") or not bars:
|
if not d.get("token") or not d.get("server") or not bars:
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user