"""策略状态机:选向开仓 / 盯盘平仓 / 休息(无开仓窗、无轮次上限)。""" 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 ..sim.matcher import Matcher from .clock import window_key from .exits import 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.matcher = Matcher(self.db) self.ledger = Ledger(self.db) self._task: asyncio.Task[None] | None = None self._lock = asyncio.Lock() 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) 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 ) 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 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, "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, "can_open": True, "last_error": last_error, "position": upl, "ledger": self.ledger.snapshot(), } 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) return self.state() async def start(self) -> dict[str, Any]: self._set_state(running=1, last_error=None, phase="idle") if self._task is None or self._task.done(): self._task = asyncio.create_task(self._loop(), name="strategy-engine") return self.state() async def emergency_close(self) -> dict[str, Any]: async with self._lock: # 紧急全平:绕过期权流动性/偏差校验 r = self.matcher.close_group(reason="emergency", bypass_liquidity=True) if r.ok: self._after_close() return { "close": { "ok": r.ok, "detail": r.detail, "liquidity_wait": r.liquidity_wait, "data": r.data, }, "state": self.state(), } 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) 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") if not row or not int(row["running"]): 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: logger.exception("strategy tick failed") self._set_state(last_error=str(e)) await asyncio.sleep(1) async def _tick_async(self) -> None: 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 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() # 有未平仓:只盯平仓,绝不开下一组 if pos.get("status") == "open": upl = self.matcher.unrealized() 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 decision.should_close or pending_close: reason = decision.reason or "liquidity_retry" if not pending_close: self._set_state(phase="closing", last_error=None) r = await asyncio.to_thread( self.matcher.close_group, reason=reason, bypass_liquidity=False ) if r.ok: self._after_close() elif r.liquidity_wait: self._set_state(phase="liquidity_wait", last_error=r.detail) else: self._set_state(phase="closing", last_error=r.detail) else: self._set_state(phase="open", last_error=None) 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") # 双保险:账本仍显示有仓则不开 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="无合格期权:需剩余时长与杠杆倍数同时满足" ) return self._set_state(phase="opening", last_error=None) 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 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) else: self._set_state(phase="idle", last_error=r.detail) _engine: StrategyEngine | None = None def get_engine() -> StrategyEngine: global _engine if _engine is None: _engine = StrategyEngine() return _engine def set_engine(e: StrategyEngine | None) -> None: global _engine _engine = e