From 0cf3756b09ba35d285b4964787698a86d1090eb1 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 26 Jul 2026 22:39:27 +0800 Subject: [PATCH] Harden LIVE opens with slot claim and exchange reconcile. Prevent duplicate opens by atomically claiming an opening slot, verifying exchange perp is flat before live orders, setting leverage from ledger, and preferring exchange position size when closing perps. Co-authored-by: Cursor --- backend/app/live/binance_executor.py | 45 ++++++-- backend/app/live/binance_trade.py | 33 ++++++ backend/app/live/executor.py | 55 ++++++++-- backend/app/live/okx_trade.py | 37 +++++++ backend/app/live/reconcile.py | 148 ++++++++++++++++++++++++++ backend/app/main.py | 7 ++ backend/app/sim/matcher.py | 6 +- backend/app/strategy/engine.py | 7 ++ backend/tests/test_live_recovery.py | 1 + backend/tests/test_reconcile_claim.py | 72 +++++++++++++ 10 files changed, 390 insertions(+), 21 deletions(-) create mode 100644 backend/app/live/reconcile.py create mode 100644 backend/tests/test_reconcile_claim.py diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 3640a6a..d4358dd 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -12,6 +12,12 @@ from ..sim.matcher import CloseResult, Matcher, OpenResult from ..sim.pricing import option_expiry_settle, option_intrinsic from ..strategy.session import get_session from .binance_trade import BinanceTradeClient +from .reconcile import ( + assert_safe_to_open_live, + claim_open_slot, + perp_close_qty_eth_binance, + release_open_slot_if_opening, +) from .symbols import live_settings, resolve_perp_inst_id logger = logging.getLogger(__name__) @@ -83,14 +89,16 @@ class BinanceLiveExecutor(Matcher): if err: return OpenResult(ok=False, detail=err) - s = live_settings() - if self.has_open_position(): - st = self.position_status() - return OpenResult( - ok=False, - detail=f"已有持仓/半仓状态({st}),请先修复或平仓", - ) + claimed, claim_msg = claim_open_slot(self.db) + if not claimed: + return OpenResult(ok=False, detail=claim_msg) + safe, safe_msg = assert_safe_to_open_live(self) + if not safe: + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=safe_msg) + + s = live_settings() client = self._client() perp_inst = resolve_perp_inst_id(self.db) perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) @@ -106,6 +114,7 @@ class BinanceLiveExecutor(Matcher): ) except Exception as e: logger.exception("binance live open option failed") + release_open_slot_if_opening(self.db) return OpenResult(ok=False, detail=f"币安开期权失败: {e}") filled_opt_contracts = float(opt_fill.sz) if opt_fill.sz and opt_fill.sz > 0 else float( @@ -120,6 +129,11 @@ class BinanceLiveExecutor(Matcher): side, pos_side = "BUY", "LONG" else: side, pos_side = "SELL", "SHORT" + leverage = self.ledger.get_setting_float("leverage", s.leverage) + try: + client.set_leverage(perp_inst, leverage) + except Exception as e_lev: + logger.warning("binance set_leverage failed: %s", e_lev) perp_fill_live = client.place_perp_market( symbol=perp_inst, side=side, @@ -157,6 +171,7 @@ class BinanceLiveExecutor(Matcher): group_id=group_id, detail=f"永续开仓失败(保证金)且期权回滚失败,已标记 half_open: {e} / {e2}", ) + release_open_slot_if_opening(self.db) return OpenResult( ok=False, detail=f"永续开仓失败(多为保证金不足),已回滚期权: {e}", @@ -598,10 +613,16 @@ class BinanceLiveExecutor(Matcher): side, pos_side = "SELL", "LONG" else: side, pos_side = "BUY", "SHORT" + perp_qty_close = perp_close_qty_eth_binance( + client, + perp_inst=perp_inst, + perp_side=perp_side, + perp_qty_eth=perp_qty, + ) perp_live = client.place_perp_market( symbol=perp_inst, side=side, - qty_eth=perp_qty, + qty_eth=perp_qty_close, position_side=pos_side, reduce_only=True, ) @@ -901,10 +922,16 @@ class BinanceLiveExecutor(Matcher): side, pos_side = "SELL", "LONG" else: side, pos_side = "BUY", "SHORT" + perp_qty_close = perp_close_qty_eth_binance( + client, + perp_inst=perp_inst, + perp_side=perp_side, + perp_qty_eth=perp_qty, + ) perp_live = client.place_perp_market( symbol=perp_inst, side=side, - qty_eth=perp_qty, + qty_eth=perp_qty_close, position_side=pos_side, reduce_only=True, ) diff --git a/backend/app/live/binance_trade.py b/backend/app/live/binance_trade.py index 8530e92..c8d17a5 100644 --- a/backend/app/live/binance_trade.py +++ b/backend/app/live/binance_trade.py @@ -314,6 +314,39 @@ class BinanceTradeClient: return to_usdt(float(upl), "USDT") return 0.0 + def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None: + """当前永续绝对持仓(ETH)。""" + try: + rows = self._signed( + self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol} + ) + except Exception as e: + logger.warning("binance get_perp_pos_sz failed: %s", e) + return None + if isinstance(rows, dict): + rows = [rows] + want = (position_side or "").strip().upper() + for row in rows: + amt = safe_float(row.get("positionAmt")) or 0.0 + if abs(amt) < 1e-12: + continue + ps = str(row.get("positionSide") or "").upper() + if want and ps and ps not in ("BOTH",) and ps != want: + continue + return abs(float(amt)) + return 0.0 + + def set_leverage(self, symbol: str, leverage: int | float) -> None: + lev = int(round(float(leverage))) + if lev < 1: + lev = 1 + self._signed( + self._fapi, + "POST", + "/fapi/v1/leverage", + {"symbol": symbol, "leverage": lev}, + ) + def get_funding_usdt( self, symbol: str, *, begin_ms: int, end_ms: int | None = None ) -> float: diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index b1371c2..c2a1f94 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -14,6 +14,12 @@ from ..sim.matcher import CloseResult, Matcher, OpenResult from ..sim.pricing import option_expiry_settle, option_intrinsic from ..strategy.session import get_session from .okx_trade import OkxTradeClient +from .reconcile import ( + assert_safe_to_open_live, + claim_open_slot, + perp_close_contracts_okx, + release_open_slot_if_opening, +) from .symbols import live_settings, resolve_perp_inst_id logger = logging.getLogger(__name__) @@ -87,14 +93,16 @@ class OkxLiveExecutor(Matcher): if err: return OpenResult(ok=False, detail=err) - s = live_settings() - if self.has_open_position(): - st = self.position_status() - return OpenResult( - ok=False, - detail=f"已有持仓/半仓状态({st}),请先修复或平仓", - ) + claimed, claim_msg = claim_open_slot(self.db) + if not claimed: + return OpenResult(ok=False, detail=claim_msg) + safe, safe_msg = assert_safe_to_open_live(self) + if not safe: + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=safe_msg) + + s = live_settings() client = self._client() perp_inst = resolve_perp_inst_id(self.db) perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) @@ -112,6 +120,7 @@ class OkxLiveExecutor(Matcher): ) except Exception as e: logger.exception("live open option failed") + release_open_slot_if_opening(self.db) return OpenResult(ok=False, detail=f"实盘开期权失败: {e}") # 以交易所实际成交张数回写名义 @@ -124,11 +133,24 @@ class OkxLiveExecutor(Matcher): # 永续市价:按产品假设,失败原因实质为保证金不足 → 必须回滚期权 try: ct_val = client.get_ct_val(perp_inst, inst_type="SWAP") - perp_sz = max(1, int(round(perp_qty / ct_val))) + perp_sz = perp_close_contracts_okx( + client, + perp_inst=perp_inst, + perp_side=perp_side, + perp_qty_eth=perp_qty, + ct_val=ct_val, + ) if perp_side == "long": side, pos_side = "buy", "long" else: side, pos_side = "sell", "short" + leverage = self.ledger.get_setting_float("leverage", s.leverage) + try: + client.set_leverage( + perp_inst, leverage, mgn_mode="cross", pos_side=pos_side + ) + except Exception as e_lev: + logger.warning("okx set_leverage failed: %s", e_lev) perp_fill_live = client.place_market( inst_id=perp_inst, side=side, @@ -168,6 +190,7 @@ class OkxLiveExecutor(Matcher): group_id=group_id, detail=f"永续开仓失败(保证金)且期权回滚失败,已标记 half_open: {e} / {e2}", ) + release_open_slot_if_opening(self.db) return OpenResult( ok=False, detail=f"永续开仓失败(多为保证金不足),已回滚期权: {e}", @@ -604,7 +627,13 @@ class OkxLiveExecutor(Matcher): try: ct_val = client.get_ct_val(perp_inst, inst_type="SWAP") - perp_sz = max(1, int(round(perp_qty / ct_val))) + perp_sz = perp_close_contracts_okx( + client, + perp_inst=perp_inst, + perp_side=perp_side, + perp_qty_eth=perp_qty, + ct_val=ct_val, + ) if perp_side == "long": side, pos_side = "sell", "long" else: @@ -883,7 +912,13 @@ class OkxLiveExecutor(Matcher): client = self._client() try: ct_val = client.get_ct_val(perp_inst, inst_type="SWAP") - perp_sz = max(1, int(round(perp_qty / ct_val))) + perp_sz = perp_close_contracts_okx( + client, + perp_inst=perp_inst, + perp_side=perp_side, + perp_qty_eth=perp_qty, + ct_val=ct_val, + ) if perp_side == "long": side, pos_side = "sell", "long" else: diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index 79ca047..313578e 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -232,6 +232,43 @@ class OkxTradeClient: return to_usdt(float(upl), ccy) return 0.0 + def get_perp_pos_sz(self, inst_id: str, *, pos_side: str | None = None) -> float | None: + """当前永续绝对持仓张数。""" + try: + rows = self._request( + "GET", f"/api/v5/account/positions?instId={inst_id}" + ) + except Exception as e: + logger.warning("okx get_perp_pos_sz failed: %s", e) + return None + want = (pos_side or "").strip().lower() + for row in rows: + ps = str(row.get("posSide") or "").lower() + pos = safe_float(row.get("pos")) or 0.0 + if abs(pos) < 1e-12: + continue + if want and want not in ("net", "") and ps and ps != want and ps != "net": + continue + return abs(float(pos)) + return 0.0 + + def set_leverage( + self, + inst_id: str, + leverage: float, + *, + mgn_mode: str = "cross", + pos_side: str | None = None, + ) -> None: + body: dict[str, Any] = { + "instId": inst_id, + "lever": str(leverage), + "mgnMode": mgn_mode, + } + if pos_side: + body["posSide"] = pos_side + self._request("POST", "/api/v5/account/set-leverage", body) + def get_funding_usdt( self, inst_id: str, *, begin_ms: int, end_ms: int | None = None ) -> float: diff --git a/backend/app/live/reconcile.py b/backend/app/live/reconcile.py new file mode 100644 index 0000000..b4ab094 --- /dev/null +++ b/backend/app/live/reconcile.py @@ -0,0 +1,148 @@ +"""LIVE 开仓对账:占槽、交易所持仓核对、平永续数量解析。""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..config import get_settings +from ..exchange.runtime import load_runtime_settings +from ..sim.matcher import BLOCKING_STATUSES +from .symbols import resolve_perp_inst_id + +logger = logging.getLogger(__name__) + +_PERP_EPS = 1e-8 + + +def claim_open_slot(db) -> tuple[bool, str]: + """原子占用开仓槽:flat/空 → opening;已有 blocking 状态则拒绝。""" + with db._lock: + row = db._conn.execute("SELECT status FROM positions WHERE id=1").fetchone() + st = str(row["status"] or "") if row else "" + if st in BLOCKING_STATUSES: + return False, f"已有持仓/半仓状态({st}),请先修复或平仓" + cur = db._conn.execute( + """UPDATE positions SET status='opening' + WHERE id=1 AND (status IS NULL OR status='' OR status='flat')""" + ) + if cur.rowcount != 1: + st2 = st or "unknown" + return False, f"无法占用开仓槽(当前 status={st2})" + db._conn.commit() + return True, "ok" + + +def release_open_slot_if_opening(db) -> None: + """开仓失败且未落 half_open/open 时,释放 opening 占槽。""" + with db._lock: + db._conn.execute( + "UPDATE positions SET status='flat' WHERE id=1 AND status='opening'" + ) + db._conn.commit() + + +def exchange_perp_abs_size( + client: Any, + exchange: str, + perp_inst_id: str, + perp_side: str, +) -> float | None: + """查询交易所永续绝对持仓:OKX 张数,Binance ETH。""" + ex = (exchange or "").strip().lower() + try: + if ex == "binance": + ps = "LONG" if perp_side == "long" else "SHORT" + return client.get_perp_pos_sz(perp_inst_id, position_side=ps) + ps = "long" if perp_side == "long" else "short" + return client.get_perp_pos_sz(perp_inst_id, pos_side=ps) + except Exception as e: + logger.warning("exchange_perp_abs_size failed exchange=%s: %s", ex, e) + return None + + +def assert_safe_to_open_live(executor) -> tuple[bool, str]: + """LIVE 开仓前:本地无 blocking 仓,且交易所无残留永续(flat/opening 时)。""" + if get_settings().is_sim: + return True, "ok" + + pos = executor.current_position() + st = str(pos.get("status") or "") + if st in BLOCKING_STATUSES and st != "opening": + return False, f"已有持仓/半仓状态({st}),请先修复或平仓" + + client, ex_name = _executor_client_and_exchange(executor) + if client is None: + return False, "无法核对交易所持仓" + + perp_inst = resolve_perp_inst_id(executor.db) + perp_side = str(pos.get("perp_side") or "long") + ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, perp_side) + if ex_sz is None: + return False, "无法核对交易所持仓" + + if ex_sz > _PERP_EPS and st in ("flat", "", "opening"): + return ( + False, + "交易所有永续仓但本地无持仓,禁止新开,请人工核对", + ) + return True, "ok" + + +def log_exchange_db_mismatch(executor) -> None: + """LIVE 启动时记录交易所 vs 本地持仓不一致(仅日志,不阻断)。""" + if get_settings().is_sim: + return + ok, msg = assert_safe_to_open_live(executor) + if ok: + logger.info("LIVE startup reconcile: exchange/DB perp OK") + else: + logger.warning("LIVE startup reconcile mismatch: %s", msg) + + +def perp_close_contracts_okx( + client: Any, + *, + perp_inst: str, + perp_side: str, + perp_qty_eth: float, + ct_val: float, +) -> int: + """平永续张数:优先交易所持仓,否则 DB qty/ct_val。""" + ps = "long" if perp_side == "long" else "short" + ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps) + if ex_sz is not None and ex_sz > _PERP_EPS: + return max(1, int(round(ex_sz))) + return max(1, int(round(perp_qty_eth / ct_val))) + + +def perp_close_qty_eth_binance( + client: Any, + *, + perp_inst: str, + perp_side: str, + perp_qty_eth: float, +) -> float: + """平永续 ETH 数量:优先交易所持仓,否则 DB qty。""" + ps = "LONG" if perp_side == "long" else "SHORT" + ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps) + if ex_sz is not None and ex_sz > _PERP_EPS: + return float(ex_sz) + return float(perp_qty_eth) + + +def _executor_client_and_exchange(executor) -> tuple[Any | None, str | None]: + if get_settings().is_sim: + return None, None + try: + ex_name = load_runtime_settings().exchange + except Exception: + ex_name = get_settings().exchange + client = None + if hasattr(executor, "_client"): + try: + client = executor._client() + except Exception as e: + logger.warning("live executor client unavailable: %s", e) + return None, ex_name + return client, ex_name diff --git a/backend/app/main.py b/backend/app/main.py index ec26bec..d17f517 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,13 @@ async def lifespan(app: FastAPI): logger.info("LIVE startup: forced strategy pause (manual start required)") except Exception: logger.exception("LIVE startup force-pause failed") + try: + from .live import get_executor + from .live.reconcile import log_exchange_db_mismatch + + log_exchange_db_mismatch(get_executor(db)) + except Exception: + logger.exception("LIVE startup reconcile log failed") engine.ensure_loop() session = bootstrap_session(settings) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 38d091c..50192cd 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -23,7 +23,7 @@ from .pricing import ( # 禁止新开仓的本地仓位状态(实盘防卡) BLOCKING_STATUSES = frozenset( - {"open", "half_open", "option_closed_perp_pending"} + {"open", "half_open", "option_closed_perp_pending", "opening"} ) @@ -72,11 +72,13 @@ class Matcher: return dict(row) def has_open_position(self) -> bool: - """是否禁止新开:含 open / half_open / option_closed_perp_pending。""" + """是否禁止新开:含 open / half_open / option_closed_perp_pending / opening。""" pos = self.current_position() st = str(pos.get("status") or "") if st not in BLOCKING_STATUSES: return False + if st == "opening": + return True return bool(pos.get("group_id") or pos.get("option_inst_id")) def position_status(self) -> str: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 4a6f989..5d3cb1d 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -513,6 +513,13 @@ class StrategyEngine: pick.pair.call_inst_id if pick.option_side == "call" else pick.pair.put_inst_id ) entry_idx = pick.underlying_px + if not get_settings().is_sim: + from ..live.reconcile import assert_safe_to_open_live + + safe, safe_msg = assert_safe_to_open_live(self.matcher) + if not safe: + self._set_state(phase="idle", last_error=safe_msg) + return r = await asyncio.to_thread( self.matcher.open_group, group_id=gid, diff --git a/backend/tests/test_live_recovery.py b/backend/tests/test_live_recovery.py index 30a6840..d705474 100644 --- a/backend/tests/test_live_recovery.py +++ b/backend/tests/test_live_recovery.py @@ -10,6 +10,7 @@ def test_blocking_statuses_include_repair_states() -> None: assert "half_open" in BLOCKING_STATUSES assert "option_closed_perp_pending" in BLOCKING_STATUSES assert "open" in BLOCKING_STATUSES + assert "opening" in BLOCKING_STATUSES def test_has_open_position_blocks_half_open(tmp_path, monkeypatch) -> None: diff --git a/backend/tests/test_reconcile_claim.py b/backend/tests/test_reconcile_claim.py new file mode 100644 index 0000000..e78c5d9 --- /dev/null +++ b/backend/tests/test_reconcile_claim.py @@ -0,0 +1,72 @@ +"""claim_open_slot / release_open_slot 单元测试。""" + +from __future__ import annotations + +from app.live.reconcile import claim_open_slot, release_open_slot_if_opening +from app.sim.matcher import BLOCKING_STATUSES, Matcher + + +def test_blocking_statuses_include_opening() -> None: + assert "opening" in BLOCKING_STATUSES + + +def test_claim_open_slot_from_flat(tmp_path) -> None: + from app.models.db import Database + + db = Database(tmp_path / "claim.db") + m = Matcher(db) + assert m.has_open_position() is False + + ok, msg = claim_open_slot(db) + assert ok is True + assert msg == "ok" + assert m.position_status() == "opening" + assert m.has_open_position() is True + + ok2, _ = claim_open_slot(db) + assert ok2 is False + + release_open_slot_if_opening(db) + assert m.position_status() == "flat" + assert m.has_open_position() is False + + ok3, _ = claim_open_slot(db) + assert ok3 is True + release_open_slot_if_opening(db) + db.close() + + +def test_claim_rejects_blocking_states(tmp_path) -> None: + from app.models.db import Database + + db = Database(tmp_path / "block.db") + for st in ("open", "half_open", "option_closed_perp_pending"): + with db._lock: + db._conn.execute( + "UPDATE positions SET status=?, group_id=? WHERE id=1", + (st, "G-test"), + ) + db._conn.commit() + ok, msg = claim_open_slot(db) + assert ok is False + assert st in msg + with db._lock: + db._conn.execute( + "UPDATE positions SET status='flat', group_id=NULL WHERE id=1" + ) + db._conn.commit() + db.close() + + +def test_release_only_when_opening(tmp_path) -> None: + from app.models.db import Database + + db = Database(tmp_path / "rel.db") + with db._lock: + db._conn.execute("UPDATE positions SET status='open' WHERE id=1") + db._conn.commit() + release_open_slot_if_opening(db) + row = db.fetchone("SELECT status FROM positions WHERE id=1") + assert row is not None + assert row["status"] == "open" + db.close()