Files
eth_hedge_sim/backend/app/sim/matcher.py
T
2026-07-25 12:02:36 +08:00

563 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地模拟撮合:永续市价 + 期权只买开/卖平。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from ..config import get_settings
from ..exchange import get_exchange
from ..models.db import Database, get_db
from ..strategy.session import get_session
from .ledger import Ledger
from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth
from .pricing import option_fill, perp_fill
@dataclass(slots=True)
class OpenResult:
ok: bool
group_id: str | None = None
detail: str = ""
data: dict[str, Any] | None = None
@dataclass(slots=True)
class CloseResult:
ok: bool
detail: str = ""
liquidity_wait: bool = False
data: dict[str, Any] | None = None
class Matcher:
def __init__(self, db: Database | None = None) -> None:
self.db = db or get_db()
self.ledger = Ledger(self.db)
def _fee_rate(self) -> float:
return self.ledger.get_setting_float("fee_rate", get_settings().fee_rate)
def _ct_mult(self, option_inst_id: str) -> float:
try:
from ..exchange.runtime import load_runtime_settings
s = load_runtime_settings()
except Exception:
s = get_settings()
try:
return get_exchange().get_ct_mult(
option_inst_id, s.option_inst_family, s.option_ct_mult_default
)
except Exception:
pass
return float(s.option_ct_mult_default)
def current_position(self) -> dict[str, Any]:
row = self.db.fetchone("SELECT * FROM positions WHERE id=1")
assert row is not None
return dict(row)
def has_open_position(self) -> bool:
pos = self.current_position()
return pos.get("status") == "open" and bool(pos.get("group_id"))
def _liquidity_wait(self, group_id: str, detail: str) -> CloseResult:
note = f"liquidity_wait:{int(time.time())}:{detail[:80]}"
self.db.execute(
"UPDATE groups SET note=? WHERE group_id=? AND status='open'",
(note, group_id),
)
return CloseResult(ok=False, detail=detail, liquidity_wait=True)
def open_group(
self,
*,
group_id: str,
bias: str,
option_side: str, # call|put
perp_side: str, # long|short
option_inst_id: str,
entry_index_px: float,
strike: float | None = None,
expiry_ymd: str | None = None,
) -> OpenResult:
s = get_settings()
pos = self.current_position()
if pos.get("status") == "open" and pos.get("group_id"):
return OpenResult(ok=False, detail="已有持仓组,请先平仓")
sess = get_session()
snap = sess.snapshot()
if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
return OpenResult(ok=False, detail="永续盘口不可用")
oq = snap.call if option_side == "call" else snap.put
# 若 ATM 对与持仓合约不一致,直接取持仓合约盘口
held = get_exchange().quote(option_inst_id)
if held and held.ask is not None:
oq = held
if not oq or oq.ask is None:
return OpenResult(ok=False, detail="期权卖一不可用")
fee_rate = self._fee_rate()
s = get_settings()
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
ct_mult = self._ct_mult(option_inst_id)
opt_contracts = contracts_for_eth(opt_qty, ct_mult)
pf = perp_fill(
side=perp_side,
action="open",
bid=float(snap.perp.bid),
ask=float(snap.perp.ask),
qty_eth=perp_qty,
fee_rate=fee_rate,
)
of = option_fill(
action="open",
bid=float(oq.bid or 0),
ask=float(oq.ask),
qty_eth=opt_qty,
fee_rate=fee_rate,
)
initial_premium = of.fill_px * opt_qty # 锁定口径:成交价×名义,不含费
premium_cost = of.notional + of.fee
total_debit = premium_cost + pf.fee # 永续开仓只扣费;期权支付权利金+费
try:
self.ledger.apply_cash(
-total_debit,
kind="open_debit",
group_id=group_id,
note=f"open {group_id}",
)
except RuntimeError as e:
return OpenResult(ok=False, detail=str(e))
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
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"open",
bias,
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
strike,
expiry_ymd,
entry_index_px,
initial_premium,
now,
pf.fee + of.fee,
pf.slip + of.slip,
),
)
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"open",
perp_side,
s.perp_inst_id,
perp_qty,
None,
pf.base_px,
pf.fill_px,
pf.fee,
pf.slip,
pf.notional,
now,
),
)
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option",
"open",
"long",
option_inst_id,
opt_qty,
opt_contracts,
of.base_px,
of.fill_px,
of.fee,
of.slip,
of.notional,
now,
),
)
self.db._conn.execute(
"""UPDATE positions SET
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
option_entry_px=?, entry_index_px=?, initial_premium=?, status=?
WHERE id=1""",
(
group_id,
perp_side,
perp_qty,
pf.fill_px,
option_inst_id,
option_side,
opt_qty,
opt_contracts,
of.fill_px,
entry_index_px,
initial_premium,
"open",
),
)
self.db._conn.commit()
return OpenResult(
ok=True,
group_id=group_id,
detail="opened",
data={
"group_id": group_id,
"perp": pf.to_dict(),
"option": of.to_dict(),
"initial_premium": initial_premium,
"fees": pf.fee + of.fee,
},
)
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
"""
全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。
bypass_liquidity=True:紧急全平可绕过(仍需有可用买一价才能成交;无买一时用标记近似)。
"""
s = get_settings()
pos = self.current_position()
if pos.get("status") != "open" or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
group_id = str(pos["group_id"])
sess = get_session()
snap = sess.snapshot()
if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
return CloseResult(ok=False, detail="永续盘口不可用")
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
oq = get_exchange().quote(option_inst_id) or (
snap.call if option_side == "call" else snap.put
)
if not oq:
return CloseResult(
ok=False,
detail="期权盘口不可用",
liquidity_wait=not bypass_liquidity,
)
ct_mult = self._ct_mult(option_inst_id)
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
max_dev = self.ledger.get_setting_float(
"close_bid_mark_max_pct", s.close_bid_mark_max_pct
)
close_bid = oq.bid
if not bypass_liquidity:
if close_bid is None:
return self._liquidity_wait(group_id, "期权买一不可用")
if not bid_covers_eth(
bid_sz_contracts=oq.bid_sz,
ct_mult=ct_mult,
need_eth=need_eth,
):
return self._liquidity_wait(group_id, "期权买一流动性不足")
ok_dev, why = bid_mark_ok(
bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev
)
if not ok_dev:
return self._liquidity_wait(group_id, why)
else:
# 紧急:优先买一,否则用标记价近似成交(SIM)
if close_bid is None:
close_bid = oq.mark_px
if close_bid is None:
return CloseResult(ok=False, detail="紧急全平失败:无买一/标记价")
fee_rate = self._fee_rate()
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
opt_qty = float(pos["option_qty_eth"])
perp_entry = float(pos["perp_entry_px"])
opt_entry = float(pos["option_entry_px"])
pf = perp_fill(
side=perp_side,
action="close",
bid=float(snap.perp.bid),
ask=float(snap.perp.ask),
qty_eth=perp_qty,
fee_rate=fee_rate,
)
of = option_fill(
action="close",
bid=float(close_bid),
ask=float(oq.ask or close_bid),
qty_eth=opt_qty,
fee_rate=fee_rate,
)
# 永续盈亏
if perp_side == "long":
perp_pnl = (pf.fill_px - perp_entry) * perp_qty
else:
perp_pnl = (perp_entry - pf.fill_px) * perp_qty
# 期权多头盈亏
opt_pnl = (of.fill_px - opt_entry) * opt_qty
cash_in = of.notional - of.fee + pf.fee * 0 # 收回权利金(扣卖出费);永续平仓费另扣
# 永续平仓:实现盈亏入账并扣平仓手续费
net = perp_pnl + opt_pnl - pf.fee - of.fee
# 更清晰:现金变动 = 期权卖出净额 + 永续盈亏 - 永续平仓费
# 开仓已付期权权利金+开仓费;平仓收回 of.notional 并付 of.fee;永续只记 pnl 与 fee
cash_delta = (of.notional - of.fee) + perp_pnl - pf.fee
self.ledger.apply_cash(
cash_delta,
kind="close_settle",
group_id=group_id,
note=f"close {reason}",
)
now = int(time.time() * 1000)
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"close",
"flat",
s.perp_inst_id,
perp_qty,
None,
pf.base_px,
pf.fill_px,
pf.fee,
pf.slip,
pf.notional,
now,
),
)
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option",
"close",
"flat",
option_inst_id,
opt_qty,
float(pos["option_qty_contracts"] or 0),
of.base_px,
of.fill_px,
of.fee,
of.slip,
of.notional,
now,
),
)
g = self.db._conn.execute(
"SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
).fetchone()
fees = float(g["fees"] or 0) + pf.fee + of.fee
slip = float(g["slip_cost"] or 0) + pf.slip + of.slip
self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
fees=?, slip_cost=?, note=NULL WHERE group_id=?""",
("closed", now, reason, net, fees, slip, 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, status='flat'
WHERE id=1"""
)
self.db._conn.commit()
return CloseResult(
ok=True,
detail="closed",
data={
"group_id": group_id,
"reason": reason,
"perp_pnl": perp_pnl,
"option_pnl": opt_pnl,
"net": net,
"cash_delta": cash_delta,
},
)
def unrealized(self) -> dict[str, Any]:
pos = self.current_position()
if pos.get("status") != "open":
return {
"has_position": False,
"perp_upl": 0.0,
"option_upl": 0.0,
"net_pnl": 0.0,
"est_close_fees": 0.0,
"index_px": None,
"move_points": 0.0,
"move_pct": 0.0,
"premium_gap": None,
}
sess = get_session()
snap = sess.snapshot()
s = get_settings()
fee_rate = self._fee_rate()
index_px = snap.index_px
if index_px is None and snap.perp:
index_px = snap.perp.mark_px
perp_side = str(pos["perp_side"])
perp_entry = float(pos["perp_entry_px"])
perp_qty = float(pos["perp_qty_eth"])
opt_qty = float(pos["option_qty_eth"] or 0)
opt_entry = float(pos["option_entry_px"] or 0)
# 与平仓一致:用对手价估算可平盈亏 + 手续费
perp_upl = 0.0
est_perp_close_fee = 0.0
mark = None
if snap.perp and snap.perp.bid is not None and snap.perp.ask is not None:
pf = perp_fill(
side=perp_side,
action="close",
bid=float(snap.perp.bid),
ask=float(snap.perp.ask),
qty_eth=perp_qty,
fee_rate=fee_rate,
)
if perp_side == "long":
perp_upl = (pf.fill_px - perp_entry) * perp_qty
else:
perp_upl = (perp_entry - pf.fill_px) * perp_qty
est_perp_close_fee = pf.fee
mark = pf.fill_px
elif snap.perp:
if perp_side == "long":
mark = snap.perp.bid or snap.perp.mark_px
else:
mark = snap.perp.ask or snap.perp.mark_px
if mark is not None:
if perp_side == "long":
perp_upl = (float(mark) - perp_entry) * perp_qty
else:
perp_upl = (perp_entry - float(mark)) * perp_qty
option_side = str(pos["option_side"])
opt_inst = str(pos.get("option_inst_id") or "")
oq = get_exchange().quote(opt_inst) if opt_inst else None
if oq is None:
oq = snap.call if option_side == "call" else snap.put
initial_premium = float(pos["initial_premium"] or 0)
option_upl = 0.0
est_opt_close_fee = 0.0
opt_mark = None
if oq and oq.bid is not None:
bid = float(oq.bid)
of = option_fill(
action="close",
bid=bid,
ask=float(oq.ask or bid),
qty_eth=opt_qty,
fee_rate=fee_rate,
)
est_opt_close_fee = of.fee
opt_mark = bid
# 浮盈亏:买一×数量 初始权利金
option_upl = bid * opt_qty - initial_premium
elif oq:
opt_mark = oq.bid or oq.mark_px
if opt_mark is not None:
option_upl = float(opt_mark) * opt_qty - initial_premium
est_close_fees = est_perp_close_fee + est_opt_close_fee
# 净盈利:永续浮盈 + 期权浮盈 − 预估平仓手续费
net_pnl = perp_upl + option_upl - est_close_fees
entry_idx = float(pos["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
premium_gap = initial_premium - perp_upl
leverage = self.ledger.get_setting_float("leverage", s.leverage)
notional = abs(perp_entry * perp_qty)
margin = notional / leverage if leverage > 0 else None
group_id = pos.get("group_id")
g = (
self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
if group_id
else None
)
strike = float(g["strike"]) if g and g["strike"] is not None else None
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
expiry_ms = None
if expiry_ymd and len(expiry_ymd) == 6:
try:
from ..exchange.expiry import expiry_ms_from_ymd
expiry_ms = expiry_ms_from_ymd(expiry_ymd)
except Exception:
expiry_ms = None
perp_inst_id = (
str(g["perp_inst_id"])
if g and g["perp_inst_id"]
else s.perp_inst_id
)
return {
"has_position": True,
"group_id": group_id,
"perp_side": perp_side,
"option_side": option_side,
"perp_inst_id": perp_inst_id,
"perp_entry_px": perp_entry,
"perp_qty_eth": perp_qty,
"perp_mark_px": float(mark) if mark is not None else None,
"perp_notional": notional,
"perp_margin": margin,
"leverage": leverage,
"option_inst_id": pos.get("option_inst_id"),
"option_entry_px": opt_entry,
"option_qty_eth": opt_qty,
"option_qty_contracts": float(pos["option_qty_contracts"] or 0),
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
"strike": strike,
"expiry_ymd": expiry_ymd,
"expiry_ms": expiry_ms,
"perp_upl": perp_upl,
"option_upl": option_upl,
"est_close_fees": est_close_fees,
"net_pnl": net_pnl,
"index_px": index_px,
"entry_index_px": entry_idx,
"move_points": move,
"move_pct": move_pct,
"initial_premium": initial_premium,
"premium_gap": premium_gap,
"status": pos.get("status"),
}