"""期期对冲选约:振幅高低点匹配虚值 Call + Put。""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime from typing import Any from ..exchange.candles import AmplitudeHL, fetch_amplitude_hl_for_runtime from .selection import ( _complete_by_expiry, hours_until_ms, list_eligible_expiry_ymds, option_leverage, ) @dataclass(frozen=True, slots=True) class OoLeg: side: str # call|put strike: float inst_id: str ask: float leverage: float @dataclass(frozen=True, slots=True) class OoPickCore: expiry_ymd: str expiry_ms: int hours_left: float underlying_px: float amplitude: AmplitudeHL call: OoLeg put: OoLeg detail: str = "ok" def pick_otm_call_strike(strikes: list[float], *, spot: float, high: float) -> float | None: """虚值 Call:K > spot,优先贴近振幅高点。""" cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9] if not cands: return None return min(cands, key=lambda s: (abs(s - float(high)), s)) def pick_otm_put_strike(strikes: list[float], *, spot: float, low: float) -> float | None: """虚值 Put:K < spot,优先贴近振幅低点。""" cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9] if not cands: return None return min(cands, key=lambda s: (abs(s - float(low)), s)) def select_oo_pair( contracts: list[dict[str, Any]], *, spot: float, high: float, low: float, min_hours: float, now: datetime | None = None, skip_expiry_ymds: set[str] | None = None, ) -> tuple[str, int, float, float, str, str] | None: """ 返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。 Call/Put 可不同行权价;须同到期且均为虚值。 """ if spot <= 0 or high <= 0 or low <= 0 or high < low: return None complete = _complete_by_expiry(contracts) if not complete: return None skip = skip_expiry_ymds or set() eligible = [ y for y in list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now) if y not in skip ] for ymd in eligible: ems, strikes_map = complete[ymd] strikes = list(strikes_map.keys()) ck = pick_otm_call_strike(strikes, spot=spot, high=high) pk = pick_otm_put_strike(strikes, spot=spot, low=low) if ck is None or pk is None: continue call_inst = strikes_map[ck].get("C") put_inst = strikes_map[pk].get("P") if not call_inst or not put_inst: continue hours_left = hours_until_ms(ems, now) return ( ymd, int(ems), float(ck), float(pk), str(call_inst), str(put_inst), ) return None def build_oo_pick_core( *, contracts: list[dict[str, Any]], spot: float, call_ask: float, put_ask: float, min_hours: float, min_leverage: float, amplitude_hours: float, amplitude_pct: float, amplitude: AmplitudeHL | None = None, skip_expiry_ymds: set[str] | None = None, now: datetime | None = None, ) -> OoPickCore | None: """完整期期选约:振幅门 + 虚值双腿 + 杠杆。""" amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours) if amp is None: return None if float(amp.range_pct) + 1e-12 < float(amplitude_pct): return None if spot <= 0: spot = float(amp.mid) picked = select_oo_pair( contracts, spot=float(spot), high=float(amp.high), low=float(amp.low), min_hours=float(min_hours), now=now, skip_expiry_ymds=skip_expiry_ymds, ) if picked is None: return None ymd, ems, ck, pk, call_inst, put_inst = picked if call_ask <= 0 or put_ask <= 0: return None c_lev = option_leverage(float(spot), float(call_ask)) p_lev = option_leverage(float(spot), float(put_ask)) if c_lev is None or p_lev is None: return None if c_lev + 1e-12 < float(min_leverage) or p_lev + 1e-12 < float(min_leverage): return None hours_left = hours_until_ms(ems, now) return OoPickCore( expiry_ymd=ymd, expiry_ms=int(ems), hours_left=float(hours_left), underlying_px=float(spot), amplitude=amp, call=OoLeg( side="call", strike=float(ck), inst_id=call_inst, ask=float(call_ask), leverage=float(c_lev), ), put=OoLeg( side="put", strike=float(pk), inst_id=put_inst, ask=float(put_ask), leverage=float(p_lev), ), detail=( f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} " f"C@{ck:g} P@{pk:g}" ), )