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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 22:39:27 +08:00
parent f48ea5bbcc
commit 0cf3756b09
10 changed files with 390 additions and 21 deletions
+36 -9
View File
@@ -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,
)
+33
View File
@@ -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:
+45 -10
View File
@@ -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:
+37
View File
@@ -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:
+148
View File
@@ -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
+7
View File
@@ -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)
+4 -2
View File
@@ -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:
+7
View File
@@ -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,
+1
View File
@@ -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:
+72
View File
@@ -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()