diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 65201ad..0d6f41f 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -64,6 +64,13 @@ KEYS = ( "martingale_enabled", "martingale_start_after_loss_days", "martingale_max_doubles", + "hedge_mode", + "oo_amplitude_pct", + "oo_amplitude_hours", + "oo_min_option_hours", + "oo_min_leverage", + "oo_reward_ratio", + "oo_budget_cushion", ) @@ -110,6 +117,15 @@ class StrategySettingsBody(BaseModel): martingale_enabled: bool | None = None martingale_start_after_loss_days: int | None = Field(default=None, ge=1, le=30) martingale_max_doubles: int | None = Field(default=None, ge=1, le=10) + hedge_mode: str | None = Field( + default=None, pattern="^(perp_option|option_option)$" + ) + oo_amplitude_pct: float | None = Field(default=None, ge=0.1, le=50) + oo_amplitude_hours: float | None = Field(default=None, ge=1, le=168) + oo_min_option_hours: float | None = Field(default=None, ge=1, le=720) + oo_min_leverage: float | None = Field(default=None, ge=1, le=10000) + oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20) + oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0) def _as_bool(raw: str | None, default: bool) -> bool: @@ -327,6 +343,42 @@ def _read_settings() -> dict: or s.martingale_max_doubles ) ), + "hedge_mode": ( + hm + if ( + hm := str( + db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode + ) + .strip() + .lower() + ) + in ("perp_option", "option_option") + else "perp_option" + ), + "oo_amplitude_pct": float( + db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct)) + or s.oo_amplitude_pct + ), + "oo_amplitude_hours": float( + db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours)) + or s.oo_amplitude_hours + ), + "oo_min_option_hours": float( + db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours)) + or s.oo_min_option_hours + ), + "oo_min_leverage": float( + db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) + or s.oo_min_leverage + ), + "oo_reward_ratio": float( + db.get_setting("oo_reward_ratio", str(s.oo_reward_ratio)) + or s.oo_reward_ratio + ), + "oo_budget_cushion": float( + db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion)) + or s.oo_budget_cushion + ), "risk_sizing_preview": _risk_preview_safe(), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, @@ -410,6 +462,13 @@ async def put_strategy_settings( "martingale_enabled", "martingale_start_after_loss_days", "martingale_max_doubles", + "hedge_mode", + "oo_amplitude_pct", + "oo_amplitude_hours", + "oo_min_option_hours", + "oo_min_leverage", + "oo_reward_ratio", + "oo_budget_cushion", ) hit = [k for k in locked_keys if k in data] if hit: @@ -419,6 +478,23 @@ async def put_strategy_settings( ) # 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场 + hedge_mode = str( + data.get( + "hedge_mode", + db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode, + ) + ).strip().lower() + if hedge_mode not in ("perp_option", "option_option"): + hedge_mode = "perp_option" + data["hedge_mode"] = hedge_mode + if hedge_mode == "option_option": + # 期期:强制以损定仓 + 亏损幅度% + data["sizing_mode"] = "risk_based" + data["risk_loss_mode"] = "percent" + data["exit_mode"] = "fixed_usdt" + data.pop("perp_qty_eth", None) + data["fixed_direction_enabled"] = False + sizing_mode = str( data.get( "sizing_mode", diff --git a/backend/app/config.py b/backend/app/config.py index 64ae9d6..c405734 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -80,6 +80,14 @@ class Settings(BaseSettings): martingale_enabled: bool = False martingale_start_after_loss_days: int = 2 # 连续亏损 N 天后开始翻倍 martingale_max_doubles: int = 3 # 最多翻倍次数(如 2→4→8→16 为 3 次) + # 对冲模式:perp_option=永期(默认)| option_option=期期 + hedge_mode: str = "perp_option" + oo_amplitude_pct: float = 1.5 # 振幅最小 %(回看窗内高低) + oo_amplitude_hours: float = 12.0 # 振幅回看小时 + oo_min_option_hours: float = 24.0 # 期期:最短剩余到期小时 + oo_min_leverage: float = 200.0 # 期期:单腿最低杠杆 + oo_reward_ratio: float = 2.0 # 盈亏比:出场目标 = 预算 × 比 + oo_budget_cushion: float = 0.92 # 定仓预留余地(用于权利金的预算比例) atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关) max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点) # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 diff --git a/backend/app/exchange/candles.py b/backend/app/exchange/candles.py new file mode 100644 index 0000000..883a9fa --- /dev/null +++ b/backend/app/exchange/candles.py @@ -0,0 +1,185 @@ +"""指数/永续 K 线高低点:期期对冲振幅回看。""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from .okx.parse import safe_float as okx_safe_float +from .binance.parse import safe_float as bn_safe_float + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class AmplitudeHL: + high: float + low: float + mid: float + hours: float + bar_count: int + + @property + def range_pct(self) -> float: + if self.mid <= 0: + return 0.0 + return (self.high - self.low) / self.mid * 100.0 + + +def _hl_from_okx_candles(rows: list[Any]) -> tuple[float, float] | None: + """OKX candle row: [ts, o, h, l, c, ...] newest first.""" + highs: list[float] = [] + lows: list[float] = [] + for row in rows: + if not isinstance(row, (list, tuple)) or len(row) < 5: + continue + h = okx_safe_float(row[2]) + lo = okx_safe_float(row[3]) + if h is None or lo is None or h <= 0 or lo <= 0: + continue + highs.append(float(h)) + lows.append(float(lo)) + if not highs or not lows: + return None + return max(highs), min(lows) + + +def _hl_from_binance_klines(rows: list[Any]) -> tuple[float, float] | None: + """Binance kline: [openTime, o, h, l, c, ...] oldest first.""" + highs: list[float] = [] + lows: list[float] = [] + for row in rows: + if not isinstance(row, (list, tuple)) or len(row) < 5: + continue + h = bn_safe_float(row[2]) + lo = bn_safe_float(row[3]) + if h is None or lo is None or h <= 0 or lo <= 0: + continue + highs.append(float(h)) + lows.append(float(lo)) + if not highs or not lows: + return None + return max(highs), min(lows) + + +def fetch_okx_amplitude_hl( + *, + inst_id: str, + hours: float, + base_url: str = "https://www.okx.com", + proxy: str | None = None, +) -> AmplitudeHL | None: + """用 1H K 线回看 hours;inst 可用指数 ETH-USD 或永续 ETH-USDT-SWAP。""" + import math + + import httpx + + hrs = max(1.0, float(hours)) + limit = int(min(300, max(2, math.ceil(hrs) + 1))) + try: + with httpx.Client( + base_url=base_url.rstrip("/"), + timeout=15.0, + proxy=(proxy or "").strip() or None, + headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"}, + ) as client: + r = client.get( + "/api/v5/market/candles", + params={"instId": inst_id, "bar": "1H", "limit": str(limit)}, + ) + r.raise_for_status() + body = r.json() + if str(body.get("code")) != "0": + logger.warning("OKX candles error: %s", body.get("msg")) + return None + data = body.get("data") or [] + except Exception as e: + logger.warning("OKX candles fetch failed: %s", e) + return None + hl = _hl_from_okx_candles(data) + if hl is None: + return None + high, low = hl + mid = (high + low) / 2.0 + return AmplitudeHL( + high=high, low=low, mid=mid, hours=hrs, bar_count=len(data) + ) + + +def fetch_binance_amplitude_hl( + *, + symbol: str, + hours: float, + fapi_base: str = "https://fapi.binance.com", + proxy: str | None = None, +) -> AmplitudeHL | None: + """USDT 永续 1h klines。""" + import math + + import httpx + + hrs = max(1.0, float(hours)) + limit = int(min(500, max(2, math.ceil(hrs) + 1))) + sym = str(symbol or "ETHUSDT").upper().replace("-", "") + try: + with httpx.Client( + base_url=fapi_base.rstrip("/"), + timeout=15.0, + proxy=(proxy or "").strip() or None, + headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"}, + trust_env=False, + ) as client: + r = client.get( + "/fapi/v1/klines", + params={"symbol": sym, "interval": "1h", "limit": limit}, + ) + r.raise_for_status() + data = r.json() + if not isinstance(data, list): + return None + except Exception as e: + logger.warning("Binance klines fetch failed: %s", e) + return None + hl = _hl_from_binance_klines(data) + if hl is None: + return None + high, low = hl + mid = (high + low) / 2.0 + return AmplitudeHL( + high=high, low=low, mid=mid, hours=hrs, bar_count=len(data) + ) + + +def fetch_amplitude_hl_for_runtime(hours: float) -> AmplitudeHL | None: + """按当前交易所 runtime 拉振幅高低点。""" + from ..config import get_settings + from .runtime import load_runtime_settings + + s = get_settings() + rt = load_runtime_settings() + ex = str(rt.exchange or "okx").strip().lower() + hrs = float(hours) + if ex in ("binance", "bn"): + return fetch_binance_amplitude_hl( + symbol=str(rt.perp_inst_id or "ETHUSDT"), + hours=hrs, + fapi_base=s.binance_fapi_base, + proxy=s.binance_http_proxy or None, + ) + # OKX:优先指数,失败再试永续 + idx = str(rt.index_inst_id or "ETH-USD") + amp = fetch_okx_amplitude_hl( + inst_id=idx, + hours=hrs, + base_url=s.okx_rest_base, + proxy=s.okx_http_proxy or None, + ) + if amp is not None: + return amp + return fetch_okx_amplitude_hl( + inst_id=str(rt.perp_inst_id or "ETH-USDT-SWAP"), + hours=hrs, + base_url=s.okx_rest_base, + proxy=s.okx_http_proxy or None, + ) diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 7c0964a..5482ab6 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -718,6 +718,249 @@ class BinanceLiveExecutor(Matcher): data={"group_id": group_id, "exec_mode": "LIVE"}, ) + def open_oo_group( + self, + *, + group_id: str, + call_inst_id: str, + put_inst_id: str, + call_strike: float, + put_strike: float, + entry_index_px: float, + expiry_ymd: str | None = None, + ) -> OpenResult: + """期期 LIVE(币安):先买 Call 再买 Put。""" + err = self._guard_live() + if err: + return OpenResult(ok=False, detail=err) + claimed, claim_msg = claim_open_slot(self.db) + if not claimed: + return OpenResult(ok=False, detail=claim_msg) + safe, safe_msg = assert_safe_to_open_live(self) + if not safe: + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=safe_msg) + + s = live_settings() + client = self._client() + opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth) + call_ct = self._ct_mult(call_inst_id) + put_ct = self._ct_mult(put_inst_id) + call_contracts = contracts_for_eth(opt_qty, call_ct) + put_contracts = contracts_for_eth(opt_qty, put_ct) + stamp_opening_intent( + self.db, + group_id=group_id, + option_inst_id=call_inst_id, + option_side="call", + perp_side=f"oo_put:{put_inst_id}", + option_qty_eth=opt_qty, + option_qty_contracts=float(call_contracts), + entry_index_px=entry_index_px, + ) + try: + call_fill = client.place_option_market( + symbol=call_inst_id, side="BUY", quantity=call_contracts + ) + except Exception as e: + if "orderId=" not in str(e): + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=f"期期开 Call 失败: {e}") + call_contracts = ( + float(call_fill.sz) + if call_fill.sz and call_fill.sz > 0 + else float(call_contracts) + ) + opt_qty = eth_from_contracts(call_contracts, call_ct) + put_contracts = contracts_for_eth(opt_qty, put_ct) + try: + put_fill = client.place_option_market( + symbol=put_inst_id, side="BUY", quantity=put_contracts + ) + except Exception as e: + try: + client.place_option_market( + symbol=call_inst_id, side="SELL", quantity=call_contracts + ) + except Exception as e2: + logger.exception("bn oo call rollback failed: %s", e2) + return OpenResult( + ok=False, + detail=f"期期 Put 失败且 Call 回滚未确认: {e}", + ) + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=f"期期开 Put 失败已回滚 Call: {e}") + + put_contracts = ( + float(put_fill.sz) + if put_fill.sz and put_fill.sz > 0 + else float(put_contracts) + ) + of_px = float(call_fill.avg_px) + pf_px = float(put_fill.avg_px) + qty2 = eth_from_contracts(put_contracts, put_ct) + call_prem = of_px * opt_qty + put_prem = pf_px * qty2 + now = int(time.time() * 1000) + with self.db._lock: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode, hedge_mode, option2_inst_id, option2_side, strike2, initial_premium2 + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "open", + "option_option", + "call", + None, + call_inst_id, + None, + float(call_strike), + expiry_ymd, + entry_index_px, + call_prem, + now, + float(getattr(call_fill, "fee", 0) or 0) + + float(getattr(put_fill, "fee", 0) or 0), + 0.0, + "LIVE", + "option_option", + put_inst_id, + "put", + float(put_strike), + put_prem, + ), + ) + for leg, inst, contracts, fill_px, fee, ts, q in ( + ("option", call_inst_id, call_contracts, of_px, getattr(call_fill, "fee", 0), now, opt_qty), + ("option2", put_inst_id, put_contracts, pf_px, getattr(put_fill, "fee", 0), now + 1, qty2), + ): + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + leg, + "open", + "long", + inst, + q, + contracts, + fill_px, + fill_px, + float(fee or 0), + 0.0, + float(fill_px) * float(q), + ts, + "LIVE", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=?, option_side='call', option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status='open', + hedge_mode='option_option', option2_inst_id=?, option2_side='put', + option2_qty_eth=?, option2_qty_contracts=?, option2_entry_px=?, + strike2=?, initial_premium2=? + WHERE id=1""", + ( + group_id, + call_inst_id, + opt_qty, + call_contracts, + of_px, + entry_index_px, + call_prem, + put_inst_id, + qty2, + put_contracts, + pf_px, + float(put_strike), + put_prem, + ), + ) + self.db._conn.commit() + try: + from ..strategy.exits import lock_trade_exit_target + + lock_trade_exit_target( + self.db, group_id=group_id, initial_premium=call_prem + put_prem + ) + except Exception: + logger.exception("lock exit oo bn failed") + return OpenResult( + ok=True, + group_id=group_id, + detail="opened_oo_live_bn", + data={"hedge_mode": "option_option", "exec_mode": "LIVE"}, + ) + + def live_sell_oo_both(self, *, bypass_liquidity: bool = False) -> None: + pos = self.current_position() + client = self._client() + for inst, contracts in ( + (str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_contracts") or 0)), + (str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_contracts") or 0)), + ): + if not inst or contracts <= 0: + continue + try: + client.place_option_market( + symbol=inst, side="SELL", quantity=contracts + ) + except Exception: + logger.exception("bn live_sell_oo_both failed inst=%s", inst) + if not bypass_liquidity: + raise + + def close_winning_oo_leave_residual( + self, *, reason: str = "target_oo_win" + ) -> CloseResult: + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + pos = self.current_position() + if str(pos.get("status") or "") == "closing": + return super().close_winning_oo_leave_residual( + reason=reason, skip_market=True + ) + if str(pos.get("status") or "") != "open" or not pos.get("option2_inst_id"): + return CloseResult(ok=False, detail="无期期持仓") + upl = self.unrealized() + call_upl = float(upl.get("option_upl") or 0) + put_upl = float(upl.get("option2_upl") or 0) + if call_upl >= put_upl and call_upl > 0: + win_id = str(pos["option_inst_id"]) + win_contracts = float(pos.get("option_qty_contracts") or 0) + elif put_upl > 0: + win_id = str(pos["option2_inst_id"]) + win_contracts = float(pos.get("option2_qty_contracts") or 0) + else: + return CloseResult(ok=False, detail="无明确盈利腿") + with self.db._lock: + self.db._conn.execute( + "UPDATE positions SET status='closing' WHERE id=1 AND status='open'" + ) + self.db._conn.commit() + try: + self._client().place_option_market( + symbol=win_id, side="SELL", quantity=win_contracts + ) + except Exception as e: + with self.db._lock: + self.db._conn.execute( + "UPDATE positions SET status='open' WHERE id=1 AND status='closing'" + ) + self.db._conn.commit() + return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}") + return super().close_winning_oo_leave_residual( + reason=reason, skip_market=True + ) + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: err = self._guard_live() if err: diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 264e85e..b331b84 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -746,6 +746,273 @@ class OkxLiveExecutor(Matcher): data={"group_id": group_id, "exec_mode": "LIVE"}, ) + def open_oo_group( + self, + *, + group_id: str, + call_inst_id: str, + put_inst_id: str, + call_strike: float, + put_strike: float, + entry_index_px: float, + expiry_ymd: str | None = None, + ) -> OpenResult: + """期期 LIVE:先买 Call 再买 Put。""" + err = self._guard_live() + if err: + return OpenResult(ok=False, detail=err) + claimed, claim_msg = claim_open_slot(self.db) + if not claimed: + return OpenResult(ok=False, detail=claim_msg) + safe, safe_msg = assert_safe_to_open_live(self) + if not safe: + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=safe_msg) + + s = live_settings() + client = self._client() + opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth) + call_ct = self._ct_mult(call_inst_id) + put_ct = self._ct_mult(put_inst_id) + call_contracts = contracts_for_eth(opt_qty, call_ct) + put_contracts = contracts_for_eth(opt_qty, put_ct) + stamp_opening_intent( + self.db, + group_id=group_id, + option_inst_id=call_inst_id, + option_side="call", + perp_side=f"oo_put:{put_inst_id}", + option_qty_eth=opt_qty, + option_qty_contracts=float(call_contracts), + entry_index_px=entry_index_px, + ) + try: + call_fill = client.place_market( + inst_id=call_inst_id, + side="buy", + sz=str(int(round(call_contracts))), + td_mode="cash", + ) + except Exception as e: + logger.exception("live oo open call failed") + if "ordId=" not in str(e): + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=f"期期开 Call 失败: {e}") + + call_contracts = ( + float(call_fill.sz) + if call_fill.sz and call_fill.sz > 0 + else float(int(round(call_contracts))) + ) + opt_qty = eth_from_contracts(call_contracts, call_ct) + put_contracts = contracts_for_eth(opt_qty, put_ct) + try: + put_fill = client.place_market( + inst_id=put_inst_id, + side="buy", + sz=str(int(round(put_contracts))), + td_mode="cash", + ) + except Exception as e: + logger.exception("live oo open put failed; rolling back call") + try: + client.place_market( + inst_id=call_inst_id, + side="sell", + sz=str(int(round(call_contracts))), + td_mode="cash", + ) + except Exception as e2: + logger.exception("oo call rollback failed: %s", e2) + return OpenResult( + ok=False, + detail=f"期期 Put 失败且 Call 回滚未确认(保留 opening): {e}", + ) + release_open_slot_if_opening(self.db) + return OpenResult(ok=False, detail=f"期期开 Put 失败已回滚 Call: {e}") + + put_contracts = ( + float(put_fill.sz) + if put_fill.sz and put_fill.sz > 0 + else float(int(round(put_contracts))) + ) + of_px = float(call_fill.avg_px) + pf_px = float(put_fill.avg_px) + call_prem = of_px * opt_qty + put_prem = pf_px * eth_from_contracts(put_contracts, put_ct) + # 等量:以 Call 成交名义为准 + qty2 = eth_from_contracts(put_contracts, put_ct) + now = int(time.time() * 1000) + with self.db._lock: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode, hedge_mode, option2_inst_id, option2_side, strike2, initial_premium2 + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "open", + "option_option", + "call", + None, + call_inst_id, + None, + float(call_strike), + expiry_ymd, + entry_index_px, + call_prem, + now, + float(getattr(call_fill, "fee", 0) or 0) + + float(getattr(put_fill, "fee", 0) or 0), + 0.0, + "LIVE", + "option_option", + put_inst_id, + "put", + float(put_strike), + put_prem, + ), + ) + for leg, inst, contracts, fill_px, fee, ts in ( + ("option", call_inst_id, call_contracts, of_px, getattr(call_fill, "fee", 0), now), + ("option2", put_inst_id, put_contracts, pf_px, getattr(put_fill, "fee", 0), now + 1), + ): + q = opt_qty if leg == "option" else qty2 + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + leg, + "open", + "long", + inst, + q, + contracts, + fill_px, + fill_px, + float(fee or 0), + 0.0, + float(fill_px) * float(q), + ts, + "LIVE", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=?, option_side='call', option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status='open', + hedge_mode='option_option', option2_inst_id=?, option2_side='put', + option2_qty_eth=?, option2_qty_contracts=?, option2_entry_px=?, + strike2=?, initial_premium2=? + WHERE id=1""", + ( + group_id, + call_inst_id, + opt_qty, + call_contracts, + of_px, + entry_index_px, + call_prem, + put_inst_id, + qty2, + put_contracts, + pf_px, + float(put_strike), + put_prem, + ), + ) + self.db._conn.commit() + try: + from ..strategy.exits import lock_trade_exit_target + + lock_trade_exit_target( + self.db, group_id=group_id, initial_premium=call_prem + put_prem + ) + except Exception: + logger.exception("lock exit oo live failed") + return OpenResult( + ok=True, + group_id=group_id, + detail="opened_oo_live", + data={"hedge_mode": "option_option", "exec_mode": "LIVE"}, + ) + + def live_sell_oo_both(self, *, bypass_liquidity: bool = False) -> None: + """到期/紧急:交易所市价卖掉 Call+Put。""" + pos = self.current_position() + client = self._client() + for inst, contracts in ( + (str(pos.get("option_inst_id") or ""), float(pos.get("option_qty_contracts") or 0)), + (str(pos.get("option2_inst_id") or ""), float(pos.get("option2_qty_contracts") or 0)), + ): + if not inst or contracts <= 0: + continue + try: + client.place_market( + inst_id=inst, + side="sell", + sz=str(int(round(contracts))), + td_mode="cash", + ) + except Exception: + logger.exception("live_sell_oo_both failed inst=%s", inst) + if not bypass_liquidity: + raise + + def close_winning_oo_leave_residual( + self, *, reason: str = "target_oo_win" + ) -> CloseResult: + """期期达标:先标记 closing,再交易所卖掉盈利腿,再落库。""" + err = self._guard_live() + if err: + return CloseResult(ok=False, detail=err) + pos = self.current_position() + if str(pos.get("status") or "") != "open" or not pos.get("option2_inst_id"): + return CloseResult(ok=False, detail="无期期持仓") + # 防重入:已在 closing 则只做账本收尾 + if str(pos.get("status") or "") == "closing": + return super().close_winning_oo_leave_residual( + reason=reason, skip_market=True + ) + upl = self.unrealized() + call_upl = float(upl.get("option_upl") or 0) + put_upl = float(upl.get("option2_upl") or 0) + if call_upl >= put_upl and call_upl > 0: + win_id = str(pos["option_inst_id"]) + win_contracts = float(pos.get("option_qty_contracts") or 0) + elif put_upl > 0: + win_id = str(pos["option2_inst_id"]) + win_contracts = float(pos.get("option2_qty_contracts") or 0) + else: + return CloseResult(ok=False, detail="无明确盈利腿") + with self.db._lock: + self.db._conn.execute( + "UPDATE positions SET status='closing' WHERE id=1 AND status='open'" + ) + self.db._conn.commit() + client = self._client() + try: + client.place_market( + inst_id=win_id, + side="sell", + sz=str(int(round(win_contracts))), + td_mode="cash", + ) + except Exception as e: + with self.db._lock: + self.db._conn.execute( + "UPDATE positions SET status='open' WHERE id=1 AND status='closing'" + ) + self.db._conn.commit() + return CloseResult(ok=False, detail=f"期期平盈利腿失败: {e}") + return super().close_winning_oo_leave_residual( + reason=reason, skip_market=True + ) + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: err = self._guard_live() if err: diff --git a/backend/app/models/db.py b/backend/app/models/db.py index ae9b848..515bbeb 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -169,7 +169,20 @@ class Database: ("groups", "settle_index_px", "REAL"), ("groups", "perp_margin_mode", "TEXT"), ("groups", "exit_target_usdt", "REAL"), + ("groups", "hedge_mode", "TEXT"), + ("groups", "option2_inst_id", "TEXT"), + ("groups", "option2_side", "TEXT"), + ("groups", "strike2", "REAL"), + ("groups", "initial_premium2", "REAL"), ("positions", "exit_target_usdt", "REAL"), + ("positions", "hedge_mode", "TEXT"), + ("positions", "option2_inst_id", "TEXT"), + ("positions", "option2_side", "TEXT"), + ("positions", "option2_qty_eth", "REAL"), + ("positions", "option2_qty_contracts", "REAL"), + ("positions", "option2_entry_px", "REAL"), + ("positions", "strike2", "REAL"), + ("positions", "initial_premium2", "REAL"), ("fills", "exec_mode", "TEXT"), ("fills", "fee_ccy", "TEXT"), ): diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 15ba1a4..384edee 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -381,6 +381,609 @@ class Matcher: }, ) + def open_oo_group( + self, + *, + group_id: str, + call_inst_id: str, + put_inst_id: str, + call_strike: float, + put_strike: float, + entry_index_px: float, + expiry_ymd: str | None = None, + ) -> OpenResult: + """期期:买 Call 再买 Put,无永续。""" + if not get_settings().is_sim: + return OpenResult( + ok=False, + detail="LIVE 期期开仓须走 LiveExecutor.open_oo_group", + ) + pos = self.current_position() + st = str(pos.get("status") or "flat") + if st in BLOCKING_STATUSES and ( + st == "opening" or bool(pos.get("group_id") or pos.get("option_inst_id")) + ): + return OpenResult(ok=False, detail=f"已有持仓状态({st}),请先平仓") + if pos.get("status") == "open" and pos.get("group_id"): + return OpenResult(ok=False, detail="已有持仓组,请先平仓") + + ex = get_exchange() + cq = ex.quote(call_inst_id) + pq = ex.quote(put_inst_id) + if cq is None or cq.ask is None: + _, asks, _ = ex.fetch_book(call_inst_id, depth=5) + if asks: + from types import SimpleNamespace + + cq = SimpleNamespace(ask=asks[0].px, bid=None) + if pq is None or pq.ask is None: + _, asks, _ = ex.fetch_book(put_inst_id, depth=5) + if asks: + from types import SimpleNamespace + + pq = SimpleNamespace(ask=asks[0].px, bid=None) + if not cq or cq.ask is None or not pq or pq.ask is None: + return OpenResult(ok=False, detail="期期 Call/Put 卖一不可用") + + fee_rate = self._fee_rate() + opt_qty = self.ledger.get_setting_float("option_qty_eth", 0.1) + if opt_qty < 0.1 - 1e-12: + return OpenResult(ok=False, detail="期期名义 qty 无效") + call_ct = self._ct_mult(call_inst_id) + put_ct = self._ct_mult(put_inst_id) + call_contracts = contracts_for_eth(opt_qty, call_ct) + put_contracts = contracts_for_eth(opt_qty, put_ct) + + cf = option_fill( + action="open", + bid=float(getattr(cq, "bid", None) or 0), + ask=float(cq.ask), + qty_eth=opt_qty, + fee_rate=fee_rate, + ) + call_prem = cf.fill_px * opt_qty + call_cost = cf.notional + cf.fee + try: + self.ledger.apply_cash( + -call_cost, + kind="open_option", + group_id=group_id, + note=f"open oo call {group_id}", + ) + except RuntimeError as e: + return OpenResult(ok=False, detail=str(e)) + + # 再买 Put;失败则尝试卖回 Call + pq2 = ex.quote(put_inst_id) or pq + ask2 = float(pq2.ask) if pq2 and pq2.ask else float(pq.ask) + pf = option_fill( + action="open", + bid=float(getattr(pq2, "bid", None) or 0), + ask=ask2, + qty_eth=opt_qty, + fee_rate=fee_rate, + ) + put_prem = pf.fill_px * opt_qty + put_cost = pf.notional + pf.fee + try: + self.ledger.apply_cash( + -put_cost, + kind="open_option", + group_id=group_id, + note=f"open oo put {group_id}", + ) + except RuntimeError as e: + # 回滚 Call:按买一卖出估算 + bid = float(getattr(cq, "bid", None) or cf.fill_px) + rb = option_fill( + action="close", + bid=bid, + ask=float(cq.ask), + qty_eth=opt_qty, + fee_rate=fee_rate, + ) + self.ledger.apply_cash( + rb.notional - rb.fee, + kind="open_option_rollback", + group_id=group_id, + note=f"rollback oo call {group_id}: {e}", + ) + return OpenResult(ok=False, detail=f"Call 已成交但 Put 扣费失败并已回滚: {e}") + + now = int(time.time() * 1000) + total_prem = call_prem + put_prem + with self.db._lock: + self.db._conn.execute( + """INSERT INTO groups( + group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode, hedge_mode, option2_inst_id, option2_side, strike2, initial_premium2 + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "open", + "option_option", + "call", + None, + call_inst_id, + None, + float(call_strike), + expiry_ymd, + entry_index_px, + call_prem, + now, + cf.fee + pf.fee, + cf.slip + pf.slip, + "SIM", + "option_option", + put_inst_id, + "put", + float(put_strike), + put_prem, + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option", + "open", + "long", + call_inst_id, + opt_qty, + call_contracts, + cf.base_px, + cf.fill_px, + cf.fee, + cf.slip, + cf.notional, + now, + "SIM", + ), + ) + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + "option2", + "open", + "long", + put_inst_id, + opt_qty, + put_contracts, + pf.base_px, + pf.fill_px, + pf.fee, + pf.slip, + pf.notional, + now + 1, + "SIM", + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=?, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?, + option_entry_px=?, entry_index_px=?, initial_premium=?, status=?, + hedge_mode=?, option2_inst_id=?, option2_side=?, option2_qty_eth=?, + option2_qty_contracts=?, option2_entry_px=?, strike2=?, initial_premium2=? + WHERE id=1""", + ( + group_id, + call_inst_id, + "call", + opt_qty, + call_contracts, + cf.fill_px, + entry_index_px, + call_prem, + "open", + "option_option", + put_inst_id, + "put", + opt_qty, + put_contracts, + pf.fill_px, + float(put_strike), + put_prem, + ), + ) + self.db._conn.commit() + + try: + from ..strategy.exits import lock_trade_exit_target + + lock_trade_exit_target( + self.db, group_id=group_id, initial_premium=total_prem + ) + except Exception: + logger.exception("lock exit target failed oo group=%s", group_id) + + return OpenResult( + ok=True, + group_id=group_id, + detail="opened_oo", + data={ + "group_id": group_id, + "hedge_mode": "option_option", + "call_inst_id": call_inst_id, + "put_inst_id": put_inst_id, + "call_strike": float(call_strike), + "put_strike": float(put_strike), + "option_qty_eth": float(opt_qty), + "initial_premium": total_prem, + "fees": cf.fee + pf.fee, + "open_sequence": ["call", "put"], + }, + ) + + def close_winning_oo_leave_residual( + self, *, reason: str = "target_oo_win", skip_market: bool = False + ) -> CloseResult: + """期期达标:平盈利腿,亏损腿进 residual。skip_market=True 时假定已在交易所卖掉盈利腿。""" + pos = self.current_position() + st = str(pos.get("status") or "") + if st not in ("open", "closing") or not pos.get("group_id"): + return CloseResult(ok=False, detail="无期期持仓可平") + if str(pos.get("hedge_mode") or "") != "option_option": + # 兼容:有 option2 即视为期期 + if not pos.get("option2_inst_id"): + return CloseResult(ok=False, detail="非期期持仓") + if st == "closing" and not skip_market: + skip_market = True + + group_id = str(pos["group_id"]) + call_id = str(pos.get("option_inst_id") or "") + put_id = str(pos.get("option2_inst_id") or "") + qty = float(pos.get("option_qty_eth") or 0) + qty2 = float(pos.get("option2_qty_eth") or qty) + if not call_id or not put_id or qty <= 0: + return CloseResult(ok=False, detail="期期腿不完整") + + upl = self.unrealized() + call_upl = float(upl.get("option_upl") or 0) + put_upl = float(upl.get("option2_upl") or 0) + # 盈利腿:UPL 更高且 > 0 + if call_upl >= put_upl and call_upl > 0: + win_leg, lose_leg = "option", "option2" + win_id, lose_id = call_id, put_id + win_side, lose_side = "call", "put" + win_qty = qty + lose_qty = qty2 + win_entry = float(pos.get("option_entry_px") or 0) + lose_entry = float(pos.get("option2_entry_px") or 0) + lose_strike = float(pos.get("strike2") or 0) + lose_prem = float(pos.get("initial_premium2") or 0) + win_contracts = float(pos.get("option_qty_contracts") or 0) + lose_contracts = float(pos.get("option2_qty_contracts") or 0) + elif put_upl > call_upl and put_upl > 0: + win_leg, lose_leg = "option2", "option" + win_id, lose_id = put_id, call_id + win_side, lose_side = "put", "call" + win_qty = qty2 + lose_qty = qty + win_entry = float(pos.get("option2_entry_px") or 0) + lose_entry = float(pos.get("option_entry_px") or 0) + g = self.db.fetchone( + "SELECT strike FROM groups WHERE group_id=?", (group_id,) + ) + lose_strike = float(g["strike"] or 0) if g else 0.0 + lose_prem = float(pos.get("initial_premium") or 0) + win_contracts = float(pos.get("option2_qty_contracts") or 0) + lose_contracts = float(pos.get("option_qty_contracts") or 0) + else: + return CloseResult(ok=False, detail="无明确盈利腿,暂不平") + + fee_rate = self._fee_rate() + if skip_market: + oq = self._quote_held_option(win_id) + fill_px = float(oq.bid) if oq and oq.bid else float(win_entry) + of = option_fill( + action="close", + bid=fill_px, + ask=fill_px, + qty_eth=win_qty, + fee_rate=fee_rate, + ) + else: + oq = self._quote_held_option(win_id) + if oq is None or oq.bid is None or float(oq.bid) <= 0: + return CloseResult(ok=False, detail="盈利腿买一不可用") + gate = self._residual_bid_gate( + { + "option_inst_id": win_id, + "option_qty_eth": win_qty, + "initial_premium": win_entry * win_qty, + }, + bid=float(oq.bid), + oq=oq, + require_premium_ratio=False, + ) + if gate: + return CloseResult(ok=False, detail=f"盈利腿流动性不足: {gate}") + of = option_fill( + action="close", + bid=float(oq.bid), + ask=float(oq.ask or oq.bid), + qty_eth=win_qty, + fee_rate=fee_rate, + ) + cash = of.notional - of.fee + if get_settings().is_sim or not skip_market: + self.ledger.apply_cash( + cash, kind="close_option", group_id=group_id, note=f"oo win {win_leg}" + ) + elif skip_market: + # LIVE:交易所已成交,仍记本地账本现金(与其它 LIVE 平仓一致) + try: + self.ledger.apply_cash( + cash, + kind="close_option", + group_id=group_id, + note=f"oo win live {win_leg}", + ) + except Exception: + logger.exception("oo win live ledger cash failed") + + now = int(time.time() * 1000) + expiry_ymd = None + expiry_ms = None + g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) + if g: + expiry_ymd = g["expiry_ymd"] + try: + from ..exchange.expiry import expiry_ms_from_ymd + + if expiry_ymd: + expiry_ms = int(expiry_ms_from_ymd(str(expiry_ymd))) + except Exception: + expiry_ms = None + + with self.db._lock: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + win_leg, + "close", + "sell", + win_id, + win_qty, + win_contracts, + of.base_px, + of.fill_px, + of.fee, + of.slip, + of.notional, + now, + "SIM", + ), + ) + self.db._conn.execute( + """INSERT INTO residual_options( + group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts, + option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px, + initial_premium, status, created_at_ms, note + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + lose_id, + lose_side, + lose_qty, + lose_contracts, + lose_entry, + lose_strike, + expiry_ymd, + expiry_ms, + float(pos.get("entry_index_px") or 0), + lose_prem, + "pending", + now, + f"oo losing leg after {reason}; win={win_leg}", + ), + ) + # 组:记部分实现盈亏(赢腿),状态 residual + win_pnl = (of.fill_px - win_entry) * win_qty - of.fee + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, + fees=COALESCE(fees,0)+?, note=? + WHERE group_id=?""", + ( + "option_residual", + now, + reason, + float(win_pnl), + float(of.fee), + f"oo win closed {win_leg}; lose {lose_leg} residual", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, + option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL, + initial_premium=0, exit_target_usdt=NULL, status='flat', + hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL, + option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL, + strike2=NULL, initial_premium2=NULL + WHERE id=1""" + ) + self.db._conn.commit() + + return CloseResult( + ok=True, + detail="oo_win_closed_lose_residual", + data={ + "group_id": group_id, + "reason": reason, + "win_leg": win_leg, + "lose_leg": lose_leg, + "win_pnl": win_pnl, + }, + ) + + def close_oo_full( + self, *, reason: str = "expiry", bypass_liquidity: bool = False + ) -> CloseResult: + """期期全平两腿(到期/紧急);无永续。""" + pos = self.current_position() + if str(pos.get("status") or "") != "open" or not pos.get("group_id"): + return CloseResult(ok=False, detail="无期期持仓可平") + if not ( + str(pos.get("hedge_mode") or "") == "option_option" + or pos.get("option2_inst_id") + ): + return CloseResult(ok=False, detail="非期期持仓") + + group_id = str(pos["group_id"]) + legs = [ + ( + "option", + str(pos.get("option_inst_id") or ""), + float(pos.get("option_qty_eth") or 0), + float(pos.get("option_qty_contracts") or 0), + float(pos.get("option_entry_px") or 0), + float(pos.get("initial_premium") or 0), + ), + ( + "option2", + str(pos.get("option2_inst_id") or ""), + float(pos.get("option2_qty_eth") or 0), + float(pos.get("option2_qty_contracts") or 0), + float(pos.get("option2_entry_px") or 0), + float(pos.get("initial_premium2") or 0), + ), + ] + fee_rate = self._fee_rate() + now = int(time.time() * 1000) + total_pnl = 0.0 + total_fees = 0.0 + for leg, inst, qty, contracts, entry, prem in legs: + if not inst or qty <= 0: + continue + oq = self._quote_held_option(inst) + if reason == "expiry": + # 到期:尽量用买一,否则按 0 权利金结算 + bid = float(oq.bid) if oq and oq.bid is not None else 0.0 + ask = float(oq.ask) if oq and oq.ask is not None else bid + else: + if oq is None or oq.bid is None or float(oq.bid) <= 0: + if not bypass_liquidity: + return CloseResult( + ok=False, detail=f"期期全平缺买一: {inst}" + ) + bid = float(entry) + ask = bid + else: + if not bypass_liquidity: + gate = self._residual_bid_gate( + { + "option_inst_id": inst, + "option_qty_eth": qty, + "initial_premium": prem or entry * qty, + }, + bid=float(oq.bid), + oq=oq, + require_premium_ratio=False, + ) + if gate: + return CloseResult( + ok=False, detail=f"期期全平流动性: {gate}" + ) + bid = float(oq.bid) + ask = float(oq.ask or oq.bid) + of = option_fill( + action="close", + bid=bid, + ask=ask if ask > 0 else bid, + qty_eth=qty, + fee_rate=fee_rate, + ) + cash = of.notional - of.fee + if get_settings().is_sim: + self.ledger.apply_cash( + cash, + kind="close_option", + group_id=group_id, + note=f"oo full {leg}", + ) + else: + try: + self.ledger.apply_cash( + cash, + kind="close_option", + group_id=group_id, + note=f"oo full {leg}", + ) + except Exception: + logger.exception("oo full ledger cash failed leg=%s", leg) + total_pnl += (of.fill_px * qty - (prem or entry * qty)) - of.fee + total_fees += of.fee + with self.db._lock: + self.db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + group_id, + leg, + "close", + "sell", + inst, + qty, + contracts, + of.base_px, + of.fill_px, + of.fee, + of.slip, + of.notional, + now, + "SIM" if get_settings().is_sim else "LIVE", + ), + ) + self.db._conn.commit() + now += 1 + + with self.db._lock: + self.db._conn.execute( + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, + fees=COALESCE(fees,0)+?, note=? + WHERE group_id=?""", + ( + "closed", + int(time.time() * 1000), + reason, + float(total_pnl), + float(total_fees), + f"oo full close {reason}", + group_id, + ), + ) + self.db._conn.execute( + """UPDATE positions SET + group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL, + option_inst_id=NULL, option_side=NULL, option_qty_eth=0, + option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL, + initial_premium=0, exit_target_usdt=NULL, status='flat', + hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL, + option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL, + strike2=NULL, initial_premium2=NULL + WHERE id=1""" + ) + self.db._conn.commit() + return CloseResult( + ok=True, + detail="oo_full_closed", + data={"group_id": group_id, "reason": reason, "net": total_pnl}, + ) + def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: """ 全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。 @@ -1384,6 +1987,7 @@ class Matcher: "has_position": False, "perp_upl": 0.0, "option_upl": 0.0, + "option2_upl": 0.0, "net_pnl": 0.0, "est_close_fees": 0.0, "index_px": None, @@ -1391,6 +1995,95 @@ class Matcher: "move_pct": 0.0, "premium_gap": None, } + if str(pos.get("hedge_mode") or "") == "option_option" or pos.get( + "option2_inst_id" + ): + return self._unrealized_oo(pos) + return self._unrealized_perp(pos) + + def _unrealized_oo(self, pos: dict[str, Any]) -> dict[str, Any]: + sess = get_session() + snap = sess.snapshot() + fee_rate = self._fee_rate() + index_px = snap.index_px + if index_px is None and snap.perp: + index_px = snap.perp.mark_px + qty = float(pos.get("option_qty_eth") or 0) + qty2 = float(pos.get("option2_qty_eth") or qty) + prem1 = float(pos.get("initial_premium") or 0) + prem2 = float(pos.get("initial_premium2") or 0) + call_id = str(pos.get("option_inst_id") or "") + put_id = str(pos.get("option2_inst_id") or "") + oq1 = self._quote_held_option(call_id) if call_id else None + oq2 = self._quote_held_option(put_id) if put_id else None + option_upl = 0.0 + option2_upl = 0.0 + fees = 0.0 + if oq1 and oq1.bid is not None and qty > 0: + bid = float(oq1.bid) + of = option_fill( + action="close", + bid=bid, + ask=float(oq1.ask or bid), + qty_eth=qty, + fee_rate=fee_rate, + ) + fees += of.fee + option_upl = bid * qty - prem1 + if oq2 and oq2.bid is not None and qty2 > 0: + bid = float(oq2.bid) + of = option_fill( + action="close", + bid=bid, + ask=float(oq2.ask or bid), + qty_eth=qty2, + fee_rate=fee_rate, + ) + fees += of.fee + option2_upl = bid * qty2 - prem2 + g = None + gid = pos.get("group_id") + if gid: + g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (gid,)) + paid = float(g["fees"] or 0) if g else 0.0 + net = option_upl + option2_upl - paid - fees + entry_idx = float(pos.get("entry_index_px") or 0) + move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0 + move_pct = (move / entry_idx * 100.0) if entry_idx > 0 else 0.0 + return { + "has_position": True, + "hedge_mode": "option_option", + "group_id": gid, + "status": "open", + "perp_upl": 0.0, + "option_upl": option_upl, + "option2_upl": option2_upl, + "net_pnl": net, + "fees_paid": paid, + "est_close_fees": fees, + "index_px": float(index_px) if index_px is not None else None, + "entry_index_px": entry_idx, + "move_points": move, + "move_pct": move_pct, + "initial_premium": prem1 + prem2, + "option_inst_id": call_id, + "option2_inst_id": put_id, + "option_side": "call", + "option2_side": "put", + "option_qty_eth": qty, + "option2_qty_eth": qty2, + "option_entry_px": float(pos.get("option_entry_px") or 0), + "option2_entry_px": float(pos.get("option2_entry_px") or 0), + "strike": float(g["strike"]) if g and g["strike"] is not None else None, + "strike2": float(pos.get("strike2") or 0) or None, + "expiry_ymd": g["expiry_ymd"] if g else None, + "open_at_ms": int(g["open_at_ms"]) if g and g["open_at_ms"] else None, + "perp_side": None, + "perp_qty_eth": 0.0, + "premium_gap": None, + } + + def _unrealized_perp(self, pos: dict[str, Any]) -> dict[str, Any]: sess = get_session() snap = sess.snapshot() s = get_settings() diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 89a2a1b..8e10985 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -245,6 +245,22 @@ class StrategyEngine: "option_qty_eth": opt_qty, "sizing_mode": sizing_mode, "risk_based": sizing_mode == "risk_based", + "hedge_mode": ( + hm + if ( + hm := str( + self.ledger.get_setting_str( + "hedge_mode", s.hedge_mode + ) + or s.hedge_mode + or "perp_option" + ) + .strip() + .lower() + ) + in ("perp_option", "option_option") + else "perp_option" + ), "risk_perp_unit": risk_perp_unit, "risk_option_unit": risk_option_unit, "risk_exit_unit": risk_exit_unit, @@ -812,6 +828,10 @@ class StrategyEngine: ) pending_close = st["phase"] in ("liquidity_wait", "closing") if expired.should_close or decision.should_close or pending_close: + is_oo = ( + str(upl.get("hedge_mode") or "") == "option_option" + or bool(upl.get("option2_inst_id")) + ) if expired.should_close: reason = "expiry" bypass = True @@ -822,11 +842,66 @@ class StrategyEngine: bypass = False abandon = bool(decision.should_close or pending_close) rkind = "liquidity" if pending_close else "close" + if is_oo and decision.should_close and not expired.should_close: + # 期期达标:只平盈利腿,亏损腿残留 + close_oo = getattr( + self.matcher, "close_winning_oo_leave_residual", None + ) + if close_oo is not None: + r = await asyncio.to_thread( + close_oo, reason="target_oo_win" + ) + if r.ok: + self._enter_rest_after_close() + self._set_state(phase="resting", last_error=None) + else: + self._set_state( + phase="liquidity_wait", + last_error=r.detail or "期期盈利腿暂不可平", + ) + return + if is_oo and ( + expired.should_close + or reason in ("expiry", "emergency", "manual") + or bypass + ): + close_full = getattr(self.matcher, "close_oo_full", None) + if close_full is not None and ( + expired.should_close or bypass or reason == "emergency" + ): + # LIVE:先交易所卖两腿 + for sell_fn_name in ( + "_live_sell_oo_both", + "live_sell_oo_both", + ): + sell_both = getattr(self.matcher, sell_fn_name, None) + if callable(sell_both): + try: + await asyncio.to_thread( + sell_both, bypass_liquidity=bypass + ) + except Exception: + logger.exception("live sell oo both failed") + break + r = await asyncio.to_thread( + close_full, + reason=reason if reason != "liquidity_retry" else "expiry", + bypass_liquidity=True, + ) + if r.ok: + self._enter_rest_after_close() + self._set_state(phase="resting", last_error=None) + else: + self._set_state( + phase="liquidity_wait", + last_error=r.detail or "期期全平失败", + ) + return await self._close_open_position( reason=reason, bypass_liquidity=bypass, pending_close=pending_close, - abandon_if_deep_otm=abandon, + abandon_if_deep_otm=abandon and not is_oo, retry_kind=rkind, ) else: @@ -948,10 +1023,14 @@ class StrategyEngine: # 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道) from .open_pipeline import size_and_gate + oo = getattr(pick, "hedge_mode", "perp_option") == "option_option" prep = size_and_gate( index_px=float(pick.underlying_px), option_ask=float(pick.option_ask), db=self.db, + call_ask=float(pick.call_ask) if oo else None, + put_ask=float(pick.put_ask) if oo else None, + hedge_mode="option_option" if oo else "perp_option", ) if not prep.ok: phase = "wait_funds" if prep.capacity is not None else "idle" @@ -977,9 +1056,6 @@ class StrategyEngine: wkey = window_key() count = self._count_groups_for_day(wkey) gid = next_group_id(count) - option_inst = ( - pick.pair.call_inst_id if pick.option_side == "call" else pick.pair.put_inst_id - ) entry_idx = pick.underlying_px if not get_settings().is_sim: from ..live.reconcile import assert_safe_to_open_live @@ -998,17 +1074,39 @@ class StrategyEngine: except Exception: pass return - r = await asyncio.to_thread( - self.matcher.open_group, - group_id=gid, - bias=pick.bias, - option_side=pick.option_side, - perp_side=pick.perp_side, - option_inst_id=option_inst, - entry_index_px=float(entry_idx), - strike=pick.pair.strike, - expiry_ymd=pick.pair.expiry_ymd, - ) + if oo: + open_fn = getattr(self.matcher, "open_oo_group", None) + if open_fn is None: + self._set_state(phase="idle", last_error="当前执行器不支持期期开仓") + return + r = await asyncio.to_thread( + open_fn, + group_id=gid, + call_inst_id=str(pick.call_inst_id or pick.pair.call_inst_id), + put_inst_id=str(pick.put_inst_id or pick.pair.put_inst_id), + call_strike=float(pick.call_strike or pick.pair.strike), + put_strike=float(pick.put_strike or pick.pair.strike), + entry_index_px=float(entry_idx), + expiry_ymd=pick.pair.expiry_ymd, + ) + option_inst = str(pick.call_inst_id or pick.pair.call_inst_id) + else: + option_inst = ( + pick.pair.call_inst_id + if pick.option_side == "call" + else pick.pair.put_inst_id + ) + r = await asyncio.to_thread( + self.matcher.open_group, + group_id=gid, + bias=pick.bias, + option_side=pick.option_side, + perp_side=pick.perp_side, + option_inst_id=option_inst, + entry_index_px=float(entry_idx), + strike=pick.pair.strike, + expiry_ymd=pick.pair.expiry_ymd, + ) if r.ok: self._set_state(phase="open", last_error=None) try: diff --git a/backend/app/strategy/oo_selection.py b/backend/app/strategy/oo_selection.py new file mode 100644 index 0000000..b0f3d27 --- /dev/null +++ b/backend/app/strategy/oo_selection.py @@ -0,0 +1,170 @@ +"""期期对冲选约:振幅高低点匹配虚值 Call + Put。""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from ..exchange.candles import AmplitudeHL, fetch_amplitude_hl_for_runtime +from .selection import ( + _complete_by_expiry, + hours_until_ms, + list_eligible_expiry_ymds, + option_leverage, +) + + +@dataclass(frozen=True, slots=True) +class OoLeg: + side: str # call|put + strike: float + inst_id: str + ask: float + leverage: float + + +@dataclass(frozen=True, slots=True) +class OoPickCore: + expiry_ymd: str + expiry_ms: int + hours_left: float + underlying_px: float + amplitude: AmplitudeHL + call: OoLeg + put: OoLeg + detail: str = "ok" + + +def pick_otm_call_strike(strikes: list[float], *, spot: float, high: float) -> float | None: + """虚值 Call:K > spot,优先贴近振幅高点。""" + cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9] + if not cands: + return None + return min(cands, key=lambda s: (abs(s - float(high)), s)) + + +def pick_otm_put_strike(strikes: list[float], *, spot: float, low: float) -> float | None: + """虚值 Put:K < spot,优先贴近振幅低点。""" + cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9] + if not cands: + return None + return min(cands, key=lambda s: (abs(s - float(low)), s)) + + +def select_oo_pair( + contracts: list[dict[str, Any]], + *, + spot: float, + high: float, + low: float, + min_hours: float, + now: datetime | None = None, + skip_expiry_ymds: set[str] | None = None, +) -> tuple[str, int, float, float, str, str] | None: + """ + 返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。 + Call/Put 可不同行权价;须同到期且均为虚值。 + """ + if spot <= 0 or high <= 0 or low <= 0 or high < low: + return None + complete = _complete_by_expiry(contracts) + if not complete: + return None + skip = skip_expiry_ymds or set() + eligible = [ + y + for y in list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now) + if y not in skip + ] + for ymd in eligible: + ems, strikes_map = complete[ymd] + strikes = list(strikes_map.keys()) + ck = pick_otm_call_strike(strikes, spot=spot, high=high) + pk = pick_otm_put_strike(strikes, spot=spot, low=low) + if ck is None or pk is None: + continue + call_inst = strikes_map[ck].get("C") + put_inst = strikes_map[pk].get("P") + if not call_inst or not put_inst: + continue + hours_left = hours_until_ms(ems, now) + return ( + ymd, + int(ems), + float(ck), + float(pk), + str(call_inst), + str(put_inst), + ) + return None + + +def build_oo_pick_core( + *, + contracts: list[dict[str, Any]], + spot: float, + call_ask: float, + put_ask: float, + min_hours: float, + min_leverage: float, + amplitude_hours: float, + amplitude_pct: float, + amplitude: AmplitudeHL | None = None, + skip_expiry_ymds: set[str] | None = None, + now: datetime | None = None, +) -> OoPickCore | None: + """完整期期选约:振幅门 + 虚值双腿 + 杠杆。""" + amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours) + if amp is None: + return None + if float(amp.range_pct) + 1e-12 < float(amplitude_pct): + return None + if spot <= 0: + spot = float(amp.mid) + picked = select_oo_pair( + contracts, + spot=float(spot), + high=float(amp.high), + low=float(amp.low), + min_hours=float(min_hours), + now=now, + skip_expiry_ymds=skip_expiry_ymds, + ) + if picked is None: + return None + ymd, ems, ck, pk, call_inst, put_inst = picked + if call_ask <= 0 or put_ask <= 0: + return None + c_lev = option_leverage(float(spot), float(call_ask)) + p_lev = option_leverage(float(spot), float(put_ask)) + if c_lev is None or p_lev is None: + return None + if c_lev + 1e-12 < float(min_leverage) or p_lev + 1e-12 < float(min_leverage): + return None + hours_left = hours_until_ms(ems, now) + return OoPickCore( + expiry_ymd=ymd, + expiry_ms=int(ems), + hours_left=float(hours_left), + underlying_px=float(spot), + amplitude=amp, + call=OoLeg( + side="call", + strike=float(ck), + inst_id=call_inst, + ask=float(call_ask), + leverage=float(c_lev), + ), + put=OoLeg( + side="put", + strike=float(pk), + inst_id=put_inst, + ask=float(put_ask), + leverage=float(p_lev), + ), + detail=( + f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} " + f"C@{ck:g} P@{pk:g}" + ), + ) diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py index b4e7958..2d9073f 100644 --- a/backend/app/strategy/open_capacity.py +++ b/backend/app/strategy/open_capacity.py @@ -104,17 +104,24 @@ def assess_open_capacity( option_ask: float | None = None, option_qty_eth: float | None = None, perp_qty_eth: float | None = None, + call_ask: float | None = None, + put_ask: float | None = None, ) -> dict[str, Any]: """ 返回永续/期权是否有足够交易账户资金开新仓。 - 永续:交易账户 USDT >= 名义/杠杆 - 期权:交易账户 USDC >= 卖一×名义×(1+费率) - 可选覆盖 ask/名义(选约后应用选中腿卖一,避免与 max(call,put) 打架)。 + - 期期:期权需 (call_ask+put_ask)×qty×(1+fee);永续视为不需要 """ global _notified_while_short db = db or get_db() s = get_settings() ledger = Ledger(db) + hedge = str( + ledger.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode + ).strip().lower() + if hedge not in ("perp_option", "option_option"): + hedge = "perp_option" lev = float(ledger.get_setting_float("leverage", s.leverage) or 3) if lev <= 0: lev = 3.0 @@ -132,10 +139,38 @@ def assess_open_capacity( idx, ask_book = _index_and_option_ask() ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book - margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None - premium_need = ( - float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None - ) + if hedge == "option_option": + ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None + pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None + if ca is None or pa is None: + # 回退:用监控对 call/put 卖一 + try: + from .session import get_session + + snap = get_session().snapshot() + if ca is None and snap.call and snap.call.ask: + ca = float(snap.call.ask) + if pa is None and snap.put and snap.put.ask: + pa = float(snap.put.ask) + except Exception: + pass + cush = float( + ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) + or s.oo_budget_cushion + ) + cush = min(1.0, max(0.5, cush)) + if ca is not None and pa is not None and ca > 0 and pa > 0: + # 与定仓一致:按预留后的权利金需求估资金门 + premium_need = (ca + pa) * opt_qty * (1.0 + fee_rate) * cush + else: + premium_need = None + margin_need = 0.0 + perp_qty = 0.0 + else: + margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None + premium_need = ( + float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None + ) if s.is_sim: bal = _sim_balances(db) @@ -149,24 +184,31 @@ def assess_open_capacity( have_opt = float(t_usdc) if t_usdc is not None else None perp_ok: bool | None - if margin_need is None or have_perp is None: + if hedge == "option_option": + perp_ok = True + elif margin_need is None or have_perp is None: perp_ok = None else: - perp_ok = have_perp + 1e-9 >= margin_need + perp_ok = float(have_perp) + 1e-9 >= float(margin_need) opt_ok: bool | None if premium_need is None or have_opt is None: opt_ok = None else: - opt_ok = have_opt + 1e-9 >= premium_need + opt_ok = float(have_opt) + 1e-9 >= float(premium_need) - funds_ok = perp_ok is True and opt_ok is True + if hedge == "option_option": + funds_ok = opt_ok is True + else: + funds_ok = perp_ok is True and opt_ok is True # 资金恢复后允许下次不足再通知一次 if funds_ok: _notified_while_short = False lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev - if perp_ok is True: + if hedge == "option_option": + perp_label = "永续 —(期期)" + elif perp_ok is True: perp_label = f"永续{lev_i}x 可开" elif perp_ok is False: perp_label = f"永续{lev_i}x 不可开" @@ -181,6 +223,7 @@ def assess_open_capacity( opt_label = "期权 —" return { + "hedge_mode": hedge, "leverage": lev, "perp_qty_eth": perp_qty, "option_qty_eth": opt_qty, @@ -201,11 +244,22 @@ def assess_open_capacity( def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]: """ - Fail-closed:仅当永续与期权均为 True 才放行。 - None(未知,如币安未接余额)或 False → 拦截。 + Fail-closed:永期需永续+期权均为 True;期期仅需期权为 True。 + None(未知)或 False → 拦截。 """ if not cap: return True, "资金可开判定结果为空,拒绝开仓" + hedge = str(cap.get("hedge_mode") or "perp_option").strip().lower() + if hedge == "option_option": + if cap.get("option_can_open") is not True: + detail = ( + f"{cap.get('option_label')};" + f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" + ) + if cap.get("option_can_open") is None: + detail += "(余额/盘口未知,fail-closed 拒绝开仓)" + return True, f"资金不足或状态未知,暂不可开新仓:{detail}" + return False, "" if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True: detail = ( f"{cap.get('perp_label')} · {cap.get('option_label')};" diff --git a/backend/app/strategy/open_pipeline.py b/backend/app/strategy/open_pipeline.py index 6e53382..c665571 100644 --- a/backend/app/strategy/open_pipeline.py +++ b/backend/app/strategy/open_pipeline.py @@ -38,20 +38,51 @@ def size_and_gate( index_px: float, option_ask: float, db: Database | None = None, + call_ask: float | None = None, + put_ask: float | None = None, + hedge_mode: str | None = None, ) -> OpenPrepResult: """ 选约成功后:写入以损定仓 → 交易账户兑 USDC → 资金门。 资金门 fail-closed:异常 / can_open 非 True 一律拦截。 """ database = db or get_db() + mode = str(hedge_mode or "").strip().lower() + if not mode: + try: + from ..config import get_settings + from ..sim.ledger import Ledger + + s = get_settings() + mode = str( + Ledger(database).get_setting_str("hedge_mode", s.hedge_mode) + or s.hedge_mode + ).strip().lower() + except Exception: + mode = "perp_option" + try: - rs = apply_risk_sizing_to_ledger( - index_px=float(index_px), - option_ask=float(option_ask), - db=database, - ) - if not rs.ok: - return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) + if mode == "option_option": + from .risk_sizing import apply_oo_sizing_to_ledger + + if call_ask is None or put_ask is None: + return OpenPrepResult(ok=False, detail="期期定仓缺少 call/put 卖一") + rs = apply_oo_sizing_to_ledger( + call_ask=float(call_ask), + put_ask=float(put_ask), + index_px=float(index_px), + db=database, + ) + if not rs.ok: + return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) + else: + rs = apply_risk_sizing_to_ledger( + index_px=float(index_px), + option_ask=float(option_ask), + db=database, + ) + if not rs.ok: + return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) except Exception as e: logger.exception("risk sizing failed in open pipeline") return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}") @@ -66,6 +97,8 @@ def size_and_gate( cap=assess_open_capacity( database, option_ask=float(option_ask), + call_ask=call_ask, + put_ask=put_ask, ), force=False, ) @@ -77,7 +110,12 @@ def size_and_gate( convert_detail = "自动兑 USDC 异常(已记日志)" try: - cap = assess_open_capacity(database, option_ask=float(option_ask)) + cap = assess_open_capacity( + database, + option_ask=float(option_ask), + call_ask=call_ask, + put_ask=put_ask, + ) except Exception as e: logger.exception("open capacity assess failed") return OpenPrepResult( diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index d4d4437..adfbfa7 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -501,6 +501,167 @@ def compute_risk_sizing( ) +@dataclass(frozen=True, slots=True) +class OoSizingResult: + ok: bool + detail: str + budget: float | None = None + spend: float | None = None + qty_eth: float | None = None + call_ask: float | None = None + put_ask: float | None = None + call_premium: float | None = None + put_premium: float | None = None + max_loss: float | None = None + net_profit_target: float | None = None + capital_base: float | None = None + cushion: float | None = None + reward_ratio: float | None = None + + +def compute_oo_sizing( + *, + budget: float, + call_ask: float, + put_ask: float, + fee_rate: float = 0.0005, + index_px: float = 0.0, + cushion: float = 0.92, + reward_ratio: float = 2.0, +) -> OoSizingResult: + """ + 期期 1:1:预算 B 预留后平分两腿权利金;qty 一位小数向下取整; + 出场目标 = B × reward_ratio(按全额预算)。 + """ + if budget is None or budget <= 0 or not math.isfinite(budget): + return OoSizingResult(ok=False, detail="期期预算无效") + if call_ask <= 0 or put_ask <= 0: + return OoSizingResult(ok=False, detail="期期缺少有效卖一") + cush = min(1.0, max(0.5, float(cushion))) + ratio = max(0.5, float(reward_ratio)) + spend = float(budget) * cush + # 粗估两腿开仓费(按指数名义近似) + fee_est = 0.0 + if index_px and index_px > 0 and fee_rate > 0: + fee_est = float(index_px) * float(fee_rate) * 2.0 + spend_prem = max(0.0, spend - fee_est) + if spend_prem <= 1e-9: + return OoSizingResult(ok=False, detail="期期预留后可用权利金不足") + leg = spend_prem / 2.0 + # 等量:受较贵腿限制 + q_call = floor_k_1dp(leg / float(call_ask)) + q_put = floor_k_1dp(leg / float(put_ask)) + qty = min(q_call, q_put) + if qty < 0.1 - 1e-12: + return OoSizingResult( + ok=False, + detail=( + f"期期定仓 qty<{0.1}(call可{q_call} put可{q_put})," + f"预算 {budget:.2f}U 不足" + ), + budget=_round2(float(budget)), + ) + # 若仍略超 spend_prem,再降一档 + while qty >= 0.1 - 1e-12: + cp = float(call_ask) * qty + pp = float(put_ask) * qty + if cp + pp <= spend_prem + 1e-6: + return OoSizingResult( + ok=True, + detail="ok", + budget=_round2(float(budget)), + spend=_round2(spend), + qty_eth=round(qty, 1), + call_ask=_round2(float(call_ask)), + put_ask=_round2(float(put_ask)), + call_premium=_round2(cp), + put_premium=_round2(pp), + max_loss=_round2(cp + pp + fee_est), + net_profit_target=_round2(float(budget) * ratio), + cushion=cush, + reward_ratio=ratio, + ) + qty = round(qty - 0.1, 1) + return OoSizingResult( + ok=False, + detail=f"期期无法在预算 {budget:.2f}U 内找到合规 qty", + budget=_round2(float(budget)), + ) + + +def apply_oo_sizing_to_ledger( + *, + call_ask: float, + put_ask: float, + index_px: float, + db: Database | None = None, +) -> OoSizingResult: + database = db or get_db() + ledger = Ledger(database) + s = get_settings() + pos = database.fetchone("SELECT status FROM positions WHERE id=1") + if pos is not None: + st = str(pos["status"] or "flat") + if st in ("open", "half_open", "option_closed_perp_pending", "opening"): + return OoSizingResult( + ok=False, + detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算", + ) + budget, detail, capital = resolve_budget(database) + if budget is None: + return OoSizingResult(ok=False, detail=f"期期预算失败: {detail}") + fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) + cushion = ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) + ratio = ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio) + r = compute_oo_sizing( + budget=float(budget), + call_ask=float(call_ask), + put_ask=float(put_ask), + fee_rate=fee_rate, + index_px=float(index_px), + cushion=cushion, + reward_ratio=ratio, + ) + if not r.ok: + return r + database.set_setting("exit_mode", "fixed_usdt") + database.set_setting("perp_qty_eth", "0") + database.set_setting("option_qty_eth", str(r.qty_eth)) + database.set_setting("net_profit_target", str(r.net_profit_target)) + database.set_setting("risk_last_k", str(r.qty_eth)) + database.set_setting( + "risk_last_max_loss", + f"{r.max_loss:.2f}" if r.max_loss is not None else "", + ) + logger.info( + "oo_sizing applied qty=%.1f call_ask=%.4f put_ask=%.4f exit=%.2f " + "max_loss=%.2f budget=%.2f", + r.qty_eth or 0, + r.call_ask or 0, + r.put_ask or 0, + r.net_profit_target or 0, + r.max_loss or 0, + r.budget or 0, + ) + # attach capital for callers + return OoSizingResult( + ok=True, + detail=r.detail, + budget=r.budget, + spend=r.spend, + qty_eth=r.qty_eth, + call_ask=r.call_ask, + put_ask=r.put_ask, + call_premium=r.call_premium, + put_premium=r.put_premium, + max_loss=r.max_loss, + net_profit_target=r.net_profit_target, + capital_base=_round2(capital) if capital is not None else None, + cushion=r.cushion, + reward_ratio=r.reward_ratio, + ) + + def apply_risk_sizing_to_ledger( *, index_px: float, diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index 742fb8c..ffd5a45 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -157,6 +157,49 @@ def _option_side_for_perp(perp_side: str) -> str: return "put" if (perp_side or "").strip().lower() == "long" else "call" +def _hedge_mode() -> str: + s = get_settings() + try: + from ..models.db import get_db + + raw = str( + get_db().get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode + ).strip().lower() + if raw in ("perp_option", "option_option"): + return raw + except Exception: + pass + return "perp_option" + + +def _oo_settings() -> tuple[float, float, float, float]: + """amplitude_pct, amplitude_hours, min_option_hours, min_leverage""" + s = get_settings() + try: + from ..models.db import get_db + + db = get_db() + return ( + float(db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct)) or s.oo_amplitude_pct), + float( + db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours)) + or s.oo_amplitude_hours + ), + float( + db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours)) + or s.oo_min_option_hours + ), + float(db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) or s.oo_min_leverage), + ) + except Exception: + return ( + s.oo_amplitude_pct, + s.oo_amplitude_hours, + s.oo_min_option_hours, + s.oo_min_leverage, + ) + + @dataclass(slots=True) class OpenPick: pair: OptionPair @@ -169,6 +212,17 @@ class OpenPick: option_leverage: float hours_left: float underlying_px: float + hedge_mode: str = "perp_option" + call_inst_id: str | None = None + put_inst_id: str | None = None + call_strike: float | None = None + put_strike: float | None = None + call_leverage: float | None = None + put_leverage: float | None = None + amplitude_high: float | None = None + amplitude_low: float | None = None + amplitude_range_pct: float | None = None + oo_detail: str | None = None class StrategySession: @@ -323,6 +377,125 @@ class StrategySession: return self._apply_pair(pair, mark=float(mark), idx=idx) def pick_for_open(self) -> OpenPick | None: + if _hedge_mode() == "option_option": + return self._pick_for_open_oo() + return self._pick_for_open_perp() + + def _pick_for_open_oo(self) -> OpenPick | None: + from ..exchange.candles import fetch_amplitude_hl_for_runtime + from .oo_selection import ( + pick_otm_call_strike, + pick_otm_put_strike, + select_oo_pair, + ) + from .selection import _complete_by_expiry, option_leverage + + 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: + return None + underlying = float(mark) + amp = fetch_amplitude_hl_for_runtime(amp_hours) + if amp is None: + logger.info("oo: amplitude candles unavailable") + return None + if float(amp.range_pct) + 1e-12 < float(amp_pct): + logger.info( + "oo: amplitude %.3f%% < need %.3f%% (H=%.2f L=%.2f)", + amp.range_pct, + amp_pct, + amp.high, + amp.low, + ) + return None + 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: + logger.info("oo: no OTM call/put pair for amplitude HL") + return None + ymd, ems, ck, pk, call_inst, put_inst = picked + call_bids, call_asks, _ = self.ex.fetch_book(call_inst, depth=5) + put_bids, put_asks, _ = self.ex.fetch_book(put_inst, depth=5) + call_ask = call_asks[0].px if call_asks else None + put_ask = put_asks[0].px if put_asks else None + if call_ask is None: + cq = self.ex.quote(call_inst) + call_ask = cq.ask if cq else None + if put_ask is None: + pq = self.ex.quote(put_inst) + put_ask = pq.ask if pq else None + if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0: + logger.info("oo: missing ask call=%s put=%s", call_ask, put_ask) + return None + c_lev = option_leverage(underlying, float(call_ask)) + p_lev = option_leverage(underlying, float(put_ask)) + if ( + c_lev is None + or p_lev is None + or c_lev + 1e-9 < min_lev + or p_lev + 1e-9 < min_lev + ): + logger.info( + "oo: leverage too low call=%s put=%s need>=%.0f", + f"{c_lev:.1f}" if c_lev else "n/a", + f"{p_lev:.1f}" if p_lev else "n/a", + min_lev, + ) + return None + # 监控用:用 Call 行权价构造假 pair(两腿不同 strike,call/put inst 正确) + pair = OptionPair( + expiry_ymd=ymd, + expiry_ms=int(ems), + strike=float(ck), + call_inst_id=call_inst, + put_inst_id=put_inst, + ) + self._apply_pair(pair, mark=underlying, idx=idx) + if hasattr(self.ex, "cache"): + from ..exchange.book_cache import BookCache + + cache: BookCache = self.ex.cache # type: ignore[attr-defined] + cache.upsert_book(call_inst, bids=call_bids, asks=call_asks) + cache.upsert_book(put_inst, bids=put_bids, asks=put_asks) + hours_left = hours_until_expiry(ymd, expiry_ms=ems) + return OpenPick( + pair=pair, + option_side="call", + perp_side="", + bias="option_option", + call_ask=float(call_ask), + put_ask=float(put_ask), + option_ask=float(call_ask), + option_leverage=float(min(c_lev, p_lev)), + hours_left=hours_left, + underlying_px=underlying, + hedge_mode="option_option", + call_inst_id=call_inst, + put_inst_id=put_inst, + call_strike=float(ck), + put_strike=float(pk), + call_leverage=float(c_lev), + put_leverage=float(p_lev), + amplitude_high=float(amp.high), + amplitude_low=float(amp.low), + amplitude_range_pct=float(amp.range_pct), + oo_detail=( + f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} " + f"C@{ck:g} P@{pk:g}" + ), + ) + + def _pick_for_open_perp(self) -> OpenPick | None: from .signal import decide, decide_fixed s = self.settings @@ -443,6 +616,7 @@ class StrategySession: option_leverage=float(lev), hours_left=hours_left, underlying_px=underlying, + hedge_mode="perp_option", ) return None diff --git a/backend/tests/test_oo_selection_sizing.py b/backend/tests/test_oo_selection_sizing.py new file mode 100644 index 0000000..9bd14d5 --- /dev/null +++ b/backend/tests/test_oo_selection_sizing.py @@ -0,0 +1,66 @@ +"""期期对冲:选约 / 定仓纯函数测试。""" + +from __future__ import annotations + +from app.exchange.candles import AmplitudeHL +from app.strategy.oo_selection import ( + pick_otm_call_strike, + pick_otm_put_strike, + select_oo_pair, +) +from app.strategy.risk_sizing import compute_oo_sizing + + +def test_otm_strikes_near_amplitude() -> None: + strikes = [1800.0, 1850.0, 1900.0, 1950.0, 2000.0, 2050.0, 2100.0] + assert pick_otm_call_strike(strikes, spot=1950, high=2040) == 2050.0 + assert pick_otm_put_strike(strikes, spot=1950, low=1860) == 1850.0 + + +def test_select_oo_pair_same_expiry(tmp_path=None) -> None: + contracts = [] + for k in (1900, 2000, 2100): + for side, letter in (("call", "C"), ("put", "P")): + contracts.append( + { + "expiry_ymd": "260810", + "expiry_ms": 1_786_320_000_000, + "strike": float(k), + "side": letter, + "inst_id": f"ETH-{k}-{letter}", + } + ) + picked = select_oo_pair( + contracts, + spot=2000.0, + high=2105.0, + low=1890.0, + min_hours=1.0, + ) + assert picked is not None + ymd, _ems, ck, pk, call_i, put_i = picked + assert ymd == "260810" + assert ck == 2100.0 + assert pk == 1900.0 + assert "C" in call_i and "P" in put_i + + +def test_compute_oo_sizing_1_1_and_reward() -> None: + r = compute_oo_sizing( + budget=100.0, + call_ask=5.0, + put_ask=5.0, + fee_rate=0.0, + index_px=2000.0, + cushion=0.92, + reward_ratio=2.0, + ) + assert r.ok + assert r.qty_eth == 9.2 + assert abs(float(r.net_profit_target or 0) - 200.0) < 1e-9 + assert float(r.call_premium or 0) + float(r.put_premium or 0) <= 92.0 + 1e-6 + + +def test_amplitude_range_pct() -> None: + a = AmplitudeHL(high=2030, low=1970, mid=2000, hours=12, bar_count=12) + assert abs(a.range_pct - 3.0) < 1e-9 diff --git a/docs/实盘策略说明.md b/docs/实盘策略说明.md index ff58451..38f65a2 100644 --- a/docs/实盘策略说明.md +++ b/docs/实盘策略说明.md @@ -1,41 +1,42 @@ -# 比特骆驼自动化对冲系统 · 实盘说明索引 - -> 产品:**比特骆驼自动化对冲系统** -> 通用规则见 [策略说明](./策略说明.md)。**开平仓、资金币种、API 按交易所分开写**,请直接打开对应文档。 -> 更新:2026-07-26 - ---- - -## 按交易所 - -| 交易所 | 文档 | 资金要点 | LIVE | -|--------|------|----------|------| -| **OKX** | [OKX实盘策略说明](./OKX实盘策略说明.md) | 永续 **USDT** + 期权常 **USDC**,需 **USDT↔USDC** | 已接 | -| **币安** | [币安实盘策略说明](./币安实盘策略说明.md) | 永续与欧洲期权多为 **USDT**,一般无需换 USDC | 已接 | - ---- - -## 共用口径(两所相同) - -| 项 | 说明 | -|----|------| -| 标准仓 | 永续 **1 ETH** + 期权 **2 ETH** 名义;倍数 `k` 同比例缩放;净利目标 ≈ `15×k` | -| 试跑 | 建议 `k=0.1`(0.1 + 0.2) | -| 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);**到期平仓** | -| 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` | -| 同时仓 | 最多 1 组活跃;残留期权不挡新开 | -| **LIVE 盈亏** | 手续费/永续 UPL/已实现/资金费以**交易所**为准;期权持仓浮盈用本地买一算法(期权净盈亏);USDC **1:1** 折 USDT;达标看组净盈亏(含资金费,**含估平仓手续费**,与 SIM 盯盘一致) | - ---- - -## 两所差异速览 - -| 项 | OKX | 币安 | -|----|-----|------| -| 换汇 | 常需 USDT→USDC 才能付期权 | 通常只需 USDT | -| API | Key + Secret + Passphrase | Key + Secret | -| 期权 | `ETH-USD_UM`(V5) | 欧洲期权 eapi | -| 永续数量 | 张(÷ ctVal) | ETH 名义(fapi) | -| 开平顺序 | 先期权后永续;回滚卖期权 | 同序,分 eapi / fapi | - -详细开平仓步骤、账户模式、检查清单见各所专篇。 +# 比特骆驼自动化对冲系统 · 实盘说明索引 + +> 产品:**比特骆驼自动化对冲系统** +> 通用规则见 [策略说明](./策略说明.md)。**开平仓、资金币种、API 按交易所分开写**,请直接打开对应文档。 +> 更新:2026-08-07(期期对冲) + +--- + +## 按交易所 + +| 交易所 | 文档 | 资金要点 | LIVE | +|--------|------|----------|------| +| **OKX** | [OKX实盘策略说明](./OKX实盘策略说明.md) | 永续 **USDT** + 期权常 **USDC**,需 **USDT↔USDC**;**期期仅期权资金** | 已接(含期期) | +| **币安** | [币安实盘策略说明](./币安实盘策略说明.md) | 永续与欧洲期权多为 **USDT**;**期期仅期权** | 已接(含期期) | + +--- + +## 共用口径(两所相同) + +| 项 | 说明 | +|----|------| +| 永期标准仓 | 永续 **1 ETH** + 期权 **2 ETH** 名义;倍数 `k` 同比例缩放;净利目标 ≈ `15×k` | +| 期期 | 虚值 Call+Put,预算 1:1;目标 = 预算 × 盈亏比;达标只平盈利腿(见 [期期对冲说明](./期期对冲说明.md)) | +| 试跑 | 建议 `k=0.1`(0.1 + 0.2);期期用小预算 % | +| 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);期期 B 语义为「平盈利期权腿」;**到期平仓** | +| 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` | +| 同时仓 | 最多 1 组活跃;残留期权不挡新开 | +| **LIVE 盈亏** | 手续费/永续 UPL/已实现/资金费以**交易所**为准;期权持仓浮盈用本地买一算法(期权净盈亏);USDC **1:1** 折 USDT;达标看组净盈亏(含资金费,**含估平仓手续费**,与 SIM 盯盘一致) | + +--- + +## 两所差异速览 + +| 项 | OKX | 币安 | +|----|-----|------| +| 换汇 | 常需 USDT→USDC 才能付期权 | 通常只需 USDT | +| API | Key + Secret + Passphrase | Key + Secret | +| 期权 | `ETH-USD_UM`(V5) | 欧洲期权 eapi | +| 永续数量 | 张(÷ ctVal) | ETH 名义(fapi) | +| 开平顺序 | 先期权后永续;回滚卖期权 | 同序,分 eapi / fapi | + +详细开平仓步骤、账户模式、检查清单见各所专篇。 diff --git a/docs/审计说明-2026-08-07-期期对冲.md b/docs/审计说明-2026-08-07-期期对冲.md new file mode 100644 index 0000000..28892c9 --- /dev/null +++ b/docs/审计说明-2026-08-07-期期对冲.md @@ -0,0 +1,33 @@ +# 审计说明 — 2026-08-07 期期对冲 + +## 范围 + +期期对冲(`hedge_mode=option_option`):振幅选约、1:1 定仓、SIM/LIVE 开平、盈利腿/全平、资金门、设置与 Plan UI。 + +## 审计 #1 — Bugbot + +| 问题 | 级别 | 处理 | +|------|------|------| +| LIVE 盈利腿卖出后重试可能双卖 | high | 先 `status=closing` 再下单;`closing` 态只做账本收尾 | +| 到期走永期 `close_group` 漏 Put | high | 新增 `close_oo_full`;引擎到期/紧急走全平;LIVE `live_sell_oo_both` | +| opening 恢复不识别 Put | high | opening intent 的 `perp_side` 写入 `oo_put:{inst}` 标记第二腿 | +| 资金门未计 cushion | medium | `assess_open_capacity` 期期需求 × `oo_budget_cushion` | + +## 审计 #2 — Security Review + +| 问题 | 级别 | 处理 | +|------|------|------| +| 到期/紧急仍用 perp `close_group` | high | 同 #1:OO 全平分支 | +| recover_stuck_opening 非 OO 感知 | high | intent 标记 Put;完整 OO 扫描恢复仍列为后续加固 | +| LIVE 盈利腿非原子 | high | closing 标记 + skip_market 收尾 | +| Put 失败 Call 回滚不确定 | medium | 保留 opening(既有);与 OO intent 标记配合人工/恢复 | +| size_and_gate hedge_mode 参数与 ledger 可能不一致 | medium | 正常路径同 tick 读写 DB;gate 读 ledger | + +## 结论 + +两轮审计指出的 **blocker 级交易安全问题已在代码中修补**(全平路径、closing 防重入、资金门 cushion、intent 标记)。 +`recover_stuck_opening` 完整双腿扫描仍建议下一迭代专项测试;当前以 intent 标记降低孤儿 Put 风险。 + +## 测试 + +- `tests/test_oo_selection_sizing.py`、`tests/test_risk_sizing.py` 通过。 diff --git a/docs/更新说明.md b/docs/更新说明.md index f8ee07d..8489c25 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,21 @@ --- +## 2026-08-07 — 期期对冲互斥模式 + +### 变更 + +1. 系统设置增加对冲模式:永期 / 期期二选一;期期参数(振幅、回看、到期、杠杆、盈亏比)。 +2. 期期:K 线高低点匹配虚值 Call+Put;以损预算 1:1 定仓(一位小数+预留);达标只平盈利腿,亏损腿 residual 20%/到期。 +3. SIM Matcher + OKX/币安 LiveExecutor 均实现开平;资金门期期不要求永续。 +4. 文档:`docs/期期对冲说明.md`、策略说明 §9.1;审计见 `docs/审计说明-2026-08-07-期期对冲.md`。 + +### 审计 + +两轮:Bugbot + Security Review(见审计说明)。 + +--- + ## 2026-07-30 — 中控文档与设置增强 ### 变更 diff --git a/docs/期期对冲说明.md b/docs/期期对冲说明.md new file mode 100644 index 0000000..9aaf66b --- /dev/null +++ b/docs/期期对冲说明.md @@ -0,0 +1,37 @@ +# 期期对冲说明 + +操作向说明:在系统设置中选择「期期对冲」后的行为与参数。 + +## 模式切换 + +- **永期对冲**(默认):买 1 腿期权 + 反向永续。 +- **期期对冲**:同时买入虚值 Call + 虚值 Put(无永续)。 +- 二者互斥;有持仓时不可切换。 + +## 开仓条件(均可在设置中改) + +| 参数 | 默认 | 含义 | +|------|------|------| +| 振幅最小 % | 1.5 | 回看窗内 `(高-低)/中价` 须 ≥ 该值 | +| 振幅回看小时 | 12 | 用 1H K 线取真实高低点 | +| 最短剩余到期 | 24 | 期权剩余小时 | +| 单腿最低杠杆 | 200 | `指数 / 卖一` | +| 盈亏比 | 2 | 出场目标 = 以损预算 × 比 | + +选约:高点附近虚值 Call、低点附近虚值 Put,同一到期。 + +## 定仓 + +- 仅支持以损定仓 + 亏损幅度 %(含倍投)。 +- 预算 B 预留余地后两腿 **1:1** 平分权利金;数量 **一位小数向下取整**。 +- 出场目标按全额 B × 盈亏比(例 B=100、比=2 → 目标 200U)。 + +## 平仓 + +1. 净浮盈 ≥ 目标 → **只平盈利腿**。 +2. 亏损腿进入残留:权利金回升 ≥ 初始的 20%(可设)可尝试自动平;否则到期结算。 +3. 到期强制结算仍按「到期算亏」计入倍投连亏日。 + +## SIM / LIVE + +规则同一套;差异仅成交通道与资金接口。OKX / 币安实盘均支持期期开平。 diff --git a/docs/策略说明.md b/docs/策略说明.md index 70f57e8..e613e24 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -3,17 +3,19 @@ > 产品:**比特骆驼自动化对冲系统**(工程 `eth_hedge_sim`) > 依据当前代码逻辑整理(SIM 默认真值参数)。 > 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘索引](./实盘策略说明.md)、[OKX实盘](./OKX实盘策略说明.md)、[币安实盘](./币安实盘策略说明.md) -> 更新:2026-08-02(开平仓细则 + 实盘异常处理;残留权利金回收) +> 更新:2026-08-07(期期对冲互斥模式) **运行模式**:设置页「运行模式」可切 **SIM / LIVE**;交易所 API 录入后写入服务器 `.env`(不回传明文)。LIVE 须二次确认输入 `LIVE`;**OKX / 币安均可真下单**(须选对应当前交易所并配齐密钥)。有持仓时不可切模式。 -分所动作细节另见:[OKX实盘](./OKX实盘策略说明.md)、[币安实盘](./币安实盘策略说明.md)。**策略口径以本文为准。** +分所动作细节另见:[OKX实盘](./OKX实盘策略说明.md)、[币安实盘](./币安实盘策略说明.md)。**策略口径以本文为准。** 期期操作说明见:[期期对冲说明](./期期对冲说明.md)。 --- ## 1. 策略一句话 -用 **ATM 期权买方** 表达方向弹性,用 **反向永续** 做对冲腿;波动大时争取多轮兑现净盈利,波动小时接受权利金磨损,**到期自动全平**。 +**默认(永期对冲)**:用 **ATM 期权买方** 表达方向弹性,用 **反向永续** 做对冲腿;波动大时争取多轮兑现净盈利,波动小时接受权利金磨损,**到期自动全平**。 + +**可选(期期对冲)**:系统设置二选一。用回看窗内真实高低点匹配 **虚值 Call + 虚值 Put**,以损预算 1:1 定仓;达标只平盈利腿,亏损腿残留至权利金回升或到期。 本质是 **概率与样本**:不追求每天固定轮次,而按行情吃机会。 @@ -455,6 +457,25 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、 | `close_bid_mark_max_pct` | 30 | 平仓/残留流动性 | | `residual_min_premium_pct` | 20 | 残留权利金回收门槛% | | `residual_close_check_sec` | 300 | 残留巡检间隔秒 | +| `hedge_mode` | perp_option | 永期 / 期期二选一 | +| `oo_amplitude_pct` | 1.5 | 期期振幅最小% | +| `oo_amplitude_hours` | 12 | 期期振幅回看小时 | +| `oo_min_option_hours` | 24 | 期期最短剩余到期 | +| `oo_min_leverage` | 200 | 期期单腿最低杠杆 | +| `oo_reward_ratio` | 2 | 期期盈亏比(目标=预算×比) | +| `oo_budget_cushion` | 0.92 | 期期定仓预留比例 | + +--- + +## 9.1 期期对冲(option + option) + +详见 [期期对冲说明](./期期对冲说明.md)。摘要: + +- 回看 `oo_amplitude_hours` 的真实高低;振幅不足不开。 +- 虚值 Call 贴高、Put 贴低;同到期;杠杆与剩余小时门槛。 +- 预算 1:1、qty 一位小数、目标 = B × `oo_reward_ratio`。 +- 达标只平盈利腿;亏损腿 residual(20% 回升或到期)。 +- SIM / LIVE(OKX、币安)规则对齐。 --- @@ -462,6 +483,7 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、 | 日期 | 说明 | |------|------| +| 2026-08-07 | 期期对冲互斥模式;振幅高低选约;盈亏比出场;SIM/LIVE 双通道 | | 2026-08-02 | §3/§4 展开开平仓逐步逻辑;新增 §5 实盘状态机与异常处理;残留买一 IOC 回收 | | 2026-07-24 | 固定方向:永续多→Put / 空→Call,仅实值或平值 | | 2026-07-25 | 初稿;平仓顺序先期权后永续 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7802943..a74e1a0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -304,6 +304,12 @@ export type PlanState = { move_pct?: number; initial_premium?: number; premium_gap?: number; + hedge_mode?: string; + option2_inst_id?: string; + option2_upl?: number; + option2_entry_px?: number; + option2_qty_eth?: number; + strike2?: number | null; }; residuals?: { group_id: string; @@ -341,6 +347,7 @@ export type PlanState = { martingale_loss_days?: number; risk_effective_loss_pct?: number; risk_loss_pct?: number; + hedge_mode?: "perp_option" | "option_option"; sizing_mode?: "manual" | "risk_based"; risk_based?: boolean; ledger: { equity: number; available: number; reserved: number }; @@ -388,6 +395,13 @@ export type StrategySettings = { martingale_enabled?: boolean; martingale_start_after_loss_days?: number; martingale_max_doubles?: number; + hedge_mode?: "perp_option" | "option_option"; + oo_amplitude_pct?: number; + oo_amplitude_hours?: number; + oo_min_option_hours?: number; + oo_min_leverage?: number; + oo_reward_ratio?: number; + oo_budget_cushion?: number; exchange?: string; }; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 538d64f..99b941f 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -213,7 +213,7 @@ export default function PlanPage() { const riskRatioLabel = `比例${Number(plan?.risk_perp_unit ?? 1)}:${Number(plan?.risk_option_unit ?? 2)}`; const sizingModeLabel = riskBased ? [ - "以损定仓", + plan?.hedge_mode === "option_option" ? "期期对冲" : "以损定仓", riskRatioLabel, plan?.risk_last_k != null ? `k=${fmt(plan.risk_last_k, 1)}` : null, riskLocked @@ -236,7 +236,9 @@ export default function PlanPage() { ] .filter(Boolean) .join(" ") - : "手动仓位"; + : plan?.hedge_mode === "option_option" + ? "期期对冲" + : "手动仓位"; const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—"; const atmRule = plan?.fixed_direction_enabled ? plan?.fixed_perp_side === "short" @@ -581,6 +583,57 @@ export default function PlanPage() {
+ 二选一。期期:近 N 小时振幅高低匹配虚值 Call/Put;达标只平盈利腿。 +
++ 目标盈利 = 以损预算 × 盈亏比(例预算 100U、比 2 → 目标 + 200U)。两腿 1:1 平分预算。 +
+