From f1e3d5527ae02429771659eb086e8dfd4ae00bce Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 14:43:00 +0800 Subject: [PATCH] Fix semi open stalls: one-leg asks, OTM realign, clearer pick errors. Monitor no longer shows ATM Put bias while semi-auto is authorized for Call. Co-authored-by: Cursor --- backend/app/strategy/engine.py | 14 +++++-- backend/app/strategy/session.py | 69 +++++++++++++++++++++++++++++++ backend/app/strategy/signal.py | 17 +++++--- backend/tests/test_p1_p2_rules.py | 11 +++++ frontend/src/pages/Plan.tsx | 22 ++++++---- 5 files changed, 117 insertions(+), 16 deletions(-) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index dfdc7f9..b4bf9d8 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -1238,9 +1238,17 @@ class StrategyEngine: return except Exception: pass - self._set_state( - last_error="无合格期权:需剩余时长、杠杆(及已开启的ATM偏差)同时满足" - ) + pick_why = None + try: + pick_why = get_session().last_pick_fail_reason() + except Exception: + pick_why = None + if pick_why: + self._set_state(last_error=f"无合格期权:{pick_why}") + else: + self._set_state( + last_error="无合格期权:需剩余时长、杠杆(及已开启的ATM偏差)同时满足" + ) return # 半自动:选约异步窗口后再次确认授权,防止取消授权后仍开仓 diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 101d0e8..97cc59c 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -277,6 +277,7 @@ class StrategySession: self.ex = exchange or get_exchange() self._pair: OptionPair | None = None self._oo_amp: dict[str, Any] | None = None + self._last_pick_fail: str | None = None self._refresh_task: asyncio.Task[None] | None = None self._started = False @@ -506,6 +507,9 @@ class StrategySession: return False return True + def last_pick_fail_reason(self) -> str | None: + return self._last_pick_fail + def amplitude_gate_fail_reason(self) -> str | None: """若最近一次振幅快照显示过滤开启且未过关,返回文案。""" amp = self._oo_amp @@ -723,6 +727,7 @@ class StrategySession: from .signal import decide, decide_fixed from .semi_auto import is_armed, is_semi_auto, read_semi_params + self._last_pick_fail = None s = self.settings min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() fixed_on, fixed_perp = _fixed_direction() @@ -732,6 +737,7 @@ class StrategySession: semi_otm_off: float | None = None if semi_on: if not is_armed(): + self._last_pick_fail = "半自动未授权" return None sp = read_semi_params() fixed_on = True @@ -744,19 +750,23 @@ class StrategySession: opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None # 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%) if not self._apply_amplitude_first_gate(): + self._last_pick_fail = "振幅门未过" return None 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: + self._last_pick_fail = "无标的价" return None underlying = float(mark) contracts = self.ex.list_option_contracts(s.option_inst_family) eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours) if not eligible: logger.info("no expiry with hours>=%.1f", min_hours) + self._last_pick_fail = f"无剩余≥{min_hours:g}h 的到期" return None skip_expiries = _skip_expiry_ymds_for_next() + last_skip = "" for ymd in eligible: if ymd in skip_expiries: @@ -775,6 +785,10 @@ class StrategySession: ) if pair is None: if semi_on and semi_mny == "otm": + last_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, @@ -782,6 +796,8 @@ class StrategySession: opt_side_hint, underlying, ) + else: + last_skip = f"{ymd} 无合格行权价" continue if fixed_on: from .selection import is_otm @@ -793,11 +809,16 @@ class StrategySession: strike=pair.strike, mark_px=underlying, ): + last_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 = ( + f"{ymd} K{pair.strike:g} 偏离>" + f"{float(semi_otm_off or 0):g}" + ) continue elif semi_on and semi_mny == "atm": # 平值:须为该到期最接近标的的档 @@ -807,6 +828,7 @@ class StrategySession: strike=pair.strike, mark_px=underlying, ): + last_skip = f"{ymd} K{pair.strike:g} 非实值/平值" logger.info( "skip expiry=%s strike=%.0f not ITM/ATM for %s mark=%.2f", ymd, @@ -823,6 +845,7 @@ class StrategySession: max_offset=max_atm_off, enabled=atm_off_on, ): + last_skip = f"{ymd} ATM偏离{offset:.1f}>{max_atm_off:g}" logger.info( "skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f", ymd, @@ -852,11 +875,19 @@ class StrategySession: mark_px=underlying, ) if sig is None: + 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}卖一" 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 = ( + f"{ymd} {sig.option_side.upper()}@{pair.strike:g} " + f"杠杆{(f'{lev:.0f}x' if lev else 'n/a')}<{min_lev:g}x" + ) logger.info( "skip expiry=%s strike=%.0f side=%s lev=%s need>=%.0f hours=%.1f", ymd, @@ -876,6 +907,7 @@ class StrategySession: cache: BookCache = self.ex.cache # type: ignore[attr-defined] cache.upsert_book(pair.call_inst_id, bids=call_bids, asks=call_asks) cache.upsert_book(pair.put_inst_id, bids=put_bids, asks=put_asks) + self._last_pick_fail = None return OpenPick( pair=pair, option_side=sig.option_side, @@ -889,6 +921,16 @@ class StrategySession: underlying_px=underlying, hedge_mode="perp_option", ) + if last_skip: + 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}" + else: + self._last_pick_fail = "合格到期均被跳过(一日一到期/残余等)" return None async def realign_async(self) -> OptionPair | None: @@ -944,6 +986,33 @@ class StrategySession: if mark is None or mark <= 0: return False fixed_on, fixed_perp = _fixed_direction() + # 半自动虚值/平值:监控对齐勿按「必须实值」强行重钉,否则 OTM 会一直 realign + try: + from .semi_auto import is_semi_auto, read_semi_params + from .selection import is_otm + + if is_semi_auto(): + sp = read_semi_params() + mny = str(sp.get("moneyness") or "otm") + opt = _option_side_for_perp(str(sp["perp_side"])) + if mny == "otm": + off = float(sp.get("otm_max_offset") or 0) + if not is_otm( + option_side=opt, + strike=float(self._pair.strike), + mark_px=float(mark), + ): + return True + if atm_open_offset(self._pair.strike, mark) > off + 1e-9: + return True + return False + if mny == "atm": + return ( + abs(float(self._pair.strike) - float(mark)) + >= _ATM_DRIFT_POINTS + ) + except Exception: + logger.debug("semi atm_needs_realign check failed", exc_info=True) if fixed_on: opt = _option_side_for_perp(fixed_perp) if not is_itm_or_atm( diff --git a/backend/app/strategy/signal.py b/backend/app/strategy/signal.py index c2a3875..0d5498c 100644 --- a/backend/app/strategy/signal.py +++ b/backend/app/strategy/signal.py @@ -76,15 +76,16 @@ def decide_fixed( ) -> Signal | None: """ 固定方向: - - 永续多 → 买 Put - - 永续空 → 买 Call + - 永续多 → 买 Put(只需 Put 卖一) + - 永续空 → 买 Call(只需 Call 卖一) + 对侧卖一缺失时用本侧占位,避免半自动虚值因对侧盘口空而拒单。 """ - if call_ask is None or put_ask is None: - return None side = (perp_side or "").strip().lower() - ca = float(call_ask) - pa = float(put_ask) if side == "long": + if put_ask is None or float(put_ask) <= 0: + return None + pa = float(put_ask) + ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else pa return Signal( bias="fixed_long_put", option_side="put", @@ -93,6 +94,10 @@ def decide_fixed( put_ask=pa, ) if side == "short": + if call_ask is None or float(call_ask) <= 0: + return None + ca = float(call_ask) + pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else ca return Signal( bias="fixed_short_call", option_side="call", diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 2b72fee..9bfce87 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -53,6 +53,17 @@ def test_decide_fixed_short_call() -> None: assert s.bias == "fixed_short_call" +def test_decide_fixed_needs_only_own_leg() -> None: + from app.strategy.signal import decide_fixed + + # 半自动多/空:对侧卖一缺失仍可定方向 + sc = decide_fixed(8.2, None, perp_side="short") + assert sc is not None and sc.option_side == "call" + sp = decide_fixed(None, 11.4, perp_side="long") + assert sp is not None and sp.option_side == "put" + assert decide_fixed(None, None, perp_side="short") is None + + def test_signal_strike_below_spot_call_short() -> None: # 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空 s = decide(10.0, 20.0, strike=1850, mark_px=1859) diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 387aff5..4b63248 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -489,6 +489,14 @@ export default function PlanPage() { // 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂) const heldOpt = String(pos?.option_side || "").toLowerCase(); const heldPerp = String(pos?.perp_side || "").toLowerCase(); + + const isOo = + plan?.hedge_mode === "option_option" || + snap?.hedge_mode === "option_option" || + pos?.hedge_mode === "option_option" || + !!pos?.option2_inst_id; + const semiOn = !!plan?.semi_auto_enabled && !isOo; + // 半自动:信号=人工看法,勿展示 ATM 盘口比价(会显示成 Put 造成误会) const biasTag = open ? ( heldOpt === "call" || heldPerp === "short" ? ( 买 Call + 永续空 @@ -497,6 +505,13 @@ export default function PlanPage() { ) : ( 持仓中 ) + ) : semiOn ? ( + (semiDirty ? semiView : plan?.semi_view_side === "short" ? "short" : "long") === + "short" ? ( + 半自动空 · Put + 永续多 + ) : ( + 半自动多 · Call + 永续空 + ) ) : bias === "strike_below_spot" || bias === "call_ask_gt_put" || bias === "fixed_short_call" ? ( @@ -508,13 +523,6 @@ export default function PlanPage() { ) : ( 等待 / 相等 ); - - const isOo = - plan?.hedge_mode === "option_option" || - snap?.hedge_mode === "option_option" || - pos?.hedge_mode === "option_option" || - !!pos?.option2_inst_id; - const semiOn = !!plan?.semi_auto_enabled && !isOo; const showAmpCard = plan?.oo_amplitude_filter_enabled === true; // 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖)