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;
// 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖)