diff --git a/backend/app/api/market.py b/backend/app/api/market.py index 0f99eb5..e2c33a9 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -23,13 +23,14 @@ async def market_option_ladder( _user: Annotated[str, Depends(require_user)], wings: int = Query(default=5, ge=1, le=12), side: str = Query(default="call", pattern="^(call|put)$"), + min_hours: float = Query(default=30, ge=1, le=720), ) -> dict: - """半自动页单边报价列表:ATM 上下各 wings 档;side=call|put。""" + """半自动页单边报价:选剩余时长≥min_hours 的最近到期。""" gw = get_gateway() ladder = getattr(gw, "option_ladder", None) if not callable(ladder): raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder") - return ladder(wings=wings, side=side) + return ladder(wings=wings, side=side, min_hours=min_hours) @router.post("/realign") diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index a654af3..101d0e8 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -1058,13 +1058,20 @@ class StrategySession: d["ask_compare"] = ac return d - def option_ladder(self, *, wings: int = 5, side: str = "call") -> dict[str, Any]: + def option_ladder( + self, + *, + wings: int = 5, + side: str = "call", + min_hours: float = 30.0, + ) -> dict[str, Any]: """ 半自动单边报价:ATM 上下若干档。 - side=call(看多)或 put(看空);每档含卖一/流动性/杠杆与实值|平值|虚值。 + 到期:剩余时长 ≥ min_hours 的最近一档(与选约门槛一致)。 """ s = self.settings wings = max(1, min(12, int(wings))) + min_h = max(1.0, float(min_hours)) opt_side = "put" if str(side).strip().lower() == "put" else "call" idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx @@ -1075,24 +1082,23 @@ class StrategySession: "rows": [], "index_px": None, "side": opt_side, + "min_hours": min_h, } underlying = float(mark) contracts = self.ex.list_option_contracts(s.option_inst_family) from .selection import _complete_by_expiry, pick_atm_strike - eligible = list_eligible_expiry_ymds(contracts, min_hours=1.0) - ymd = None - if self._pair is not None and self._pair.expiry_ymd: - ymd = str(self._pair.expiry_ymd) - if not ymd and eligible: - ymd = eligible[0] + # 与半自动选约一致:只考虑剩余 ≥ min_hours 的到期,取最近一档 + eligible = list_eligible_expiry_ymds(contracts, min_hours=min_h) + ymd = eligible[0] if eligible else None if not ymd: return { "ok": False, - "detail": "无合格到期", + "detail": f"无剩余≥{min_h:g}h 的到期", "rows": [], "index_px": underlying, "side": opt_side, + "min_hours": min_h, } complete = _complete_by_expiry(contracts) if ymd not in complete: @@ -1103,8 +1109,10 @@ class StrategySession: "index_px": underlying, "expiry_ymd": ymd, "side": opt_side, + "min_hours": min_h, } _ems, strikes_map = complete[ymd] + hours_left = hours_until_expiry(ymd, expiry_ms=_ems) strikes = sorted(float(k) for k in strikes_map.keys()) atm = pick_atm_strike(strikes, underlying) if atm is None: @@ -1172,6 +1180,8 @@ class StrategySession: "side": opt_side, "index_px": underlying, "expiry_ymd": ymd, + "hours_left": round(float(hours_left), 1) if hours_left is not None else None, + "min_hours": min_h, "atm_strike": float(atm), "rows": rows, } diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index d94891a..beec799 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -131,6 +131,8 @@ export default function PlanPage() { side?: string; index_px?: number | null; expiry_ymd?: string; + hours_left?: number | null; + min_hours?: number; atm_strike?: number; rows?: Array<{ strike: number; @@ -141,6 +143,7 @@ export default function PlanPage() { lev: number | null; }>; } | null>(null); + const semiMinHRef = useRef(30); const semiParamsBody = () => ({ semi_view_side: semiView, @@ -186,9 +189,10 @@ export default function PlanPage() { ? "short" : "long"; const optSide = viewSide === "short" ? "put" : "call"; + const minH = semiMinHRef.current; setLadder( await apiFetch>( - `/api/market/option-ladder?wings=5&side=${optSide}`, + `/api/market/option-ladder?wings=5&side=${optSide}&min_hours=${minH}`, ), ); } catch { @@ -249,6 +253,10 @@ export default function PlanPage() { semiViewRef.current = semiView; }, [semiView]); + useEffect(() => { + semiMinHRef.current = Number(semiMinH) > 0 ? Number(semiMinH) : 30; + }, [semiMinH]); + useEffect(() => { refresh(); const t = window.setInterval(refresh, 1500); @@ -327,25 +335,29 @@ export default function PlanPage() { const semiOn = !!plan?.semi_auto_enabled && !isOo; const showAmpCard = plan?.oo_amplitude_filter_enabled === true; - // 看法切换时立刻拉 Call/Put 列表 + // 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖) useEffect(() => { if (!semiOn || planTab !== "semi") return; const optSide = semiView === "short" ? "put" : "call"; + const minH = Number(semiMinH) > 0 ? Number(semiMinH) : 30; let cancelled = false; - void (async () => { - try { - const lad = await apiFetch>( - `/api/market/option-ladder?wings=5&side=${optSide}`, - ); - if (!cancelled) setLadder(lad); - } catch { - /* ignore */ - } - })(); + const t = window.setTimeout(() => { + void (async () => { + try { + const lad = await apiFetch>( + `/api/market/option-ladder?wings=5&side=${optSide}&min_hours=${minH}`, + ); + if (!cancelled) setLadder(lad); + } catch { + /* ignore */ + } + })(); + }, 350); return () => { cancelled = true; + window.clearTimeout(t); }; - }, [semiView, semiOn, planTab]); + }, [semiView, semiMinH, semiOn, planTab]); const exitMode = plan?.exit_mode ?? "fixed_usdt"; const riskBased = plan?.risk_based === true || plan?.sizing_mode === "risk_based"; @@ -893,6 +905,10 @@ export default function PlanPage() { {ladder?.expiry_ymd ? `到期 ${ladder.expiry_ymd}` : "—"} + {ladder?.hours_left != null + ? ` · 剩余≈${fmt(ladder.hours_left, 1)}h` + : ""} + {` · 门槛≥${fmt(Number(semiMinH) || 30, 0)}h`} {ladder?.atm_strike != null ? ` · ATM ${Math.round(ladder.atm_strike)}` : ""}