From 7db4c9c8a386ebff382d402e80bb4ebb8e18a2c5 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 25 Jul 2026 07:52:37 +0800 Subject: [PATCH] Realign ATM to current mark before open; skip while in position. Co-authored-by: Cursor --- backend/app/api/sim.py | 5 +++ backend/app/market/gateway.py | 60 +++++++++++++++++++++++++++++++--- backend/app/sim/matcher.py | 6 +++- backend/app/strategy/engine.py | 5 +++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index e07860e..9655ef4 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -38,6 +38,11 @@ async def sim_open_group( body: ManualOpenBody | None = None, ) -> dict: gw = get_gateway() + # 开仓前按现价强制重选 ATM,避免沿用启动时的旧行权价 + try: + await gw.ensure_atm_async(force=True) + except Exception as e: + raise HTTPException(status_code=503, detail=f"ATM 对齐失败: {e}") from e snap = gw.snapshot() if not snap.pair or not snap.call or not snap.put: raise HTTPException(status_code=503, detail="行情未就绪") diff --git a/backend/app/market/gateway.py b/backend/app/market/gateway.py index fca4d3b..f4854aa 100644 --- a/backend/app/market/gateway.py +++ b/backend/app/market/gateway.py @@ -15,6 +15,19 @@ from .types import MarketSnapshot, OptionPair logger = logging.getLogger(__name__) +# 现价偏离当前行权价超过该点数则重选 ATM(ETH 期权常见步进 5) +_ATM_DRIFT_POINTS = 5.0 + + +def _has_open_position() -> bool: + try: + from ..models.db import get_db + + row = get_db().fetchone("SELECT status FROM positions WHERE id=1") + return bool(row and row["status"] == "open") + except Exception: + return False + class MarketGateway: def __init__(self, settings: Settings | None = None) -> None: @@ -109,6 +122,41 @@ class MarketGateway: ) return pair + def _mark_for_atm(self) -> float | None: + snap = self.snapshot() + if snap.perp and snap.perp.mark_px: + return float(snap.perp.mark_px) + if snap.index_px: + return float(snap.index_px) + if snap.perp and snap.perp.bid and snap.perp.ask: + return (float(snap.perp.bid) + float(snap.perp.ask)) / 2 + return None + + def atm_needs_realign(self, mark_px: float | None = None) -> bool: + """到期日变了,或现价已偏离当前行权价超过阈值。""" + if self._pair is None: + return True + want = next_session_expiry_ymd() + if self._pair.expiry_ymd != want: + return True + mark = mark_px if mark_px is not None else self._mark_for_atm() + if mark is None or mark <= 0: + return False + return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS + + async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: + """空仓时按现价对齐 ATM。有持仓时不切换,避免盯市合约被换掉。""" + if _has_open_position(): + return self._pair + if force or self.atm_needs_realign(): + logger.info( + "ATM realign force=%s old_strike=%s", + force, + self._pair.strike if self._pair else None, + ) + return await self.realign_async() + return self._pair + def snapshot(self) -> MarketSnapshot: return self.cache.snapshot(self.settings.perp_inst_id) @@ -116,7 +164,7 @@ class MarketGateway: return self.snapshot().to_dict() async def _refresh_loop(self) -> None: - """周期性刷新指数价;跨日到期切换时重对齐。""" + """周期性刷新指数价;空仓时按到期/ATM 偏离重对齐。""" while True: await asyncio.sleep(30) try: @@ -124,10 +172,12 @@ class MarketGateway: self.rest.fetch_index_ticker, self.settings.index_inst_id ) self.cache.set_index_px(idx) - want = next_session_expiry_ymd() - if self._pair and self._pair.expiry_ymd != want: - logger.info("expiry rollover %s -> %s", self._pair.expiry_ymd, want) - await self.realign_async() + mark = await asyncio.to_thread( + self.rest.fetch_mark_price, self.settings.perp_inst_id + ) + if mark: + self.cache.set_mark_px(self.settings.perp_inst_id, mark) + await self.ensure_atm_async(force=False) except asyncio.CancelledError: raise except Exception as e: diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index e66fe8c..59c0dfa 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -412,7 +412,11 @@ class Matcher: perp_upl = (perp_entry - float(mark)) * perp_qty option_side = str(pos["option_side"]) - oq = snap.call if option_side == "call" else snap.put + # 优先用持仓合约盘口,避免 ATM 切换后盯错合约 + opt_inst = str(pos.get("option_inst_id") or "") + oq = gw.cache.get(opt_inst) if opt_inst else None + if oq is None: + oq = snap.call if option_side == "call" else snap.put opt_mark = None if oq: opt_mark = oq.bid or oq.mark_px diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 4f66969..3743267 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -135,6 +135,11 @@ class StrategyEngine: await asyncio.sleep(1) continue async with self._lock: + # 空仓且 ATM 偏离现价时先重选,再跑开仓逻辑 + try: + await get_gateway().ensure_atm_async(force=False) + except Exception as e: + logger.warning("ATM ensure before tick failed: %s", e) await asyncio.to_thread(self._tick) except asyncio.CancelledError: raise