Files

409 lines
14 KiB
Python
Raw Permalink 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", "", "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)
# 期期 openingperp_side 形如 oo_put:{put_inst}
oo_put_inst = ""
if perp_side.startswith("oo_put:"):
oo_put_inst = perp_side.split(":", 1)[1].strip()
perp_total = 0.0
if not oo_put_inst:
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
put_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 oo_put_inst:
raw_p = exchange_option_abs_size(client, oo_put_inst)
if raw_p is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期期 Put")
put_sz = float(raw_p)
# 两边皆空 → 清槽(期期须 Call+Put 皆空)
if opt_sz <= _OPT_EPS and put_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
release_open_slot_if_opening(executor.db)
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
# 期期:任一腿残留且无完整双边 → 禁止清槽;仅 Call 则 half_open repair
if oo_put_inst:
if put_sz > _OPT_EPS and opt_sz <= _OPT_EPS:
return CloseResult(
ok=False,
detail=(
f"recover_opening: 期期 Put 残留({oo_put_inst}) Call 已空,"
"禁止清槽,请人工核对"
),
)
if opt_sz > _OPT_EPS and put_sz > _OPT_EPS:
return CloseResult(
ok=False,
detail="recover_opening: 期期两腿均在交易所,请人工核对后 promote",
)
# 仅 Call → 落入下方 half_open
# 无元数据但有仓 → 不自动处理
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