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
+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: