Show OTM option quotes in option-option mode.

Align session to amplitude Call/Put pair and replace perp/ATM market panels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-07 16:30:29 +08:00
parent cd88fa5d83
commit 0659611d82
4 changed files with 388 additions and 70 deletions
+8
View File
@@ -43,12 +43,20 @@ class OptionPair:
strike: float
call_inst_id: str
put_inst_id: str
put_strike: float | None = None # 期期:Put 行权价;None=与 strike 同(ATM
def to_dict(self) -> dict[str, Any]:
put_k = (
float(self.put_strike)
if self.put_strike is not None
else float(self.strike)
)
return {
"expiry_ymd": self.expiry_ymd,
"expiry_ms": self.expiry_ms,
"strike": self.strike,
"call_strike": float(self.strike),
"put_strike": put_k,
"call_inst_id": self.call_inst_id,
"put_inst_id": self.put_inst_id,
}
+210 -11
View File
@@ -63,6 +63,26 @@ def _held_option_inst_id() -> str | None:
return None
def _held_option_legs() -> tuple[str | None, str | None]:
"""期期持仓:返回 (call_inst, put_inst);非期期或无仓则 put 为空。"""
try:
from ..models.db import get_db
row = get_db().fetchone(
"""SELECT status, hedge_mode, option_inst_id, option2_inst_id
FROM positions WHERE id=1"""
)
if not row or row["status"] not in ("open", "half_open"):
return None, None
call_id = str(row["option_inst_id"] or "").strip() or None
put_id = str(row["option2_inst_id"] or "").strip() or None
if str(row["hedge_mode"] or "").strip().lower() != "option_option":
return call_id, None
return call_id, put_id
except Exception:
return None, None
def _as_bool_setting(raw: str | None, default: bool) -> bool:
if raw is None or raw == "":
return default
@@ -236,6 +256,7 @@ class StrategySession:
self.settings = settings or get_settings()
self.ex = exchange or get_exchange()
self._pair: OptionPair | None = None
self._oo_amp: dict[str, Any] | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._started = False
@@ -250,9 +271,11 @@ class StrategySession:
ids: list[str] = [s.perp_inst_id]
if p is not None:
ids.extend([p.call_inst_id, p.put_inst_id])
held = _held_option_inst_id()
held, held2 = _held_option_legs()
if held:
ids.append(held)
if held2:
ids.append(held2)
# 去重保序
out: list[str] = []
seen: set[str] = set()
@@ -309,19 +332,59 @@ class StrategySession:
def align_to_held_position(self) -> OptionPair | None:
"""有活跃仓时:监控对锁定为持仓合约的到期/行权价。"""
held = _held_option_inst_id()
call_id, put_id = _held_option_legs()
held = call_id or _held_option_inst_id()
if not held:
return None
pair = pair_from_option_inst(held)
if pair is None:
logger.warning("cannot rebuild pair from held option %s", held)
return None
mark = self._mark_for_atm() or float(pair.strike)
mark = self._mark_for_atm()
idx = None
try:
idx = self.ex.fetch_index(self.settings.index_inst_id)
except Exception:
pass
if put_id and call_id:
# 期期:双腿分别钉住
try:
from ..models.db import get_db
row = get_db().fetchone(
"SELECT strike2 FROM positions WHERE id=1"
)
except Exception:
row = None
cpair = pair_from_option_inst(call_id)
ppair = pair_from_option_inst(put_id)
if cpair is None:
logger.warning("cannot rebuild call pair from held %s", call_id)
return None
put_strike = None
if row and row["strike2"] is not None:
put_strike = float(row["strike2"])
elif ppair is not None:
put_strike = float(ppair.strike)
pair = OptionPair(
expiry_ymd=cpair.expiry_ymd,
expiry_ms=cpair.expiry_ms,
strike=float(cpair.strike),
call_inst_id=call_id,
put_inst_id=put_id,
put_strike=put_strike,
)
logger.info(
"pin OO watch call=%s put=%s C@%.0f P@%.0f",
call_id,
put_id,
pair.strike,
float(put_strike or pair.strike),
)
return self._apply_pair(
pair, mark=float(mark or pair.strike), idx=idx
)
pair = pair_from_option_inst(held)
if pair is None:
logger.warning("cannot rebuild pair from held option %s", held)
return None
mark = mark or float(pair.strike)
logger.info(
"pin watch to held option %s strike=%.0f expiry=%s",
held,
@@ -334,6 +397,8 @@ class StrategySession:
# 重启/刷新时若仍有仓,绝不切到新 ATM
if _has_open_position():
return self.align_to_held_position()
if _hedge_mode() == "option_option":
return self.align_oo_instruments()
s = self.settings
idx = self.ex.fetch_index(s.index_inst_id)
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
@@ -376,6 +441,58 @@ class StrategySession:
)
return self._apply_pair(pair, mark=float(mark), idx=idx)
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
if _has_open_position():
return self.align_to_held_position()
s = self.settings
amp_pct, amp_hours, min_hours, _min_lev = _oo_settings()
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 mark <= 0:
raise RuntimeError("无法获取标的标记/指数价格,无法选期期虚值")
underlying = float(mark)
amp = fetch_amplitude_hl_for_runtime(amp_hours)
if amp is None:
self._oo_amp = 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),
"min_pct": float(amp_pct),
"ok": float(amp.range_pct) + 1e-12 >= float(amp_pct),
}
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),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
)
if picked is None:
raise RuntimeError(
f"未找到剩余≥{min_hours}h 的虚值 Call@高/Put@低"
)
ymd, ems, ck, pk, call_inst, put_inst = picked
pair = OptionPair(
expiry_ymd=ymd,
expiry_ms=int(ems),
strike=float(ck),
call_inst_id=call_inst,
put_inst_id=put_inst,
put_strike=float(pk),
)
return self._apply_pair(pair, mark=underlying, idx=idx)
def pick_for_open(self) -> OpenPick | None:
if _hedge_mode() == "option_option":
return self._pick_for_open_oo()
@@ -452,14 +569,24 @@ class StrategySession:
min_lev,
)
return None
# 监控用:Call 行权价构造假 pair(两腿不同 strikecall/put inst 正确)
# 监控用:Call/Put 不同行权价
pair = OptionPair(
expiry_ymd=ymd,
expiry_ms=int(ems),
strike=float(ck),
call_inst_id=call_inst,
put_inst_id=put_inst,
put_strike=float(pk),
)
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),
"min_pct": float(amp_pct),
"ok": True,
}
self._apply_pair(pair, mark=underlying, idx=idx)
if hasattr(self.ex, "cache"):
from ..exchange.book_cache import BookCache
@@ -655,6 +782,8 @@ class StrategySession:
return None
def atm_needs_realign(self, mark_px: float | None = None) -> bool:
if _hedge_mode() == "option_option":
return self.oo_needs_realign()
if self._pair is None:
return True
min_hours, _, _, _ = _strategy_floats()
@@ -679,19 +808,78 @@ class StrategySession:
return True
return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS
def oo_needs_realign(self) -> bool:
if self._pair is None:
return True
_amp_pct, amp_hours, min_hours, _ = _oo_settings()
if (
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
+ 1e-9
< min_hours
):
return True
skip = _skip_expiry_ymds_for_next()
if str(self._pair.expiry_ymd or "") in skip:
return True
try:
from ..exchange.candles import fetch_amplitude_hl_for_runtime
from .oo_selection import select_oo_pair
mark = self._mark_for_atm()
if mark is None or mark <= 0:
return False
amp = fetch_amplitude_hl_for_runtime(amp_hours)
if amp is None:
return False
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),
"min_pct": float(_amp_pct),
"ok": float(amp.range_pct) + 1e-12 >= float(_amp_pct),
}
contracts = self.ex.list_option_contracts(self.settings.option_inst_family)
picked = select_oo_pair(
contracts,
spot=float(mark),
high=float(amp.high),
low=float(amp.low),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
)
if picked is None:
return False
_ymd, _ems, _ck, _pk, call_inst, put_inst = picked
return (
call_inst != self._pair.call_inst_id
or put_inst != self._pair.put_inst_id
)
except Exception:
logger.exception("oo_needs_realign failed")
return False
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
if _has_open_position():
# 持仓期间:钉住持仓行权价(禁止漂到新 ATM)
held = _held_option_inst_id()
# 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值
call_id, put_id = _held_option_legs()
held = call_id or _held_option_inst_id()
if held and (
self._pair is None
or held not in (self._pair.call_inst_id, self._pair.put_inst_id)
or (
put_id
and put_id
not in (self._pair.call_inst_id, self._pair.put_inst_id)
)
):
return await asyncio.to_thread(self.align_to_held_position)
return self._pair
if force or self.atm_needs_realign():
logger.info(
"ATM realign force=%s old_strike=%s old_exp=%s",
"%s realign force=%s old_strike=%s old_exp=%s",
"OO" if _hedge_mode() == "option_option" else "ATM",
force,
self._pair.strike if self._pair else None,
self._pair.expiry_ymd if self._pair else None,
@@ -706,6 +894,17 @@ class StrategySession:
d = self.ex.snapshot_dict(self.settings.perp_inst_id)
d["exchange"] = getattr(self.ex, "name", self.settings.exchange)
d["perp_inst_id"] = self.settings.perp_inst_id
hm = _hedge_mode()
d["hedge_mode"] = hm
if self._pair is not None:
pd = self._pair.to_dict()
d["pair"] = pd
if self._oo_amp is not None:
d["oo_amplitude"] = dict(self._oo_amp)
if hm == "option_option":
ac = dict(d.get("ask_compare") or {})
ac["bias"] = "option_option"
d["ask_compare"] = ac
return d
async def _refresh_loop(self) -> None:
+12
View File
@@ -204,9 +204,21 @@ export type MarketSnapshot = {
index_px: number | null;
exchange?: string;
perp_inst_id?: string;
hedge_mode?: "perp_option" | "option_option";
oo_amplitude?: {
high?: number;
low?: number;
mid?: number;
range_pct?: number;
hours?: number;
min_pct?: number;
ok?: boolean;
} | null;
pair: {
expiry_ymd: string;
strike: number;
call_strike?: number;
put_strike?: number;
call_inst_id: string;
put_inst_id: string;
} | null;
+158 -59
View File
@@ -187,6 +187,7 @@ export default function PlanPage() {
const pos = plan?.position;
const isOo =
plan?.hedge_mode === "option_option" ||
snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id;
const open = !!pos?.has_position;
@@ -851,6 +852,11 @@ export default function PlanPage() {
</div>
</div>
</>
) : isOo ? (
<>
<div className="pos-empty">Call · </div>
<div className="pos-empty">Put · </div>
</>
) : (
<>
<div className="pos-empty"> · </div>
@@ -861,43 +867,102 @@ export default function PlanPage() {
</div>
<div className="plan-market">
<div className="card">
<h3 className="plan-panel-title"></h3>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.bid)}</span>
{isOo ? (
<div className="card">
<h3 className="plan-panel-title"> / </h3>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("index", snap?.index_px)}</span>
</div>
<div className="kv">
<span>
{snap?.oo_amplitude?.hours != null
? `${fmt(snap.oo_amplitude.hours, 0)}h`
: ""}
</span>
<span className="mono">
{fmtExPx("index", snap?.oo_amplitude?.high)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtExPx("index", snap?.oo_amplitude?.low)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{snap?.oo_amplitude?.range_pct != null
? `${fmt(snap.oo_amplitude.range_pct, 2)}%`
: "—"}
{snap?.oo_amplitude?.min_pct != null
? ` · 门限≥${fmt(snap.oo_amplitude.min_pct, 1)}%`
: ""}
{snap?.oo_amplitude?.ok === false
? " · 不足"
: snap?.oo_amplitude?.ok === true
? " · 达标"
: ""}
</span>
</div>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.ask)}</span>
) : (
<div className="card">
<h3 className="plan-panel-title"></h3>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.bid)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.ask)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtExPx(
"perp",
snap?.perp?.mark_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null),
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("index", snap?.index_px)}</span>
</div>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtExPx(
"perp",
snap?.perp?.mark_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null),
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("index", snap?.index_px)}</span>
</div>
</div>
)}
<div className="card">
<h3 className="plan-panel-title">
{open ? "持仓期权" : "期权 ATM"}
{snap?.pair
? ` @ ${
snap.pair.strike != null
? Math.round(Number(snap.pair.strike))
: "—"
}`
: ""}
{isOo
? open
? "持仓虚值"
: "期期虚值"
: open
? "持仓期权"
: "期权 ATM"}
{isOo && snap?.pair
? ` · C@${Math.round(
Number(
snap.pair.call_strike ?? snap.pair.strike ?? 0,
),
)} / P@${Math.round(
Number(
snap.pair.put_strike ?? snap.pair.strike ?? 0,
),
)}`
: snap?.pair
? ` @ ${
snap.pair.strike != null
? Math.round(Number(snap.pair.strike))
: "—"
}`
: ""}
</h3>
{(() => {
const under =
@@ -921,17 +986,29 @@ export default function PlanPage() {
: open && heldSide === "put"
? putLev
: null;
const callK = snap?.pair?.call_strike ?? snap?.pair?.strike;
const putK = snap?.pair?.put_strike ?? snap?.pair?.strike;
return (
<>
<div className="kv">
<span>Call {sideLabel}</span>
<span>
Call {sideLabel}
{isOo && callK != null
? ` @${Math.round(Number(callK))}`
: ""}
</span>
<span className="mono">
{fmtTopEx("option", callPx, callSz)}
{!open ? ` · 杠杆 ${callLev}` : ""}
</span>
</div>
<div className="kv">
<span>Put {sideLabel}</span>
<span>
Put {sideLabel}
{isOo && putK != null
? ` @${Math.round(Number(putK))}`
: ""}
</span>
<span className="mono">
{fmtTopEx("option", putPx, putSz)}
{!open ? ` · 杠杆 ${putLev}` : ""}
@@ -940,7 +1017,7 @@ export default function PlanPage() {
<div className="kv">
<span></span>
<span className="mono">
{open
{open && !isOo
? heldLev ||
(heldSide
? "—"
@@ -951,29 +1028,51 @@ export default function PlanPage() {
</>
);
})()}
<div className="kv">
<span></span>
<span className="mono">
{(() => {
const strike = snap?.pair?.strike;
const px =
snap?.index_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null);
if (strike == null || px == null) return "—";
const abs = Math.abs(strike - px);
const sign = strike - px > 0 ? "+" : "";
const delta = `${sign}${(strike - px).toFixed(0)}`;
if (!plan?.atm_open_offset_enabled) {
return `${delta} · 偏差限制关`;
}
const lim = plan?.max_atm_open_offset ?? 3;
const ok = abs <= lim + 1e-9;
return `${delta} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`;
})()}
</span>
</div>
{isOo ? (
<div className="kv">
<span></span>
<span className="mono">
{(() => {
const px = snap?.index_px;
const ck = snap?.pair?.call_strike ?? snap?.pair?.strike;
const pk = snap?.pair?.put_strike;
if (px == null || ck == null) return "—";
const cOff = Number(ck) - Number(px);
const pOff =
pk != null ? Number(pk) - Number(px) : null;
const fmtOff = (n: number) =>
`${n > 0 ? "+" : ""}${n.toFixed(0)}`;
return pOff != null
? `C ${fmtOff(cOff)} · P ${fmtOff(pOff)}`
: `C ${fmtOff(cOff)}`;
})()}
</span>
</div>
) : (
<div className="kv">
<span></span>
<span className="mono">
{(() => {
const strike = snap?.pair?.strike;
const px =
snap?.index_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null);
if (strike == null || px == null) return "—";
const abs = Math.abs(strike - px);
const sign = strike - px > 0 ? "+" : "";
const delta = `${sign}${(strike - px).toFixed(0)}`;
if (!plan?.atm_open_offset_enabled) {
return `${delta} · 偏差限制关`;
}
const lim = plan?.max_atm_open_offset ?? 3;
const ok = abs <= lim + 1e-9;
return `${delta} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`;
})()}
</span>
</div>
)}
<div className="kv">
<span></span>
<span className="mono">{snap?.pair?.expiry_ymd || "—"}</span>