diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index acdd4d5..4a590aa 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -77,15 +77,15 @@ async def sim_open_group( if one_expiry: from ..strategy.clock import ( expiry_blocked_by_one_per_day, - used_expiry_ymds_for_day, + used_expiry_ymds, ) - used = used_expiry_ymds_for_day(get_db()) + used = used_expiry_ymds(get_db()) if expiry_blocked_by_one_per_day(pick.pair.expiry_ymd, used, enabled=True): raise HTTPException( status_code=409, detail=( - f"同到期日一天只开一次:今日已用过 {pick.pair.expiry_ymd}," + f"每个到期只开一次:已用过 {pick.pair.expiry_ymd}," "请等下一到期日" ), ) diff --git a/backend/app/config.py b/backend/app/config.py index bc58a24..1e9ca3d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -67,7 +67,7 @@ class Settings(BaseSettings): rest_seconds: int = 300 live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒) skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) - # 同到期日一天只开一次:当日已开过某 expiry_ymd 后,平仓也不可再开同到期,只能开更远到期 + # 每个到期只开一次(跨日):历史上已开过某 expiry_ymd 后不可再开同到期,只能盯下一档 one_expiry_per_day: bool = True leverage: float = 3.0 # 永续杠杆 # 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金) diff --git a/backend/app/strategy/clock.py b/backend/app/strategy/clock.py index 6a42d42..1cec139 100644 --- a/backend/app/strategy/clock.py +++ b/backend/app/strategy/clock.py @@ -41,15 +41,15 @@ def group_date_ymd(now: datetime | None = None) -> str: return window_key(now) -def used_expiry_ymds_for_day(db: Any, now: datetime | None = None) -> set[str]: +def used_expiry_ymds(db: Any, now: datetime | None = None) -> set[str]: """ - 上海日历日已开过的期权到期日(groups.expiry_ymd,YYMMDD)。 - 按当日组号 G-{YYYYMMDD}-% 统计;含已平仓,用于「同到期一天只开一次」。 + 历史上已开过的期权到期日(groups.expiry_ymd,YYMMDD)。 + 跨日历日:任一已开组(含已平仓)占用该到期后,不可再开同到期,只能盯下一档。 """ - wkey = window_key(now) + _ = now rows = db.fetchall( - "SELECT DISTINCT expiry_ymd FROM groups WHERE group_id LIKE ?", - (f"G-{wkey}-%",), + """SELECT DISTINCT expiry_ymd FROM groups + WHERE expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''""" ) out: set[str] = set() for r in rows or []: @@ -59,6 +59,10 @@ def used_expiry_ymds_for_day(db: Any, now: datetime | None = None) -> set[str]: return out +# 兼容旧名(语义已改为跨日/历史到期占用) +used_expiry_ymds_for_day = used_expiry_ymds + + def pending_residual_expiry_ymds(db: Any) -> set[str]: """待到期结算的残留期权到期日(YYMMDD);监控/下一组开仓应跳过这些档。""" rows = db.fetchall( @@ -79,7 +83,7 @@ def expiry_blocked_by_one_per_day( *, enabled: bool = True, ) -> bool: - """开启时:候选到期已在当日用过则拦截。""" + """开启时:候选到期已在历史上用过则拦截。""" if not enabled: return False y = str(expiry_ymd or "").strip() diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 49a73fe..8f398d8 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -873,17 +873,17 @@ class StrategyEngine: if pick is not None and one_expiry_per_day: from .clock import ( expiry_blocked_by_one_per_day, - used_expiry_ymds_for_day, + used_expiry_ymds, ) - used = used_expiry_ymds_for_day(self.db) + used = used_expiry_ymds(self.db) if expiry_blocked_by_one_per_day( pick.pair.expiry_ymd, used, enabled=True ): self._set_state( phase="idle", last_error=( - f"同到期日一天只开一次:今日已用过 {pick.pair.expiry_ymd}," + f"每个到期只开一次:已用过 {pick.pair.expiry_ymd}," "请等下一到期日" ), ) diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 60834b4..742fb8c 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -72,13 +72,13 @@ def _as_bool_setting(raw: str | None, default: bool) -> bool: def _skip_expiry_ymds_for_next() -> set[str]: """ 空仓选约/监控应跳过的到期日: - - 当日已开过(one_expiry_per_day) + - 历史上已开过该到期(one_expiry_per_day,跨日) - 仍有待结算残留期权的到期档(该档已「完成」开平,盯下一档) """ skip: set[str] = set() try: from ..models.db import get_db - from .clock import pending_residual_expiry_ymds, used_expiry_ymds_for_day + from .clock import pending_residual_expiry_ymds, used_expiry_ymds s = get_settings() db = get_db() @@ -87,7 +87,7 @@ def _skip_expiry_ymds_for_next() -> set[str]: s.one_expiry_per_day, ) if one_exp_day: - skip |= used_expiry_ymds_for_day(db) + skip |= used_expiry_ymds(db) skip |= pending_residual_expiry_ymds(db) except Exception: logger.exception("skip-expiry lookup failed; continue without skip") diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 4a65c41..2b72fee 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -152,22 +152,23 @@ def test_one_expiry_per_day(tmp_path, monkeypatch) -> None: from app.models.db import Database db = Database(tmp_path / "one_exp.db") - day = datetime(2026, 8, 1, 12, 0, tzinfo=_SH) + # 日历已跨到 8.3,但 8.2 开过的 260803 仍须占用 + day = datetime(2026, 8, 3, 0, 0, 2, tzinfo=_SH) with db._lock: + db._conn.execute( + """INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms) + VALUES ('G-20260802-01','closed','260803',1)""" + ) db._conn.execute( """INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms) VALUES ('G-20260801-01','closed','260802',1)""" ) - db._conn.execute( - """INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms) - VALUES ('G-20260731-01','closed','260801',1)""" - ) db._conn.commit() used = used_expiry_ymds_for_day(db, day) - assert used == {"260802"} - assert expiry_blocked_by_one_per_day("260802", used, enabled=True) is True - assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is False - assert expiry_blocked_by_one_per_day("260802", used, enabled=False) is False + assert used == {"260803", "260802"} + assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is True + assert expiry_blocked_by_one_per_day("260804", used, enabled=True) is False + assert expiry_blocked_by_one_per_day("260803", used, enabled=False) is False db.close() diff --git a/backend/tests/test_skip_expiry_next.py b/backend/tests/test_skip_expiry_next.py index e8d7edb..b5cec47 100644 --- a/backend/tests/test_skip_expiry_next.py +++ b/backend/tests/test_skip_expiry_next.py @@ -1,11 +1,14 @@ -"""空仓选约:跳过当日已用到期与残留待结算到期。""" +"""空仓选约:跳过历史上已用到期与残留待结算到期。""" from __future__ import annotations +from datetime import datetime +from zoneinfo import ZoneInfo + from app.strategy.clock import ( expiry_blocked_by_one_per_day, pending_residual_expiry_ymds, - used_expiry_ymds_for_day, + used_expiry_ymds, ) @@ -22,32 +25,29 @@ class _FakeDB: def fetchall(self, sql: str, params: tuple = ()) -> list[dict]: s = " ".join(sql.split()).lower() if "from groups" in s: - like = params[0] if params else "" - prefix = like.replace("%", "") return [ r for r in self._groups - if str(r.get("group_id", "")).startswith(prefix) + if str(r.get("expiry_ymd") or "").strip() ] if "from residual_options" in s: return [r for r in self._residuals if r.get("status") == "pending"] return [] -def test_used_expiry_ymds_for_day() -> None: +def test_used_expiry_ymds_across_calendar_days() -> None: + """8.2 开过 260803 后,8.3 零点仍须拦截同到期。""" db = _FakeDB( groups=[ {"group_id": "G-20260802-01", "expiry_ymd": "260803"}, {"group_id": "G-20260801-01", "expiry_ymd": "260802"}, ] ) - from datetime import datetime - from zoneinfo import ZoneInfo - - now = datetime(2026, 8, 2, 12, 0, tzinfo=ZoneInfo("Asia/Shanghai")) - assert used_expiry_ymds_for_day(db, now) == {"260803"} - assert expiry_blocked_by_one_per_day("260803", {"260803"}, enabled=True) - assert not expiry_blocked_by_one_per_day("260804", {"260803"}, enabled=True) + now_aug3 = datetime(2026, 8, 3, 0, 0, 2, tzinfo=ZoneInfo("Asia/Shanghai")) + used = used_expiry_ymds(db, now_aug3) + assert used == {"260803", "260802"} + assert expiry_blocked_by_one_per_day("260803", used, enabled=True) + assert not expiry_blocked_by_one_per_day("260804", used, enabled=True) def test_pending_residual_expiry_ymds() -> None: diff --git a/docs/策略说明.md b/docs/策略说明.md index fd8ed84..70f57e8 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -160,7 +160,7 @@ claim 开仓槽 status=opening |------|------|------| | `min_option_hours` | 12 | 过滤过近到期 | | `min_option_leverage` | 100 | 权利金不能太贵 | -| `one_expiry_per_day` | true | 同到期日一天只开一次 | +| `one_expiry_per_day` | true | 每个到期只开一次(跨日,按 expiry_ymd 历史占用) | | `fixed_direction_enabled` | false | 固定方向 | | `fixed_perp_side` | long | long→Put / short→Call | | `atm_open_offset_enabled` | false | ATM 偏差开关 | diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 1a097f5..9fc47ab 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -302,7 +302,7 @@ export default function PlanPage() { {fmt(plan?.min_option_leverage, 0)}x · {atmRule} {plan?.skip_weekends ? " · 周末跳过开仓" : ""} {plan?.one_expiry_per_day !== false - ? " · 同到期日一天只开一次" + ? " · 每个到期只开一次" : ""}

@@ -313,7 +313,7 @@ export default function PlanPage() { 节奏 组间休息 {plan?.rest_seconds ?? "—"}s · 可开判定看交易账户 {plan?.one_expiry_per_day !== false - ? " · 同到期平仓后改开下一到期" + ? " · 同到期用过后改开下一到期" : ""}

diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a47c322..707ef0a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1230,7 +1230,7 @@ export default function SettingsPage() {

- 开启后:当日已开过某到期日(如 0802)并平仓后,不能再开同到期,只能开下一到期(如 0803)。 + 开启后:历史上已开过某到期日(如 0803)后,不论今天还是明天都不能再开同到期,只能监控/开下一到期(如 0804)。