From 6f983ed2ab85e8033df323600bc89941c543cce5 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 14:52:26 +0800 Subject: [PATCH] Try all OTM strikes in offset when nearest fails leverage. Closest Call@1920 at 152x no longer blocks 1930/1940 that already clear 200x. Co-authored-by: Cursor --- backend/app/strategy/selection.py | 32 ++++++-- backend/app/strategy/session.py | 121 +++++++++++++++++++----------- backend/tests/test_semi_auto.py | 9 ++- 3 files changed, 110 insertions(+), 52 deletions(-) diff --git a/backend/app/strategy/selection.py b/backend/app/strategy/selection.py index f30393e..6253751 100644 --- a/backend/app/strategy/selection.py +++ b/backend/app/strategy/selection.py @@ -46,18 +46,19 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None: return min(strikes, key=lambda s: (abs(s - mark_px), s)) -def pick_otm_strike( +def list_otm_strikes( strikes: list[float], mark_px: float, *, option_side: str, max_offset: float, -) -> float | None: +) -> list[float]: """ - 虚值:Call K>S、Put KS、Put K float | None: + """虚值:取最接近标的且 |K−S|≤max_offset 的一档。""" + cands = list_otm_strikes( + strikes, + mark_px, + option_side=option_side, + max_offset=max_offset, + ) + return cands[0] if cands else None def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool: diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index df177f0..b37f266 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -778,39 +778,12 @@ class StrategySession: 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: residual pending (semi=%s)", - ymd, - semi_on, - ) - continue - pair = select_option_pair( - contracts, - mark_px=underlying, - expiry_ymd=ymd, - option_side=opt_side_hint, - moneyness=semi_mny if semi_on else None, - otm_max_offset=semi_otm_off if semi_on else None, - ) - if pair is None: - if semi_on and semi_mny == "otm": - _note_skip( - f"{ymd} 无{opt_side_hint or '?'}虚值" - f"(偏离≤{float(semi_otm_off or 0):g})" - ) - logger.info( - "skip expiry=%s no OTM within offset=%.1f for %s mark=%.2f", - ymd, - float(semi_otm_off or 0), - opt_side_hint, - underlying, - ) - else: - _note_skip(f"{ymd} 无合格行权价") - continue + from .selection import _complete_by_expiry, list_otm_strikes + + complete = _complete_by_expiry(contracts) + + def _try_pair(pair: OptionPair) -> OpenPick | None: + """验盘口/方向/杠杆;不合格记 skip 并返回 None(调用方换下一档)。""" if fixed_on: from .selection import is_otm @@ -822,7 +795,7 @@ class StrategySession: mark_px=underlying, ): _note_skip(f"{ymd} K{pair.strike:g} 非虚值") - continue + return None if ( atm_open_offset(pair.strike, underlying) > float(semi_otm_off or 0) + 1e-9 @@ -831,9 +804,8 @@ class StrategySession: f"{ymd} K{pair.strike:g} 偏离>" f"{float(semi_otm_off or 0):g}" ) - continue + return None elif semi_on and semi_mny == "atm": - # 平值:须为该到期最接近标的的档 pass elif not is_itm_or_atm( option_side=side, @@ -848,7 +820,7 @@ class StrategySession: opt_side_hint, underlying, ) - continue + return None else: offset = atm_open_offset(pair.strike, underlying) if not atm_allows_open( @@ -865,12 +837,11 @@ class StrategySession: offset, max_atm_off, ) - continue + return None call_bids, call_asks, _ = self.ex.fetch_book(pair.call_inst_id, depth=5) put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=5) call_ask = call_asks[0].px if call_asks else None put_ask = put_asks[0].px if put_asks else None - # REST 被限流时回退 WS/缓存盘口 if call_ask is None: cq = self.ex.quote(pair.call_inst_id) call_ask = cq.ask if cq else None @@ -891,7 +862,7 @@ class StrategySession: "Put" if opt_side_hint == "put" else "Call/Put" ) _note_skip(f"{ymd} K{pair.strike:g} 缺{need}卖一") - continue + return None 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) @@ -909,12 +880,10 @@ class StrategySession: min_lev, hours_left, ) - continue + return None self._apply_pair(pair, mark=underlying, idx=idx) - # warm_and_subscribe 已写盘口;再覆盖刚拉的 ask 侧 from ..exchange.book_cache import BookCache - # 直接通过 exchange quote path:再 upsert if hasattr(self.ex, "cache"): cache: BookCache = self.ex.cache # type: ignore[attr-defined] cache.upsert_book(pair.call_inst_id, bids=call_bids, asks=call_asks) @@ -933,6 +902,72 @@ class StrategySession: underlying_px=underlying, hedge_mode="perp_option", ) + + for ymd in eligible: + if ymd in skip_expiries: + _note_skip(f"{ymd} 有残余期权(跳过该到期)") + logger.info( + "skip expiry=%s: residual pending (semi=%s)", + ymd, + semi_on, + ) + continue + + # 半自动虚值:同到期内由近到远试偏离内各档,近档杠杆不够再试 1930/1940 + if semi_on and semi_mny == "otm" and opt_side_hint in ("call", "put"): + if ymd not in complete: + _note_skip(f"{ymd} 无完整对") + continue + ems, strikes_map = complete[ymd] + otm_ks = list_otm_strikes( + list(strikes_map.keys()), + underlying, + option_side=str(opt_side_hint), + max_offset=float(semi_otm_off or 0), + ) + if not otm_ks: + _note_skip( + f"{ymd} 无{opt_side_hint}虚值" + f"(偏离≤{float(semi_otm_off or 0):g})" + ) + logger.info( + "skip expiry=%s no OTM within offset=%.1f for %s mark=%.2f", + ymd, + float(semi_otm_off or 0), + opt_side_hint, + underlying, + ) + continue + for k in otm_ks: + legs = strikes_map.get(float(k)) or strikes_map.get(k) + if not legs or "C" not in legs or "P" not in legs: + continue + pair = OptionPair( + expiry_ymd=ymd, + expiry_ms=ems, + strike=float(k), + call_inst_id=legs["C"], + put_inst_id=legs["P"], + ) + picked = _try_pair(pair) + if picked is not None: + return picked + continue + + pair = select_option_pair( + contracts, + mark_px=underlying, + expiry_ymd=ymd, + option_side=opt_side_hint, + moneyness=semi_mny if semi_on else None, + otm_max_offset=semi_otm_off if semi_on else None, + ) + if pair is None: + _note_skip(f"{ymd} 无合格行权价") + continue + picked = _try_pair(pair) + if picked is not None: + return picked # 报最近到期(列表最前)的原因,避免只显示远月 261225 造成误会 why = first_skip or last_skip if why: diff --git a/backend/tests/test_semi_auto.py b/backend/tests/test_semi_auto.py index f2fd38d..7f5f08f 100644 --- a/backend/tests/test_semi_auto.py +++ b/backend/tests/test_semi_auto.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.strategy.selection import pick_otm_strike +from app.strategy.selection import list_otm_strikes, pick_otm_strike from app.strategy.semi_auto import ( REASON_PERP_NET, REASON_POINTS, @@ -98,3 +98,10 @@ def test_pick_otm_within_offset() -> None: # Put 虚值 k2 = pick_otm_strike(strikes, 1830, option_side="put", max_offset=30) assert k2 == 1825.0 + + +def test_list_otm_strikes_near_to_far() -> None: + # 现价 1917 → Call 虚值 1920/1930/1940(偏离≤25),近→远 + strikes = [1910.0, 1920.0, 1930.0, 1940.0, 1950.0] + ks = list_otm_strikes(strikes, 1917.0, option_side="call", max_offset=25) + assert ks == [1920.0, 1930.0, 1940.0]