16efa44ffb
Also fix flat-side reconcile to check both long and short residuals; document in 更新说明. Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
5.1 KiB
Python
156 lines
5.1 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)
|
|
# flat/opening:两侧都查,避免只查默认 long 漏掉 short 残留
|
|
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,
|
|
"交易所有永续仓但本地无持仓,禁止新开,请人工核对",
|
|
)
|
|
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
|