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:
+113
-15
@@ -245,6 +245,22 @@ class StrategyEngine:
|
||||
"option_qty_eth": opt_qty,
|
||||
"sizing_mode": sizing_mode,
|
||||
"risk_based": sizing_mode == "risk_based",
|
||||
"hedge_mode": (
|
||||
hm
|
||||
if (
|
||||
hm := str(
|
||||
self.ledger.get_setting_str(
|
||||
"hedge_mode", s.hedge_mode
|
||||
)
|
||||
or s.hedge_mode
|
||||
or "perp_option"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
in ("perp_option", "option_option")
|
||||
else "perp_option"
|
||||
),
|
||||
"risk_perp_unit": risk_perp_unit,
|
||||
"risk_option_unit": risk_option_unit,
|
||||
"risk_exit_unit": risk_exit_unit,
|
||||
@@ -812,6 +828,10 @@ class StrategyEngine:
|
||||
)
|
||||
pending_close = st["phase"] in ("liquidity_wait", "closing")
|
||||
if expired.should_close or decision.should_close or pending_close:
|
||||
is_oo = (
|
||||
str(upl.get("hedge_mode") or "") == "option_option"
|
||||
or bool(upl.get("option2_inst_id"))
|
||||
)
|
||||
if expired.should_close:
|
||||
reason = "expiry"
|
||||
bypass = True
|
||||
@@ -822,11 +842,66 @@ class StrategyEngine:
|
||||
bypass = False
|
||||
abandon = bool(decision.should_close or pending_close)
|
||||
rkind = "liquidity" if pending_close else "close"
|
||||
if is_oo and decision.should_close and not expired.should_close:
|
||||
# 期期达标:只平盈利腿,亏损腿残留
|
||||
close_oo = getattr(
|
||||
self.matcher, "close_winning_oo_leave_residual", None
|
||||
)
|
||||
if close_oo is not None:
|
||||
r = await asyncio.to_thread(
|
||||
close_oo, reason="target_oo_win"
|
||||
)
|
||||
if r.ok:
|
||||
self._enter_rest_after_close()
|
||||
self._set_state(phase="resting", last_error=None)
|
||||
else:
|
||||
self._set_state(
|
||||
phase="liquidity_wait",
|
||||
last_error=r.detail or "期期盈利腿暂不可平",
|
||||
)
|
||||
return
|
||||
if is_oo and (
|
||||
expired.should_close
|
||||
or reason in ("expiry", "emergency", "manual")
|
||||
or bypass
|
||||
):
|
||||
close_full = getattr(self.matcher, "close_oo_full", None)
|
||||
if close_full is not None and (
|
||||
expired.should_close or bypass or reason == "emergency"
|
||||
):
|
||||
# LIVE:先交易所卖两腿
|
||||
for sell_fn_name in (
|
||||
"_live_sell_oo_both",
|
||||
"live_sell_oo_both",
|
||||
):
|
||||
sell_both = getattr(self.matcher, sell_fn_name, None)
|
||||
if callable(sell_both):
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
sell_both, bypass_liquidity=bypass
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("live sell oo both failed")
|
||||
break
|
||||
r = await asyncio.to_thread(
|
||||
close_full,
|
||||
reason=reason if reason != "liquidity_retry" else "expiry",
|
||||
bypass_liquidity=True,
|
||||
)
|
||||
if r.ok:
|
||||
self._enter_rest_after_close()
|
||||
self._set_state(phase="resting", last_error=None)
|
||||
else:
|
||||
self._set_state(
|
||||
phase="liquidity_wait",
|
||||
last_error=r.detail or "期期全平失败",
|
||||
)
|
||||
return
|
||||
await self._close_open_position(
|
||||
reason=reason,
|
||||
bypass_liquidity=bypass,
|
||||
pending_close=pending_close,
|
||||
abandon_if_deep_otm=abandon,
|
||||
abandon_if_deep_otm=abandon and not is_oo,
|
||||
retry_kind=rkind,
|
||||
)
|
||||
else:
|
||||
@@ -948,10 +1023,14 @@ class StrategyEngine:
|
||||
# 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道)
|
||||
from .open_pipeline import size_and_gate
|
||||
|
||||
oo = getattr(pick, "hedge_mode", "perp_option") == "option_option"
|
||||
prep = size_and_gate(
|
||||
index_px=float(pick.underlying_px),
|
||||
option_ask=float(pick.option_ask),
|
||||
db=self.db,
|
||||
call_ask=float(pick.call_ask) if oo else None,
|
||||
put_ask=float(pick.put_ask) if oo else None,
|
||||
hedge_mode="option_option" if oo else "perp_option",
|
||||
)
|
||||
if not prep.ok:
|
||||
phase = "wait_funds" if prep.capacity is not None else "idle"
|
||||
@@ -977,9 +1056,6 @@ class StrategyEngine:
|
||||
wkey = window_key()
|
||||
count = self._count_groups_for_day(wkey)
|
||||
gid = next_group_id(count)
|
||||
option_inst = (
|
||||
pick.pair.call_inst_id if pick.option_side == "call" else pick.pair.put_inst_id
|
||||
)
|
||||
entry_idx = pick.underlying_px
|
||||
if not get_settings().is_sim:
|
||||
from ..live.reconcile import assert_safe_to_open_live
|
||||
@@ -998,17 +1074,39 @@ class StrategyEngine:
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
r = await asyncio.to_thread(
|
||||
self.matcher.open_group,
|
||||
group_id=gid,
|
||||
bias=pick.bias,
|
||||
option_side=pick.option_side,
|
||||
perp_side=pick.perp_side,
|
||||
option_inst_id=option_inst,
|
||||
entry_index_px=float(entry_idx),
|
||||
strike=pick.pair.strike,
|
||||
expiry_ymd=pick.pair.expiry_ymd,
|
||||
)
|
||||
if oo:
|
||||
open_fn = getattr(self.matcher, "open_oo_group", None)
|
||||
if open_fn is None:
|
||||
self._set_state(phase="idle", last_error="当前执行器不支持期期开仓")
|
||||
return
|
||||
r = await asyncio.to_thread(
|
||||
open_fn,
|
||||
group_id=gid,
|
||||
call_inst_id=str(pick.call_inst_id or pick.pair.call_inst_id),
|
||||
put_inst_id=str(pick.put_inst_id or pick.pair.put_inst_id),
|
||||
call_strike=float(pick.call_strike or pick.pair.strike),
|
||||
put_strike=float(pick.put_strike or pick.pair.strike),
|
||||
entry_index_px=float(entry_idx),
|
||||
expiry_ymd=pick.pair.expiry_ymd,
|
||||
)
|
||||
option_inst = str(pick.call_inst_id or pick.pair.call_inst_id)
|
||||
else:
|
||||
option_inst = (
|
||||
pick.pair.call_inst_id
|
||||
if pick.option_side == "call"
|
||||
else pick.pair.put_inst_id
|
||||
)
|
||||
r = await asyncio.to_thread(
|
||||
self.matcher.open_group,
|
||||
group_id=gid,
|
||||
bias=pick.bias,
|
||||
option_side=pick.option_side,
|
||||
perp_side=pick.perp_side,
|
||||
option_inst_id=option_inst,
|
||||
entry_index_px=float(entry_idx),
|
||||
strike=pick.pair.strike,
|
||||
expiry_ymd=pick.pair.expiry_ymd,
|
||||
)
|
||||
if r.ok:
|
||||
self._set_state(phase="open", last_error=None)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""期期对冲选约:振幅高低点匹配虚值 Call + Put。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ..exchange.candles import AmplitudeHL, fetch_amplitude_hl_for_runtime
|
||||
from .selection import (
|
||||
_complete_by_expiry,
|
||||
hours_until_ms,
|
||||
list_eligible_expiry_ymds,
|
||||
option_leverage,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoLeg:
|
||||
side: str # call|put
|
||||
strike: float
|
||||
inst_id: str
|
||||
ask: float
|
||||
leverage: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoPickCore:
|
||||
expiry_ymd: str
|
||||
expiry_ms: int
|
||||
hours_left: float
|
||||
underlying_px: float
|
||||
amplitude: AmplitudeHL
|
||||
call: OoLeg
|
||||
put: OoLeg
|
||||
detail: str = "ok"
|
||||
|
||||
|
||||
def pick_otm_call_strike(strikes: list[float], *, spot: float, high: float) -> float | None:
|
||||
"""虚值 Call:K > spot,优先贴近振幅高点。"""
|
||||
cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9]
|
||||
if not cands:
|
||||
return None
|
||||
return min(cands, key=lambda s: (abs(s - float(high)), s))
|
||||
|
||||
|
||||
def pick_otm_put_strike(strikes: list[float], *, spot: float, low: float) -> float | None:
|
||||
"""虚值 Put:K < spot,优先贴近振幅低点。"""
|
||||
cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9]
|
||||
if not cands:
|
||||
return None
|
||||
return min(cands, key=lambda s: (abs(s - float(low)), s))
|
||||
|
||||
|
||||
def select_oo_pair(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
spot: float,
|
||||
high: float,
|
||||
low: float,
|
||||
min_hours: float,
|
||||
now: datetime | None = None,
|
||||
skip_expiry_ymds: set[str] | None = None,
|
||||
) -> tuple[str, int, float, float, str, str] | None:
|
||||
"""
|
||||
返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。
|
||||
Call/Put 可不同行权价;须同到期且均为虚值。
|
||||
"""
|
||||
if spot <= 0 or high <= 0 or low <= 0 or high < low:
|
||||
return None
|
||||
complete = _complete_by_expiry(contracts)
|
||||
if not complete:
|
||||
return None
|
||||
skip = skip_expiry_ymds or set()
|
||||
eligible = [
|
||||
y
|
||||
for y in list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now)
|
||||
if y not in skip
|
||||
]
|
||||
for ymd in eligible:
|
||||
ems, strikes_map = complete[ymd]
|
||||
strikes = list(strikes_map.keys())
|
||||
ck = pick_otm_call_strike(strikes, spot=spot, high=high)
|
||||
pk = pick_otm_put_strike(strikes, spot=spot, low=low)
|
||||
if ck is None or pk is None:
|
||||
continue
|
||||
call_inst = strikes_map[ck].get("C")
|
||||
put_inst = strikes_map[pk].get("P")
|
||||
if not call_inst or not put_inst:
|
||||
continue
|
||||
hours_left = hours_until_ms(ems, now)
|
||||
return (
|
||||
ymd,
|
||||
int(ems),
|
||||
float(ck),
|
||||
float(pk),
|
||||
str(call_inst),
|
||||
str(put_inst),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_oo_pick_core(
|
||||
*,
|
||||
contracts: list[dict[str, Any]],
|
||||
spot: float,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
min_hours: float,
|
||||
min_leverage: float,
|
||||
amplitude_hours: float,
|
||||
amplitude_pct: float,
|
||||
amplitude: AmplitudeHL | None = None,
|
||||
skip_expiry_ymds: set[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> OoPickCore | None:
|
||||
"""完整期期选约:振幅门 + 虚值双腿 + 杠杆。"""
|
||||
amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours)
|
||||
if amp is None:
|
||||
return None
|
||||
if float(amp.range_pct) + 1e-12 < float(amplitude_pct):
|
||||
return None
|
||||
if spot <= 0:
|
||||
spot = float(amp.mid)
|
||||
picked = select_oo_pair(
|
||||
contracts,
|
||||
spot=float(spot),
|
||||
high=float(amp.high),
|
||||
low=float(amp.low),
|
||||
min_hours=float(min_hours),
|
||||
now=now,
|
||||
skip_expiry_ymds=skip_expiry_ymds,
|
||||
)
|
||||
if picked is None:
|
||||
return None
|
||||
ymd, ems, ck, pk, call_inst, put_inst = picked
|
||||
if call_ask <= 0 or put_ask <= 0:
|
||||
return None
|
||||
c_lev = option_leverage(float(spot), float(call_ask))
|
||||
p_lev = option_leverage(float(spot), float(put_ask))
|
||||
if c_lev is None or p_lev is None:
|
||||
return None
|
||||
if c_lev + 1e-12 < float(min_leverage) or p_lev + 1e-12 < float(min_leverage):
|
||||
return None
|
||||
hours_left = hours_until_ms(ems, now)
|
||||
return OoPickCore(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=int(ems),
|
||||
hours_left=float(hours_left),
|
||||
underlying_px=float(spot),
|
||||
amplitude=amp,
|
||||
call=OoLeg(
|
||||
side="call",
|
||||
strike=float(ck),
|
||||
inst_id=call_inst,
|
||||
ask=float(call_ask),
|
||||
leverage=float(c_lev),
|
||||
),
|
||||
put=OoLeg(
|
||||
side="put",
|
||||
strike=float(pk),
|
||||
inst_id=put_inst,
|
||||
ask=float(put_ask),
|
||||
leverage=float(p_lev),
|
||||
),
|
||||
detail=(
|
||||
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
|
||||
f"C@{ck:g} P@{pk:g}"
|
||||
),
|
||||
)
|
||||
@@ -104,17 +104,24 @@ def assess_open_capacity(
|
||||
option_ask: float | None = None,
|
||||
option_qty_eth: float | None = None,
|
||||
perp_qty_eth: float | None = None,
|
||||
call_ask: float | None = None,
|
||||
put_ask: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回永续/期权是否有足够交易账户资金开新仓。
|
||||
- 永续:交易账户 USDT >= 名义/杠杆
|
||||
- 期权:交易账户 USDC >= 卖一×名义×(1+费率)
|
||||
可选覆盖 ask/名义(选约后应用选中腿卖一,避免与 max(call,put) 打架)。
|
||||
- 期期:期权需 (call_ask+put_ask)×qty×(1+fee);永续视为不需要
|
||||
"""
|
||||
global _notified_while_short
|
||||
db = db or get_db()
|
||||
s = get_settings()
|
||||
ledger = Ledger(db)
|
||||
hedge = str(
|
||||
ledger.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode
|
||||
).strip().lower()
|
||||
if hedge not in ("perp_option", "option_option"):
|
||||
hedge = "perp_option"
|
||||
lev = float(ledger.get_setting_float("leverage", s.leverage) or 3)
|
||||
if lev <= 0:
|
||||
lev = 3.0
|
||||
@@ -132,10 +139,38 @@ def assess_open_capacity(
|
||||
|
||||
idx, ask_book = _index_and_option_ask()
|
||||
ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book
|
||||
margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None
|
||||
premium_need = (
|
||||
float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None
|
||||
)
|
||||
if hedge == "option_option":
|
||||
ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None
|
||||
pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None
|
||||
if ca is None or pa is None:
|
||||
# 回退:用监控对 call/put 卖一
|
||||
try:
|
||||
from .session import get_session
|
||||
|
||||
snap = get_session().snapshot()
|
||||
if ca is None and snap.call and snap.call.ask:
|
||||
ca = float(snap.call.ask)
|
||||
if pa is None and snap.put and snap.put.ask:
|
||||
pa = float(snap.put.ask)
|
||||
except Exception:
|
||||
pass
|
||||
cush = float(
|
||||
ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
|
||||
or s.oo_budget_cushion
|
||||
)
|
||||
cush = min(1.0, max(0.5, cush))
|
||||
if ca is not None and pa is not None and ca > 0 and pa > 0:
|
||||
# 与定仓一致:按预留后的权利金需求估资金门
|
||||
premium_need = (ca + pa) * opt_qty * (1.0 + fee_rate) * cush
|
||||
else:
|
||||
premium_need = None
|
||||
margin_need = 0.0
|
||||
perp_qty = 0.0
|
||||
else:
|
||||
margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None
|
||||
premium_need = (
|
||||
float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None
|
||||
)
|
||||
|
||||
if s.is_sim:
|
||||
bal = _sim_balances(db)
|
||||
@@ -149,24 +184,31 @@ def assess_open_capacity(
|
||||
have_opt = float(t_usdc) if t_usdc is not None else None
|
||||
|
||||
perp_ok: bool | None
|
||||
if margin_need is None or have_perp is None:
|
||||
if hedge == "option_option":
|
||||
perp_ok = True
|
||||
elif margin_need is None or have_perp is None:
|
||||
perp_ok = None
|
||||
else:
|
||||
perp_ok = have_perp + 1e-9 >= margin_need
|
||||
perp_ok = float(have_perp) + 1e-9 >= float(margin_need)
|
||||
|
||||
opt_ok: bool | None
|
||||
if premium_need is None or have_opt is None:
|
||||
opt_ok = None
|
||||
else:
|
||||
opt_ok = have_opt + 1e-9 >= premium_need
|
||||
opt_ok = float(have_opt) + 1e-9 >= float(premium_need)
|
||||
|
||||
funds_ok = perp_ok is True and opt_ok is True
|
||||
if hedge == "option_option":
|
||||
funds_ok = opt_ok is True
|
||||
else:
|
||||
funds_ok = perp_ok is True and opt_ok is True
|
||||
# 资金恢复后允许下次不足再通知一次
|
||||
if funds_ok:
|
||||
_notified_while_short = False
|
||||
|
||||
lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev
|
||||
if perp_ok is True:
|
||||
if hedge == "option_option":
|
||||
perp_label = "永续 —(期期)"
|
||||
elif perp_ok is True:
|
||||
perp_label = f"永续{lev_i}x 可开"
|
||||
elif perp_ok is False:
|
||||
perp_label = f"永续{lev_i}x 不可开"
|
||||
@@ -181,6 +223,7 @@ def assess_open_capacity(
|
||||
opt_label = "期权 —"
|
||||
|
||||
return {
|
||||
"hedge_mode": hedge,
|
||||
"leverage": lev,
|
||||
"perp_qty_eth": perp_qty,
|
||||
"option_qty_eth": opt_qty,
|
||||
@@ -201,11 +244,22 @@ def assess_open_capacity(
|
||||
|
||||
def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]:
|
||||
"""
|
||||
Fail-closed:仅当永续与期权均为 True 才放行。
|
||||
None(未知,如币安未接余额)或 False → 拦截。
|
||||
Fail-closed:永期需永续+期权均为 True;期期仅需期权为 True。
|
||||
None(未知)或 False → 拦截。
|
||||
"""
|
||||
if not cap:
|
||||
return True, "资金可开判定结果为空,拒绝开仓"
|
||||
hedge = str(cap.get("hedge_mode") or "perp_option").strip().lower()
|
||||
if hedge == "option_option":
|
||||
if cap.get("option_can_open") is not True:
|
||||
detail = (
|
||||
f"{cap.get('option_label')};"
|
||||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||||
)
|
||||
if cap.get("option_can_open") is None:
|
||||
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
|
||||
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
|
||||
return False, ""
|
||||
if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True:
|
||||
detail = (
|
||||
f"{cap.get('perp_label')} · {cap.get('option_label')};"
|
||||
|
||||
@@ -38,20 +38,51 @@ def size_and_gate(
|
||||
index_px: float,
|
||||
option_ask: float,
|
||||
db: Database | None = None,
|
||||
call_ask: float | None = None,
|
||||
put_ask: float | None = None,
|
||||
hedge_mode: str | None = None,
|
||||
) -> OpenPrepResult:
|
||||
"""
|
||||
选约成功后:写入以损定仓 → 交易账户兑 USDC → 资金门。
|
||||
资金门 fail-closed:异常 / can_open 非 True 一律拦截。
|
||||
"""
|
||||
database = db or get_db()
|
||||
mode = str(hedge_mode or "").strip().lower()
|
||||
if not mode:
|
||||
try:
|
||||
from ..config import get_settings
|
||||
from ..sim.ledger import Ledger
|
||||
|
||||
s = get_settings()
|
||||
mode = str(
|
||||
Ledger(database).get_setting_str("hedge_mode", s.hedge_mode)
|
||||
or s.hedge_mode
|
||||
).strip().lower()
|
||||
except Exception:
|
||||
mode = "perp_option"
|
||||
|
||||
try:
|
||||
rs = apply_risk_sizing_to_ledger(
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
db=database,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
if mode == "option_option":
|
||||
from .risk_sizing import apply_oo_sizing_to_ledger
|
||||
|
||||
if call_ask is None or put_ask is None:
|
||||
return OpenPrepResult(ok=False, detail="期期定仓缺少 call/put 卖一")
|
||||
rs = apply_oo_sizing_to_ledger(
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
index_px=float(index_px),
|
||||
db=database,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
else:
|
||||
rs = apply_risk_sizing_to_ledger(
|
||||
index_px=float(index_px),
|
||||
option_ask=float(option_ask),
|
||||
db=database,
|
||||
)
|
||||
if not rs.ok:
|
||||
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
|
||||
except Exception as e:
|
||||
logger.exception("risk sizing failed in open pipeline")
|
||||
return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}")
|
||||
@@ -66,6 +97,8 @@ def size_and_gate(
|
||||
cap=assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(option_ask),
|
||||
call_ask=call_ask,
|
||||
put_ask=put_ask,
|
||||
),
|
||||
force=False,
|
||||
)
|
||||
@@ -77,7 +110,12 @@ def size_and_gate(
|
||||
convert_detail = "自动兑 USDC 异常(已记日志)"
|
||||
|
||||
try:
|
||||
cap = assess_open_capacity(database, option_ask=float(option_ask))
|
||||
cap = assess_open_capacity(
|
||||
database,
|
||||
option_ask=float(option_ask),
|
||||
call_ask=call_ask,
|
||||
put_ask=put_ask,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("open capacity assess failed")
|
||||
return OpenPrepResult(
|
||||
|
||||
@@ -501,6 +501,167 @@ def compute_risk_sizing(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OoSizingResult:
|
||||
ok: bool
|
||||
detail: str
|
||||
budget: float | None = None
|
||||
spend: float | None = None
|
||||
qty_eth: float | None = None
|
||||
call_ask: float | None = None
|
||||
put_ask: float | None = None
|
||||
call_premium: float | None = None
|
||||
put_premium: float | None = None
|
||||
max_loss: float | None = None
|
||||
net_profit_target: float | None = None
|
||||
capital_base: float | None = None
|
||||
cushion: float | None = None
|
||||
reward_ratio: float | None = None
|
||||
|
||||
|
||||
def compute_oo_sizing(
|
||||
*,
|
||||
budget: float,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
fee_rate: float = 0.0005,
|
||||
index_px: float = 0.0,
|
||||
cushion: float = 0.92,
|
||||
reward_ratio: float = 2.0,
|
||||
) -> OoSizingResult:
|
||||
"""
|
||||
期期 1:1:预算 B 预留后平分两腿权利金;qty 一位小数向下取整;
|
||||
出场目标 = B × reward_ratio(按全额预算)。
|
||||
"""
|
||||
if budget is None or budget <= 0 or not math.isfinite(budget):
|
||||
return OoSizingResult(ok=False, detail="期期预算无效")
|
||||
if call_ask <= 0 or put_ask <= 0:
|
||||
return OoSizingResult(ok=False, detail="期期缺少有效卖一")
|
||||
cush = min(1.0, max(0.5, float(cushion)))
|
||||
ratio = max(0.5, float(reward_ratio))
|
||||
spend = float(budget) * cush
|
||||
# 粗估两腿开仓费(按指数名义近似)
|
||||
fee_est = 0.0
|
||||
if index_px and index_px > 0 and fee_rate > 0:
|
||||
fee_est = float(index_px) * float(fee_rate) * 2.0
|
||||
spend_prem = max(0.0, spend - fee_est)
|
||||
if spend_prem <= 1e-9:
|
||||
return OoSizingResult(ok=False, detail="期期预留后可用权利金不足")
|
||||
leg = spend_prem / 2.0
|
||||
# 等量:受较贵腿限制
|
||||
q_call = floor_k_1dp(leg / float(call_ask))
|
||||
q_put = floor_k_1dp(leg / float(put_ask))
|
||||
qty = min(q_call, q_put)
|
||||
if qty < 0.1 - 1e-12:
|
||||
return OoSizingResult(
|
||||
ok=False,
|
||||
detail=(
|
||||
f"期期定仓 qty<{0.1}(call可{q_call} put可{q_put}),"
|
||||
f"预算 {budget:.2f}U 不足"
|
||||
),
|
||||
budget=_round2(float(budget)),
|
||||
)
|
||||
# 若仍略超 spend_prem,再降一档
|
||||
while qty >= 0.1 - 1e-12:
|
||||
cp = float(call_ask) * qty
|
||||
pp = float(put_ask) * qty
|
||||
if cp + pp <= spend_prem + 1e-6:
|
||||
return OoSizingResult(
|
||||
ok=True,
|
||||
detail="ok",
|
||||
budget=_round2(float(budget)),
|
||||
spend=_round2(spend),
|
||||
qty_eth=round(qty, 1),
|
||||
call_ask=_round2(float(call_ask)),
|
||||
put_ask=_round2(float(put_ask)),
|
||||
call_premium=_round2(cp),
|
||||
put_premium=_round2(pp),
|
||||
max_loss=_round2(cp + pp + fee_est),
|
||||
net_profit_target=_round2(float(budget) * ratio),
|
||||
cushion=cush,
|
||||
reward_ratio=ratio,
|
||||
)
|
||||
qty = round(qty - 0.1, 1)
|
||||
return OoSizingResult(
|
||||
ok=False,
|
||||
detail=f"期期无法在预算 {budget:.2f}U 内找到合规 qty",
|
||||
budget=_round2(float(budget)),
|
||||
)
|
||||
|
||||
|
||||
def apply_oo_sizing_to_ledger(
|
||||
*,
|
||||
call_ask: float,
|
||||
put_ask: float,
|
||||
index_px: float,
|
||||
db: Database | None = None,
|
||||
) -> OoSizingResult:
|
||||
database = db or get_db()
|
||||
ledger = Ledger(database)
|
||||
s = get_settings()
|
||||
pos = database.fetchone("SELECT status FROM positions WHERE id=1")
|
||||
if pos is not None:
|
||||
st = str(pos["status"] or "flat")
|
||||
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
|
||||
return OoSizingResult(
|
||||
ok=False,
|
||||
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
|
||||
)
|
||||
budget, detail, capital = resolve_budget(database)
|
||||
if budget is None:
|
||||
return OoSizingResult(ok=False, detail=f"期期预算失败: {detail}")
|
||||
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
||||
cushion = ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
|
||||
ratio = ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio)
|
||||
r = compute_oo_sizing(
|
||||
budget=float(budget),
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
fee_rate=fee_rate,
|
||||
index_px=float(index_px),
|
||||
cushion=cushion,
|
||||
reward_ratio=ratio,
|
||||
)
|
||||
if not r.ok:
|
||||
return r
|
||||
database.set_setting("exit_mode", "fixed_usdt")
|
||||
database.set_setting("perp_qty_eth", "0")
|
||||
database.set_setting("option_qty_eth", str(r.qty_eth))
|
||||
database.set_setting("net_profit_target", str(r.net_profit_target))
|
||||
database.set_setting("risk_last_k", str(r.qty_eth))
|
||||
database.set_setting(
|
||||
"risk_last_max_loss",
|
||||
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
|
||||
)
|
||||
logger.info(
|
||||
"oo_sizing applied qty=%.1f call_ask=%.4f put_ask=%.4f exit=%.2f "
|
||||
"max_loss=%.2f budget=%.2f",
|
||||
r.qty_eth or 0,
|
||||
r.call_ask or 0,
|
||||
r.put_ask or 0,
|
||||
r.net_profit_target or 0,
|
||||
r.max_loss or 0,
|
||||
r.budget or 0,
|
||||
)
|
||||
# attach capital for callers
|
||||
return OoSizingResult(
|
||||
ok=True,
|
||||
detail=r.detail,
|
||||
budget=r.budget,
|
||||
spend=r.spend,
|
||||
qty_eth=r.qty_eth,
|
||||
call_ask=r.call_ask,
|
||||
put_ask=r.put_ask,
|
||||
call_premium=r.call_premium,
|
||||
put_premium=r.put_premium,
|
||||
max_loss=r.max_loss,
|
||||
net_profit_target=r.net_profit_target,
|
||||
capital_base=_round2(capital) if capital is not None else None,
|
||||
cushion=r.cushion,
|
||||
reward_ratio=r.reward_ratio,
|
||||
)
|
||||
|
||||
|
||||
def apply_risk_sizing_to_ledger(
|
||||
*,
|
||||
index_px: float,
|
||||
|
||||
@@ -157,6 +157,49 @@ def _option_side_for_perp(perp_side: str) -> str:
|
||||
return "put" if (perp_side or "").strip().lower() == "long" else "call"
|
||||
|
||||
|
||||
def _hedge_mode() -> str:
|
||||
s = get_settings()
|
||||
try:
|
||||
from ..models.db import get_db
|
||||
|
||||
raw = str(
|
||||
get_db().get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
|
||||
).strip().lower()
|
||||
if raw in ("perp_option", "option_option"):
|
||||
return raw
|
||||
except Exception:
|
||||
pass
|
||||
return "perp_option"
|
||||
|
||||
|
||||
def _oo_settings() -> tuple[float, float, float, float]:
|
||||
"""amplitude_pct, amplitude_hours, min_option_hours, min_leverage"""
|
||||
s = get_settings()
|
||||
try:
|
||||
from ..models.db import get_db
|
||||
|
||||
db = get_db()
|
||||
return (
|
||||
float(db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct)) or s.oo_amplitude_pct),
|
||||
float(
|
||||
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
|
||||
or s.oo_amplitude_hours
|
||||
),
|
||||
float(
|
||||
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
|
||||
or s.oo_min_option_hours
|
||||
),
|
||||
float(db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) or s.oo_min_leverage),
|
||||
)
|
||||
except Exception:
|
||||
return (
|
||||
s.oo_amplitude_pct,
|
||||
s.oo_amplitude_hours,
|
||||
s.oo_min_option_hours,
|
||||
s.oo_min_leverage,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OpenPick:
|
||||
pair: OptionPair
|
||||
@@ -169,6 +212,17 @@ class OpenPick:
|
||||
option_leverage: float
|
||||
hours_left: float
|
||||
underlying_px: float
|
||||
hedge_mode: str = "perp_option"
|
||||
call_inst_id: str | None = None
|
||||
put_inst_id: str | None = None
|
||||
call_strike: float | None = None
|
||||
put_strike: float | None = None
|
||||
call_leverage: float | None = None
|
||||
put_leverage: float | None = None
|
||||
amplitude_high: float | None = None
|
||||
amplitude_low: float | None = None
|
||||
amplitude_range_pct: float | None = None
|
||||
oo_detail: str | None = None
|
||||
|
||||
|
||||
class StrategySession:
|
||||
@@ -323,6 +377,125 @@ class StrategySession:
|
||||
return self._apply_pair(pair, mark=float(mark), idx=idx)
|
||||
|
||||
def pick_for_open(self) -> OpenPick | None:
|
||||
if _hedge_mode() == "option_option":
|
||||
return self._pick_for_open_oo()
|
||||
return self._pick_for_open_perp()
|
||||
|
||||
def _pick_for_open_oo(self) -> OpenPick | None:
|
||||
from ..exchange.candles import fetch_amplitude_hl_for_runtime
|
||||
from .oo_selection import (
|
||||
pick_otm_call_strike,
|
||||
pick_otm_put_strike,
|
||||
select_oo_pair,
|
||||
)
|
||||
from .selection import _complete_by_expiry, option_leverage
|
||||
|
||||
s = self.settings
|
||||
amp_pct, amp_hours, min_hours, min_lev = _oo_settings()
|
||||
idx = self.ex.fetch_index(s.index_inst_id)
|
||||
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
|
||||
if mark is None or mark <= 0:
|
||||
return None
|
||||
underlying = float(mark)
|
||||
amp = fetch_amplitude_hl_for_runtime(amp_hours)
|
||||
if amp is None:
|
||||
logger.info("oo: amplitude candles unavailable")
|
||||
return None
|
||||
if float(amp.range_pct) + 1e-12 < float(amp_pct):
|
||||
logger.info(
|
||||
"oo: amplitude %.3f%% < need %.3f%% (H=%.2f L=%.2f)",
|
||||
amp.range_pct,
|
||||
amp_pct,
|
||||
amp.high,
|
||||
amp.low,
|
||||
)
|
||||
return None
|
||||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||||
skip = _skip_expiry_ymds_for_next()
|
||||
picked = select_oo_pair(
|
||||
contracts,
|
||||
spot=underlying,
|
||||
high=float(amp.high),
|
||||
low=float(amp.low),
|
||||
min_hours=float(min_hours),
|
||||
skip_expiry_ymds=skip,
|
||||
)
|
||||
if picked is None:
|
||||
logger.info("oo: no OTM call/put pair for amplitude HL")
|
||||
return None
|
||||
ymd, ems, ck, pk, call_inst, put_inst = picked
|
||||
call_bids, call_asks, _ = self.ex.fetch_book(call_inst, depth=5)
|
||||
put_bids, put_asks, _ = self.ex.fetch_book(put_inst, depth=5)
|
||||
call_ask = call_asks[0].px if call_asks else None
|
||||
put_ask = put_asks[0].px if put_asks else None
|
||||
if call_ask is None:
|
||||
cq = self.ex.quote(call_inst)
|
||||
call_ask = cq.ask if cq else None
|
||||
if put_ask is None:
|
||||
pq = self.ex.quote(put_inst)
|
||||
put_ask = pq.ask if pq else None
|
||||
if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0:
|
||||
logger.info("oo: missing ask call=%s put=%s", call_ask, put_ask)
|
||||
return None
|
||||
c_lev = option_leverage(underlying, float(call_ask))
|
||||
p_lev = option_leverage(underlying, float(put_ask))
|
||||
if (
|
||||
c_lev is None
|
||||
or p_lev is None
|
||||
or c_lev + 1e-9 < min_lev
|
||||
or p_lev + 1e-9 < min_lev
|
||||
):
|
||||
logger.info(
|
||||
"oo: leverage too low call=%s put=%s need>=%.0f",
|
||||
f"{c_lev:.1f}" if c_lev else "n/a",
|
||||
f"{p_lev:.1f}" if p_lev else "n/a",
|
||||
min_lev,
|
||||
)
|
||||
return None
|
||||
# 监控用:用 Call 行权价构造假 pair(两腿不同 strike,call/put inst 正确)
|
||||
pair = OptionPair(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=int(ems),
|
||||
strike=float(ck),
|
||||
call_inst_id=call_inst,
|
||||
put_inst_id=put_inst,
|
||||
)
|
||||
self._apply_pair(pair, mark=underlying, idx=idx)
|
||||
if hasattr(self.ex, "cache"):
|
||||
from ..exchange.book_cache import BookCache
|
||||
|
||||
cache: BookCache = self.ex.cache # type: ignore[attr-defined]
|
||||
cache.upsert_book(call_inst, bids=call_bids, asks=call_asks)
|
||||
cache.upsert_book(put_inst, bids=put_bids, asks=put_asks)
|
||||
hours_left = hours_until_expiry(ymd, expiry_ms=ems)
|
||||
return OpenPick(
|
||||
pair=pair,
|
||||
option_side="call",
|
||||
perp_side="",
|
||||
bias="option_option",
|
||||
call_ask=float(call_ask),
|
||||
put_ask=float(put_ask),
|
||||
option_ask=float(call_ask),
|
||||
option_leverage=float(min(c_lev, p_lev)),
|
||||
hours_left=hours_left,
|
||||
underlying_px=underlying,
|
||||
hedge_mode="option_option",
|
||||
call_inst_id=call_inst,
|
||||
put_inst_id=put_inst,
|
||||
call_strike=float(ck),
|
||||
put_strike=float(pk),
|
||||
call_leverage=float(c_lev),
|
||||
put_leverage=float(p_lev),
|
||||
amplitude_high=float(amp.high),
|
||||
amplitude_low=float(amp.low),
|
||||
amplitude_range_pct=float(amp.range_pct),
|
||||
oo_detail=(
|
||||
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
|
||||
f"C@{ck:g} P@{pk:g}"
|
||||
),
|
||||
)
|
||||
|
||||
def _pick_for_open_perp(self) -> OpenPick | None:
|
||||
from .signal import decide, decide_fixed
|
||||
|
||||
s = self.settings
|
||||
@@ -443,6 +616,7 @@ class StrategySession:
|
||||
option_leverage=float(lev),
|
||||
hours_left=hours_left,
|
||||
underlying_px=underlying,
|
||||
hedge_mode="perp_option",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user