Files
eth_hedge_sim/backend/app/sim/matcher.py
T
2026-07-24 17:09:38 +08:00

441 lines
15 KiB
Python

"""本地模拟撮合:永续市价 + 期权只买开/卖平。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from ..config import get_settings
from ..market import get_gateway
from ..models.db import Database, get_db
from .ledger import Ledger
from .liquidity import bid_covers_eth, 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:
# 尝试 REST meta;失败用默认
s = get_settings()
try:
gw = get_gateway()
rows = gw.rest.fetch_instruments(inst_type="OPTION", inst_family=s.option_inst_family)
for r in rows:
if str(r.get("instId")) == option_inst_id:
from ..market.instruments import safe_float
m = safe_float(r.get("ctMult"))
if m and m > 0:
return float(m)
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 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="已有持仓组,请先平仓")
gw = get_gateway()
snap = gw.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
if not oq or oq.ask is None:
return OpenResult(ok=False, detail="期权卖一不可用")
fee_rate = self._fee_rate()
perp_qty = float(s.perp_qty_eth)
opt_qty = float(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) -> CloseResult:
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"])
gw = get_gateway()
snap = gw.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 = snap.call if option_side == "call" else snap.put
if not oq or oq.bid is None:
return CloseResult(ok=False, detail="期权买一不可用", liquidity_wait=True)
ct_mult = self._ct_mult(option_inst_id)
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
if not bid_covers_eth(
bid_sz_contracts=oq.bid_sz,
ct_mult=ct_mult,
need_eth=need_eth,
):
# 记流动性不足到组 note,不改变仓位
note = f"liquidity_wait:{int(time.time())}"
self.db.execute(
"UPDATE groups SET note=? WHERE group_id=? AND status='open'",
(note, group_id),
)
return CloseResult(
ok=False,
detail="期权买一流动性不足",
liquidity_wait=True,
)
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(oq.bid),
ask=float(oq.ask or oq.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,
"index_px": None,
"move_points": 0.0,
"premium_gap": None,
}
gw = get_gateway()
snap = gw.snapshot()
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"])
mark = None
if snap.perp:
# 浮盈用对手方可平价粗估
if perp_side == "long":
mark = snap.perp.bid
else:
mark = snap.perp.ask
mark = mark or snap.perp.mark_px
perp_upl = 0.0
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"])
oq = snap.call if option_side == "call" else snap.put
opt_mark = None
if oq:
opt_mark = oq.bid or oq.mark_px
option_upl = 0.0
if opt_mark is not None:
option_upl = (float(opt_mark) - float(pos["option_entry_px"])) * float(
pos["option_qty_eth"]
)
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
initial_premium = float(pos["initial_premium"] or 0)
premium_gap = initial_premium - perp_upl
return {
"has_position": True,
"group_id": pos["group_id"],
"perp_side": perp_side,
"option_side": option_side,
"perp_upl": perp_upl,
"option_upl": option_upl,
"index_px": index_px,
"entry_index_px": entry_idx,
"move_points": move,
"initial_premium": initial_premium,
"premium_gap": premium_gap,
"status": pos.get("status"),
}