Pin market watch to held option strike while a position is open.
Stop falling back to ATM quotes for unrealized/close PnL after ATM drifts or restart. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
"""期权合约 ID 工具:从持仓合约还原同到期同行权价的 Call/Put 对。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .expiry import expiry_ms_from_ymd
|
||||||
|
from .types import OptionPair
|
||||||
|
|
||||||
|
|
||||||
|
def flip_option_side(inst_id: str) -> str | None:
|
||||||
|
"""ETH-...-1880-P ↔ ...-C;币安 ETH-YYMMDD-STRIKE-P ↔ -C。"""
|
||||||
|
s = (inst_id or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.endswith("-C"):
|
||||||
|
return s[:-1] + "P"
|
||||||
|
if s.endswith("-P"):
|
||||||
|
return s[:-1] + "C"
|
||||||
|
if s.endswith("-c"):
|
||||||
|
return s[:-1] + "p"
|
||||||
|
if s.endswith("-p"):
|
||||||
|
return s[:-1] + "c"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pair_from_option_inst(inst_id: str) -> OptionPair | None:
|
||||||
|
"""由任一腿合约 ID 还原同 strike/expiry 的 OptionPair。"""
|
||||||
|
from .okx.parse import parse_option_inst_id
|
||||||
|
from .binance.parse import parse_option_symbol
|
||||||
|
|
||||||
|
s = (inst_id or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
ymd, strike, side = parse_option_inst_id(s)
|
||||||
|
if ymd is None:
|
||||||
|
ymd, strike, side = parse_option_symbol(s)
|
||||||
|
if ymd is None or strike is None or side not in ("C", "P"):
|
||||||
|
return None
|
||||||
|
other = flip_option_side(s)
|
||||||
|
if not other:
|
||||||
|
return None
|
||||||
|
call_id = s if side == "C" else other
|
||||||
|
put_id = s if side == "P" else other
|
||||||
|
try:
|
||||||
|
ems = expiry_ms_from_ymd(ymd)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return OptionPair(
|
||||||
|
expiry_ymd=ymd,
|
||||||
|
expiry_ms=int(ems),
|
||||||
|
strike=float(strike),
|
||||||
|
call_inst_id=call_id,
|
||||||
|
put_inst_id=put_id,
|
||||||
|
)
|
||||||
@@ -348,8 +348,12 @@ class Matcher:
|
|||||||
|
|
||||||
option_inst_id = str(pos["option_inst_id"])
|
option_inst_id = str(pos["option_inst_id"])
|
||||||
option_side = str(pos["option_side"])
|
option_side = str(pos["option_side"])
|
||||||
oq = get_exchange().quote(option_inst_id) or (
|
# 严禁回退到 ATM 对:持仓行权价可能已偏离当前 ATM
|
||||||
snap.call if option_side == "call" else snap.put
|
oq = self._quote_held_option(option_inst_id)
|
||||||
|
if oq is None and reason != "expiry":
|
||||||
|
return CloseResult(
|
||||||
|
ok=False,
|
||||||
|
detail=f"持仓期权盘口不可用: {option_inst_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
ct_mult = self._ct_mult(option_inst_id)
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
@@ -888,6 +892,29 @@ class Matcher:
|
|||||||
"forced": force,
|
"forced": force,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _quote_held_option(self, option_inst_id: str):
|
||||||
|
"""只取持仓合约盘口;缺失时 REST 补一次,绝不借用 ATM 对。"""
|
||||||
|
if not option_inst_id:
|
||||||
|
return None
|
||||||
|
ex = get_exchange()
|
||||||
|
oq = ex.quote(option_inst_id)
|
||||||
|
if oq is not None and (oq.bid is not None or oq.ask is not None or oq.mark_px is not None):
|
||||||
|
return oq
|
||||||
|
try:
|
||||||
|
bids, asks, ts = ex.fetch_book(option_inst_id, depth=5)
|
||||||
|
cache = getattr(ex, "cache", None)
|
||||||
|
if cache is not None and (bids or asks):
|
||||||
|
cache.upsert_book(option_inst_id, bids=bids, asks=asks, ts_ms=ts)
|
||||||
|
try:
|
||||||
|
mp = ex.fetch_mark(option_inst_id)
|
||||||
|
if mp:
|
||||||
|
cache.set_mark_px(option_inst_id, mp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ex.quote(option_inst_id)
|
||||||
|
except Exception:
|
||||||
|
return ex.quote(option_inst_id)
|
||||||
|
|
||||||
def unrealized(self) -> dict[str, Any]:
|
def unrealized(self) -> dict[str, Any]:
|
||||||
pos = self.current_position()
|
pos = self.current_position()
|
||||||
if pos.get("status") != "open":
|
if pos.get("status") != "open":
|
||||||
@@ -947,9 +974,7 @@ class Matcher:
|
|||||||
|
|
||||||
option_side = str(pos["option_side"])
|
option_side = str(pos["option_side"])
|
||||||
opt_inst = str(pos.get("option_inst_id") or "")
|
opt_inst = str(pos.get("option_inst_id") or "")
|
||||||
oq = get_exchange().quote(opt_inst) if opt_inst else None
|
oq = self._quote_held_option(opt_inst)
|
||||||
if oq is None:
|
|
||||||
oq = snap.call if option_side == "call" else snap.put
|
|
||||||
initial_premium = float(pos["initial_premium"] or 0)
|
initial_premium = float(pos["initial_premium"] or 0)
|
||||||
option_upl = 0.0
|
option_upl = 0.0
|
||||||
est_opt_close_fee = 0.0
|
est_opt_close_fee = 0.0
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any
|
|||||||
|
|
||||||
from ..config import Settings, get_settings
|
from ..config import Settings, get_settings
|
||||||
from ..exchange import get_exchange, set_exchange, build_exchange
|
from ..exchange import get_exchange, set_exchange, build_exchange
|
||||||
|
from ..exchange.option_ids import pair_from_option_inst
|
||||||
from ..exchange.protocol import ExchangeMarket
|
from ..exchange.protocol import ExchangeMarket
|
||||||
from ..exchange.types import MarketSnapshot, OptionPair
|
from ..exchange.types import MarketSnapshot, OptionPair
|
||||||
from .selection import (
|
from .selection import (
|
||||||
@@ -36,6 +37,22 @@ def _has_open_position() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _held_option_inst_id() -> str | None:
|
||||||
|
"""活跃持仓期权合约;无仓返回 None。"""
|
||||||
|
try:
|
||||||
|
from ..models.db import get_db
|
||||||
|
|
||||||
|
row = get_db().fetchone(
|
||||||
|
"SELECT status, option_inst_id FROM positions WHERE id=1"
|
||||||
|
)
|
||||||
|
if not row or row["status"] != "open":
|
||||||
|
return None
|
||||||
|
inst = str(row["option_inst_id"] or "").strip()
|
||||||
|
return inst or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _as_bool_setting(raw: str | None, default: bool) -> bool:
|
def _as_bool_setting(raw: str | None, default: bool) -> bool:
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
return default
|
return default
|
||||||
@@ -107,23 +124,40 @@ class StrategySession:
|
|||||||
def pair(self) -> OptionPair | None:
|
def pair(self) -> OptionPair | None:
|
||||||
return self._pair
|
return self._pair
|
||||||
|
|
||||||
|
def _watch_ids(self, pair: OptionPair | None = None) -> list[str]:
|
||||||
|
"""永续 + 监控对 + 持仓腿(有仓时绝不能 drop 持仓盘口)。"""
|
||||||
|
s = self.settings
|
||||||
|
p = pair if pair is not None else self._pair
|
||||||
|
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()
|
||||||
|
if held:
|
||||||
|
ids.append(held)
|
||||||
|
# 去重保序
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i in ids:
|
||||||
|
if i and i not in seen:
|
||||||
|
seen.add(i)
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._started:
|
if self._started:
|
||||||
return
|
return
|
||||||
self._started = True
|
self._started = True
|
||||||
await self.ex.start()
|
await self.ex.start()
|
||||||
try:
|
try:
|
||||||
|
# 有持仓时必须钉在持仓行权价,禁止重启后漂到新 ATM
|
||||||
|
if _has_open_position():
|
||||||
|
await asyncio.to_thread(self.align_to_held_position)
|
||||||
|
else:
|
||||||
await asyncio.to_thread(self.align_instruments)
|
await asyncio.to_thread(self.align_instruments)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# eapi 418/429 时允许先起会话,后续 refresh 再对齐
|
# eapi 418/429 时允许先起会话,后续 refresh 再对齐
|
||||||
logger.warning("initial ATM align failed (will retry): %s", e)
|
logger.warning("initial ATM align failed (will retry): %s", e)
|
||||||
await self.ex.resubscribe(
|
await self.ex.resubscribe(self._watch_ids())
|
||||||
[
|
|
||||||
self.settings.perp_inst_id,
|
|
||||||
self._pair.call_inst_id if self._pair else "",
|
|
||||||
self._pair.put_inst_id if self._pair else "",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="strategy-align")
|
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="strategy-align")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
@@ -143,8 +177,7 @@ class StrategySession:
|
|||||||
self.ex.set_pair(pair)
|
self.ex.set_pair(pair)
|
||||||
if idx is not None:
|
if idx is not None:
|
||||||
self.ex.set_index_px(idx)
|
self.ex.set_index_px(idx)
|
||||||
ids = [s.perp_inst_id, pair.call_inst_id, pair.put_inst_id]
|
self.ex.warm_and_subscribe(self._watch_ids(pair))
|
||||||
self.ex.warm_and_subscribe(ids)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"aligned pair exchange=%s expiry=%s strike=%s mark=%.2f hours=%.1f",
|
"aligned pair exchange=%s expiry=%s strike=%s mark=%.2f hours=%.1f",
|
||||||
getattr(self.ex, "name", "?"),
|
getattr(self.ex, "name", "?"),
|
||||||
@@ -155,7 +188,33 @@ class StrategySession:
|
|||||||
)
|
)
|
||||||
return pair
|
return pair
|
||||||
|
|
||||||
|
def align_to_held_position(self) -> OptionPair | None:
|
||||||
|
"""有活跃仓时:监控对锁定为持仓合约的到期/行权价。"""
|
||||||
|
held = _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)
|
||||||
|
idx = None
|
||||||
|
try:
|
||||||
|
idx = self.ex.fetch_index(self.settings.index_inst_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(
|
||||||
|
"pin watch to held option %s strike=%.0f expiry=%s",
|
||||||
|
held,
|
||||||
|
pair.strike,
|
||||||
|
pair.expiry_ymd,
|
||||||
|
)
|
||||||
|
return self._apply_pair(pair, mark=float(mark), idx=idx)
|
||||||
|
|
||||||
def align_instruments(self) -> OptionPair | None:
|
def align_instruments(self) -> OptionPair | None:
|
||||||
|
# 重启/刷新时若仍有仓,绝不切到新 ATM
|
||||||
|
if _has_open_position():
|
||||||
|
return self.align_to_held_position()
|
||||||
s = self.settings
|
s = self.settings
|
||||||
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
|
||||||
@@ -271,13 +330,7 @@ class StrategySession:
|
|||||||
or pair.put_inst_id != old.put_inst_id
|
or pair.put_inst_id != old.put_inst_id
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
await self.ex.resubscribe(
|
await self.ex.resubscribe(self._watch_ids(pair))
|
||||||
[
|
|
||||||
self.settings.perp_inst_id,
|
|
||||||
pair.call_inst_id,
|
|
||||||
pair.put_inst_id,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return pair
|
return pair
|
||||||
|
|
||||||
async def pick_for_open_async(self) -> OpenPick | None:
|
async def pick_for_open_async(self) -> OpenPick | None:
|
||||||
@@ -288,13 +341,7 @@ class StrategySession:
|
|||||||
or pick.pair.call_inst_id != old.call_inst_id
|
or pick.pair.call_inst_id != old.call_inst_id
|
||||||
or pick.pair.put_inst_id != old.put_inst_id
|
or pick.pair.put_inst_id != old.put_inst_id
|
||||||
):
|
):
|
||||||
await self.ex.resubscribe(
|
await self.ex.resubscribe(self._watch_ids(pick.pair))
|
||||||
[
|
|
||||||
self.settings.perp_inst_id,
|
|
||||||
pick.pair.call_inst_id,
|
|
||||||
pick.pair.put_inst_id,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return pick
|
return pick
|
||||||
|
|
||||||
def _mark_for_atm(self) -> float | None:
|
def _mark_for_atm(self) -> float | None:
|
||||||
@@ -324,6 +371,13 @@ class StrategySession:
|
|||||||
|
|
||||||
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
|
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
|
||||||
if _has_open_position():
|
if _has_open_position():
|
||||||
|
# 持仓期间:钉住持仓行权价(禁止漂到新 ATM)
|
||||||
|
held = _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)
|
||||||
|
):
|
||||||
|
return await asyncio.to_thread(self.align_to_held_position)
|
||||||
return self._pair
|
return self._pair
|
||||||
if force or self.atm_needs_realign():
|
if force or self.atm_needs_realign():
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -156,6 +156,26 @@ def test_deep_otm_and_expiry_settle() -> None:
|
|||||||
assert settled.notional == 0.0
|
assert settled.notional == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_from_held_option_inst() -> None:
|
||||||
|
from app.exchange.option_ids import flip_option_side, pair_from_option_inst
|
||||||
|
|
||||||
|
put = "ETH-USD_UM-260727-1880-P"
|
||||||
|
pair = pair_from_option_inst(put)
|
||||||
|
assert pair is not None
|
||||||
|
assert pair.strike == 1880
|
||||||
|
assert pair.expiry_ymd == "260727"
|
||||||
|
assert pair.put_inst_id == put
|
||||||
|
assert pair.call_inst_id == "ETH-USD_UM-260727-1880-C"
|
||||||
|
assert flip_option_side(put) == pair.call_inst_id
|
||||||
|
|
||||||
|
bn = "ETH-260727-1890-C"
|
||||||
|
bp = pair_from_option_inst(bn)
|
||||||
|
assert bp is not None
|
||||||
|
assert bp.strike == 1890
|
||||||
|
assert bp.call_inst_id == bn
|
||||||
|
assert bp.put_inst_id == "ETH-260727-1890-P"
|
||||||
|
|
||||||
|
|
||||||
def test_option_intrinsic_and_close_bid_floor() -> None:
|
def test_option_intrinsic_and_close_bid_floor() -> None:
|
||||||
from app.sim.pricing import (
|
from app.sim.pricing import (
|
||||||
option_expiry_settle,
|
option_expiry_settle,
|
||||||
|
|||||||
@@ -415,7 +415,8 @@ export default function PlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="plan-panel-title">
|
<h3 className="plan-panel-title">
|
||||||
期权 ATM {snap?.pair ? `@ ${snap.pair.strike}` : ""}
|
{open ? "持仓期权" : "期权 ATM"}
|
||||||
|
{snap?.pair ? ` @ ${snap.pair.strike}` : ""}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>Call 卖一/买一</span>
|
<span>Call 卖一/买一</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user