diff --git a/backend/app/api/market.py b/backend/app/api/market.py index d44e0dd..0f99eb5 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -21,14 +21,15 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict: @router.get("/option-ladder") async def market_option_ladder( _user: Annotated[str, Depends(require_user)], - wings: int = Query(default=4, ge=1, le=12), + wings: int = Query(default=5, ge=1, le=12), + side: str = Query(default="call", pattern="^(call|put)$"), ) -> dict: - """半自动页 T 型报价:ATM 上下各 wings 档。""" + """半自动页单边报价列表:ATM 上下各 wings 档;side=call|put。""" 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) + return ladder(wings=wings, side=side) @router.post("/realign") diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 157b9ee..a654af3 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -1058,17 +1058,24 @@ class StrategySession: d["ask_compare"] = ac return d - def option_ladder(self, *, wings: int = 4) -> dict[str, Any]: + def option_ladder(self, *, wings: int = 5, side: str = "call") -> dict[str, Any]: """ - T 型报价:当前监控到期附近若干档 Call/Put 盘口。 - 行:虚值 Call(上行)/ ATM / 虚值 Put(下行);列:Call 卖一·杠杆 | 行权价 | Put 卖一·杠杆。 + 半自动单边报价:ATM 上下若干档。 + side=call(看多)或 put(看空);每档含卖一/流动性/杠杆与实值|平值|虚值。 """ s = self.settings wings = max(1, min(12, int(wings))) + 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 if mark is None or float(mark) <= 0: - return {"ok": False, "detail": "无标的价", "rows": [], "index_px": None} + return { + "ok": False, + "detail": "无标的价", + "rows": [], + "index_px": None, + "side": opt_side, + } underlying = float(mark) contracts = self.ex.list_option_contracts(s.option_inst_family) from .selection import _complete_by_expiry, pick_atm_strike @@ -1085,6 +1092,7 @@ class StrategySession: "detail": "无合格到期", "rows": [], "index_px": underlying, + "side": opt_side, } complete = _complete_by_expiry(contracts) if ymd not in complete: @@ -1094,6 +1102,7 @@ class StrategySession: "rows": [], "index_px": underlying, "expiry_ymd": ymd, + "side": opt_side, } _ems, strikes_map = complete[ymd] strikes = sorted(float(k) for k in strikes_map.keys()) @@ -1105,46 +1114,62 @@ class StrategySession: "rows": [], "index_px": underlying, "expiry_ymd": ymd, + "side": opt_side, } atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm))) lo = max(0, atm_i - wings) hi = min(len(strikes), atm_i + wings + 1) + + def _quote_side(inst_id: str | None) -> tuple[float | None, float | None]: + if not inst_id: + return None, None + q = self.ex.quote(str(inst_id)) + ask = float(q.ask) if q and q.ask is not None else None + ask_sz = float(q.ask_sz) if q and q.ask_sz is not None else None + if ask is not None: + return ask, ask_sz + try: + _bids, asks, _ = self.ex.fetch_book(str(inst_id), depth=1) + if asks: + return float(asks[0].px), ( + float(asks[0].sz) if asks[0].sz is not None else None + ) + except Exception: + logger.debug("ladder fetch_book failed inst=%s", inst_id, exc_info=True) + return None, None + rows: list[dict[str, Any]] = [] for k in strikes[lo:hi]: legs = strikes_map[k] - call_id = legs.get("C") - put_id = legs.get("P") - cq = self.ex.quote(str(call_id)) if call_id else None - pq = self.ex.quote(str(put_id)) if put_id else None - c_ask = float(cq.ask) if cq and cq.ask is not None else None - p_ask = float(pq.ask) if pq and pq.ask is not None else None - c_bid = float(cq.bid) if cq and cq.bid is not None else None - p_bid = float(pq.bid) if pq and pq.bid is not None else None + inst = legs.get("C" if opt_side == "call" else "P") + ask, ask_sz = _quote_side(inst) off = float(k) - underlying if abs(float(k) - float(atm)) < 1e-9: tag = "atm" - elif float(k) > underlying + 1e-9: - tag = "otm_call" # Call 虚值 / Put 实值 + elif opt_side == "call": + tag = "itm" if float(k) < underlying - 1e-9 else "otm" else: - tag = "otm_put" # Put 虚值 / Call 实值 + tag = "itm" if float(k) > underlying + 1e-9 else "otm" rows.append( { "strike": float(k), "offset": round(off, 2), "tag": tag, - "call_ask": c_ask, - "call_bid": c_bid, - "call_lev": option_leverage(underlying, c_ask), - "put_ask": p_ask, - "put_bid": p_bid, - "put_lev": option_leverage(underlying, p_ask), - "call_inst_id": call_id, - "put_inst_id": put_id, + "ask": ask, + "ask_sz": ask_sz, + "lev": option_leverage(underlying, ask) if ask else None, + "inst_id": inst, } ) + # Call:高行权价在上(虚值在上);Put:低行权价在上(虚值在上) + if opt_side == "call": + rows.sort(key=lambda r: -float(r["strike"])) + else: + rows.sort(key=lambda r: float(r["strike"])) return { "ok": True, "detail": "", + "side": opt_side, "index_px": underlying, "expiry_ymd": ymd, "atm_strike": float(atm), diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 266f993..2d3600e 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { apiFetch, MarketSnapshot, PlanState } from "../api/client"; import { fmtBidLiqEx, fmtExPx, fmtTopEx } from "../format"; @@ -122,10 +122,13 @@ export default function PlanPage() { const [semiPerpU, setSemiPerpU] = useState(1); const [semiOptU, setSemiOptU] = useState(4); const [semiDirty, setSemiDirty] = useState(false); + const semiDirtyRef = useRef(false); + const semiViewRef = useRef<"long" | "short">("long"); const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi"); const [ladder, setLadder] = useState<{ ok?: boolean; detail?: string; + side?: string; index_px?: number | null; expiry_ymd?: string; atm_strike?: number; @@ -133,10 +136,9 @@ export default function PlanPage() { strike: number; offset: number; tag: string; - call_ask: number | null; - call_lev: number | null; - put_ask: number | null; - put_lev: number | null; + ask: number | null; + ask_sz: number | null; + lev: number | null; }>; } | null>(null); @@ -160,7 +162,8 @@ export default function PlanPage() { ]); setSnap(m); setPlan(p); - if (!semiDirty) { + // 用 ref:interval 闭包里的 semiDirty 会过期,导致改行权类型被刷回虚值 + if (!semiDirtyRef.current) { setSemiView(p.semi_view_side === "short" ? "short" : "long"); setSemiMove(Number(p.semi_option_move_points ?? 50)); setSemiExitU(Number(p.semi_perp_exit_unit ?? 5)); @@ -177,9 +180,15 @@ export default function PlanPage() { String(p.hedge_mode || "") !== "option_option" ) { try { + const viewSide = semiDirtyRef.current + ? semiViewRef.current + : p.semi_view_side === "short" + ? "short" + : "long"; + const optSide = viewSide === "short" ? "put" : "call"; setLadder( await apiFetch>( - "/api/market/option-ladder?wings=4", + `/api/market/option-ladder?wings=5&side=${optSide}`, ), ); } catch { @@ -232,6 +241,14 @@ export default function PlanPage() { } } + useEffect(() => { + semiDirtyRef.current = semiDirty; + }, [semiDirty]); + + useEffect(() => { + semiViewRef.current = semiView; + }, [semiView]); + useEffect(() => { refresh(); const t = window.setInterval(refresh, 1500); @@ -309,6 +326,26 @@ export default function PlanPage() { !!pos?.option2_inst_id; 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"; + let cancelled = false; + void (async () => { + try { + const lad = await apiFetch>( + `/api/market/option-ladder?wings=5&side=${optSide}`, + ); + if (!cancelled) setLadder(lad); + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + }, [semiView, semiOn, planTab]); const exitMode = plan?.exit_mode ?? "fixed_usdt"; const riskBased = plan?.risk_based === true || plan?.sizing_mode === "risk_based"; @@ -847,9 +884,11 @@ export default function PlanPage() { -
+
-

T 型报价 · 实值 / 平值 / 虚值

+

+ {semiView === "short" ? "Put" : "Call"} 报价 · 实值 / 平值 / 虚值 +

{ladder?.expiry_ymd ? `到期 ${ladder.expiry_ymd}` @@ -869,79 +908,56 @@ export default function PlanPage() { - - - - + + - {[...(ladder.rows || [])] - .slice() - .reverse() - .map((r) => { - const tagZh = - r.tag === "atm" - ? "平值" - : r.tag === "otm_call" - ? "Call虚/Put实" - : "Put虚/Call实"; - const hi = - (semiMny === "otm" && - ((semiView === "long" && r.tag === "otm_call") || - (semiView === "short" && r.tag === "otm_put")) && - Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) || - (semiMny === "atm" && r.tag === "atm") || - (semiMny === "itm" && - ((semiView === "long" && - (r.tag === "otm_put" || r.tag === "atm")) || - (semiView === "short" && - (r.tag === "otm_call" || r.tag === "atm")))); - return ( - - - - - - - - - ); - })} + {(ladder.rows || []).map((r) => { + const tagZh = + r.tag === "atm" + ? "平值" + : r.tag === "itm" + ? "实值" + : "虚值"; + const hi = + (semiMny === "otm" && + r.tag === "otm" && + Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) || + (semiMny === "atm" && r.tag === "atm") || + (semiMny === "itm" && + (r.tag === "itm" || r.tag === "atm")); + return ( + + + + + + + ); + })}
Call卖一Call杠杆 行权价 类型Put杠杆Put卖一卖一 / 流动性杠杆
- {r.call_ask != null - ? fmtExPx("option", r.call_ask) - : "—"} - - {r.call_lev != null - ? `${r.call_lev.toFixed(0)}x` - : "—"} - - {Math.round(r.strike)} - - {" "} - ({r.offset >= 0 ? "+" : ""} - {fmt(r.offset, 0)}) - - {tagZh} - {r.put_lev != null - ? `${r.put_lev.toFixed(0)}x` - : "—"} - - {r.put_ask != null - ? fmtExPx("option", r.put_ask) - : "—"} -
+ {Math.round(r.strike)} + + {" "} + ({r.offset >= 0 ? "+" : ""} + {fmt(r.offset, 0)}) + + {tagZh} + {fmtTopEx("option", r.ask, r.ask_sz)} + + {r.lev != null ? `${r.lev.toFixed(0)}x` : "—"} +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index d10a203..6154f2d 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -573,10 +573,10 @@ input { white-space: nowrap; } -.plan-t-table th:nth-child(3), -.plan-t-table td:nth-child(3), -.plan-t-table th:nth-child(4), -.plan-t-table td:nth-child(4) { +.plan-t-table th:nth-child(1), +.plan-t-table td:nth-child(1), +.plan-t-table th:nth-child(2), +.plan-t-table td:nth-child(2) { text-align: center; }