From 79c074ec6c4e452424043a749f5c8bde07d05b93 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 25 Jul 2026 12:39:23 +0800 Subject: [PATCH] Prefer open direction from ATM vs spot (below=Call/short, above=Put/long). Co-authored-by: Cursor --- backend/app/exchange/types.py | 29 ++++++++++++++---- backend/app/strategy/session.py | 7 ++++- backend/app/strategy/signal.py | 50 ++++++++++++++++++++++++++----- backend/tests/test_p1_p2_rules.py | 25 ++++++++++++++++ docs/策略说明.md | 20 +++++++------ frontend/src/pages/Plan.tsx | 4 +-- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/backend/app/exchange/types.py b/backend/app/exchange/types.py index ca4b5a7..34bbdc5 100644 --- a/backend/app/exchange/types.py +++ b/backend/app/exchange/types.py @@ -76,19 +76,38 @@ class MarketSnapshot: "ask_compare": { "call_ask": self.call.ask if self.call else None, "put_ask": self.put.ask if self.put else None, - "bias": _ask_bias(self.call, self.put), + "bias": _open_bias(self.pair, self.index_px, self.perp, self.call, self.put), }, } -def _ask_bias(call: Quote | None, put: Quote | None) -> str: - """卖一比价仅用于选向展示;相等则 wait。""" +def _open_bias( + pair: OptionPair | None, + index_px: float | None, + perp: Quote | None, + call: Quote | None, + put: Quote | None, +) -> str: + """与开仓 decide 一致:先按 ATM 相对现价,贴平时再卖一比价。""" + mark = None + if index_px is not None and index_px > 0: + mark = float(index_px) + elif perp and perp.mark_px and perp.mark_px > 0: + mark = float(perp.mark_px) + elif perp and perp.bid and perp.ask: + mark = (float(perp.bid) + float(perp.ask)) / 2 + if pair is not None and mark is not None: + diff = float(pair.strike) - mark + if diff < -1e-9: + return "strike_below_spot" + if diff > 1e-9: + return "strike_above_spot" ca = call.ask if call else None pa = put.ask if put else None if ca is None or pa is None: return "unknown" if ca > pa: - return "call_ask_gt_put" # 永续多 + 期权空(腿待拍板) + return "call_ask_gt_put" if ca < pa: - return "put_ask_gt_call" # 永续空 + 期权多(腿待拍板) + return "put_ask_gt_call" return "equal" diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index b9d8e95..cee3c0c 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -187,7 +187,12 @@ class StrategySession: put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=5) call_ask = call_asks[0].px if call_asks else None put_ask = put_asks[0].px if put_asks else None - sig = decide(call_ask, put_ask) + sig = decide( + call_ask, + put_ask, + strike=pair.strike, + mark_px=underlying, + ) if sig is None: continue opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask diff --git a/backend/app/strategy/signal.py b/backend/app/strategy/signal.py index 10e319f..def2b8d 100644 --- a/backend/app/strategy/signal.py +++ b/backend/app/strategy/signal.py @@ -5,30 +5,64 @@ from dataclasses import dataclass @dataclass(slots=True) class Signal: - bias: str # call_ask_gt_put | put_ask_gt_call + bias: str # strike_below_spot | strike_above_spot | call_ask_gt_put | put_ask_gt_call option_side: str # call | put perp_side: str # long | short call_ask: float put_ask: float -def decide(call_ask: float | None, put_ask: float | None) -> Signal | None: +def decide( + call_ask: float | None, + put_ask: float | None, + *, + strike: float | None = None, + mark_px: float | None = None, +) -> Signal | None: + """ + 开仓方向: + - 行权价 < 标的 → 买 Call + 永续空(ATM 偏下) + - 行权价 > 标的 → 买 Put + 永续多(ATM 偏上) + - 行权价 ≈ 标的 → 回退 Call/Put 卖一比价 + """ if call_ask is None or put_ask is None: return None - if call_ask > put_ask: + ca = float(call_ask) + pa = float(put_ask) + + if strike is not None and mark_px is not None and float(mark_px) > 0: + diff = float(strike) - float(mark_px) + if diff < -1e-9: + return Signal( + bias="strike_below_spot", + option_side="call", + perp_side="short", + call_ask=ca, + put_ask=pa, + ) + if diff > 1e-9: + return Signal( + bias="strike_above_spot", + option_side="put", + perp_side="long", + call_ask=ca, + put_ask=pa, + ) + + if ca > pa: return Signal( bias="call_ask_gt_put", option_side="call", perp_side="short", - call_ask=float(call_ask), - put_ask=float(put_ask), + call_ask=ca, + put_ask=pa, ) - if put_ask > call_ask: + if pa > ca: return Signal( bias="put_ask_gt_call", option_side="put", perp_side="long", - call_ask=float(call_ask), - put_ask=float(put_ask), + call_ask=ca, + put_ask=pa, ) return None diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 69063f0..f865673 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -28,6 +28,31 @@ def test_signal_equal() -> None: assert decide(10.0, 10.0) 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) + assert s is not None + assert s.option_side == "call" + assert s.perp_side == "short" + assert s.bias == "strike_below_spot" + + +def test_signal_strike_above_spot_put_long() -> None: + # 现价 1859、ATM 1875:即使 Call 卖一更高,也走 Put+多 + s = decide(20.0, 10.0, strike=1875, mark_px=1859) + assert s is not None + assert s.option_side == "put" + assert s.perp_side == "long" + assert s.bias == "strike_above_spot" + + +def test_signal_strike_flat_falls_back_to_ask() -> None: + s = decide(20.0, 15.0, strike=1860, mark_px=1860) + assert s is not None + assert s.bias == "call_ask_gt_put" + assert s.option_side == "call" + + def test_exit_fixed_and_premium_multiple() -> None: fixed = check_exits( net_pnl=15.0, diff --git a/docs/策略说明.md b/docs/策略说明.md index 1249408..c095219 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -32,17 +32,19 @@ 永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 -### 2.1 开仓方向(卖一比价) +### 2.1 开仓方向(ATM 相对现价优先) -比较同一 ATM 行权价的 **Call 卖一** 与 **Put 卖一**: +行权价相对标的有偏离时(币安粗档常见),**先按 ATM 偏上/偏下选向**;仅当行权价与标的贴平(≈)时,才回退卖一比价。 -| 条件 | 期权 | 永续 | -|------|------|------| -| Call 卖一 > Put 卖一 | 买入 Call | 做空 | -| Call 卖一 < Put 卖一 | 买入 Put | 做多 | -| 相等 | 不开仓,继续等待 | — | +| 条件 | 期权 | 永续 | bias | +|------|------|------|------| +| ATM 行权价 **<** 标的(如 1850 vs 1859) | 买入 Call | 做空 | `strike_below_spot` | +| ATM 行权价 **>** 标的(如 1875 vs 1859) | 买入 Put | 做多 | `strike_above_spot` | +| ATM ≈ 标的,且 Call 卖一 > Put 卖一 | 买入 Call | 做空 | `call_ask_gt_put` | +| ATM ≈ 标的,且 Put 卖一 > Call 卖一 | 买入 Put | 做多 | `put_ask_gt_call` | +| ATM ≈ 标的且卖一相等 | 不开仓,继续等待 | — | — | -直觉:卖一更高的一侧往往隐含更强的短期方向/溢价偏好,买入该侧期权;永续反向以平衡部分 Delta。 +直觉:偏下行权价用 Call+空,偏上行权价用 Put+多;贴平时再按权利金溢价侧选向。 --- @@ -57,7 +59,7 @@ → 选到期:剩余时长 ≥ min_option_hours(默认 12h) → 该到期 ATM 行权价(最接近标的) → |ATM − 标的| ≤ max_atm_open_offset(默认 3)否则跳过该到期 - → Call/Put 卖一比价选方向 + → 选向:ATM 偏下→Call+空;偏上→Put+多;贴平→卖一比价 → 期权杠杆 = 标的价 ÷ 卖一权利金 ≥ min_option_leverage(默认 100) → 开永续 + 开期权(一组) → 锁定 initial_premium = 期权成交价 × 期权名义(不含费) diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 8665ff3..a698583 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -94,9 +94,9 @@ export default function PlanPage() { const bias = snap?.ask_compare?.bias; const biasTag = - bias === "call_ask_gt_put" ? ( + bias === "strike_below_spot" || bias === "call_ask_gt_put" ? ( 买 Call + 永续空 - ) : bias === "put_ask_gt_call" ? ( + ) : bias === "strike_above_spot" || bias === "put_ask_gt_call" ? ( 买 Put + 永续多 ) : ( 等待 / 相等