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