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:
@@ -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
|
||||
|
||||
# 半自动:选约异步窗口后再次确认授权,防止取消授权后仍开仓
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖)
|
||||
|
||||
Reference in New Issue
Block a user