Add weekend open skip, expiry force-close, and Chinese trade labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,7 @@ KEYS = (
|
||||
"net_profit_target",
|
||||
"premium_exit_multiple",
|
||||
"rest_seconds",
|
||||
"skip_weekends",
|
||||
"initial_equity",
|
||||
"leverage",
|
||||
"min_option_hours",
|
||||
@@ -36,6 +37,7 @@ class StrategySettingsBody(BaseModel):
|
||||
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
|
||||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||||
skip_weekends: bool | None = None
|
||||
initial_equity: float | None = Field(default=None, ge=1000)
|
||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
||||
min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||||
@@ -45,6 +47,12 @@ class StrategySettingsBody(BaseModel):
|
||||
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
|
||||
|
||||
def _as_bool(raw: str | None, default: bool) -> bool:
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _read_settings() -> dict:
|
||||
db = get_db()
|
||||
s = get_settings()
|
||||
@@ -68,6 +76,9 @@ def _read_settings() -> dict:
|
||||
"rest_seconds": int(
|
||||
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||||
),
|
||||
"skip_weekends": _as_bool(
|
||||
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
|
||||
),
|
||||
"initial_equity": float(
|
||||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||||
),
|
||||
|
||||
+10
-5
@@ -5,11 +5,12 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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 ..strategy.clock import window_key
|
||||
from ..strategy.clock import can_open_new, window_key
|
||||
from ..strategy.group import next_group_id
|
||||
from .auth import require_user
|
||||
|
||||
@@ -39,6 +40,13 @@ async def sim_open_group(
|
||||
) -> dict:
|
||||
if Matcher().has_open_position():
|
||||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||
s = get_settings()
|
||||
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
if not can_open_new(skip_weekends=skip_weekends):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="周六/周日跳过开仓(上海时区)",
|
||||
)
|
||||
gw = get_gateway()
|
||||
pick = await gw.pick_for_open_async()
|
||||
if pick is None:
|
||||
@@ -53,12 +61,9 @@ async def sim_open_group(
|
||||
perp_side = "short" if force == "call" else "long"
|
||||
bias = "manual_" + force
|
||||
option_ask = pick.call_ask if force == "call" else pick.put_ask
|
||||
from ..config import get_settings
|
||||
from ..strategy.selection import option_leverage
|
||||
from ..sim.ledger import Ledger as Led
|
||||
|
||||
s = get_settings()
|
||||
min_lev = Led().get_setting_float("min_option_leverage", s.min_option_leverage)
|
||||
min_lev = Ledger().get_setting_float("min_option_leverage", s.min_option_leverage)
|
||||
lev = option_leverage(pick.underlying_px, option_ask)
|
||||
if lev is None or lev < min_lev:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -48,6 +48,7 @@ class Settings(BaseSettings):
|
||||
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT)
|
||||
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
|
||||
rest_seconds: int = 300
|
||||
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
|
||||
leverage: float = 3.0 # 永续杠杆
|
||||
min_option_hours: float = 12.0 # 期权最小剩余小时
|
||||
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
||||
|
||||
@@ -35,6 +35,7 @@ async def lifespan(app: FastAPI):
|
||||
set_db(db)
|
||||
engine = StrategyEngine()
|
||||
set_engine(engine)
|
||||
engine.ensure_loop()
|
||||
|
||||
session = bootstrap_session(settings)
|
||||
try:
|
||||
|
||||
@@ -65,3 +65,9 @@ class Ledger:
|
||||
if v is None or v == "":
|
||||
return default
|
||||
return str(v)
|
||||
|
||||
def get_setting_bool(self, key: str, default: bool) -> bool:
|
||||
v = self.db.get_setting(key)
|
||||
if v is None or v == "":
|
||||
return default
|
||||
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo
|
||||
from app.sim.liquidity import bid_mark_ok
|
||||
from app.sim.pricing import option_fill, perp_fill
|
||||
from app.strategy.clock import can_open_new, window_key
|
||||
from app.strategy.exits import check_exits
|
||||
from app.strategy.exits import check_expiry_close, check_exits
|
||||
from app.strategy.signal import decide
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
@@ -83,13 +83,18 @@ def test_option_open_close_pricing() -> None:
|
||||
assert c.fill_px < 10
|
||||
|
||||
|
||||
def test_window_always_open() -> None:
|
||||
n = datetime(2026, 7, 24, 17, 0, tzinfo=_SH)
|
||||
assert can_open_new(n) is True
|
||||
assert window_key(n) == "20260724"
|
||||
n2 = datetime(2026, 7, 24, 10, 0, tzinfo=_SH)
|
||||
assert can_open_new(n2) is True
|
||||
assert window_key(n2) == "20260724"
|
||||
def test_window_weekend_skip() -> None:
|
||||
# 2026-07-24 周五可开;25/26 周六日不可开
|
||||
fri = datetime(2026, 7, 24, 17, 0, tzinfo=_SH)
|
||||
sat = datetime(2026, 7, 25, 12, 0, tzinfo=_SH)
|
||||
sun = datetime(2026, 7, 26, 10, 0, tzinfo=_SH)
|
||||
mon = datetime(2026, 7, 27, 9, 0, tzinfo=_SH)
|
||||
assert can_open_new(fri, skip_weekends=True) is True
|
||||
assert can_open_new(sat, skip_weekends=True) is False
|
||||
assert can_open_new(sun, skip_weekends=True) is False
|
||||
assert can_open_new(mon, skip_weekends=True) is True
|
||||
assert can_open_new(sat, skip_weekends=False) is True
|
||||
assert window_key(fri) == "20260724"
|
||||
|
||||
|
||||
def test_bid_mark_deviation_30pct() -> None:
|
||||
@@ -101,3 +106,14 @@ def test_bid_mark_deviation_30pct() -> None:
|
||||
ok3, why = bid_mark_ok(bid=None, mark=10.0, max_dev_pct=30)
|
||||
assert ok3 is False
|
||||
assert "买一" in why
|
||||
|
||||
|
||||
def test_expiry_close() -> None:
|
||||
assert check_expiry_close(expiry_ms=None).should_close is False
|
||||
d = check_expiry_close(expiry_ms=1_000, now_ms=999)
|
||||
assert d.should_close is False
|
||||
d2 = check_expiry_close(expiry_ms=1_000, now_ms=1_000)
|
||||
assert d2.should_close is True
|
||||
assert d2.reason == "expiry"
|
||||
d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001)
|
||||
assert d3.should_close is True
|
||||
|
||||
Reference in New Issue
Block a user