Fix semi moneyness reset and show Call/Put ladder by view.

Use dirty ref so refresh cannot overwrite unsaved params; ladder fetches books for the selected side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:13:37 +08:00
parent d98bf46549
commit 58c12aa160
4 changed files with 149 additions and 107 deletions
+4 -3
View File
@@ -21,14 +21,15 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
@router.get("/option-ladder") @router.get("/option-ladder")
async def market_option_ladder( async def market_option_ladder(
_user: Annotated[str, Depends(require_user)], _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: ) -> dict:
"""半自动页 T 型报价ATM 上下各 wings 档。""" """半自动页单边报价列表ATM 上下各 wings 档side=call|put"""
gw = get_gateway() gw = get_gateway()
ladder = getattr(gw, "option_ladder", None) ladder = getattr(gw, "option_ladder", None)
if not callable(ladder): if not callable(ladder):
raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder") raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder")
return ladder(wings=wings) return ladder(wings=wings, side=side)
@router.post("/realign") @router.post("/realign")
+48 -23
View File
@@ -1058,17 +1058,24 @@ class StrategySession:
d["ask_compare"] = ac d["ask_compare"] = ac
return d 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 盘口 半自动单边报价:ATM 上下若干档
行:虚值 Call(上行)/ ATM / 虚值 Put(下行);列:Call 卖一·杠杆 | 行权价 | Put 卖一·杠杆 side=call(看多)或 put(看空);每档含卖一/流动性/杠杆与实值|平值|虚值
""" """
s = self.settings s = self.settings
wings = max(1, min(12, int(wings))) 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) idx = self.ex.fetch_index(s.index_inst_id)
mark = self.ex.fetch_mark(s.perp_inst_id) or idx mark = self.ex.fetch_mark(s.perp_inst_id) or idx
if mark is None or float(mark) <= 0: 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) underlying = float(mark)
contracts = self.ex.list_option_contracts(s.option_inst_family) contracts = self.ex.list_option_contracts(s.option_inst_family)
from .selection import _complete_by_expiry, pick_atm_strike from .selection import _complete_by_expiry, pick_atm_strike
@@ -1085,6 +1092,7 @@ class StrategySession:
"detail": "无合格到期", "detail": "无合格到期",
"rows": [], "rows": [],
"index_px": underlying, "index_px": underlying,
"side": opt_side,
} }
complete = _complete_by_expiry(contracts) complete = _complete_by_expiry(contracts)
if ymd not in complete: if ymd not in complete:
@@ -1094,6 +1102,7 @@ class StrategySession:
"rows": [], "rows": [],
"index_px": underlying, "index_px": underlying,
"expiry_ymd": ymd, "expiry_ymd": ymd,
"side": opt_side,
} }
_ems, strikes_map = complete[ymd] _ems, strikes_map = complete[ymd]
strikes = sorted(float(k) for k in strikes_map.keys()) strikes = sorted(float(k) for k in strikes_map.keys())
@@ -1105,46 +1114,62 @@ class StrategySession:
"rows": [], "rows": [],
"index_px": underlying, "index_px": underlying,
"expiry_ymd": ymd, "expiry_ymd": ymd,
"side": opt_side,
} }
atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm))) atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm)))
lo = max(0, atm_i - wings) lo = max(0, atm_i - wings)
hi = min(len(strikes), atm_i + wings + 1) 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]] = [] rows: list[dict[str, Any]] = []
for k in strikes[lo:hi]: for k in strikes[lo:hi]:
legs = strikes_map[k] legs = strikes_map[k]
call_id = legs.get("C") inst = legs.get("C" if opt_side == "call" else "P")
put_id = legs.get("P") ask, ask_sz = _quote_side(inst)
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 off = float(k) - underlying
if abs(float(k) - float(atm)) < 1e-9: if abs(float(k) - float(atm)) < 1e-9:
tag = "atm" tag = "atm"
elif float(k) > underlying + 1e-9: elif opt_side == "call":
tag = "otm_call" # Call 虚值 / Put 实值 tag = "itm" if float(k) < underlying - 1e-9 else "otm"
else: else:
tag = "otm_put" # Put 虚值 / Call 实值 tag = "itm" if float(k) > underlying + 1e-9 else "otm"
rows.append( rows.append(
{ {
"strike": float(k), "strike": float(k),
"offset": round(off, 2), "offset": round(off, 2),
"tag": tag, "tag": tag,
"call_ask": c_ask, "ask": ask,
"call_bid": c_bid, "ask_sz": ask_sz,
"call_lev": option_leverage(underlying, c_ask), "lev": option_leverage(underlying, ask) if ask else None,
"put_ask": p_ask, "inst_id": inst,
"put_bid": p_bid,
"put_lev": option_leverage(underlying, p_ask),
"call_inst_id": call_id,
"put_inst_id": put_id,
} }
) )
# Call:高行权价在上(虚值在上);Put:低行权价在上(虚值在上)
if opt_side == "call":
rows.sort(key=lambda r: -float(r["strike"]))
else:
rows.sort(key=lambda r: float(r["strike"]))
return { return {
"ok": True, "ok": True,
"detail": "", "detail": "",
"side": opt_side,
"index_px": underlying, "index_px": underlying,
"expiry_ymd": ymd, "expiry_ymd": ymd,
"atm_strike": float(atm), "atm_strike": float(atm),
+93 -77
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { apiFetch, MarketSnapshot, PlanState } from "../api/client"; import { apiFetch, MarketSnapshot, PlanState } from "../api/client";
import { fmtBidLiqEx, fmtExPx, fmtTopEx } from "../format"; import { fmtBidLiqEx, fmtExPx, fmtTopEx } from "../format";
@@ -122,10 +122,13 @@ export default function PlanPage() {
const [semiPerpU, setSemiPerpU] = useState(1); const [semiPerpU, setSemiPerpU] = useState(1);
const [semiOptU, setSemiOptU] = useState(4); const [semiOptU, setSemiOptU] = useState(4);
const [semiDirty, setSemiDirty] = useState(false); const [semiDirty, setSemiDirty] = useState(false);
const semiDirtyRef = useRef(false);
const semiViewRef = useRef<"long" | "short">("long");
const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi"); const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi");
const [ladder, setLadder] = useState<{ const [ladder, setLadder] = useState<{
ok?: boolean; ok?: boolean;
detail?: string; detail?: string;
side?: string;
index_px?: number | null; index_px?: number | null;
expiry_ymd?: string; expiry_ymd?: string;
atm_strike?: number; atm_strike?: number;
@@ -133,10 +136,9 @@ export default function PlanPage() {
strike: number; strike: number;
offset: number; offset: number;
tag: string; tag: string;
call_ask: number | null; ask: number | null;
call_lev: number | null; ask_sz: number | null;
put_ask: number | null; lev: number | null;
put_lev: number | null;
}>; }>;
} | null>(null); } | null>(null);
@@ -160,7 +162,8 @@ export default function PlanPage() {
]); ]);
setSnap(m); setSnap(m);
setPlan(p); setPlan(p);
if (!semiDirty) { // 用 refinterval 闭包里的 semiDirty 会过期,导致改行权类型被刷回虚值
if (!semiDirtyRef.current) {
setSemiView(p.semi_view_side === "short" ? "short" : "long"); setSemiView(p.semi_view_side === "short" ? "short" : "long");
setSemiMove(Number(p.semi_option_move_points ?? 50)); setSemiMove(Number(p.semi_option_move_points ?? 50));
setSemiExitU(Number(p.semi_perp_exit_unit ?? 5)); setSemiExitU(Number(p.semi_perp_exit_unit ?? 5));
@@ -177,9 +180,15 @@ export default function PlanPage() {
String(p.hedge_mode || "") !== "option_option" String(p.hedge_mode || "") !== "option_option"
) { ) {
try { try {
const viewSide = semiDirtyRef.current
? semiViewRef.current
: p.semi_view_side === "short"
? "short"
: "long";
const optSide = viewSide === "short" ? "put" : "call";
setLadder( setLadder(
await apiFetch<NonNullable<typeof ladder>>( await apiFetch<NonNullable<typeof ladder>>(
"/api/market/option-ladder?wings=4", `/api/market/option-ladder?wings=5&side=${optSide}`,
), ),
); );
} catch { } catch {
@@ -232,6 +241,14 @@ export default function PlanPage() {
} }
} }
useEffect(() => {
semiDirtyRef.current = semiDirty;
}, [semiDirty]);
useEffect(() => {
semiViewRef.current = semiView;
}, [semiView]);
useEffect(() => { useEffect(() => {
refresh(); refresh();
const t = window.setInterval(refresh, 1500); const t = window.setInterval(refresh, 1500);
@@ -309,6 +326,26 @@ export default function PlanPage() {
!!pos?.option2_inst_id; !!pos?.option2_inst_id;
const semiOn = !!plan?.semi_auto_enabled && !isOo; const semiOn = !!plan?.semi_auto_enabled && !isOo;
const showAmpCard = plan?.oo_amplitude_filter_enabled === true; 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<NonNullable<typeof ladder>>(
`/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 exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased = const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based"; plan?.risk_based === true || plan?.sizing_mode === "risk_based";
@@ -847,9 +884,11 @@ export default function PlanPage() {
</div> </div>
</section> </section>
<section className="card plan-t-quote" aria-label="T型报价"> <section className="card plan-t-quote" aria-label="期权报价列表">
<div className="plan-semi-head"> <div className="plan-semi-head">
<h3 className="plan-panel-title">T · / / </h3> <h3 className="plan-panel-title">
{semiView === "short" ? "Put" : "Call"} · / /
</h3>
<span className="mono meta"> <span className="mono meta">
{ladder?.expiry_ymd {ladder?.expiry_ymd
? `到期 ${ladder.expiry_ymd}` ? `到期 ${ladder.expiry_ymd}`
@@ -869,79 +908,56 @@ export default function PlanPage() {
<table className="plan-t-table"> <table className="plan-t-table">
<thead> <thead>
<tr> <tr>
<th>Call卖一</th>
<th>Call杠杆</th>
<th></th> <th></th>
<th></th> <th></th>
<th>Put杠杆</th> <th> / </th>
<th>Put卖一</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[...(ladder.rows || [])] {(ladder.rows || []).map((r) => {
.slice() const tagZh =
.reverse() r.tag === "atm"
.map((r) => { ? "平值"
const tagZh = : r.tag === "itm"
r.tag === "atm" ? "实值"
? "值" : "值";
: r.tag === "otm_call" const hi =
? "Call虚/Put实" (semiMny === "otm" &&
: "Put虚/Call实"; r.tag === "otm" &&
const hi = Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) ||
(semiMny === "otm" && (semiMny === "atm" && r.tag === "atm") ||
((semiView === "long" && r.tag === "otm_call") || (semiMny === "itm" &&
(semiView === "short" && r.tag === "otm_put")) && (r.tag === "itm" || r.tag === "atm"));
Math.abs(r.offset) <= Number(semiOtmOff) + 1e-9) || return (
(semiMny === "atm" && r.tag === "atm") || <tr
(semiMny === "itm" && key={r.strike}
((semiView === "long" && className={
(r.tag === "otm_put" || r.tag === "atm")) || r.tag === "atm"
(semiView === "short" && ? "plan-t-atm"
(r.tag === "otm_call" || r.tag === "atm")))); : hi
return ( ? "plan-t-pick"
<tr : undefined
key={r.strike} }
className={ >
r.tag === "atm" <td className="mono plan-t-k">
? "plan-t-atm" {Math.round(r.strike)}
: hi <span className="meta">
? "plan-t-pick" {" "}
: undefined ({r.offset >= 0 ? "+" : ""}
} {fmt(r.offset, 0)})
> </span>
<td className="mono"> </td>
{r.call_ask != null <td>{tagZh}</td>
? fmtExPx("option", r.call_ask) <td className="mono">
: "—"} {fmtTopEx("option", r.ask, r.ask_sz)}
</td> </td>
<td className="mono"> <td className="mono">
{r.call_lev != null {r.lev != null ? `${r.lev.toFixed(0)}x` : "—"}
? `${r.call_lev.toFixed(0)}x` </td>
: "—"} </tr>
</td> );
<td className="mono plan-t-k"> })}
{Math.round(r.strike)}
<span className="meta">
{" "}
({r.offset >= 0 ? "+" : ""}
{fmt(r.offset, 0)})
</span>
</td>
<td>{tagZh}</td>
<td className="mono">
{r.put_lev != null
? `${r.put_lev.toFixed(0)}x`
: "—"}
</td>
<td className="mono">
{r.put_ask != null
? fmtExPx("option", r.put_ask)
: "—"}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+4 -4
View File
@@ -573,10 +573,10 @@ input {
white-space: nowrap; white-space: nowrap;
} }
.plan-t-table th:nth-child(3), .plan-t-table th:nth-child(1),
.plan-t-table td:nth-child(3), .plan-t-table td:nth-child(1),
.plan-t-table th:nth-child(4), .plan-t-table th:nth-child(2),
.plan-t-table td:nth-child(4) { .plan-t-table td:nth-child(2) {
text-align: center; text-align: center;
} }