diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 00479fd..0b8db03 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -2011,9 +2011,19 @@ class Matcher: if index_px is None and snap.perp: index_px = snap.perp.mark_px qty = float(pos.get("option_qty_eth") or 0) - qty2 = float(pos.get("option2_qty_eth") or qty) + raw_q2 = pos.get("option2_qty_eth") + try: + qty2 = float(raw_q2) if raw_q2 is not None else 0.0 + except (TypeError, ValueError): + qty2 = 0.0 + if qty2 <= 0: + qty2 = qty contracts1 = float(pos.get("option_qty_contracts") or 0) - contracts2 = float(pos.get("option2_qty_contracts") or 0) + raw_c2 = pos.get("option2_qty_contracts") + try: + contracts2 = float(raw_c2) if raw_c2 is not None else 0.0 + except (TypeError, ValueError): + contracts2 = 0.0 prem1 = float(pos.get("initial_premium") or 0) prem2 = float(pos.get("initial_premium2") or 0) call_id = str(pos.get("option_inst_id") or "") @@ -2022,6 +2032,19 @@ class Matcher: entry2 = float(pos.get("option2_entry_px") or 0) oq1 = self._quote_held_option(call_id) if call_id else None oq2 = self._quote_held_option(put_id) if put_id else None + # 盘口缓存未订阅到 Put 时,回退 session 监控腿(与「持仓虚值」同数据源) + if (oq2 is None or (oq2.bid is None and oq2.mark_px is None)) and put_id: + try: + if snap.put and str(getattr(snap.put, "inst_id", "") or "") == put_id: + oq2 = snap.put + except Exception: + pass + if (oq1 is None or (oq1.bid is None and oq1.mark_px is None)) and call_id: + try: + if snap.call and str(getattr(snap.call, "inst_id", "") or "") == call_id: + oq1 = snap.call + except Exception: + pass option_upl = 0.0 option2_upl = 0.0 fees = 0.0 diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 0ccba8e..5164585 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -332,6 +332,8 @@ class StrategySession: def align_to_held_position(self) -> OptionPair | None: """有活跃仓时:监控对锁定为持仓合约的到期/行权价。""" + if _hedge_mode() == "option_option": + self.refresh_oo_amplitude() call_id, put_id = _held_option_legs() held = call_id or _held_option_inst_id() if not held: @@ -441,11 +443,37 @@ 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 + try: + from ..exchange.candles import fetch_amplitude_hl_for_runtime + + amp_pct, amp_hours, _, _ = _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), + "ok": float(amp.range_pct) <= float(amp_pct) + 1e-12, + } + return self._oo_amp + except Exception: + logger.exception("refresh_oo_amplitude failed") + return self._oo_amp + def align_oo_instruments(self) -> OptionPair | None: """期期监控:按振幅高低点选虚值 Call/Put(展示用;振幅超限仍对齐候选)。""" - from ..exchange.candles import fetch_amplitude_hl_for_runtime from .oo_selection import select_oo_pair + # 有仓也刷新振幅(钉仓不再走选约,否则 _oo_amp 一直空) + self.refresh_oo_amplitude() if _has_open_position(): return self.align_to_held_position() s = self.settings @@ -455,26 +483,17 @@ class StrategySession: if mark is None or mark <= 0: raise RuntimeError("无法获取标的标记/指数价格,无法选期期虚值") underlying = float(mark) - amp = fetch_amplitude_hl_for_runtime(amp_hours) - if amp is None: - self._oo_amp = None + if self._oo_amp is None: raise RuntimeError("无法获取振幅 K 线高低点") - 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), - "ok": float(amp.range_pct) <= float(amp_pct) + 1e-12, - } + amp_high = float(self._oo_amp["high"]) + amp_low = float(self._oo_amp["low"]) contracts = self.ex.list_option_contracts(s.option_inst_family) skip = _skip_expiry_ymds_for_next() picked = select_oo_pair( contracts, spot=underlying, - high=float(amp.high), - low=float(amp.low), + high=amp_high, + low=amp_low, min_hours=float(min_hours), skip_expiry_ymds=skip, ) @@ -518,6 +537,16 @@ class StrategySession: if amp is None: logger.info("oo: amplitude candles unavailable") return None + # 无论是否超限都写入,供「指数/振幅」面板展示 + 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), + "ok": float(amp.range_pct) <= float(amp_pct) + 1e-12, + } if float(amp.range_pct) > float(amp_pct) + 1e-12: logger.info( "oo: amplitude %.3f%% > max %.3f%% (H=%.2f L=%.2f)", @@ -862,7 +891,9 @@ class StrategySession: async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: if _has_open_position(): - # 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值) + # 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值);期期仍刷新振幅供 UI + if _hedge_mode() == "option_option": + 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/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 0ed321e..f2a31d0 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -766,14 +766,25 @@ export default function PlanPage() { : "—"} - {fmt(pos?.option2_qty_eth, 2)} ETH ·{" "} - {fmt(pos?.option2_qty_contracts, 0)} 张 + {fmt( + pos?.option2_qty_eth ?? + plan?.oo_put_qty_eth ?? + plan?.risk_sizing_preview?.put_qty_eth, + 2, + )}{" "} + ETH · {fmt(pos?.option2_qty_contracts, 0)} 张 - 杠杆 {fmt(pos?.option2_leverage, 0)}x + 杠杆{" "} + {pos?.option2_leverage != null + ? `${fmt(pos.option2_leverage, 0)}x` + : optionLevLabel( + pos?.entry_index_px ?? snap?.index_px, + pos?.option2_entry_px, + )}