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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:43:00 +08:00
parent 4db1e0c470
commit f1e3d5527a
5 changed files with 117 additions and 16 deletions
+11 -3
View File
@@ -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
# 半自动:选约异步窗口后再次确认授权,防止取消授权后仍开仓
+69
View File
@@ -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(
+11 -6
View File
@@ -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",
+11
View File
@@ -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)
+15 -7
View File
@@ -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" ? (
<span className="tag up"> Call + </span>
@@ -497,6 +505,13 @@ export default function PlanPage() {
) : (
<span className="tag"></span>
)
) : semiOn ? (
(semiDirty ? semiView : plan?.semi_view_side === "short" ? "short" : "long") ===
"short" ? (
<span className="tag down"> · Put + </span>
) : (
<span className="tag up"> · Call + </span>
)
) : bias === "strike_below_spot" ||
bias === "call_ask_gt_put" ||
bias === "fixed_short_call" ? (
@@ -508,13 +523,6 @@ export default function PlanPage() {
) : (
<span className="tag"> / </span>
);
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;
// 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖)