diff --git a/backend/app/api/market.py b/backend/app/api/market.py index 251109e..d44e0dd 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from ..market import get_gateway from .auth import require_user @@ -18,6 +18,19 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict: return snap +@router.get("/option-ladder") +async def market_option_ladder( + _user: Annotated[str, Depends(require_user)], + wings: int = Query(default=4, ge=1, le=12), +) -> dict: + """半自动页 T 型报价:ATM 上下各 wings 档。""" + 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) + + @router.post("/realign") async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict: """手动重对齐次日到期 ATM 合约(运维/调试用)。""" diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index f915d24..157b9ee 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -1058,6 +1058,99 @@ class StrategySession: d["ask_compare"] = ac return d + def option_ladder(self, *, wings: int = 4) -> dict[str, Any]: + """ + T 型报价:当前监控到期附近若干档 Call/Put 盘口。 + 行:虚值 Call(上行)/ ATM / 虚值 Put(下行);列:Call 卖一·杠杆 | 行权价 | Put 卖一·杠杆。 + """ + s = self.settings + wings = max(1, min(12, int(wings))) + 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} + 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] + if not ymd: + return { + "ok": False, + "detail": "无合格到期", + "rows": [], + "index_px": underlying, + } + complete = _complete_by_expiry(contracts) + if ymd not in complete: + return { + "ok": False, + "detail": f"到期 {ymd} 无完整对", + "rows": [], + "index_px": underlying, + "expiry_ymd": ymd, + } + _ems, strikes_map = complete[ymd] + strikes = sorted(float(k) for k in strikes_map.keys()) + atm = pick_atm_strike(strikes, underlying) + if atm is None: + return { + "ok": False, + "detail": "无 ATM", + "rows": [], + "index_px": underlying, + "expiry_ymd": ymd, + } + 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) + 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 + 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 实值 + else: + tag = "otm_put" # Put 虚值 / Call 实值 + 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, + } + ) + return { + "ok": True, + "detail": "", + "index_px": underlying, + "expiry_ymd": ymd, + "atm_strike": float(atm), + "rows": rows, + } + async def _refresh_loop(self) -> None: while True: await asyncio.sleep(30 if self._pair is not None else 10) diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index b6aa73a..266f993 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -122,6 +122,23 @@ export default function PlanPage() { const [semiPerpU, setSemiPerpU] = useState(1); const [semiOptU, setSemiOptU] = useState(4); const [semiDirty, setSemiDirty] = useState(false); + const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi"); + const [ladder, setLadder] = useState<{ + ok?: boolean; + detail?: string; + index_px?: number | null; + expiry_ymd?: string; + atm_strike?: number; + rows?: Array<{ + strike: number; + offset: number; + tag: string; + call_ask: number | null; + call_lev: number | null; + put_ask: number | null; + put_lev: number | null; + }>; + } | null>(null); const semiParamsBody = () => ({ semi_view_side: semiView, @@ -149,12 +166,26 @@ export default function PlanPage() { setSemiExitU(Number(p.semi_perp_exit_unit ?? 5)); setSemiMinH(Number(p.semi_min_option_hours ?? 30)); setSemiMinLev(Number(p.semi_min_option_leverage ?? 200)); - const m = p.semi_moneyness; - setSemiMny(m === "itm" || m === "atm" || m === "otm" ? m : "otm"); + const mny = p.semi_moneyness; + setSemiMny(mny === "itm" || mny === "atm" || mny === "otm" ? mny : "otm"); setSemiOtmOff(Number(p.semi_otm_max_offset ?? 25)); setSemiPerpU(Number(p.semi_perp_unit ?? 1)); setSemiOptU(Number(p.semi_option_unit ?? 4)); } + if ( + p.semi_auto_enabled && + String(p.hedge_mode || "") !== "option_option" + ) { + try { + setLadder( + await apiFetch>( + "/api/market/option-ladder?wings=4", + ), + ); + } catch { + /* ignore ladder errors */ + } + } setErr(""); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); @@ -276,6 +307,8 @@ export default function PlanPage() { snap?.hedge_mode === "option_option" || pos?.hedge_mode === "option_option" || !!pos?.option2_inst_id; + const semiOn = !!plan?.semi_auto_enabled && !isOo; + const showAmpCard = plan?.oo_amplitude_filter_enabled === true; const exitMode = plan?.exit_mode ?? "fixed_usdt"; const riskBased = plan?.risk_based === true || plan?.sizing_mode === "risk_based"; @@ -573,209 +606,351 @@ export default function PlanPage() { {busy ? {busy}… : null} - {plan?.semi_auto_enabled && !isOo ? ( -
-

半自动 · 本单

-

- 人工定方向/行权类型/配比 → 授权后机器盯开平 → 平完停。虚值杠杆门≥180(默认200)。顺向:开仓指数±点数且净利>0;逆向:净利≥基数×k。 -

-
-
- - + {semiOn ? ( +
+ + +
+ ) : null} + + {semiOn && planTab === "semi" ? ( +
+
+
+

半自动 · 本单

+ + {plan.semi_armed ? "已授权" : "未授权"} + {" · "} + {semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"} + {" · "} + {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)} +
-
- - -
- {semiMny === "otm" ? ( +
+ 规则说明 +
    +
  • 人工定方向、行权类型、永续:期权配比与出场 →「授权开下一单」后机器盯选约/开/平。
  • +
  • 平仓后停在等待授权,不自动连开;与全自动循环无关。
  • +
  • 虚值:偏离 ≤ 设定点数;杠杆门 ≥180(默认 200)。实值/平值用本单最低杠杆。
  • +
  • 顺向:开仓指数 ± 波动点数,且组合净利 > 0 → 双腿全平(先期权后永续)。
  • +
  • 逆向兑现:组合净利 ≥ 净利基数 × k → 全平。
  • +
  • 以损定仓时配比为单位再乘 k;手动仓按配比直接开。
  • +
+
+
- + + +
+
+ + +
+ {semiMny === "otm" ? ( +
+ + { + setSemiOtmOff(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+ ) : null} +
+ { + setSemiPerpU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiOptU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { - setSemiOtmOff(Number(e.target.value)); + setSemiMove(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiExitU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiMinH(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiMinLev(Number(e.target.value)); setSemiDirty(true); }} />
- ) : null} -
- - { - setSemiPerpU(Number(e.target.value)); - setSemiDirty(true); - }} - />
-
- - { - setSemiOptU(Number(e.target.value)); - setSemiDirty(true); - }} - /> +
+ {(() => { + const idx = snap?.index_px; + if (idx == null || !Number.isFinite(Number(idx))) { + return `净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`; + } + const n = Number(idx); + const tgt = + semiView === "long" + ? n + Number(semiMove) + : n - Number(semiMove); + return `${fmtExPx("index", n)}→${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`; + })()}
-
- - + + +
-
- - { - setSemiExitU(Number(e.target.value)); - setSemiDirty(true); - }} - /> +
+ +
+
+

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

+ + {ladder?.expiry_ymd + ? `到期 ${ladder.expiry_ymd}` + : "—"} + {ladder?.atm_strike != null + ? ` · ATM ${Math.round(ladder.atm_strike)}` + : ""} + {ladder?.index_px != null + ? ` · 指数 ${fmtExPx("index", ladder.index_px)}` + : ""} +
-
- - { - setSemiMinH(Number(e.target.value)); - setSemiDirty(true); - }} - /> -
-
- - { - setSemiMinLev(Number(e.target.value)); - setSemiDirty(true); - }} - /> -
-
-
- {(() => { - const idx = snap?.index_px; - if (idx == null || !Number.isFinite(Number(idx))) { - return `目标净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · 指数 —`; - } - const n = Number(idx); - const tgt = - semiView === "long" ? n + Number(semiMove) : n - Number(semiMove); - const mnyZh = - semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"; - return `指数 ${fmtExPx("index", n)} → 到点 ${fmtExPx("index", tgt)} · ${mnyZh}${ - semiMny === "otm" ? `≤${fmt(semiOtmOff, 0)}点` : "" - } · 配比 ${fmt(semiPerpU, 2)}:${fmt(semiOptU, 2)} · 净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · ${plan.semi_armed ? "已授权盯开" : "未授权"}`; - })()} -
-
- - - -
-
+ {!ladder?.ok ? ( +

{ladder?.detail || "暂无报价链"}

+ ) : ( +
+ + + + + + + + + + + + + {[...(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 ( + + + + + + + + + ); + })} + +
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) + : "—"} +
+
+ )} + + ) : null} + {!semiOn || planTab === "monitor" ? (
@@ -798,7 +973,18 @@ export default function PlanPage() {
模式 - {riskBased ? ( + {semiOn ? ( + + 半自动 ·{" "} + {semiMny === "itm" + ? "实值" + : semiMny === "atm" + ? "平值" + : "虚值"}{" "} + · 配比 {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)} + {plan?.semi_armed ? " · 已授权" : " · 未授权"} + + ) : riskBased ? ( {sizingModeLabel} ) : ( sizingModeLabel @@ -808,7 +994,17 @@ export default function PlanPage() {
选约条件 - {isOo ? ( + {semiOn ? ( + <> + 剩余≥{fmt(semiMinH, 0)}h · 杠杆≥{fmt(semiMinLev, 0)}x + {semiMny === "otm" + ? ` · 虚值≤${fmt(semiOtmOff, 0)}点` + : ""} + {" · "} + 顺向±{fmt(semiMove, 0)}点且净利>0 / 净利≥ + {fmt(semiExitU, 1)}×k + + ) : isOo ? ( ooSelectLabel ) : ( <> @@ -1326,7 +1522,8 @@ export default function PlanPage() {
-
+ {showAmpCard ? ( +

指数 / 振幅

指数 @@ -1375,6 +1572,7 @@ export default function PlanPage() {
+ ) : null} {!isOo ? (

永续行情

@@ -1617,6 +1815,7 @@ export default function PlanPage() {
) : null}
+ ) : null}
); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c336b1c..d10a203 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -468,6 +468,144 @@ input { color: #5ec8ff; } +.plan-main-tabs { + margin-top: 4px; + margin-bottom: 10px; +} + +.plan-semi-tab { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.plan-semi-compact { + padding: 10px 12px; +} + +.plan-semi-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.plan-semi-head .plan-panel-title { + margin: 0; + font-size: 13px; +} + +.plan-semi-rules { + margin: 0 0 8px; + font-size: 12px; + color: var(--muted); +} + +.plan-semi-rules summary { + cursor: pointer; + color: #5ec8ff; + user-select: none; +} + +.plan-semi-rules ul { + margin: 6px 0 0; + padding-left: 1.2em; + line-height: 1.45; +} + +.plan-semi-fields { + margin-top: 0 !important; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) !important; + gap: 8px !important; +} + +.plan-semi-fields .field label { + font-size: 11px; +} + +.plan-semi-fields .field input, +.plan-semi-fields .field select { + padding: 6px 8px; + font-size: 12px; + min-height: 32px; +} + +.plan-semi-summary { + margin-top: 6px !important; + font-size: 12px; +} + +.plan-semi-btns { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.plan-semi-btns .btn { + min-height: 34px; + padding: 6px 12px; + font-size: 13px; +} + +.plan-t-quote { + padding: 10px 12px; +} + +.plan-t-wrap { + overflow-x: auto; + margin-top: 4px; +} + +.plan-t-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.plan-t-table th, +.plan-t-table td { + padding: 4px 6px; + border-bottom: 1px solid var(--line); + text-align: right; + 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) { + text-align: center; +} + +.plan-t-table th { + color: var(--muted); + font-weight: 600; +} + +.plan-t-atm { + background: rgba(94, 200, 255, 0.08); +} + +.plan-t-pick { + background: rgba(14, 203, 129, 0.08); +} + +.plan-t-k { + font-weight: 700; +} + +.plan-card-compact { + padding: 10px 12px; +} + +.plan-card-compact .plan-panel-title { + margin-bottom: 6px; + font-size: 13px; +} + @media (max-width: 900px) { .app-container { padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));