Add option-option hedge mode with SIM/LIVE parity.
Mutual hedge_mode, amplitude OTM selection, 1:1 risk sizing, win-leg/full close, dual audits and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 且须实值或平值
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
|
||||
+113
-15
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
),
|
||||
)
|
||||
@@ -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')};"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+42
-41
@@ -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 |
|
||||
|
||||
详细开平仓步骤、账户模式、检查清单见各所专篇。
|
||||
|
||||
@@ -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` 通过。
|
||||
@@ -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 — 中控文档与设置增强
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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 / 币安实盘均支持期期开平。
|
||||
+25
-3
@@ -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 | 初稿;平仓顺序先期权后永续 |
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<div className="plan-positions">
|
||||
{open ? (
|
||||
<>
|
||||
{plan?.hedge_mode === "option_option" ||
|
||||
pos?.hedge_mode === "option_option" ||
|
||||
pos?.option2_inst_id ? (
|
||||
<div className="pos-card">
|
||||
<div className="pos-card-head">
|
||||
<div className="pos-card-symbol">
|
||||
<strong>
|
||||
{pos?.option_inst_id || "Call"}
|
||||
</strong>
|
||||
<span className="pos-side-badge pos-side-long">
|
||||
看涨 Call
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pos-meta">
|
||||
<span className="pos-meta-item">期期 · Call</span>
|
||||
<span className="pos-meta-item">组 {pos?.group_id}</span>
|
||||
<span className="pos-meta-item mono">
|
||||
数量 {fmt(pos?.option_qty_eth, 1)} ETH
|
||||
</span>
|
||||
</div>
|
||||
<div className="pos-grid">
|
||||
<div className="pos-cell">
|
||||
<span className="pos-label">开仓价</span>
|
||||
<span className="pos-value mono">
|
||||
{fmt(pos?.option_entry_px)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pos-cell">
|
||||
<span className="pos-label">浮盈亏</span>
|
||||
<span
|
||||
className={`pos-value mono ${pnlClass(pos?.option_upl)}`}
|
||||
>
|
||||
{fmt(pos?.option_upl)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pos-cell">
|
||||
<span className="pos-label">行权价</span>
|
||||
<span className="pos-value mono">
|
||||
{fmt(pos?.strike, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pos-cell">
|
||||
<span className="pos-label">持仓时长</span>
|
||||
<span className="pos-value mono">
|
||||
{holdDurationLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pos-card">
|
||||
<div className="pos-card-head">
|
||||
<div className="pos-card-symbol">
|
||||
@@ -649,19 +702,32 @@ export default function PlanPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pos-card">
|
||||
<div className="pos-card-head">
|
||||
<div className="pos-card-symbol">
|
||||
<strong>{pos?.option_inst_id || "期权"}</strong>
|
||||
<strong>
|
||||
{plan?.hedge_mode === "option_option" ||
|
||||
pos?.option2_inst_id
|
||||
? pos?.option2_inst_id || "Put"
|
||||
: pos?.option_inst_id || "期权"}
|
||||
</strong>
|
||||
<span
|
||||
className={
|
||||
plan?.hedge_mode === "option_option" ||
|
||||
pos?.option2_inst_id ||
|
||||
pos?.option_side === "call"
|
||||
? "pos-side-badge pos-side-long"
|
||||
: "pos-side-badge pos-side-short"
|
||||
}
|
||||
>
|
||||
{pos?.option_side === "call" ? "看涨 Call" : "看跌 Put"}
|
||||
{plan?.hedge_mode === "option_option" ||
|
||||
pos?.option2_inst_id
|
||||
? "看跌 Put"
|
||||
: pos?.option_side === "call"
|
||||
? "看涨 Call"
|
||||
: "看跌 Put"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -108,6 +108,14 @@ export default function SettingsPage() {
|
||||
const [martingaleOn, setMartingaleOn] = useState(false);
|
||||
const [martingaleStartAfter, setMartingaleStartAfter] = useState(2);
|
||||
const [martingaleMaxDoubles, setMartingaleMaxDoubles] = useState(3);
|
||||
const [hedgeMode, setHedgeMode] = useState<"perp_option" | "option_option">(
|
||||
"perp_option",
|
||||
);
|
||||
const [ooAmpPct, setOoAmpPct] = useState(1.5);
|
||||
const [ooAmpHours, setOoAmpHours] = useState(12);
|
||||
const [ooMinHours, setOoMinHours] = useState(24);
|
||||
const [ooMinLev, setOoMinLev] = useState(200);
|
||||
const [ooRewardRatio, setOoRewardRatio] = useState(2);
|
||||
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -221,6 +229,14 @@ export default function SettingsPage() {
|
||||
setMartingaleOn(s.martingale_enabled === true);
|
||||
setMartingaleStartAfter(s.martingale_start_after_loss_days ?? 2);
|
||||
setMartingaleMaxDoubles(s.martingale_max_doubles ?? 3);
|
||||
setHedgeMode(
|
||||
s.hedge_mode === "option_option" ? "option_option" : "perp_option",
|
||||
);
|
||||
setOoAmpPct(s.oo_amplitude_pct ?? 1.5);
|
||||
setOoAmpHours(s.oo_amplitude_hours ?? 12);
|
||||
setOoMinHours(s.oo_min_option_hours ?? 24);
|
||||
setOoMinLev(s.oo_min_leverage ?? 200);
|
||||
setOoRewardRatio(s.oo_reward_ratio ?? 2);
|
||||
setRiskPreview(
|
||||
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
|
||||
? s.risk_sizing_preview
|
||||
@@ -389,6 +405,12 @@ export default function SettingsPage() {
|
||||
martingaleOn,
|
||||
martingale_start_after_loss_days: martingaleStartAfter,
|
||||
martingale_max_doubles: martingaleMaxDoubles,
|
||||
hedge_mode: hedgeMode,
|
||||
oo_amplitude_pct: ooAmpPct,
|
||||
oo_amplitude_hours: ooAmpHours,
|
||||
oo_min_option_hours: ooMinHours,
|
||||
oo_min_leverage: ooMinLev,
|
||||
oo_reward_ratio: ooRewardRatio,
|
||||
exchange,
|
||||
};
|
||||
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
||||
@@ -682,12 +704,113 @@ export default function SettingsPage() {
|
||||
<option value="isolated">逐仓</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="hedgeMode">对冲模式</label>
|
||||
<select
|
||||
id="hedgeMode"
|
||||
className="mono"
|
||||
value={hedgeMode}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
e.target.value === "option_option"
|
||||
? "option_option"
|
||||
: "perp_option";
|
||||
setHedgeMode(v);
|
||||
if (v === "option_option") {
|
||||
setSizingMode("risk_based");
|
||||
setRiskLossMode("percent");
|
||||
setFixedDirOn(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="perp_option">永期对冲(永续+期权)</option>
|
||||
<option value="option_option">期期对冲(双期权)</option>
|
||||
</select>
|
||||
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
|
||||
二选一。期期:近 N 小时振幅高低匹配虚值 Call/Put;达标只平盈利腿。
|
||||
</p>
|
||||
</div>
|
||||
{hedgeMode === "option_option" ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="ooAmp">振幅最小(%)</label>
|
||||
<input
|
||||
id="ooAmp"
|
||||
className="mono"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
value={ooAmpPct}
|
||||
onChange={(e) => setOoAmpPct(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="ooAmpH">振幅回看(小时)</label>
|
||||
<input
|
||||
id="ooAmpH"
|
||||
className="mono"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={ooAmpHours}
|
||||
onChange={(e) =>
|
||||
setOoAmpHours(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="ooMinH">最短剩余到期(小时)</label>
|
||||
<input
|
||||
id="ooMinH"
|
||||
className="mono"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={ooMinHours}
|
||||
onChange={(e) =>
|
||||
setOoMinHours(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="ooLev">单腿最低杠杆</label>
|
||||
<input
|
||||
id="ooLev"
|
||||
className="mono"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={ooMinLev}
|
||||
onChange={(e) => setOoMinLev(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="ooRR">盈亏比</label>
|
||||
<input
|
||||
id="ooRR"
|
||||
className="mono"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
value={ooRewardRatio}
|
||||
onChange={(e) =>
|
||||
setOoRewardRatio(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
|
||||
目标盈利 = 以损预算 × 盈亏比(例预算 100U、比 2 → 目标
|
||||
200U)。两腿 1:1 平分预算。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="field">
|
||||
<label htmlFor="sizingMode">定仓模式</label>
|
||||
<select
|
||||
id="sizingMode"
|
||||
className="mono"
|
||||
value={sizingMode}
|
||||
disabled={hedgeMode === "option_option"}
|
||||
onChange={(e) =>
|
||||
setSizingMode(
|
||||
e.target.value === "risk_based"
|
||||
|
||||
Reference in New Issue
Block a user