Revise strategy: TTM+ATM+leverage option pick, % exit, perp leverage/margin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 08:38:03 +08:00
parent 7db4c9c8a3
commit 3957a83761
16 changed files with 532 additions and 261 deletions
+12 -1
View File
@@ -1,5 +1,4 @@
from .clock import can_open_new, window_key
from .engine import StrategyEngine, get_engine, set_engine
from .exits import check_exits
from .group import next_group_id
from .signal import Signal, decide
@@ -15,3 +14,15 @@ __all__ = [
"set_engine",
"window_key",
]
def __getattr__(name: str):
if name in ("StrategyEngine", "get_engine", "set_engine"):
from .engine import StrategyEngine, get_engine, set_engine
return {
"StrategyEngine": StrategyEngine,
"get_engine": get_engine,
"set_engine": set_engine,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+6 -33
View File
@@ -1,8 +1,8 @@
"""业务窗时钟:16:00 开 → 08:00 停开;轮次与休息"""
"""日历日分组键(开仓时间窗已取消,由期权剩余时长约束)"""
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime
from zoneinfo import ZoneInfo
_SH = ZoneInfo("Asia/Shanghai")
@@ -12,28 +12,9 @@ def now_sh(now: datetime | None = None) -> datetime:
return (now or datetime.now(tz=_SH)).astimezone(_SH)
def parse_hhmm(s: str) -> tuple[int, int]:
parts = (s or "16:00").strip().split(":")
return int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
def window_key(now: datetime | None = None) -> str:
"""
业务窗键:若当前 >= 当日 16:00,窗从今日 16:00 起,键=今日日期;
若 < 16:00,仍可能属于「昨日起的窗」(到今日 08:00),键=昨日。
"""
n = now_sh(now)
open_h, open_m = 16, 0
stop_h, stop_m = 8, 0
today_open = n.replace(hour=open_h, minute=open_m, second=0, microsecond=0)
today_stop = n.replace(hour=stop_h, minute=stop_m, second=0, microsecond=0)
if n >= today_open:
return n.strftime("%Y%m%d")
if n < today_stop:
# 仍在昨 16:00 开启的窗内
return (n.date() - timedelta(days=1)).strftime("%Y%m%d")
# 08:00~16:00:不在开仓窗,键用「即将开始」的今日窗
return n.strftime("%Y%m%d")
"""组号日期键:日历日 YYYYMMDD。"""
return now_sh(now).strftime("%Y%m%d")
def can_open_new(
@@ -42,16 +23,8 @@ def can_open_new(
open_hhmm: str = "16:00",
stop_hhmm: str = "08:00",
) -> bool:
n = now_sh(now)
oh, om = parse_hhmm(open_hhmm)
sh, sm = parse_hhmm(stop_hhmm)
today_open = n.replace(hour=oh, minute=om, second=0, microsecond=0)
today_stop = n.replace(hour=sh, minute=sm, second=0, microsecond=0)
if n >= today_open:
return True
if n < today_stop:
return True
return False
"""开仓窗已取消,始终允许(仍受期权剩余时长/杠杆筛选)。"""
return True
def group_date_ymd(now: datetime | None = None) -> str:
+48 -64
View File
@@ -1,4 +1,4 @@
"""策略状态机:选向开仓 / 盯盘平仓 / 休息 / 限轮"""
"""策略状态机:选向开仓 / 盯盘平仓 / 休息(无开仓窗、无轮次上限)"""
from __future__ import annotations
@@ -12,10 +12,9 @@ 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 .clock import window_key
from .exits import check_exits
from .group import next_group_id
from .signal import decide
logger = logging.getLogger(__name__)
@@ -33,15 +32,18 @@ class StrategyEngine:
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)
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
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
@@ -49,13 +51,15 @@ class StrategyEngine:
"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),
"exit_move_pct": exit_pct,
"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(),
@@ -103,23 +107,14 @@ class StrategyEngine:
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,
)
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
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}-%",),
@@ -135,12 +130,11 @@ class StrategyEngine:
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)
await self._tick_async()
except asyncio.CancelledError:
raise
except Exception as e:
@@ -148,34 +142,33 @@ class StrategyEngine:
self._set_state(last_error=str(e))
await asyncio.sleep(1)
def _tick(self) -> None:
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, rounds_done=0, phase="idle", rest_until_ms=None)
self._set_state(window_key=wkey, phase="idle")
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)
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
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,
move_pct=float(upl.get("move_pct") or 0),
exit_move_pct=exit_pct,
)
if decision.should_close:
self._set_state(phase="closing")
r = self.matcher.close_group(reason=decision.reason)
r = await asyncio.to_thread(
self.matcher.close_group, reason=decision.reason
)
if r.ok:
self._after_close()
elif r.liquidity_wait:
@@ -184,7 +177,6 @@ class StrategyEngine:
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
@@ -192,46 +184,38 @@ class StrategyEngine:
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")
if st["phase"] in ("paused",):
return
# 旧「轮次停开」状态:自动恢复为空闲以便继续
if st["phase"] in ("stopped", "outside_window"):
self._set_state(phase="idle")
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:
pick = await gw.pick_for_open_async()
if pick is None:
self._set_state(
last_error="无合格期权:需剩余时长与杠杆倍数同时满足"
)
return
self._set_state(phase="opening")
count = self._count_groups_for_window(wkey)
self._set_state(phase="opening", last_error=None)
count = self._count_groups_for_day(wkey)
gid = next_group_id(count)
option_inst = (
snap.pair.call_inst_id if sig.option_side == "call" else snap.pair.put_inst_id
pick.pair.call_inst_id if pick.option_side == "call" else pick.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(
entry_idx = pick.underlying_px
r = await asyncio.to_thread(
self.matcher.open_group,
group_id=gid,
bias=sig.bias,
option_side=sig.option_side,
perp_side=sig.perp_side,
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=snap.pair.strike,
expiry_ymd=snap.pair.expiry_ymd,
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
if r.ok:
self._set_state(phase="open", last_error=None)
+4 -4
View File
@@ -13,11 +13,11 @@ def check_exits(
*,
perp_upl: float,
initial_premium: float,
move_points: float,
exit_move_points: float,
move_pct: float,
exit_move_pct: float,
) -> ExitDecision:
if initial_premium > 0 and perp_upl + 1e-9 >= initial_premium:
return ExitDecision(True, "premium_cover")
if exit_move_points > 0 and move_points + 1e-9 >= exit_move_points:
return ExitDecision(True, "move_points")
if exit_move_pct > 0 and move_pct + 1e-9 >= exit_move_pct:
return ExitDecision(True, "move_pct")
return ExitDecision(False, "")