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:
dekun
2026-08-07 16:01:16 +08:00
parent 15fe2f72dc
commit ec244c63c6
22 changed files with 2640 additions and 83 deletions
+76
View File
@@ -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",
+8
View File
@@ -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 且须实值或平值
+185
View File
@@ -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 线回看 hoursinst 可用指数 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,
)
+243
View File
@@ -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:
+267
View File
@@ -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:
+13
View File
@@ -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"),
):
+693
View File
@@ -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
View File
@@ -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:
+170
View File
@@ -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:
"""虚值 CallK > 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:
"""虚值 PutK < 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}"
),
)
+66 -12
View File
@@ -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')}"
+46 -8
View File
@@ -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(
+161
View File
@@ -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,
+174
View File
@@ -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(两腿不同 strikecall/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
+66
View File
@@ -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
View File
@@ -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 | 同 #1OO 全平分支 |
| 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 读写 DBgate 读 ledger |
## 结论
两轮审计指出的 **blocker 级交易安全问题已在代码中修补**(全平路径、closing 防重入、资金门 cushion、intent 标记)。
`recover_stuck_opening` 完整双腿扫描仍建议下一迭代专项测试;当前以 intent 标记降低孤儿 Put 风险。
## 测试
- `tests/test_oo_selection_sizing.py``tests/test_risk_sizing.py` 通过。
+15
View File
@@ -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 — 中控文档与设置增强
### 变更
+37
View File
@@ -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
View File
@@ -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 / LIVEOKX、币安)规则对齐。
---
@@ -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 | 初稿;平仓顺序先期权后永续 |
+14
View File
@@ -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;
};
+70 -4
View File
@@ -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
+123
View File
@@ -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"