diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 3b9bfc9..ce57137 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -71,6 +71,7 @@ KEYS = ( "oo_min_leverage", "oo_reward_ratio", "oo_budget_cushion", + "oo_strike_max_dev_pct", ) @@ -126,6 +127,7 @@ class StrategySettingsBody(BaseModel): oo_min_leverage: float | None = Field(default=None, ge=1, le=10000) oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20) oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0) + oo_strike_max_dev_pct: float | None = Field(default=None, ge=0.1, le=10) def _as_bool(raw: str | None, default: bool) -> bool: @@ -379,6 +381,12 @@ def _read_settings() -> dict: db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion)) or s.oo_budget_cushion ), + "oo_strike_max_dev_pct": float( + db.get_setting( + "oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct) + ) + or s.oo_strike_max_dev_pct + ), "risk_sizing_preview": _risk_preview_safe(), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, @@ -472,6 +480,7 @@ async def put_strategy_settings( "oo_min_leverage", "oo_reward_ratio", "oo_budget_cushion", + "oo_strike_max_dev_pct", ) hit = [k for k in locked_keys if k in data] if hit: diff --git a/backend/app/config.py b/backend/app/config.py index 58297c9..23e994a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,6 +88,7 @@ class Settings(BaseSettings): oo_min_leverage: float = 200.0 # 期期:单腿最低杠杆 oo_reward_ratio: float = 2.0 # 盈亏比:出场目标 = 预算 × 比 oo_budget_cushion: float = 0.92 # 定仓预留余地(用于权利金的预算比例) + oo_strike_max_dev_pct: float = 1.0 # 虚值行权价相对振幅高低点最大偏离 % atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关) max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点) # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 0f3b35b..3f85d55 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -297,6 +297,12 @@ class StrategyEngine: ) or s.oo_reward_ratio ), + "oo_strike_max_dev_pct": float( + self.ledger.get_setting_float( + "oo_strike_max_dev_pct", s.oo_strike_max_dev_pct + ) + or s.oo_strike_max_dev_pct + ), "risk_perp_unit": risk_perp_unit, "risk_option_unit": risk_option_unit, "risk_exit_unit": risk_exit_unit, diff --git a/backend/app/strategy/oo_selection.py b/backend/app/strategy/oo_selection.py index a894ac7..ad0ccb0 100644 --- a/backend/app/strategy/oo_selection.py +++ b/backend/app/strategy/oo_selection.py @@ -36,17 +36,42 @@ class OoPickCore: detail: str = "ok" -def pick_otm_call_strike(strikes: list[float], *, spot: float, high: float) -> float | None: - """虚值 Call:K > spot,优先贴近振幅高点。""" +def _within_ref_pct(strike: float, ref: float, max_dev_pct: float) -> bool: + """|K−ref|/ref ≤ max_dev_pct%。""" + if ref <= 0 or max_dev_pct < 0: + return False + return abs(float(strike) - float(ref)) / float(ref) * 100.0 <= float( + max_dev_pct + ) + 1e-12 + + +def pick_otm_call_strike( + strikes: list[float], + *, + spot: float, + high: float, + max_dev_pct: float = 1.0, +) -> float | None: + """虚值 Call:K > spot,贴近振幅高点,且 |K−高|/高 ≤ max_dev_pct%。""" cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9] + if max_dev_pct >= 0 and high > 0: + cands = [s for s in cands if _within_ref_pct(s, high, max_dev_pct)] 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,优先贴近振幅低点。""" +def pick_otm_put_strike( + strikes: list[float], + *, + spot: float, + low: float, + max_dev_pct: float = 1.0, +) -> float | None: + """虚值 Put:K < spot,贴近振幅低点,且 |K−低|/低 ≤ max_dev_pct%。""" cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9] + if max_dev_pct >= 0 and low > 0: + cands = [s for s in cands if _within_ref_pct(s, low, max_dev_pct)] if not cands: return None return min(cands, key=lambda s: (abs(s - float(low)), s)) @@ -61,10 +86,11 @@ def select_oo_pair( min_hours: float, now: datetime | None = None, skip_expiry_ymds: set[str] | None = None, + max_dev_pct: float = 1.0, ) -> tuple[str, int, float, float, str, str] | None: """ 返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。 - Call/Put 可不同行权价;须同到期且均为虚值。 + Call/Put 可不同行权价;须同到期、均为虚值,且相对高低点偏离不超过 max_dev_pct%。 """ if spot <= 0 or high <= 0 or low <= 0 or high < low: return None @@ -80,8 +106,12 @@ def select_oo_pair( 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) + ck = pick_otm_call_strike( + strikes, spot=spot, high=high, max_dev_pct=max_dev_pct + ) + pk = pick_otm_put_strike( + strikes, spot=spot, low=low, max_dev_pct=max_dev_pct + ) if ck is None or pk is None: continue call_inst = strikes_map[ck].get("C") @@ -113,8 +143,9 @@ def build_oo_pick_core( amplitude: AmplitudeHL | None = None, skip_expiry_ymds: set[str] | None = None, now: datetime | None = None, + max_dev_pct: float = 1.0, ) -> OoPickCore | None: - """完整期期选约:振幅门 + 虚值双腿 + 杠杆。""" + """完整期期选约:振幅门 + 虚值双腿(贴高低≤max_dev%) + 杠杆。""" amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours) if amp is None: return None @@ -130,6 +161,7 @@ def build_oo_pick_core( min_hours=float(min_hours), now=now, skip_expiry_ymds=skip_expiry_ymds, + max_dev_pct=float(max_dev_pct), ) if picked is None: return None diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 5164585..944d53f 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -192,8 +192,8 @@ def _hedge_mode() -> str: return "perp_option" -def _oo_settings() -> tuple[float, float, float, float]: - """amplitude_pct, amplitude_hours, min_option_hours, min_leverage""" +def _oo_settings() -> tuple[float, float, float, float, float]: + """amplitude_pct, amplitude_hours, min_option_hours, min_leverage, strike_max_dev_pct""" s = get_settings() try: from ..models.db import get_db @@ -210,6 +210,12 @@ def _oo_settings() -> tuple[float, float, float, float]: or s.oo_min_option_hours ), float(db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) or s.oo_min_leverage), + float( + db.get_setting( + "oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct) + ) + or s.oo_strike_max_dev_pct + ), ) except Exception: return ( @@ -217,6 +223,7 @@ def _oo_settings() -> tuple[float, float, float, float]: s.oo_amplitude_hours, s.oo_min_option_hours, s.oo_min_leverage, + s.oo_strike_max_dev_pct, ) @@ -450,7 +457,7 @@ class StrategySession: try: from ..exchange.candles import fetch_amplitude_hl_for_runtime - amp_pct, amp_hours, _, _ = _oo_settings() + amp_pct, amp_hours, _, _, _ = _oo_settings() amp = fetch_amplitude_hl_for_runtime(amp_hours) if amp is None: return self._oo_amp @@ -477,7 +484,7 @@ class StrategySession: if _has_open_position(): return self.align_to_held_position() s = self.settings - amp_pct, amp_hours, min_hours, _min_lev = _oo_settings() + amp_pct, amp_hours, min_hours, _min_lev, max_dev = _oo_settings() idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx if mark is None or mark <= 0: @@ -496,10 +503,11 @@ class StrategySession: low=amp_low, min_hours=float(min_hours), skip_expiry_ymds=skip, + max_dev_pct=float(max_dev), ) if picked is None: raise RuntimeError( - f"未找到剩余≥{min_hours}h 的虚值 Call@高/Put@低" + f"未找到剩余≥{min_hours}h 且贴高低≤{max_dev:g}% 的虚值 Call/Put" ) ymd, ems, ck, pk, call_inst, put_inst = picked pair = OptionPair( @@ -527,7 +535,7 @@ class StrategySession: from .selection import _complete_by_expiry, option_leverage s = self.settings - amp_pct, amp_hours, min_hours, min_lev = _oo_settings() + amp_pct, amp_hours, min_hours, min_lev, max_dev = _oo_settings() idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx if mark is None or mark <= 0: @@ -565,9 +573,13 @@ class StrategySession: low=float(amp.low), min_hours=float(min_hours), skip_expiry_ymds=skip, + max_dev_pct=float(max_dev), ) if picked is None: - logger.info("oo: no OTM call/put pair for amplitude HL") + logger.info( + "oo: no OTM call/put within %.2f%% of amplitude HL", + max_dev, + ) return None ymd, ems, ck, pk, call_inst, put_inst = picked call_bids, call_asks, _ = self.ex.fetch_book(call_inst, depth=5) @@ -840,7 +852,7 @@ class StrategySession: def oo_needs_realign(self) -> bool: if self._pair is None: return True - _amp_pct, amp_hours, min_hours, _ = _oo_settings() + _amp_pct, amp_hours, min_hours, _, max_dev = _oo_settings() if ( hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms) + 1e-9 @@ -877,6 +889,7 @@ class StrategySession: low=float(amp.low), min_hours=float(min_hours), skip_expiry_ymds=skip, + max_dev_pct=float(max_dev), ) if picked is None: return False diff --git a/backend/tests/test_oo_selection_sizing.py b/backend/tests/test_oo_selection_sizing.py index 50ecf90..89679f9 100644 --- a/backend/tests/test_oo_selection_sizing.py +++ b/backend/tests/test_oo_selection_sizing.py @@ -17,6 +17,23 @@ def test_otm_strikes_near_amplitude() -> None: assert pick_otm_put_strike(strikes, spot=1950, low=1860) == 1850.0 +def test_otm_strikes_reject_beyond_1pct() -> None: + # 高点 2000,最近虚值 Call 仅 2100(偏离 5%)→ 拒绝 + strikes = [1900.0, 1950.0, 2100.0] + assert pick_otm_call_strike(strikes, spot=1950, high=2000, max_dev_pct=1.0) is None + # 低点 1900,最近虚值 Put 仅 1800(偏离 ~5.3%)→ 拒绝 + assert ( + pick_otm_put_strike( + [1800.0, 1950.0, 2000.0], spot=1950, low=1900, max_dev_pct=1.0 + ) + is None + ) + # 高点 2095,Call 2100 偏离约 0.24% → 通过 + assert ( + pick_otm_call_strike(strikes, spot=1950, high=2095, max_dev_pct=1.0) == 2100.0 + ) + + def test_select_oo_pair_same_expiry(tmp_path=None) -> None: contracts = [] for k in (1900, 2000, 2100): @@ -36,6 +53,7 @@ def test_select_oo_pair_same_expiry(tmp_path=None) -> None: high=2105.0, low=1890.0, min_hours=1.0, + max_dev_pct=1.0, ) assert picked is not None ymd, _ems, ck, pk, call_i, put_i = picked @@ -114,7 +132,7 @@ def test_amplitude_max_gate() -> None: from app.strategy.oo_selection import build_oo_pick_core contracts = [] - for k in (1900, 2000, 2100): + for k in (1900, 1975, 2000, 2025, 2100): for side, letter in (("call", "C"), ("put", "P")): contracts.append( { @@ -141,7 +159,7 @@ def test_amplitude_max_gate() -> None: ) is None ) - # 3% ≤ 上限 3.5% → 可过振幅门(杠杆/卖一足够) + # 3% ≤ 上限 3.5%,且 2025/1975 贴高低 ≤1% → 通过 ok = build_oo_pick_core( contracts=contracts, spot=2000, @@ -152,5 +170,8 @@ def test_amplitude_max_gate() -> None: amplitude_hours=12, amplitude_pct=3.5, amplitude=amp, + max_dev_pct=1.0, ) assert ok is not None + assert ok.call.strike == 2025.0 + assert ok.put.strike == 1975.0 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ea5fb2..e9e91d5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -373,6 +373,7 @@ export type PlanState = { oo_min_option_hours?: number; oo_min_leverage?: number; oo_reward_ratio?: number; + oo_strike_max_dev_pct?: number; oo_put_qty_eth?: number; sizing_mode?: "manual" | "risk_based"; risk_based?: boolean; @@ -428,6 +429,8 @@ export type StrategySettings = { oo_min_leverage?: number; oo_reward_ratio?: number; oo_budget_cushion?: number; + oo_strike_max_dev_pct?: number; + oo_strike_max_dev_pct?: number; exchange?: string; }; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index f2a31d0..afb7b2b 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -265,6 +265,7 @@ export default function PlanPage() { : "ATM偏差关"; const ooSelectLabel = [ `振幅≤${fmt(plan?.oo_amplitude_pct ?? 1.5, 1)}%/${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h`, + `贴高低≤${fmt(plan?.oo_strike_max_dev_pct ?? 1, 1)}%`, "虚值Call@高·Put@低", `剩余≥${fmt(plan?.oo_min_option_hours ?? 24, 0)}h`, `杠杆≥${fmt(plan?.oo_min_leverage ?? 200, 0)}x`, diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2c61a1c..29409a2 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -115,6 +115,7 @@ export default function SettingsPage() { const [ooAmpHours, setOoAmpHours] = useState(12); const [ooMinHours, setOoMinHours] = useState(24); const [ooMinLev, setOoMinLev] = useState(200); + const [ooStrikeDev, setOoStrikeDev] = useState(1); const [ooRewardRatio, setOoRewardRatio] = useState(2); const [riskPreview, setRiskPreview] = useState | null>( null, @@ -236,6 +237,7 @@ export default function SettingsPage() { setOoAmpHours(s.oo_amplitude_hours ?? 12); setOoMinHours(s.oo_min_option_hours ?? 24); setOoMinLev(s.oo_min_leverage ?? 200); + setOoStrikeDev(s.oo_strike_max_dev_pct ?? 1); setOoRewardRatio(s.oo_reward_ratio ?? 2); setRiskPreview( s.risk_sizing_preview && typeof s.risk_sizing_preview === "object" @@ -412,6 +414,7 @@ export default function SettingsPage() { oo_amplitude_hours: ooAmpHours, oo_min_option_hours: ooMinHours, oo_min_leverage: ooMinLev, + oo_strike_max_dev_pct: ooStrikeDev, oo_reward_ratio: ooRewardRatio, exchange, }; @@ -1281,8 +1284,24 @@ export default function SettingsPage() { value={ooMinLev} onChange={(e) => setOoMinLev(Number(e.target.value))} /> + +
+ + + setOoStrikeDev(Number(e.target.value)) + } + />

- 期期:同到期虚值 Call 贴高点、Put 贴低点;不使用 ATM/固定方向。 + 虚值 Call/Put 行权价相对振幅高/低点 |K−HL|/HL + 不超过该比例(默认 1%)。