Add Binance live trading, anti-stuck open/close recovery, and configurable rate limits.
OKX/Binance LIVE share half_open and option_closed_perp_pending repair paths; private REST throttles default to 1s and are tunable in settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+130
-13
@@ -12,6 +12,7 @@ from .session import get_session
|
||||
from ..models.db import get_db
|
||||
from ..sim.ledger import Ledger
|
||||
from ..live import get_executor
|
||||
from ..live.rate_limit import LiveRetryGate, is_rate_limit_error
|
||||
from ..env_store import live_ready
|
||||
from .clock import can_open_new, window_key
|
||||
from .exits import check_expiry_close, check_exits, resolve_exit_target
|
||||
@@ -27,11 +28,39 @@ class StrategyEngine:
|
||||
self.matcher = get_executor(self.db)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._retry_gate = LiveRetryGate()
|
||||
self._extra_sleep_sec = 0.0
|
||||
|
||||
def refresh_executor(self) -> None:
|
||||
"""MODE 变更后刷新执行器。"""
|
||||
self.matcher = get_executor(self.db)
|
||||
|
||||
def _gate_key(self, kind: str) -> str:
|
||||
pos = self.matcher.current_position()
|
||||
gid = str(pos.get("group_id") or "none")
|
||||
return f"{kind}:{gid}"
|
||||
|
||||
def _note_retry_result(self, kind: str, *, ok: bool, detail: str = "") -> None:
|
||||
key = self._gate_key(kind)
|
||||
if ok:
|
||||
self._retry_gate.success(key)
|
||||
return
|
||||
rl = is_rate_limit_error(detail)
|
||||
delay = self._retry_gate.fail(key, rate_limited=rl)
|
||||
if rl:
|
||||
self._extra_sleep_sec = max(self._extra_sleep_sec, min(delay, 60.0))
|
||||
logger.warning(
|
||||
"live retry backoff kind=%s fails=%s delay=%.1fs rate_limited=%s detail=%s",
|
||||
kind,
|
||||
self._retry_gate.fails(key),
|
||||
delay,
|
||||
rl,
|
||||
(detail or "")[:160],
|
||||
)
|
||||
|
||||
def _retry_allowed(self, kind: str) -> tuple[bool, float]:
|
||||
return self._retry_gate.allow(self._gate_key(kind))
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||
assert row is not None
|
||||
@@ -139,13 +168,38 @@ class StrategyEngine:
|
||||
detail = "flat"
|
||||
ok = True
|
||||
pos = self.matcher.current_position()
|
||||
if pos.get("status") == "open":
|
||||
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
|
||||
st = str(pos.get("status") or "flat")
|
||||
|
||||
if st == "half_open":
|
||||
repair = getattr(self.matcher, "repair_half_open", None)
|
||||
if callable(repair):
|
||||
r = repair()
|
||||
else:
|
||||
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
|
||||
ok = r.ok
|
||||
detail = r.detail
|
||||
close_data = r.data
|
||||
if r.ok:
|
||||
self._after_close()
|
||||
elif st in ("open", "option_closed_perp_pending"):
|
||||
# A:双腿(或续平永续)
|
||||
r = self.matcher.close_group(reason="emergency", bypass_liquidity=True)
|
||||
if not r.ok and st == "open":
|
||||
# B:砸不出期权时强制只平永续(不要求远虚)
|
||||
abandon = getattr(self.matcher, "close_perp_abandon_option", None)
|
||||
if callable(abandon):
|
||||
try:
|
||||
r2 = abandon(reason="emergency_perp", require_deep_otm=False)
|
||||
except TypeError:
|
||||
r2 = abandon(reason="emergency_perp")
|
||||
if r2.ok:
|
||||
r = r2
|
||||
ok = r.ok
|
||||
detail = r.detail
|
||||
close_data = r.data
|
||||
if r.ok:
|
||||
self._after_close()
|
||||
|
||||
residuals = self.matcher.settle_all_residuals_now()
|
||||
return {
|
||||
"close": {
|
||||
@@ -201,23 +255,44 @@ class StrategyEngine:
|
||||
bypass_liquidity: bool,
|
||||
pending_close: bool,
|
||||
abandon_if_deep_otm: bool = False,
|
||||
retry_kind: str | None = None,
|
||||
) -> None:
|
||||
kind = retry_kind or (
|
||||
"perp_pending"
|
||||
if reason == "perp_pending_retry"
|
||||
else ("liquidity" if pending_close or not bypass_liquidity else "close")
|
||||
)
|
||||
allowed, left = self._retry_allowed(kind)
|
||||
if not allowed:
|
||||
self._set_state(
|
||||
phase="liquidity_wait" if kind == "liquidity" else "closing",
|
||||
last_error=f"限流/失败退避中,{left:.0f}s 后再试 ({kind})",
|
||||
)
|
||||
return
|
||||
|
||||
if not pending_close:
|
||||
self._set_state(phase="closing", last_error=None)
|
||||
|
||||
# 目标平仓 B:远虚 → 只平永续,期权归档
|
||||
if abandon_if_deep_otm and reason != "expiry" and self.matcher.option_is_deep_otm():
|
||||
r = await asyncio.to_thread(
|
||||
self.matcher.close_perp_abandon_option,
|
||||
reason="target_perp_only",
|
||||
)
|
||||
abandon = self.matcher.close_perp_abandon_option
|
||||
try:
|
||||
r = await asyncio.to_thread(
|
||||
abandon,
|
||||
reason="target_perp_only",
|
||||
require_deep_otm=True,
|
||||
)
|
||||
except TypeError:
|
||||
r = await asyncio.to_thread(abandon, reason="target_perp_only")
|
||||
if r.ok:
|
||||
self._note_retry_result(kind, ok=True)
|
||||
self._after_close()
|
||||
self._set_state(
|
||||
last_error=None,
|
||||
phase="resting",
|
||||
)
|
||||
else:
|
||||
self._note_retry_result(kind, ok=False, detail=r.detail)
|
||||
self._set_state(phase="closing", last_error=r.detail)
|
||||
return
|
||||
|
||||
@@ -227,6 +302,7 @@ class StrategyEngine:
|
||||
bypass_liquidity=bypass_liquidity,
|
||||
)
|
||||
if r.ok:
|
||||
self._note_retry_result(kind, ok=True)
|
||||
self._after_close()
|
||||
elif r.liquidity_wait and not bypass_liquidity:
|
||||
# 等待期间若已变成远虚,下一 tick 走归档
|
||||
@@ -236,10 +312,13 @@ class StrategyEngine:
|
||||
reason="target_perp_only",
|
||||
)
|
||||
if r2.ok:
|
||||
self._note_retry_result(kind, ok=True)
|
||||
self._after_close()
|
||||
return
|
||||
self._note_retry_result("liquidity", ok=False, detail=r.detail)
|
||||
self._set_state(phase="liquidity_wait", last_error=r.detail)
|
||||
else:
|
||||
self._note_retry_result(kind, ok=False, detail=r.detail)
|
||||
self._set_state(phase="closing", last_error=r.detail)
|
||||
|
||||
async def _settle_residuals(self) -> None:
|
||||
@@ -249,7 +328,7 @@ class StrategyEngine:
|
||||
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
|
||||
await self._settle_residuals()
|
||||
pos = self.matcher.current_position()
|
||||
if pos.get("status") != "open":
|
||||
if pos.get("status") not in ("open", "option_closed_perp_pending"):
|
||||
return False
|
||||
upl = self.matcher.unrealized()
|
||||
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
|
||||
@@ -263,6 +342,7 @@ class StrategyEngine:
|
||||
bypass_liquidity=True,
|
||||
pending_close=pending,
|
||||
abandon_if_deep_otm=False,
|
||||
retry_kind="expiry",
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -288,15 +368,16 @@ class StrategyEngine:
|
||||
raise
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
# 限流时勿刷屏;拉长休眠给 eapi 冷却
|
||||
if "418" in err or "429" in err or "cooldown" in err.lower():
|
||||
if is_rate_limit_error(err):
|
||||
logger.warning("strategy tick rate-limited: %s", err[:200])
|
||||
self._set_state(last_error="币安期权接口限流,稍后自动重试")
|
||||
await asyncio.sleep(15)
|
||||
self._set_state(last_error="交易/行情接口限流,稍后自动重试")
|
||||
await asyncio.sleep(20)
|
||||
continue
|
||||
logger.exception("strategy tick failed")
|
||||
self._set_state(last_error=err)
|
||||
await asyncio.sleep(1)
|
||||
sleep_for = 1.0 + max(0.0, self._extra_sleep_sec)
|
||||
self._extra_sleep_sec = 0.0
|
||||
await asyncio.sleep(min(sleep_for, 60.0))
|
||||
|
||||
async def _tick_async(self) -> None:
|
||||
# 残留期权到期结算(与活跃组隔离,不挡开仓)
|
||||
@@ -319,9 +400,42 @@ class StrategyEngine:
|
||||
"premium_exit_multiple", s.premium_exit_multiple
|
||||
)
|
||||
pos = self.matcher.current_position()
|
||||
st_pos = str(pos.get("status") or "flat")
|
||||
|
||||
# 实盘半仓修复:禁止新开;失败指数退避,避免每秒砸期权
|
||||
if st_pos == "half_open":
|
||||
allowed, left = self._retry_allowed("half_open")
|
||||
if not allowed:
|
||||
self._set_state(
|
||||
phase="closing",
|
||||
last_error=f"half_open 修复退避中,{left:.0f}s 后再试",
|
||||
)
|
||||
return
|
||||
repair = getattr(self.matcher, "repair_half_open", None)
|
||||
if callable(repair):
|
||||
r = await asyncio.to_thread(repair)
|
||||
if r.ok:
|
||||
self._note_retry_result("half_open", ok=True)
|
||||
self._after_close()
|
||||
self._set_state(phase="resting", last_error=None)
|
||||
else:
|
||||
self._note_retry_result("half_open", ok=False, detail=r.detail)
|
||||
self._set_state(phase="closing", last_error=r.detail)
|
||||
return
|
||||
|
||||
# 期权已平、永续待平:只续平永续(带退避)
|
||||
if st_pos == "option_closed_perp_pending":
|
||||
await self._close_open_position(
|
||||
reason="perp_pending_retry",
|
||||
bypass_liquidity=True,
|
||||
pending_close=True,
|
||||
abandon_if_deep_otm=False,
|
||||
retry_kind="perp_pending",
|
||||
)
|
||||
return
|
||||
|
||||
# 有活跃持仓:只盯当前组平仓;残留期权不在此扫描
|
||||
if pos.get("status") == "open":
|
||||
if st_pos == "open":
|
||||
upl = self.matcher.unrealized()
|
||||
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
|
||||
decision = check_exits(
|
||||
@@ -337,16 +451,19 @@ class StrategyEngine:
|
||||
reason = "expiry"
|
||||
bypass = True
|
||||
abandon = False
|
||||
rkind = "expiry"
|
||||
else:
|
||||
reason = decision.reason or "liquidity_retry"
|
||||
bypass = False
|
||||
# 目标达标(或流动性等待重试)时:远虚走只平永续
|
||||
abandon = bool(decision.should_close or pending_close)
|
||||
rkind = "liquidity" if pending_close else "close"
|
||||
await self._close_open_position(
|
||||
reason=reason,
|
||||
bypass_liquidity=bypass,
|
||||
pending_close=pending_close,
|
||||
abandon_if_deep_otm=abandon,
|
||||
retry_kind=rkind,
|
||||
)
|
||||
else:
|
||||
self._set_state(phase="open", last_error=None)
|
||||
|
||||
@@ -30,9 +30,15 @@ _session: StrategySession | None = None
|
||||
def _has_open_position() -> bool:
|
||||
try:
|
||||
from ..models.db import get_db
|
||||
from ..sim.matcher import BLOCKING_STATUSES
|
||||
|
||||
row = get_db().fetchone("SELECT status FROM positions WHERE id=1")
|
||||
return bool(row and row["status"] == "open")
|
||||
row = get_db().fetchone("SELECT status, group_id, option_inst_id FROM positions WHERE id=1")
|
||||
if not row:
|
||||
return False
|
||||
st = str(row["status"] or "")
|
||||
if st not in BLOCKING_STATUSES:
|
||||
return False
|
||||
return bool(row["group_id"] or row["option_inst_id"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -45,7 +51,10 @@ def _held_option_inst_id() -> str | None:
|
||||
row = get_db().fetchone(
|
||||
"SELECT status, option_inst_id FROM positions WHERE id=1"
|
||||
)
|
||||
if not row or row["status"] != "open":
|
||||
if not row or row["status"] not in ("open", "half_open", "option_closed_perp_pending"):
|
||||
return None
|
||||
# 期权已平待平永续:不再钉期权盘口
|
||||
if row["status"] == "option_closed_perp_pending":
|
||||
return None
|
||||
inst = str(row["option_inst_id"] or "").strip()
|
||||
return inst or None
|
||||
|
||||
Reference in New Issue
Block a user