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
+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