Files
eth_hedge_sim/backend/app/strategy/engine.py
T
dekun 56a4007130 Add WeCom markdown alerts and keep target exits while strategy is paused.
Notify open/close/start/pause/fault with SIM vs LIVE venue labels; configure webhook in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:19:39 +08:00

684 lines
26 KiB
Python

"""策略状态机:选向开仓 / 盯盘平仓 / 休息(无开仓窗、无轮次上限)。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from ..config import get_settings
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
from .group import next_group_id
logger = logging.getLogger(__name__)
class StrategyEngine:
def __init__(self) -> None:
self.db = get_db()
self.ledger = Ledger(self.db)
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
upl = self.matcher.unrealized()
s = get_settings()
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
net_target = self.ledger.get_setting_float(
"net_profit_target", s.net_profit_target
)
prem_mult = self.ledger.get_setting_float(
"premium_exit_multiple", s.premium_exit_multiple
)
exit_amt, _ = resolve_exit_target(
exit_mode=exit_mode,
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(upl.get("initial_premium") or 0),
)
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends)
leverage = self.ledger.get_setting_float("leverage", s.leverage)
min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours)
min_opt_lev = self.ledger.get_setting_float(
"min_option_leverage", s.min_option_leverage
)
atm_off_on = self.ledger.get_setting_bool(
"atm_open_offset_enabled", s.atm_open_offset_enabled
)
max_atm_off = self.ledger.get_setting_float(
"max_atm_open_offset", s.max_atm_open_offset
)
rest_until = row["rest_until_ms"]
rest_left = 0
if rest_until:
rest_left = max(0, int((int(rest_until) - time.time() * 1000) / 1000))
last_error = row["last_error"]
if last_error and "PriceResult" in str(last_error) and "__dict__" in str(last_error):
self._set_state(last_error=None)
last_error = None
allow_open = can_open_new(skip_weekends=skip_weekends)
return {
"running": bool(row["running"]),
"phase": row["phase"],
"rounds_done": int(row["rounds_done"] or 0),
"window_key": row["window_key"],
"rest_until_ms": rest_until,
"rest_left_sec": rest_left,
"rest_seconds": rest_sec,
"skip_weekends": skip_weekends,
"exit_mode": exit_mode,
"net_profit_target": net_target,
"premium_exit_multiple": prem_mult,
"exit_target_usdt": exit_amt,
"leverage": leverage,
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"atm_open_offset_enabled": atm_off_on,
"max_atm_open_offset": max_atm_off,
"can_open": allow_open,
"last_error": last_error,
"position": upl,
"residuals": self.matcher.list_residual_options(pending_only=True),
"ledger": self.ledger.snapshot(),
"mode": "SIM" if s.is_sim else "LIVE",
"sim": s.is_sim,
"live_ready": (live_ready()[0] if not s.is_sim else True),
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
"show_manual_trade_buttons": self.ledger.get_setting_bool(
"show_manual_trade_buttons", False
),
}
def _set_state(self, **kwargs: Any) -> None:
cols = []
vals: list[Any] = []
for k, v in kwargs.items():
cols.append(f"{k}=?")
vals.append(v)
cols.append("updated_at_ms=?")
vals.append(int(time.time() * 1000))
sql = f"UPDATE strategy_state SET {', '.join(cols)} WHERE id=1"
self.db.execute(sql, tuple(vals))
async def pause(self) -> dict[str, Any]:
self._set_state(running=0, phase="paused", last_error=None)
try:
from ..notify import wecom
wecom.notify_pause()
except Exception:
logger.exception("wecom notify_pause failed")
return self.state()
async def start(self) -> dict[str, Any]:
self.refresh_executor()
s = get_settings()
if not s.is_sim:
ok, reason = live_ready()
if not ok:
self._set_state(running=0, phase="paused", last_error=reason)
try:
from ..notify import wecom
wecom.notify_fault(title="启动失败", detail=reason, dedupe_key="start_fail")
except Exception:
pass
return self.state()
self._set_state(running=1, last_error=None, phase="idle")
self.ensure_loop()
try:
from ..notify import wecom
wecom.notify_start()
except Exception:
logger.exception("wecom notify_start failed")
return self.state()
def ensure_loop(self) -> None:
"""保证后台循环在跑(暂停时仍盯目标/到期,不新开)。"""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
async def emergency_close(self) -> dict[str, Any]:
async with self._lock:
close_data: dict[str, Any] | None = None
detail = "flat"
ok = True
pos = self.matcher.current_position()
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.enter_rest_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.enter_rest_after_close()
residuals = self.matcher.settle_all_residuals_now()
try:
if ok:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=detail,
data=close_data or {},
)
except Exception:
logger.exception("wecom emergency notify failed")
return {
"close": {
"ok": ok,
"detail": detail,
"data": close_data,
"residuals_settled": residuals,
},
"state": self.state(),
}
def enter_rest_after_close(self) -> None:
"""全平成功后进入组间休息(自动 / 手动 / 紧急共用)。"""
self._after_close()
def _after_close(self) -> None:
s = get_settings()
row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert row is not None
rounds = int(row["rounds_done"] or 0) + 1
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
rest_until = int(time.time() * 1000) + rest_sec * 1000
self._set_state(
rounds_done=rounds,
phase="resting",
rest_until_ms=rest_until,
)
def _count_groups_for_day(self, wkey: str) -> int:
rows = self.db.fetchall(
"SELECT group_id FROM groups WHERE group_id LIKE ?",
(f"G-{wkey}-%",),
)
return len(rows)
def _position_expiry_ms(self, upl: dict[str, Any]) -> int | None:
raw = upl.get("expiry_ms")
if raw is not None:
try:
return int(raw)
except (TypeError, ValueError):
pass
ymd = upl.get("expiry_ymd")
if ymd:
try:
from ..exchange.expiry import expiry_ms_from_ymd
return int(expiry_ms_from_ymd(str(ymd)))
except Exception:
return None
return None
async def _close_open_position(
self,
*,
reason: str,
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():
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",
)
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
else:
self._note_retry_result(kind, ok=False, detail=r.detail)
self._set_state(phase="closing", last_error=r.detail)
return
r = await asyncio.to_thread(
self.matcher.close_group,
reason=reason,
bypass_liquidity=bypass_liquidity,
)
if r.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason=reason,
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
elif r.liquidity_wait and not bypass_liquidity:
# 等待期间若已变成远虚,下一 tick 走归档
if self.matcher.option_is_deep_otm():
r2 = await asyncio.to_thread(
self.matcher.close_perp_abandon_option,
reason="target_perp_only",
)
if r2.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r2.detail,
data=r2.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
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:
await asyncio.to_thread(self.matcher.settle_due_residuals)
async def _maybe_expiry_close(self) -> bool:
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
await self._settle_residuals()
pos = self.matcher.current_position()
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))
if not expired.should_close:
return False
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
pending = st["phase"] in ("liquidity_wait", "closing")
await self._close_open_position(
reason="expiry",
bypass_liquidity=True,
pending_close=pending,
abandon_if_deep_otm=False,
retry_kind="expiry",
)
return True
async def _loop(self) -> None:
logger.info("strategy engine loop started")
while True:
try:
row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1")
running = bool(row and int(row["running"]))
if not running:
# 暂停:不新开仓;仍盯目标平仓 + 到期平仓
async with self._lock:
await self._tick_manage_positions()
await asyncio.sleep(1)
continue
async with self._lock:
try:
await get_session().ensure_atm_async(force=False)
except Exception as e:
logger.warning("ATM ensure before tick failed: %s", e)
await self._tick_async()
except asyncio.CancelledError:
raise
except Exception as e:
err = str(e)
if is_rate_limit_error(err):
logger.warning("strategy tick rate-limited: %s", err[:200])
self._set_state(last_error="交易/行情接口限流,稍后自动重试")
await asyncio.sleep(20)
continue
logger.exception("strategy tick failed")
self._set_state(last_error=err)
try:
from ..notify import wecom
wecom.notify_fault(
title="策略循环异常",
detail=err,
dedupe_key=f"tick:{err[:80]}",
)
except Exception:
pass
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_manage_positions(self) -> None:
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
await self._settle_residuals()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
net_target = self.ledger.get_setting_float(
"net_profit_target", s.net_profit_target
)
prem_mult = self.ledger.get_setting_float(
"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)
try:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=r.detail or "half_open repaired",
data=r.data or {},
)
except Exception:
pass
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 st_pos == "open":
upl = self.matcher.unrealized()
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
decision = check_exits(
net_pnl=float(upl.get("net_pnl") or 0),
exit_mode=exit_mode,
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(upl.get("initial_premium") or 0),
)
pending_close = st["phase"] in ("liquidity_wait", "closing")
if expired.should_close or decision.should_close or pending_close:
if expired.should_close:
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)
async def _tick_async(self) -> None:
# 先盯仓(平仓路径)
await self._tick_manage_positions()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
wkey = window_key()
if st["window_key"] != wkey:
self._set_state(window_key=wkey, phase="idle")
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
# 仍有活跃仓或半仓:本 tick 不再开新仓
if st_pos in ("open", "half_open", "option_closed_perp_pending", "opening"):
return
if st["phase"] == "resting" and st["rest_until_ms"]:
if int(time.time() * 1000) < int(st["rest_until_ms"]):
return
self._set_state(phase="idle", rest_until_ms=None)
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
if st["phase"] in ("paused",):
return
# 旧「轮次停开」状态:自动恢复为空闲以便继续
if st["phase"] in ("stopped", "outside_window"):
self._set_state(phase="idle")
skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends)
if not can_open_new(skip_weekends=skip_weekends):
self._set_state(
phase="weekend_skip",
last_error="周六/周日跳过开仓(上海时区);持仓仍可平仓",
)
return
if st["phase"] == "weekend_skip":
self._set_state(phase="idle", last_error=None)
# 双保险:账本仍显示有仓则不开
if self.matcher.has_open_position():
self._set_state(phase="open", last_error="有未平仓,禁止开下一组")
return
self._set_state(phase="wait_signal")
pick = await get_session().pick_for_open_async()
if pick is None:
self._set_state(
last_error="无合格期权:需剩余时长、杠杆(及已开启的ATM偏差)同时满足"
)
return
self._set_state(phase="opening", last_error=None)
wkey = window_key()
count = self._count_groups_for_day(wkey)
gid = next_group_id(count)
option_inst = (
pick.pair.call_inst_id if pick.option_side == "call" else pick.pair.put_inst_id
)
entry_idx = pick.underlying_px
if not get_settings().is_sim:
from ..live.reconcile import assert_safe_to_open_live
safe, safe_msg = assert_safe_to_open_live(self.matcher)
if not safe:
self._set_state(phase="idle", last_error=safe_msg)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓前对账拒绝",
detail=safe_msg,
dedupe_key=f"recon:{safe_msg[:80]}",
)
except Exception:
pass
return
r = await asyncio.to_thread(
self.matcher.open_group,
group_id=gid,
bias=pick.bias,
option_side=pick.option_side,
perp_side=pick.perp_side,
option_inst_id=option_inst,
entry_index_px=float(entry_idx),
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
if r.ok:
self._set_state(phase="open", last_error=None)
try:
from ..notify import wecom
wecom.notify_open(
group_id=gid,
detail=r.detail,
extra={
"bias": pick.bias,
"option_side": pick.option_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
},
)
except Exception:
logger.exception("wecom notify_open failed")
else:
self._set_state(phase="idle", last_error=r.detail)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓失败",
detail=r.detail,
dedupe_key=f"open_fail:{r.detail[:80]}",
)
except Exception:
pass
_engine: StrategyEngine | None = None
def get_engine() -> StrategyEngine:
global _engine
if _engine is None:
_engine = StrategyEngine()
return _engine
def set_engine(engine: StrategyEngine | None) -> None:
global _engine
_engine = engine