From a88d708ea3ed87d20a0a3e69ed0d0cc8629a4855 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 7 Aug 2026 21:41:51 +0800 Subject: [PATCH] =?UTF-8?q?Add=20amplitude=20filter=20as=20first=20open=20?= =?UTF-8?q?gate=20for=20=E6=B0=B8=E6=9C=9F=20hedge.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse OO amplitude settings (default off); Plan shows index/HL when filtering. Co-authored-by: Cursor --- backend/app/strategy/amplitude_gate.py | 69 ++++++++++++ backend/app/strategy/engine.py | 8 ++ backend/app/strategy/session.py | 83 ++++++++++----- backend/tests/test_amplitude_gate.py | 60 +++++++++++ frontend/src/api/client.ts | 1 + frontend/src/pages/Plan.tsx | 100 +++++++++--------- frontend/src/pages/Settings.tsx | 141 +++++++++++++------------ 7 files changed, 320 insertions(+), 142 deletions(-) create mode 100644 backend/app/strategy/amplitude_gate.py create mode 100644 backend/tests/test_amplitude_gate.py diff --git a/backend/app/strategy/amplitude_gate.py b/backend/app/strategy/amplitude_gate.py new file mode 100644 index 0000000..f0e661a --- /dev/null +++ b/backend/app/strategy/amplitude_gate.py @@ -0,0 +1,69 @@ +"""振幅过滤门:回看窗内 range% 须 ≤ 上限(可关)。永期开仓第一关 / 期期共用。""" + +from __future__ import annotations + +from typing import Any + +from ..exchange.candles import AmplitudeHL + + +def evaluate_amplitude_gate( + *, + filter_enabled: bool, + amp: AmplitudeHL | None, + max_pct: float, + hours: float, +) -> dict[str, Any]: + """ + 返回: + blocked: 过滤开启且未过关(无K线或超限) + ok: 展示用是否过关(过滤关视为 True) + reason: 拒开文案(未拒则为 None) + snapshot: 写入 session._oo_amp 的字典(amp 为 None 时仅含元数据) + """ + max_p = float(max_pct) + hrs = float(hours) + filt = bool(filter_enabled) + if amp is None: + snap = { + "high": None, + "low": None, + "mid": None, + "range_pct": None, + "hours": hrs, + "max_pct": max_p, + "filter_enabled": filt, + "ok": False if filt else True, + } + if filt: + return { + "blocked": True, + "ok": False, + "reason": f"振幅未过关:无法获取近 {hrs:g}h K 线高低", + "snapshot": snap, + } + return {"blocked": False, "ok": True, "reason": None, "snapshot": snap} + + range_pct = float(amp.range_pct) + over = range_pct > max_p + 1e-12 + ok = (not filt) or (not over) + snap = { + "high": float(amp.high), + "low": float(amp.low), + "mid": float(amp.mid), + "range_pct": range_pct, + "hours": hrs, + "max_pct": max_p, + "filter_enabled": filt, + "ok": ok, + } + if filt and over: + return { + "blocked": True, + "ok": False, + "reason": ( + f"振幅未过关:{hrs:g}h 内 {range_pct:.2f}% > {max_p:g}%" + ), + "snapshot": snap, + } + return {"blocked": False, "ok": ok, "reason": None, "snapshot": snap} diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 0c349c8..a46cb1f 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -1047,6 +1047,14 @@ class StrategyEngine: ) return if pick is None: + amp_fail = None + try: + amp_fail = get_session().amplitude_gate_fail_reason() + except Exception: + amp_fail = None + if amp_fail: + self._set_state(last_error=amp_fail) + return try: from .auto_usdc import preview_capacity_for_convert from .open_capacity import funds_gate_blocks diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index fd3b587..fa62379 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -352,8 +352,7 @@ class StrategySession: def align_to_held_position(self) -> OptionPair | None: """有活跃仓时:监控对锁定为持仓合约的到期/行权价。""" - if _hedge_mode() == "option_option": - self.refresh_oo_amplitude() + self.refresh_oo_amplitude() call_id, put_id = _held_option_legs() held = call_id or _held_option_inst_id() if not held: @@ -421,6 +420,8 @@ class StrategySession: return self.align_to_held_position() if _hedge_mode() == "option_option": return self.align_oo_instruments() + # 永期:刷新振幅供 Plan(过滤关也展示;开仓门禁在 pick) + self.refresh_oo_amplitude() s = self.settings idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx @@ -464,35 +465,67 @@ class StrategySession: return self._apply_pair(pair, mark=float(mark), idx=idx) def refresh_oo_amplitude(self) -> dict[str, Any] | None: - """刷新振幅高低(有仓/无仓都要,否则持仓后 UI 指数/振幅会空)。""" - if _hedge_mode() != "option_option": - return None + """刷新振幅高低(永期/期期;有仓/无仓都要,否则 UI 指数/振幅会空)。""" try: from ..exchange.candles import fetch_amplitude_hl_for_runtime + from .amplitude_gate import evaluate_amplitude_gate amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings() amp = fetch_amplitude_hl_for_runtime(amp_hours) - if amp is None: - return self._oo_amp - self._oo_amp = { - "high": float(amp.high), - "low": float(amp.low), - "mid": float(amp.mid), - "range_pct": float(amp.range_pct), - "hours": float(amp_hours), - "max_pct": float(amp_pct), - "filter_enabled": bool(amp_filt), - "ok": ( - True - if not amp_filt - else float(amp.range_pct) <= float(amp_pct) + 1e-12 - ), - } + gate = evaluate_amplitude_gate( + filter_enabled=bool(amp_filt), + amp=amp, + max_pct=float(amp_pct), + hours=float(amp_hours), + ) + self._oo_amp = dict(gate["snapshot"]) return self._oo_amp except Exception: logger.exception("refresh_oo_amplitude failed") return self._oo_amp + def _apply_amplitude_first_gate(self) -> bool: + """ + 振幅过滤为开仓第一关。返回 True=可继续选约;False=拒开。 + 无论是否开启过滤都写入 _oo_amp 供 Plan 展示。 + """ + from ..exchange.candles import fetch_amplitude_hl_for_runtime + from .amplitude_gate import evaluate_amplitude_gate + + amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings() + amp = fetch_amplitude_hl_for_runtime(amp_hours) + gate = evaluate_amplitude_gate( + filter_enabled=bool(amp_filt), + amp=amp, + max_pct=float(amp_pct), + hours=float(amp_hours), + ) + self._oo_amp = dict(gate["snapshot"]) + if gate["blocked"]: + logger.info("amplitude gate blocked: %s", gate.get("reason")) + return False + return True + + def amplitude_gate_fail_reason(self) -> str | None: + """若最近一次振幅快照显示过滤开启且未过关,返回文案。""" + amp = self._oo_amp + if not amp or not amp.get("filter_enabled"): + return None + if amp.get("ok") is True: + return None + hours = amp.get("hours") + range_pct = amp.get("range_pct") + max_pct = amp.get("max_pct") + if range_pct is None: + return f"振幅未过关:无法获取近 {hours:g}h K 线高低" if hours is not None else "振幅未过关" + try: + return ( + f"振幅未过关:{float(hours):g}h 内 " + f"{float(range_pct):.2f}% > {float(max_pct):g}%" + ) + except (TypeError, ValueError): + return "振幅未过关" + def align_oo_instruments(self) -> OptionPair | None: """期期监控:按振幅高低点选虚值 Call/Put(展示用;振幅超限仍对齐候选)。""" from .oo_selection import select_oo_pair @@ -693,6 +726,9 @@ class StrategySession: min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() fixed_on, fixed_perp = _fixed_direction() opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None + # 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%) + if not self._apply_amplitude_first_gate(): + 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: @@ -932,9 +968,8 @@ class StrategySession: async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: if _has_open_position(): - # 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值);期期仍刷新振幅供 UI - if _hedge_mode() == "option_option": - await asyncio.to_thread(self.refresh_oo_amplitude) + # 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值);仍刷新振幅供 UI + await asyncio.to_thread(self.refresh_oo_amplitude) call_id, put_id = _held_option_legs() held = call_id or _held_option_inst_id() if held and ( diff --git a/backend/tests/test_amplitude_gate.py b/backend/tests/test_amplitude_gate.py new file mode 100644 index 0000000..c2f25b6 --- /dev/null +++ b/backend/tests/test_amplitude_gate.py @@ -0,0 +1,60 @@ +"""振幅过滤门:永期开仓第一关 / 与期期共用口径。""" + +from __future__ import annotations + +from app.exchange.candles import AmplitudeHL +from app.strategy.amplitude_gate import evaluate_amplitude_gate + + +def test_amplitude_gate_off_always_pass() -> None: + amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12) + # 6% 超常见上限,但过滤关 → 不拦 + g = evaluate_amplitude_gate( + filter_enabled=False, amp=amp, max_pct=2.0, hours=12 + ) + assert g["blocked"] is False + assert g["ok"] is True + assert g["reason"] is None + assert g["snapshot"]["filter_enabled"] is False + assert g["snapshot"]["range_pct"] == amp.range_pct + + +def test_amplitude_gate_on_blocks_over_max() -> None: + amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12) + assert amp.range_pct == 6.0 + g = evaluate_amplitude_gate( + filter_enabled=True, amp=amp, max_pct=2.0, hours=12 + ) + assert g["blocked"] is True + assert g["ok"] is False + assert "振幅未过关" in (g["reason"] or "") + assert "6.00%" in (g["reason"] or "") + assert "2%" in (g["reason"] or "") + + +def test_amplitude_gate_on_pass_within_max() -> None: + amp = AmplitudeHL(high=2010, low=1990, mid=2000, hours=12, bar_count=12) + assert amp.range_pct == 1.0 + g = evaluate_amplitude_gate( + filter_enabled=True, amp=amp, max_pct=2.0, hours=12 + ) + assert g["blocked"] is False + assert g["ok"] is True + assert g["reason"] is None + + +def test_amplitude_gate_on_no_candles_fail_closed() -> None: + g = evaluate_amplitude_gate( + filter_enabled=True, amp=None, max_pct=2.0, hours=12 + ) + assert g["blocked"] is True + assert g["ok"] is False + assert "无法获取" in (g["reason"] or "") + + +def test_amplitude_gate_off_no_candles_still_ok() -> None: + g = evaluate_amplitude_gate( + filter_enabled=False, amp=None, max_pct=2.0, hours=12 + ) + assert g["blocked"] is False + assert g["ok"] is True diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3f1402f..cd5f37e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -214,6 +214,7 @@ export type MarketSnapshot = { max_pct?: number; /** @deprecated 兼容旧字段,等同 max_pct */ min_pct?: number; + filter_enabled?: boolean; ok?: boolean; } | null; pair: { diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 455bd2f..0cb7d4c 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -288,6 +288,9 @@ export default function PlanPage() { ] .filter(Boolean) .join(" · "); + const poAmpLabel = plan?.oo_amplitude_filter_enabled + ? `振幅≤${fmt(plan?.oo_amplitude_pct ?? 1.5, 1)}%/${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h(第一关)` + : null; const exitDetail = isOo ? `净盈利≥预算×${fmt(ooRatio, 1)} → 只平盈利腿,亏损腿残留` : exitMode === "premium_multiple" @@ -380,6 +383,7 @@ export default function PlanPage() { ) : ( <> + {poAmpLabel ? `${poAmpLabel} · ` : ""} 永续{" "} {displayPerpQty != null ? `${fmt(displayPerpQty, 2)} ETH` @@ -520,6 +524,7 @@ export default function PlanPage() { ooSelectLabel ) : ( <> + {poAmpLabel ? `${poAmpLabel} · ` : ""} 永续 {displayPerpQty != null ? `${fmt(displayPerpQty, 2)}ETH` @@ -1033,55 +1038,56 @@ export default function PlanPage() {
- {isOo ? ( -
-

指数 / 振幅

-
- 指数 - {fmtExPx("index", snap?.index_px)} -
-
- - 高点 - {snap?.oo_amplitude?.hours != null - ? `(${fmt(snap.oo_amplitude.hours, 0)}h)` - : ""} - - - {fmtExPx("index", snap?.oo_amplitude?.high)} - -
-
- 低点 - - {fmtExPx("index", snap?.oo_amplitude?.low)} - -
-
- 振幅 - - {snap?.oo_amplitude?.range_pct != null - ? `${fmt(snap.oo_amplitude.range_pct, 2)}%` - : "—"} - {(snap?.oo_amplitude?.max_pct ?? snap?.oo_amplitude?.min_pct) != - null - ? ` · 上限≤${fmt( - Number( - snap.oo_amplitude?.max_pct ?? - snap.oo_amplitude?.min_pct, - ), - 1, - )}%` - : ""} - {snap?.oo_amplitude?.ok === false +
+

指数 / 振幅

+
+ 指数 + {fmtExPx("index", snap?.index_px)} +
+
+ + 高点 + {snap?.oo_amplitude?.hours != null + ? `(${fmt(snap.oo_amplitude.hours, 0)}h)` + : ""} + + + {fmtExPx("index", snap?.oo_amplitude?.high)} + +
+
+ 低点 + + {fmtExPx("index", snap?.oo_amplitude?.low)} + +
+
+ 振幅 + + {snap?.oo_amplitude?.range_pct != null + ? `${fmt(snap.oo_amplitude.range_pct, 2)}%` + : "—"} + {(snap?.oo_amplitude?.max_pct ?? snap?.oo_amplitude?.min_pct) != + null + ? ` · 上限≤${fmt( + Number( + snap.oo_amplitude?.max_pct ?? + snap.oo_amplitude?.min_pct, + ), + 1, + )}%` + : ""} + {snap?.oo_amplitude?.filter_enabled === false + ? " · 过滤关" + : snap?.oo_amplitude?.ok === false ? " · 超限" : snap?.oo_amplitude?.ok === true ? " · 可开" : ""} - -
+
- ) : ( +
+ {!isOo ? (

永续行情

@@ -1104,12 +1110,8 @@ export default function PlanPage() { )}
-
- 指数 - {fmtExPx("index", snap?.index_px)} -
- )} + ) : null}

{isOo diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index eebe968..39f26e8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -742,67 +742,65 @@ export default function SettingsPage() {

+
+ + +
+
+ + + setOoAmpHours(Number(e.target.value)) + } + /> +
+ {ooAmpFilterOn ? ( +
+ + + setOoAmpPct(Number(e.target.value)) + } + /> +
+ ) : null} {isOo ? ( - <> -
- - -
-
- - - setOoAmpHours(Number(e.target.value)) - } - /> -
- {ooAmpFilterOn ? ( -
- - - setOoAmpPct(Number(e.target.value)) - } - /> -
- ) : null} -
- - - setOoRewardRatio(Number(e.target.value)) - } - /> -
- +
+ + + setOoRewardRatio(Number(e.target.value)) + } + /> +
) : null} {!isOo ? (
@@ -1692,16 +1690,17 @@ export default function SettingsPage() { 对冲模式二选一:永期=永续+期权;期期=近 N 小时振幅高低匹配虚值 Call/Put,达标只平盈利腿。 +
  • + 振幅过滤:默认关闭。开启后回看窗内振幅超过「振幅最大%」则不开仓 + {isOo + ? ";关闭时仍用回看高低点选约,但不因振幅超限拒开。" + : "(永期开仓第一关,不过关不再看 ATM/杠杆等)。"} +
  • {isOo ? ( - <> -
  • - 期期定仓:强制以损定仓 + 亏损幅度%;预算平分 - Call/Put,各按卖一独立定仓;出场目标 = 预算 × 盈亏比。 -
  • -
  • - 振幅过滤:默认关闭。关闭时仍用「振幅回看」取高低点选约,但不因振幅超限拒开;开启后回看窗内振幅超过「振幅最大%」则不开仓。 -
  • - +
  • + 期期定仓:强制以损定仓 + 亏损幅度%;预算平分 + Call/Put,各按卖一独立定仓;出场目标 = 预算 × 盈亏比。 +
  • ) : ( <>
  • @@ -1738,6 +1737,10 @@ export default function SettingsPage() { ) : ( <> +
  • + 振幅过滤(仓位页):开启时为开仓第一关——回看窗内振幅须 ≤ + 最大%,不过关则不开仓。 +
  • 固定方向:默认关。开启后固定永续多→买 Put,或永续空→买 Call;期权只选实值或平值,不买虚值。关闭时仍按 ATM/卖一比价规则。