Files
eth_hedge_sim/backend/app/live/reconcile.py
T
dekun 0cf3756b09 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>
2026-07-26 22:39:27 +08:00

149 lines
4.9 KiB
Python

"""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