Files
eth_hedge_sim/backend/app/live/reconcile.py
T
dekun e2a19a1614 Recover stuck opening and harden LIVE open/close reconcile.
Stamp open intent, recover opening from exchange option/perp state, skip resell/reopen when already flat, and persist Binance margin mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:32:45 +08:00

375 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LIVE 开仓对账:占槽、交易所持仓核对、平永续数量解析、stuck opening 恢复。"""
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, CloseResult
from .symbols import resolve_perp_inst_id
logger = logging.getLogger(__name__)
_PERP_EPS = 1e-8
_OPT_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 stamp_opening_intent(
db,
*,
group_id: str,
option_inst_id: str,
option_side: str,
perp_side: str,
option_qty_eth: float,
option_qty_contracts: float,
entry_index_px: float | None = None,
option_entry_px: float | None = None,
) -> None:
"""开仓意图落库:崩溃后仍可按 option_inst_id 恢复,禁止「opening 无元数据」。"""
with db._lock:
self_row = db._conn.execute(
"SELECT status FROM positions WHERE id=1"
).fetchone()
st = str(self_row["status"] or "") if self_row else ""
if st != "opening":
return
db._conn.execute(
"""UPDATE positions SET
group_id=?, option_inst_id=?, option_side=?, perp_side=?,
option_qty_eth=?, option_qty_contracts=?,
entry_index_px=COALESCE(?, entry_index_px),
option_entry_px=COALESCE(?, option_entry_px),
status='opening'
WHERE id=1 AND status='opening'""",
(
group_id,
option_inst_id,
option_side,
perp_side,
float(option_qty_eth),
float(option_qty_contracts),
entry_index_px,
option_entry_px,
),
)
db._conn.commit()
def release_open_slot_if_opening(db) -> None:
"""开仓失败且未落 half_open/open 时,释放 opening 占槽。"""
with db._lock:
db._conn.execute(
"""UPDATE positions SET
status='flat', group_id=NULL, option_inst_id=NULL,
option_side=NULL, perp_side=NULL,
option_qty_eth=0, option_qty_contracts=0,
option_entry_px=NULL, perp_qty_eth=0, perp_entry_px=NULL,
entry_index_px=NULL, initial_premium=0
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 in ("binance", "bn"):
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 exchange_option_abs_size(
client: Any, option_inst_id: str
) -> float | None:
try:
return client.get_option_pos_sz(option_inst_id)
except Exception as e:
logger.warning("exchange_option_abs_size failed: %s", e)
return None
def exchange_any_option_abs(client: Any) -> float | None:
try:
return client.any_option_pos_abs()
except Exception as e:
logger.warning("exchange_any_option_abs failed: %s", 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)
if st in ("flat", "", "opening"):
sides = ("long", "short")
else:
sides = (str(pos.get("perp_side") or "long"),)
total = 0.0
for side in sides:
ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if ex_sz is None:
return False, "无法核对交易所持仓"
total += float(ex_sz)
if total > _PERP_EPS and st in ("flat", "", "opening"):
return (
False,
"交易所有永续仓但本地无持仓,禁止新开,请人工核对",
)
# 期权:有具体合约则查该合约;flat 时查账户任意期权残留
opt_inst = str(pos.get("option_inst_id") or "")
if opt_inst:
opt_sz = exchange_option_abs_size(client, opt_inst)
if opt_sz is None:
return False, "无法核对交易所期权持仓"
if opt_sz > _OPT_EPS and st in ("flat", "", "opening"):
return (
False,
f"交易所有期权仓({opt_inst})但本地未确认持仓,禁止新开,请人工核对",
)
elif st in ("flat", ""):
any_opt = exchange_any_option_abs(client)
if any_opt is None:
return False, "无法核对交易所期权持仓"
if any_opt > _OPT_EPS:
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 OK")
else:
logger.warning("LIVE startup reconcile mismatch: %s", msg)
# 启动时尝试恢复 stuck opening
try:
r = recover_stuck_opening(executor)
if r is not None:
logger.info("LIVE startup recover_opening: ok=%s detail=%s", r.ok, r.detail)
except Exception:
logger.exception("LIVE startup recover_opening failed")
def perp_close_contracts_okx(
client: Any,
*,
perp_inst: str,
perp_side: str,
perp_qty_eth: float,
ct_val: float,
allow_db_fallback: bool = True,
) -> int:
"""平永续张数:优先交易所持仓。
allow_db_fallback=False 且交易所已空仓时返回 0(勿用 DB 数量再下单,防反向开仓)。
"""
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)))
if ex_sz is not None and ex_sz <= _PERP_EPS:
if not allow_db_fallback:
return 0
if not allow_db_fallback:
return 0
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,
allow_db_fallback: bool = True,
) -> float:
"""平永续 ETH 数量:优先交易所持仓。交易所空且不允许 fallback → 0。"""
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)
if ex_sz is not None and ex_sz <= _PERP_EPS:
if not allow_db_fallback:
return 0.0
if not allow_db_fallback:
return 0.0
return float(perp_qty_eth)
def recover_stuck_opening(executor) -> CloseResult | None:
"""恢复本地 status=opening
- 交易所期权+永续皆空 → 清槽
- 仅期权 → half_open 并尝试 repair
- 期权+永续 → 提升为 open(用本地已 stamp 的数量/均价)
- 无元数据且交易所仍有仓 → 保持 opening,返回失败详情
"""
if get_settings().is_sim:
return None
pos = executor.current_position()
st = str(pos.get("status") or "")
if st != "opening":
return None
client, ex_name = _executor_client_and_exchange(executor)
if client is None:
return CloseResult(ok=False, detail="recover_opening: 无交易客户端")
option_inst = str(pos.get("option_inst_id") or "")
perp_side = str(pos.get("perp_side") or "long")
group_id = str(pos.get("group_id") or "")
perp_inst = resolve_perp_inst_id(executor.db, group_id=group_id or None)
perp_total = 0.0
for side in ("long", "short"):
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if sz is None:
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
perp_total += float(sz)
opt_sz = 0.0
if option_inst:
raw = exchange_option_abs_size(client, option_inst)
if raw is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
opt_sz = float(raw)
else:
any_opt = exchange_any_option_abs(client)
if any_opt is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
if any_opt > _OPT_EPS:
return CloseResult(
ok=False,
detail=(
"recover_opening: opening 无 option_inst_id 但交易所有期权仓,"
"禁止自动清槽,请人工核对"
),
)
opt_sz = 0.0
# 两边皆空 → 清槽
if opt_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
release_open_slot_if_opening(executor.db)
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
# 无元数据但有仓 → 不自动处理
if not option_inst:
return CloseResult(
ok=False,
detail="recover_opening: 交易所有仓但本地 opening 缺 option_inst_id",
)
# 仅期权 → half_open + repair
if opt_sz > _OPT_EPS and perp_total <= _PERP_EPS:
persist = getattr(executor, "_persist_half_open", None)
if not callable(persist):
return CloseResult(ok=False, detail="recover_opening: 无 half_open 落库")
of_px = float(pos.get("option_entry_px") or 0) or 0.0
opt_qty = float(pos.get("option_qty_eth") or 0)
opt_contracts = float(pos.get("option_qty_contracts") or 0)
if opt_contracts <= 0 and hasattr(executor, "_ct_mult"):
from ..sim.liquidity import contracts_for_eth
ct = executor._ct_mult(option_inst)
opt_contracts = float(contracts_for_eth(opt_qty or opt_sz, ct)) if opt_qty else float(opt_sz)
if opt_qty <= 0 and hasattr(executor, "_ct_mult"):
from ..sim.liquidity import eth_from_contracts
opt_qty = eth_from_contracts(opt_contracts or opt_sz, executor._ct_mult(option_inst))
persist(
group_id=group_id or f"RCV-{option_inst[-12:]}",
bias="recover",
option_side=str(pos.get("option_side") or "call"),
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pos.get("entry_index_px") or 0) or 0.0,
strike=None,
expiry_ymd=None,
opt_qty=opt_qty,
opt_contracts=opt_contracts or opt_sz,
of_px=of_px,
of_fee=0.0,
detail="recover_opening option-only → half_open",
)
repair = getattr(executor, "repair_half_open", None)
if callable(repair):
return repair()
return CloseResult(ok=True, detail="recover_opening: 已落 half_open")
# 期权+永续 → 提升为 open
promote = getattr(executor, "_promote_opening_to_open", None)
if callable(promote):
return promote(pos=pos, perp_inst=perp_inst, opt_sz=opt_sz, perp_total=perp_total)
return CloseResult(
ok=False,
detail="recover_opening: 双边有仓但执行器无 promote,请人工核对",
)
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