Files
eth_hedge_sim/backend/app/strategy/engine.py
T
2026-07-25 07:52:37 +08:00

255 lines
9.1 KiB
Python

"""策略状态机:选向开仓 / 盯盘平仓 / 休息 / 限轮。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from ..config import get_settings
from ..market import get_gateway
from ..models.db import get_db
from ..sim.ledger import Ledger
from ..sim.matcher import Matcher
from .clock import can_open_new, window_key
from .exits import check_exits
from .group import next_group_id
from .signal import decide
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_pts = self.ledger.get_setting_float("exit_move_points", s.exit_move_points)
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
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),
"max_rounds": max_rounds,
"window_key": row["window_key"],
"rest_until_ms": rest_until,
"rest_left_sec": rest_left,
"rest_seconds": rest_sec,
"exit_move_points": exit_pts,
"can_open": can_open_new(open_hhmm=s.open_hhmm, stop_hhmm=s.stop_open_hhmm),
"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")
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)
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
rest_until = int(time.time() * 1000) + rest_sec * 1000
if rounds >= max_rounds:
self._set_state(
rounds_done=rounds,
phase="stopped",
rest_until_ms=None,
)
else:
self._set_state(
rounds_done=rounds,
phase="resting",
rest_until_ms=rest_until,
)
def _count_groups_for_window(self, wkey: str) -> int:
# group_id like G-20260724-01 ; window_key is YYYYMMDD
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:
# 空仓且 ATM 偏离现价时先重选,再跑开仓逻辑
try:
await get_gateway().ensure_atm_async(force=False)
except Exception as e:
logger.warning("ATM ensure before tick failed: %s", e)
await asyncio.to_thread(self._tick)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("strategy tick failed")
self._set_state(last_error=str(e))
await asyncio.sleep(1)
def _tick(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, rounds_done=0, phase="idle", rest_until_ms=None)
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
exit_pts = self.ledger.get_setting_float("exit_move_points", s.exit_move_points)
pos = self.matcher.current_position()
# 有仓:盯平仓
if pos.get("status") == "open":
self._set_state(phase="open", last_error=None)
upl = self.matcher.unrealized()
decision = check_exits(
perp_upl=float(upl["perp_upl"]),
initial_premium=float(upl["initial_premium"] or 0),
move_points=float(upl["move_points"] or 0),
exit_move_points=exit_pts,
)
if decision.should_close:
self._set_state(phase="closing")
r = self.matcher.close_group(reason=decision.reason)
if r.ok:
self._after_close()
elif r.liquidity_wait:
self._set_state(phase="liquidity_wait", last_error=r.detail)
else:
self._set_state(last_error=r.detail)
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 int(st["rounds_done"] or 0) >= max_rounds:
self._set_state(phase="stopped")
return
if not can_open_new(open_hhmm=s.open_hhmm, stop_hhmm=s.stop_open_hhmm):
self._set_state(phase="outside_window")
return
if st["phase"] in ("stopped", "paused"):
return
# 尝试开仓
self._set_state(phase="wait_signal")
gw = get_gateway()
snap = gw.snapshot()
if not snap.pair or not snap.call or not snap.put:
return
sig = decide(snap.call.ask, snap.put.ask)
if sig is None:
return
self._set_state(phase="opening")
count = self._count_groups_for_window(wkey)
gid = next_group_id(count)
option_inst = (
snap.pair.call_inst_id if sig.option_side == "call" else snap.pair.put_inst_id
)
entry_idx = snap.index_px or (snap.perp.mark_px if snap.perp else None)
if entry_idx is None:
self._set_state(last_error="no index/mark for entry")
return
r = self.matcher.open_group(
group_id=gid,
bias=sig.bias,
option_side=sig.option_side,
perp_side=sig.perp_side,
option_inst_id=option_inst,
entry_index_px=float(entry_idx),
strike=snap.pair.strike,
expiry_ymd=snap.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