From 9427d9c4299b7d367bbffc4e267d72e1b6a58bc0 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 10:31:07 +0800 Subject: [PATCH] Skip used/residual expiries so ATM monitors the next day. Co-authored-by: Cursor --- backend/app/strategy/clock.py | 14 +++++ backend/app/strategy/session.py | 80 ++++++++++++++++++-------- backend/tests/test_skip_expiry_next.py | 61 ++++++++++++++++++++ 3 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_skip_expiry_next.py diff --git a/backend/app/strategy/clock.py b/backend/app/strategy/clock.py index fc491ac..6a42d42 100644 --- a/backend/app/strategy/clock.py +++ b/backend/app/strategy/clock.py @@ -59,6 +59,20 @@ def used_expiry_ymds_for_day(db: Any, now: datetime | None = None) -> set[str]: return out +def pending_residual_expiry_ymds(db: Any) -> set[str]: + """待到期结算的残留期权到期日(YYMMDD);监控/下一组开仓应跳过这些档。""" + rows = db.fetchall( + """SELECT DISTINCT expiry_ymd FROM residual_options + WHERE status='pending' AND expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''""" + ) + 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], diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 2b51f84..60834b4 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -69,6 +69,31 @@ def _as_bool_setting(raw: str | None, default: bool) -> bool: return str(raw).strip().lower() in ("1", "true", "yes", "on") +def _skip_expiry_ymds_for_next() -> set[str]: + """ + 空仓选约/监控应跳过的到期日: + - 当日已开过(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 + + s = get_settings() + 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: + skip |= used_expiry_ymds_for_day(db) + skip |= pending_residual_expiry_ymds(db) + except Exception: + logger.exception("skip-expiry lookup failed; continue without skip") + return skip + + def _strategy_floats() -> tuple[float, float, float, bool]: """min_hours, min_leverage, max_atm_open_offset, atm_open_offset_enabled""" s = get_settings() @@ -264,12 +289,32 @@ class StrategySession: fixed_on, fixed_perp = _fixed_direction() opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None contracts = self.ex.list_option_contracts(s.option_inst_family) - pair = select_option_pair( - contracts, - mark_px=float(mark), - min_hours=min_hours, - option_side=opt_side, - ) + eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours) + skip = _skip_expiry_ymds_for_next() + pair = None + for ymd in eligible: + if ymd in skip: + logger.info( + "align skip expiry=%s (used today and/or residual pending)", + ymd, + ) + continue + pair = select_option_pair( + contracts, + mark_px=float(mark), + expiry_ymd=ymd, + option_side=opt_side, + ) + if pair is not None: + break + if pair is None and eligible: + # 候选都被跳过时仍回退最近合格档,避免盘口空白 + pair = select_option_pair( + contracts, + mark_px=float(mark), + expiry_ymd=eligible[0], + option_side=opt_side, + ) if pair is None: kind = f"实值/平值 {opt_side}" if opt_side else "ATM" raise RuntimeError( @@ -295,26 +340,12 @@ 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") + skip_expiries = _skip_expiry_ymds_for_next() for ymd in eligible: - if one_exp_day and ymd in used_expiries: + if ymd in skip_expiries: logger.info( - "skip expiry=%s: already used today (one_expiry_per_day)", + "skip expiry=%s: used today and/or residual pending", ymd, ) continue @@ -459,6 +490,9 @@ class StrategySession: < min_hours ): return True + skip = _skip_expiry_ymds_for_next() + if str(self._pair.expiry_ymd or "") in skip: + return True mark = mark_px if mark_px is not None else self._mark_for_atm() if mark is None or mark <= 0: return False diff --git a/backend/tests/test_skip_expiry_next.py b/backend/tests/test_skip_expiry_next.py new file mode 100644 index 0000000..e8d7edb --- /dev/null +++ b/backend/tests/test_skip_expiry_next.py @@ -0,0 +1,61 @@ +"""空仓选约:跳过当日已用到期与残留待结算到期。""" + +from __future__ import annotations + +from app.strategy.clock import ( + expiry_blocked_by_one_per_day, + pending_residual_expiry_ymds, + used_expiry_ymds_for_day, +) + + +class _FakeDB: + def __init__( + self, + *, + groups: list[dict] | None = None, + residuals: list[dict] | None = None, + ) -> None: + self._groups = groups or [] + self._residuals = residuals or [] + + 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 "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: + 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) + + +def test_pending_residual_expiry_ymds() -> None: + db = _FakeDB( + residuals=[ + {"expiry_ymd": "260803", "status": "pending"}, + {"expiry_ymd": "260802", "status": "settled"}, + {"expiry_ymd": "", "status": "pending"}, + ] + ) + assert pending_residual_expiry_ymds(db) == {"260803"}