Add weekend open skip, expiry force-close, and Chinese trade labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 10:28:33 +08:00
parent 826362b556
commit b50c134f5b
19 changed files with 525 additions and 52 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
from .clock import can_open_new, window_key
from .clock import can_open_new, is_weekend, window_key
from .exits import check_exits
from .group import next_group_id
from .signal import Signal, decide
@@ -10,6 +10,7 @@ __all__ = [
"check_exits",
"decide",
"get_engine",
"is_weekend",
"next_group_id",
"set_engine",
"window_key",
+11 -2
View File
@@ -1,4 +1,4 @@
"""日历日分组键(开仓时间窗已取消,由期权剩余时长约束)。"""
"""日历日分组键;可选周末跳过开仓(上海时区)。"""
from __future__ import annotations
@@ -17,13 +17,22 @@ def window_key(now: datetime | None = None) -> str:
return now_sh(now).strftime("%Y%m%d")
def is_weekend(now: datetime | None = None) -> bool:
"""上海时区:周六=5、周日=6。"""
return now_sh(now).weekday() >= 5
def can_open_new(
now: datetime | None = None,
*,
skip_weekends: bool = True,
open_hhmm: str = "16:00",
stop_hhmm: str = "08:00",
) -> bool:
"""开仓窗已取消,始终允许(仍受期权剩余时长/杠杆筛选)"""
"""是否允许新开仓。开仓窗已取消;可选跳过周六日。持仓平仓不受此限制"""
_ = open_hhmm, stop_hhmm
if skip_weekends and is_weekend(now):
return False
return True
+98 -19
View File
@@ -12,8 +12,8 @@ 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 .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__)
@@ -46,6 +46,7 @@ class StrategyEngine:
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(
@@ -59,6 +60,7 @@ class StrategyEngine:
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"],
@@ -67,6 +69,7 @@ class StrategyEngine:
"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,
@@ -74,7 +77,7 @@ class StrategyEngine:
"leverage": leverage,
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"can_open": True,
"can_open": allow_open,
"last_error": last_error,
"position": upl,
"ledger": self.ledger.snapshot(),
@@ -97,9 +100,13 @@ class StrategyEngine:
async def start(self) -> dict[str, Any]:
self._set_state(running=1, last_error=None, phase="idle")
self.ensure_loop()
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")
return self.state()
async def emergency_close(self) -> dict[str, Any]:
async with self._lock:
@@ -137,12 +144,73 @@ class StrategyEngine:
)
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.okx.parse 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,
) -> None:
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=bypass_liquidity,
)
if r.ok:
self._after_close()
elif r.liquidity_wait and not bypass_liquidity:
self._set_state(phase="liquidity_wait", last_error=r.detail)
else:
self._set_state(phase="closing", last_error=r.detail)
async def _maybe_expiry_close(self) -> bool:
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
pos = self.matcher.current_position()
if pos.get("status") != "open":
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,
)
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")
if not row or not int(row["running"]):
running = bool(row and int(row["running"]))
if not running:
# 暂停时仍执行到期全平,避免拖过期
async with self._lock:
await self._maybe_expiry_close()
await asyncio.sleep(1)
continue
async with self._lock:
@@ -180,6 +248,7 @@ class StrategyEngine:
# 有未平仓:只盯平仓,绝不开下一组
if pos.get("status") == "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,
@@ -188,19 +257,18 @@ class StrategyEngine:
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)
if expired.should_close or decision.should_close or pending_close:
if expired.should_close:
reason = "expiry"
bypass = True
else:
self._set_state(phase="closing", last_error=r.detail)
reason = decision.reason or "liquidity_retry"
bypass = False
await self._close_open_position(
reason=reason,
bypass_liquidity=bypass,
pending_close=pending_close,
)
else:
self._set_state(phase="open", last_error=None)
return
@@ -214,10 +282,21 @@ class StrategyEngine:
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="有未平仓,禁止开下一组")
@@ -265,6 +344,6 @@ def get_engine() -> StrategyEngine:
return _engine
def set_engine(e: StrategyEngine | None) -> None:
def set_engine(engine: StrategyEngine | None) -> None:
global _engine
_engine = e
_engine = engine
+15
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import time
from dataclasses import dataclass
EXIT_MODE_FIXED = "fixed_usdt"
@@ -28,6 +29,20 @@ def resolve_exit_target(
return float(net_profit_target), EXIT_MODE_FIXED
def check_expiry_close(
*,
expiry_ms: int | None,
now_ms: int | None = None,
) -> ExitDecision:
"""期权到期时刻(含)→ 强制全平。"""
if expiry_ms is None:
return ExitDecision(False, "", 0.0)
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
if now >= int(expiry_ms):
return ExitDecision(True, "expiry", 0.0)
return ExitDecision(False, "", 0.0)
def check_exits(
*,
net_pnl: float,