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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:52:26 +08:00
parent 5ea54db43b
commit 6f983ed2ab
3 changed files with 110 additions and 52 deletions
+24 -8
View File
@@ -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 K<S;取最接近标的且 |KS|≤max_offset 的一档。
虚值候选Call K>S、Put K<S 且 |KS|≤max_offset
按靠近标的优先排序(近→远)。
"""
if not strikes or mark_px <= 0:
return None
return []
side = (option_side or "").strip().lower()
spot = float(mark_px)
cap = max(0.0, float(max_offset))
@@ -74,10 +75,25 @@ def pick_otm_strike(
if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9
]
else:
return None
if not cands:
return None
return min(cands, key=lambda s: (abs(s - spot), s))
return []
return sorted(cands, key=lambda s: (abs(s - spot), s))
def pick_otm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
max_offset: float,
) -> 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:
+78 -43
View File
@@ -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:
+8 -1
View File
@@ -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]