diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 312e80b..d5154e1 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -35,6 +35,7 @@ KEYS = ( "rest_seconds", "live_order_interval_sec", "skip_weekends", + "one_expiry_per_day", "initial_equity", "leverage", "perp_margin_mode", @@ -69,6 +70,7 @@ class StrategySettingsBody(BaseModel): rest_seconds: int | None = Field(default=None, ge=0, le=3600) live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30) skip_weekends: bool | None = None + one_expiry_per_day: bool | None = None initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000) leverage: float | None = Field(default=None, ge=1, le=125) perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$") @@ -144,6 +146,10 @@ def _read_settings() -> dict: "skip_weekends": _as_bool( db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends ), + "one_expiry_per_day": _as_bool( + db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)), + s.one_expiry_per_day, + ), "initial_equity": float( db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity ), diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index 7ffc26b..f966a9c 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -59,7 +59,8 @@ async def sim_open_group( if ex.has_open_position(): raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") s = get_settings() - skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends) + ledger = Ledger() + 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, @@ -72,6 +73,22 @@ async def sim_open_group( status_code=409, detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)", ) + one_expiry = ledger.get_setting_bool("one_expiry_per_day", s.one_expiry_per_day) + if one_expiry: + from ..strategy.clock import ( + expiry_blocked_by_one_per_day, + used_expiry_ymds_for_day, + ) + + used = used_expiry_ymds_for_day(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}," + "请等下一到期日" + ), + ) force = (body.force_option_side if body else None) or None if force in ("call", "put"): diff --git a/backend/app/config.py b/backend/app/config.py index 04089e6..45e3c6f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -67,6 +67,8 @@ class Settings(BaseSettings): rest_seconds: int = 300 live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒) skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) + # 同到期日一天只开一次:当日已开过某 expiry_ymd 后,平仓也不可再开同到期,只能开更远到期 + one_expiry_per_day: bool = True leverage: float = 3.0 # 永续杠杆 # 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金) perp_margin_mode: str = "cross" diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 2892439..6d1d8ef 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -232,6 +232,7 @@ class Database: "rest_seconds": str(s.rest_seconds), "live_order_interval_sec": str(s.live_order_interval_sec), "skip_weekends": str(s.skip_weekends), + "one_expiry_per_day": str(s.one_expiry_per_day), "max_rounds": str(s.max_rounds), "leverage": str(s.leverage), "perp_margin_mode": str(s.perp_margin_mode), diff --git a/backend/app/strategy/clock.py b/backend/app/strategy/clock.py index ed91a94..fc491ac 100644 --- a/backend/app/strategy/clock.py +++ b/backend/app/strategy/clock.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from typing import Any from zoneinfo import ZoneInfo _SH = ZoneInfo("Asia/Shanghai") @@ -38,3 +39,34 @@ def can_open_new( 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]: + """ + 上海日历日已开过的期权到期日(groups.expiry_ymd,YYMMDD)。 + 按当日组号 G-{YYYYMMDD}-% 统计;含已平仓,用于「同到期一天只开一次」。 + """ + wkey = window_key(now) + rows = db.fetchall( + "SELECT DISTINCT expiry_ymd FROM groups WHERE group_id LIKE ?", + (f"G-{wkey}-%",), + ) + out: set[str] = set() + for r in rows or []: + y = str(r["expiry_ymd"] or "").strip() + if y: + out.add(y) + return out + + +def expiry_blocked_by_one_per_day( + expiry_ymd: str | None, + used: set[str], + *, + enabled: bool = True, +) -> bool: + """开启时:候选到期已在当日用过则拦截。""" + if not enabled: + return False + y = str(expiry_ymd or "").strip() + return bool(y and y in used) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 5efe660..0bcfb6e 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -82,6 +82,9 @@ class StrategyEngine: ) rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds) skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) + one_expiry_per_day = self.ledger.get_setting_bool( + "one_expiry_per_day", s.one_expiry_per_day + ) leverage = self.ledger.get_setting_float("leverage", s.leverage) perp_mm = str( self.ledger.get_setting_str("perp_margin_mode", s.perp_margin_mode) @@ -213,6 +216,7 @@ class StrategyEngine: "rest_left_sec": rest_left, "rest_seconds": rest_sec, "skip_weekends": skip_weekends, + "one_expiry_per_day": one_expiry_per_day, "exit_mode": exit_mode, "net_profit_target": net_target, "premium_exit_multiple": prem_mult, @@ -824,6 +828,27 @@ class StrategyEngine: self._set_state(phase="wait_signal") pick = await get_session().pick_for_open_async() + one_expiry_per_day = self.ledger.get_setting_bool( + "one_expiry_per_day", s.one_expiry_per_day + ) + 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 = used_expiry_ymds_for_day(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}," + "请等下一到期日" + ), + ) + return if pick is None: try: from .open_capacity import assess_open_capacity, funds_gate_blocks diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 1185c69..2b51f84 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -295,7 +295,29 @@ class StrategySession: logger.info("no expiry with hours>=%.1f", min_hours) return None + used_expiries: set[str] = set() + one_exp_day = bool(s.one_expiry_per_day) + try: + from ..models.db import get_db + from .clock import used_expiry_ymds_for_day + + db = get_db() + one_exp_day = _as_bool_setting( + db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)), + s.one_expiry_per_day, + ) + if one_exp_day: + used_expiries = used_expiry_ymds_for_day(db) + except Exception: + logger.exception("one_expiry_per_day lookup failed; continue without skip") + for ymd in eligible: + if one_exp_day and ymd in used_expiries: + logger.info( + "skip expiry=%s: already used today (one_expiry_per_day)", + ymd, + ) + continue pair = select_option_pair( contracts, mark_px=underlying, diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 9d6abc1..4a65c41 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -3,7 +3,12 @@ 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.clock import ( + can_open_new, + expiry_blocked_by_one_per_day, + used_expiry_ymds_for_day, + window_key, +) from app.strategy.exits import check_expiry_close, check_exits from app.strategy.signal import decide @@ -142,6 +147,30 @@ def test_window_weekend_skip() -> None: assert window_key(fri) == "20260724" +def test_one_expiry_per_day(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + from app.models.db import Database + + db = Database(tmp_path / "one_exp.db") + day = datetime(2026, 8, 1, 12, 0, tzinfo=_SH) + with db._lock: + 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 + db.close() + + def test_bid_mark_deviation_30pct() -> None: # |7-10|/10 = 30% → 允许(≤30%) ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 10f5c11..1f73ada 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -225,6 +225,7 @@ export type PlanState = { rest_left_sec: number; rest_seconds: number; skip_weekends?: boolean; + one_expiry_per_day?: boolean; exit_move_pct?: number; exit_mode: "fixed_usdt" | "premium_multiple"; net_profit_target: number; @@ -315,6 +316,7 @@ export type StrategySettings = { rest_seconds: number; live_order_interval_sec?: number; skip_weekends?: boolean; + one_expiry_per_day?: boolean; initial_equity?: number; leverage?: number; perp_margin_mode?: "cross" | "isolated"; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 7cba7a4..6068ccb 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -298,6 +298,9 @@ export default function PlanPage() { {fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥ {fmt(plan?.min_option_leverage, 0)}x · {atmRule} {plan?.skip_weekends ? " · 周末跳过开仓" : ""} + {plan?.one_expiry_per_day !== false + ? " · 同到期日一天只开一次" + : ""}
平仓 @@ -306,6 +309,9 @@ 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 14b82e9..29c3d23 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -73,6 +73,7 @@ export default function SettingsPage() { const [rest, setRest] = useState(300); const [orderInterval, setOrderInterval] = useState(1); const [skipWeekends, setSkipWeekends] = useState(true); + const [oneExpiryPerDay, setOneExpiryPerDay] = useState(true); const [leverage, setLeverage] = useState(3); const [perpMarginMode, setPerpMarginMode] = useState<"cross" | "isolated">( "cross", @@ -180,6 +181,7 @@ export default function SettingsPage() { setRest(s.rest_seconds); setOrderInterval(s.live_order_interval_sec ?? 1); setSkipWeekends(s.skip_weekends !== false); + setOneExpiryPerDay(s.one_expiry_per_day !== false); setLeverage(s.leverage ?? 3); setPerpMarginMode( s.perp_margin_mode === "isolated" ? "isolated" : "cross", @@ -344,6 +346,7 @@ export default function SettingsPage() { rest_seconds: rest, live_order_interval_sec: orderInterval, skip_weekends: skipWeekends, + one_expiry_per_day: oneExpiryPerDay, leverage, perp_margin_mode: perpMarginMode, min_option_hours: minHours, @@ -1167,6 +1170,25 @@ export default function SettingsPage() { ++ 开启后:当日已开过某到期日(如 0802)并平仓后,不能再开同到期,只能开下一到期(如 0803)。 +
+