From 5ea54db43b1a146b77a5b88d39e32d92a9f3fe64 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 14:48:07 +0800 Subject: [PATCH] Let semi-auto reopen nearest expiry; ignore one-expiry-per-day. Far quarterly skips were masking usable near OTM quotes like 260810. Co-authored-by: Cursor --- backend/app/strategy/engine.py | 7 ++++- backend/app/strategy/session.py | 54 +++++++++++++++++++++------------ docs/半自动说明.md | 2 ++ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index b4bf9d8..19eb5a2 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -1186,7 +1186,12 @@ class StrategyEngine: 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: + # 半自动人工授权:允许同到期再开;全自动仍受一日一到期约束 + if ( + pick is not None + and one_expiry_per_day + and not is_semi_auto(self.ledger) + ): from .clock import ( expiry_blocked_by_one_per_day, used_expiry_ymds, diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 97cc59c..df177f0 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -89,10 +89,10 @@ 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]: +def _skip_expiry_ymds_for_next(*, include_used: bool = True) -> set[str]: """ 空仓选约/监控应跳过的到期日: - - 历史上已开过该到期(one_expiry_per_day,跨日) + - 历史上已开过该到期(one_expiry_per_day,跨日);半自动可关 - 仍有待结算残留期权的到期档(该档已「完成」开平,盯下一档) """ skip: set[str] = set() @@ -102,12 +102,13 @@ def _skip_expiry_ymds_for_next() -> set[str]: 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(db) + if include_used: + 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(db) skip |= pending_residual_expiry_ymds(db) except Exception: logger.exception("skip-expiry lookup failed; continue without skip") @@ -765,14 +766,25 @@ class StrategySession: self._last_pick_fail = f"无剩余≥{min_hours:g}h 的到期" return None - skip_expiries = _skip_expiry_ymds_for_next() + # 半自动:人工授权可重复开同到期,不受 one_expiry_per_day 挡住; + # 仍跳过有残余腿的到期。全自动保持原「一日一到期」。 + skip_expiries = _skip_expiry_ymds_for_next(include_used=not semi_on) + first_skip = "" last_skip = "" + def _note_skip(msg: str) -> None: + nonlocal first_skip, last_skip + last_skip = msg + if not first_skip: + first_skip = msg + for ymd in eligible: if ymd in skip_expiries: + _note_skip(f"{ymd} 有残余期权(跳过该到期)") logger.info( - "skip expiry=%s: used today and/or residual pending", + "skip expiry=%s: residual pending (semi=%s)", ymd, + semi_on, ) continue pair = select_option_pair( @@ -785,7 +797,7 @@ class StrategySession: ) if pair is None: if semi_on and semi_mny == "otm": - last_skip = ( + _note_skip( f"{ymd} 无{opt_side_hint or '?'}虚值" f"(偏离≤{float(semi_otm_off or 0):g})" ) @@ -797,7 +809,7 @@ class StrategySession: underlying, ) else: - last_skip = f"{ymd} 无合格行权价" + _note_skip(f"{ymd} 无合格行权价") continue if fixed_on: from .selection import is_otm @@ -809,13 +821,13 @@ class StrategySession: strike=pair.strike, mark_px=underlying, ): - last_skip = f"{ymd} K{pair.strike:g} 非虚值" + _note_skip(f"{ymd} K{pair.strike:g} 非虚值") continue if ( atm_open_offset(pair.strike, underlying) > float(semi_otm_off or 0) + 1e-9 ): - last_skip = ( + _note_skip( f"{ymd} K{pair.strike:g} 偏离>" f"{float(semi_otm_off or 0):g}" ) @@ -828,7 +840,7 @@ class StrategySession: strike=pair.strike, mark_px=underlying, ): - last_skip = f"{ymd} K{pair.strike:g} 非实值/平值" + _note_skip(f"{ymd} K{pair.strike:g} 非实值/平值") logger.info( "skip expiry=%s strike=%.0f not ITM/ATM for %s mark=%.2f", ymd, @@ -845,7 +857,7 @@ class StrategySession: max_offset=max_atm_off, enabled=atm_off_on, ): - last_skip = f"{ymd} ATM偏离{offset:.1f}>{max_atm_off:g}" + _note_skip(f"{ymd} ATM偏离{offset:.1f}>{max_atm_off:g}") logger.info( "skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f", ymd, @@ -878,13 +890,13 @@ class StrategySession: need = "Call" if (opt_side_hint == "call") else ( "Put" if opt_side_hint == "put" else "Call/Put" ) - last_skip = f"{ymd} K{pair.strike:g} 缺{need}卖一" + _note_skip(f"{ymd} K{pair.strike:g} 缺{need}卖一") continue opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask lev = option_leverage(underlying, opt_ask) hours_left = hours_until_expiry(ymd, expiry_ms=pair.expiry_ms) if lev is None or lev + 1e-9 < min_lev: - last_skip = ( + _note_skip( f"{ymd} {sig.option_side.upper()}@{pair.strike:g} " f"杠杆{(f'{lev:.0f}x' if lev else 'n/a')}<{min_lev:g}x" ) @@ -921,14 +933,16 @@ class StrategySession: underlying_px=underlying, hedge_mode="perp_option", ) - if last_skip: + # 报最近到期(列表最前)的原因,避免只显示远月 261225 造成误会 + why = first_skip or last_skip + if why: hint = "" if semi_on: hint = ( f"(半自动{opt_side_hint or '?'}·" f"{semi_mny or '?'}·≥{min_lev:g}x·≥{min_hours:g}h)" ) - self._last_pick_fail = f"最近跳过: {last_skip}{hint}" + self._last_pick_fail = f"{why}{hint}" else: self._last_pick_fail = "合格到期均被跳过(一日一到期/残余等)" return None diff --git a/docs/半自动说明.md b/docs/半自动说明.md index b83394d..b8c368e 100644 --- a/docs/半自动说明.md +++ b/docs/半自动说明.md @@ -49,6 +49,8 @@ **以损定仓(半自动)**:系统设置 `sizing_mode=risk_based` 时,开仓前用**盘口卖一**估算单位成本 → 预算反推 k → `期权量 = 期权单位 × k`,`永续量 = 永续单位 × k`(配比不变)。首页「半自动 · 本单」下方按当前报价档卖一实时预览开仓量。 +选约到期与报价列表一致:取剩余 ≥ 最短 h 的**最近**到期。半自动**不受**全自动「一日一到期」限制(仍可跳过有残余腿的到期)。 + --- ## 3. 出场