diff --git a/backend/app/exchange/option_ids.py b/backend/app/exchange/option_ids.py new file mode 100644 index 0000000..831fde3 --- /dev/null +++ b/backend/app/exchange/option_ids.py @@ -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, + ) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 96bd708..e5361cf 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -348,9 +348,13 @@ class Matcher: option_inst_id = str(pos["option_inst_id"]) option_side = str(pos["option_side"]) - oq = get_exchange().quote(option_inst_id) or ( - snap.call if option_side == "call" else snap.put - ) + # 严禁回退到 ATM 对:持仓行权价可能已偏离当前 ATM + 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) need_eth = float(pos["option_qty_eth"] or s.option_qty_eth) @@ -888,6 +892,29 @@ class Matcher: "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]: pos = self.current_position() if pos.get("status") != "open": @@ -947,9 +974,7 @@ class Matcher: option_side = str(pos["option_side"]) opt_inst = str(pos.get("option_inst_id") or "") - oq = get_exchange().quote(opt_inst) if opt_inst else None - if oq is None: - oq = snap.call if option_side == "call" else snap.put + oq = self._quote_held_option(opt_inst) initial_premium = float(pos["initial_premium"] or 0) option_upl = 0.0 est_opt_close_fee = 0.0 diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 74a08e3..ac8c8c4 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -9,6 +9,7 @@ from typing import Any from ..config import Settings, get_settings 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.types import MarketSnapshot, OptionPair from .selection import ( @@ -36,6 +37,22 @@ def _has_open_position() -> bool: 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: if raw is None or raw == "": return default @@ -107,23 +124,40 @@ class StrategySession: def pair(self) -> OptionPair | None: 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: if self._started: return self._started = True await self.ex.start() try: - await asyncio.to_thread(self.align_instruments) + # 有持仓时必须钉在持仓行权价,禁止重启后漂到新 ATM + if _has_open_position(): + await asyncio.to_thread(self.align_to_held_position) + else: + await asyncio.to_thread(self.align_instruments) except Exception as e: # eapi 418/429 时允许先起会话,后续 refresh 再对齐 logger.warning("initial ATM align failed (will retry): %s", e) - await self.ex.resubscribe( - [ - self.settings.perp_inst_id, - self._pair.call_inst_id if self._pair else "", - self._pair.put_inst_id if self._pair else "", - ] - ) + await self.ex.resubscribe(self._watch_ids()) self._refresh_task = asyncio.create_task(self._refresh_loop(), name="strategy-align") async def stop(self) -> None: @@ -143,8 +177,7 @@ class StrategySession: self.ex.set_pair(pair) if idx is not None: self.ex.set_index_px(idx) - ids = [s.perp_inst_id, pair.call_inst_id, pair.put_inst_id] - self.ex.warm_and_subscribe(ids) + self.ex.warm_and_subscribe(self._watch_ids(pair)) logger.info( "aligned pair exchange=%s expiry=%s strike=%s mark=%.2f hours=%.1f", getattr(self.ex, "name", "?"), @@ -155,7 +188,33 @@ class StrategySession: ) 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: + # 重启/刷新时若仍有仓,绝不切到新 ATM + if _has_open_position(): + return self.align_to_held_position() s = self.settings idx = self.ex.fetch_index(s.index_inst_id) 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 ) ): - await self.ex.resubscribe( - [ - self.settings.perp_inst_id, - pair.call_inst_id, - pair.put_inst_id, - ] - ) + await self.ex.resubscribe(self._watch_ids(pair)) return pair 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.put_inst_id != old.put_inst_id ): - await self.ex.resubscribe( - [ - self.settings.perp_inst_id, - pick.pair.call_inst_id, - pick.pair.put_inst_id, - ] - ) + await self.ex.resubscribe(self._watch_ids(pick.pair)) return pick def _mark_for_atm(self) -> float | None: @@ -324,6 +371,13 @@ class StrategySession: async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: 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 if force or self.atm_needs_realign(): logger.info( diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index fc10b78..f1e333a 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -156,6 +156,26 @@ def test_deep_otm_and_expiry_settle() -> None: 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: from app.sim.pricing import ( option_expiry_settle, diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index f9d851c..48ca7bf 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -415,7 +415,8 @@ export default function PlanPage() {

- 期权 ATM {snap?.pair ? `@ ${snap.pair.strike}` : ""} + {open ? "持仓期权" : "期权 ATM"} + {snap?.pair ? ` @ ${snap.pair.strike}` : ""}

Call 卖一/买一