99e58910d3
Co-authored-by: Cursor <cursoragent@cursor.com>
380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""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", "", "opening"):
|
||
# opening 且尚未 stamp option_inst_id 时仍须扫任意期权残留
|
||
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 = False,
|
||
) -> int | None:
|
||
"""平永续张数:以交易所持仓为准。
|
||
|
||
返回 >0 应下单;0=已确认空仓;None=查仓失败(调用方不得当空仓 finalize)。
|
||
交易所已确认空仓时绝不回退 DB。allow_db_fallback 仅在查仓失败时可用。
|
||
"""
|
||
ps = "long" if perp_side == "long" else "short"
|
||
ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps)
|
||
if ex_sz is None:
|
||
if not allow_db_fallback:
|
||
return None
|
||
return max(1, int(round(perp_qty_eth / ct_val)))
|
||
if ex_sz > _PERP_EPS:
|
||
return max(1, int(round(ex_sz)))
|
||
return 0
|
||
|
||
|
||
def perp_close_qty_eth_binance(
|
||
client: Any,
|
||
*,
|
||
perp_inst: str,
|
||
perp_side: str,
|
||
perp_qty_eth: float,
|
||
allow_db_fallback: bool = False,
|
||
) -> float | None:
|
||
"""平永续 ETH:>0 下单;0=已确认空;None=查仓失败。已空绝不回退 DB。"""
|
||
ps = "LONG" if perp_side == "long" else "SHORT"
|
||
ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps)
|
||
if ex_sz is None:
|
||
if not allow_db_fallback:
|
||
return None
|
||
return float(perp_qty_eth)
|
||
if ex_sz > _PERP_EPS:
|
||
return float(ex_sz)
|
||
return 0.0
|
||
|
||
|
||
def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int:
|
||
"""开仓张数:仅按设置名义/面值,不跟交易所残留。"""
|
||
if ct_val <= 0:
|
||
raise RuntimeError("ct_val invalid")
|
||
return max(1, int(round(float(perp_qty_eth) / float(ct_val))))
|
||
|
||
|
||
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_contracts = float(opt_sz)
|
||
opt_qty = float(pos.get("option_qty_eth") or 0)
|
||
if hasattr(executor, "_ct_mult"):
|
||
from ..sim.liquidity import eth_from_contracts
|
||
|
||
opt_qty = eth_from_contracts(opt_contracts, executor._ct_mult(option_inst))
|
||
elif opt_qty <= 0:
|
||
opt_qty = float(opt_sz)
|
||
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
|