Mirror option expiry settlements from exchange bills; debit OO LIVE open premiums.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -829,6 +829,22 @@ class BinanceLiveExecutor(Matcher):
|
||||
put_qty = eth_from_contracts(put_contracts, put_ct)
|
||||
call_prem = of_px * call_qty
|
||||
put_prem = pf_px * put_qty
|
||||
call_fee = float(getattr(call_fill, "fee", 0) or 0)
|
||||
put_fee = float(getattr(put_fill, "fee", 0) or 0)
|
||||
self.ledger.apply_cash(
|
||||
-(call_prem + call_fee),
|
||||
kind="open_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN oo open call {group_id}",
|
||||
allow_negative=True,
|
||||
)
|
||||
self.ledger.apply_cash(
|
||||
-(put_prem + put_fee),
|
||||
kind="open_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN oo open put {group_id}",
|
||||
allow_negative=True,
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
@@ -850,8 +866,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
entry_index_px,
|
||||
call_prem,
|
||||
now,
|
||||
float(getattr(call_fill, "fee", 0) or 0)
|
||||
+ float(getattr(put_fill, "fee", 0) or 0),
|
||||
call_fee + put_fee,
|
||||
0.0,
|
||||
"LIVE",
|
||||
"option_option",
|
||||
@@ -1023,6 +1038,20 @@ class BinanceLiveExecutor(Matcher):
|
||||
for i, (leg, inst, qty, contracts) in enumerate(legs):
|
||||
if not inst:
|
||||
continue
|
||||
if reason == "expiry":
|
||||
px, fee, notional, cash = self._live_option_settlement_fill(
|
||||
option_inst_id=inst, qty_eth=qty, group_id=group_id
|
||||
)
|
||||
else:
|
||||
px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0
|
||||
if abs(cash) > 1e-12:
|
||||
self.ledger.apply_cash(
|
||||
cash,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE-BN oo settle {leg} {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
@@ -1036,11 +1065,11 @@ class BinanceLiveExecutor(Matcher):
|
||||
inst,
|
||||
qty,
|
||||
contracts,
|
||||
px,
|
||||
px,
|
||||
fee,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
notional,
|
||||
now + i,
|
||||
"LIVE",
|
||||
),
|
||||
@@ -1062,7 +1091,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
int(time.time() * 1000),
|
||||
reason,
|
||||
net,
|
||||
f"oo full close {reason} exchange_flat_mirror",
|
||||
f"oo full close {reason} exchange_flat_or_settle",
|
||||
group_id,
|
||||
),
|
||||
)
|
||||
@@ -1296,13 +1325,17 @@ class BinanceLiveExecutor(Matcher):
|
||||
ex_opt,
|
||||
group_id,
|
||||
)
|
||||
of_px = 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = 0.0
|
||||
of_px, of_fee, of_notional, settle_cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=opt_qty,
|
||||
group_id=group_id,
|
||||
)
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
option_apply_cash = abs(settle_cash) > 1e-12
|
||||
logger.info(
|
||||
"bn expiry: skip option order, close perp only group=%s", group_id
|
||||
"bn expiry: skip option order, close perp only group=%s settle_cash=%.4f",
|
||||
group_id,
|
||||
settle_cash,
|
||||
)
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
@@ -1314,7 +1347,7 @@ class BinanceLiveExecutor(Matcher):
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
apply_cash=option_apply_cash,
|
||||
)
|
||||
pending_perp_only = True
|
||||
else:
|
||||
@@ -1474,6 +1507,44 @@ class BinanceLiveExecutor(Matcher):
|
||||
settle_index_px=spot,
|
||||
)
|
||||
|
||||
def _live_option_settlement_fill(
|
||||
self,
|
||||
*,
|
||||
option_inst_id: str,
|
||||
qty_eth: float,
|
||||
group_id: str | None = None,
|
||||
begin_ms: int | None = None,
|
||||
) -> tuple[float, float, float, float]:
|
||||
from .option_settle import fetch_option_settlement, settlement_to_fill
|
||||
|
||||
open_ms = begin_ms
|
||||
if open_ms is None and group_id:
|
||||
g = self.db.fetchone(
|
||||
"SELECT open_at_ms FROM groups WHERE group_id=?", (group_id,)
|
||||
)
|
||||
if g and g["open_at_ms"]:
|
||||
open_ms = int(g["open_at_ms"])
|
||||
st = fetch_option_settlement(
|
||||
self._client(),
|
||||
exchange="binance",
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=float(qty_eth),
|
||||
begin_ms=open_ms,
|
||||
)
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=float(qty_eth))
|
||||
cash = float(st.cash) if st.found else 0.0
|
||||
if st.found:
|
||||
logger.info(
|
||||
"bn option settlement %s source=%s px=%.6f fee=%.6f cash=%.6f (%s)",
|
||||
option_inst_id,
|
||||
st.source,
|
||||
px,
|
||||
fee,
|
||||
cash,
|
||||
st.detail,
|
||||
)
|
||||
return px, fee, notional, cash
|
||||
|
||||
def _ensure_option_closed_perp_pending(
|
||||
self,
|
||||
*,
|
||||
@@ -1772,16 +1843,36 @@ class BinanceLiveExecutor(Matcher):
|
||||
)
|
||||
if ex_sz <= 1e-8:
|
||||
now_ms = int(time.time() * 1000)
|
||||
qty_eth = float(row.get("option_qty_eth") or 0)
|
||||
begin = None
|
||||
try:
|
||||
begin = int(row.get("created_at_ms") or 0) or None
|
||||
except Exception:
|
||||
begin = None
|
||||
px, fee, notional, _cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=qty_eth,
|
||||
group_id=group_id,
|
||||
begin_ms=begin,
|
||||
)
|
||||
booked = self._book_residual_market_close(
|
||||
row,
|
||||
fill_px=0.0,
|
||||
fee=0.0,
|
||||
notional=0.0,
|
||||
fill_px=px,
|
||||
fee=fee,
|
||||
notional=notional,
|
||||
slip=0.0,
|
||||
now_ms=now_ms,
|
||||
note="LIVE-BN residual already flat on exchange",
|
||||
note=(
|
||||
f"LIVE-BN residual already flat; settlement px={px}"
|
||||
if notional > 0 or fee > 0
|
||||
else "LIVE-BN residual already flat on exchange"
|
||||
),
|
||||
exec_mode="LIVE",
|
||||
filled_contracts=0.0,
|
||||
filled_contracts=(
|
||||
float(row.get("option_qty_contracts") or 0)
|
||||
if (notional > 0 or fee > 0)
|
||||
else 0.0
|
||||
),
|
||||
remaining_contracts=0.0,
|
||||
close_reason="residual_premium_close",
|
||||
)
|
||||
@@ -1927,14 +2018,31 @@ class BinanceLiveExecutor(Matcher):
|
||||
)
|
||||
return None
|
||||
if ex_sz <= 1e-8:
|
||||
qty_eth = float(row.get("option_qty_eth") or 0)
|
||||
begin = None
|
||||
try:
|
||||
begin = int(row.get("created_at_ms") or 0) or None
|
||||
except Exception:
|
||||
begin = None
|
||||
gid = str(row.get("group_id") or "")
|
||||
px, fee, notional, _cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=qty_eth,
|
||||
group_id=gid or None,
|
||||
begin_ms=begin,
|
||||
)
|
||||
return {
|
||||
"fill_px": 0.0,
|
||||
"fee": 0.0,
|
||||
"notional": 0.0,
|
||||
"fill_px": px,
|
||||
"fee": fee,
|
||||
"notional": notional,
|
||||
"slip": 0.0,
|
||||
"filled_contracts": 0.0,
|
||||
"filled_contracts": float(row.get("option_qty_contracts") or 0),
|
||||
"remaining_contracts": 0.0,
|
||||
"note": "LIVE-BN residual flat on exchange before settle",
|
||||
"note": (
|
||||
f"LIVE-BN residual flat; settlement px={px}"
|
||||
if notional > 0 or fee > 0
|
||||
else "LIVE-BN residual flat on exchange before settle"
|
||||
),
|
||||
"exec_mode": "LIVE",
|
||||
"close_reason": "emergency" if force else "expiry",
|
||||
}
|
||||
|
||||
@@ -506,6 +506,33 @@ class BinanceTradeClient:
|
||||
{"symbol": symbol, "leverage": lev},
|
||||
)
|
||||
|
||||
def get_option_exercise_records(
|
||||
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> list[dict] | None:
|
||||
"""用户期权行权/到期结算记录 GET /eapi/v1/exerciseRecord。"""
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
begin = int(begin_ms)
|
||||
try:
|
||||
rows = self._signed(
|
||||
self._eapi,
|
||||
"GET",
|
||||
"/eapi/v1/exerciseRecord",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"startTime": begin,
|
||||
"endTime": end,
|
||||
"limit": 100,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("binance exerciseRecord failed: %s", e)
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
def get_funding_usdt(
|
||||
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float:
|
||||
|
||||
+134
-24
@@ -871,6 +871,23 @@ class OkxLiveExecutor(Matcher):
|
||||
put_qty = eth_from_contracts(put_contracts, put_ct)
|
||||
call_prem = of_px * call_qty
|
||||
put_prem = pf_px * put_qty
|
||||
call_fee = float(getattr(call_fill, "fee", 0) or 0)
|
||||
put_fee = float(getattr(put_fill, "fee", 0) or 0)
|
||||
# 与永期 LIVE 开仓一致:镜像扣权利金+手续费
|
||||
self.ledger.apply_cash(
|
||||
-(call_prem + call_fee),
|
||||
kind="open_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE oo open call {group_id}",
|
||||
allow_negative=True,
|
||||
)
|
||||
self.ledger.apply_cash(
|
||||
-(put_prem + put_fee),
|
||||
kind="open_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE oo open put {group_id}",
|
||||
allow_negative=True,
|
||||
)
|
||||
now = int(time.time() * 1000)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
@@ -892,8 +909,7 @@ class OkxLiveExecutor(Matcher):
|
||||
entry_index_px,
|
||||
call_prem,
|
||||
now,
|
||||
float(getattr(call_fill, "fee", 0) or 0)
|
||||
+ float(getattr(put_fill, "fee", 0) or 0),
|
||||
call_fee + put_fee,
|
||||
0.0,
|
||||
"LIVE",
|
||||
"option_option",
|
||||
@@ -1069,6 +1085,20 @@ class OkxLiveExecutor(Matcher):
|
||||
for i, (leg, inst, qty, contracts) in enumerate(legs):
|
||||
if not inst:
|
||||
continue
|
||||
if reason == "expiry":
|
||||
px, fee, notional, cash = self._live_option_settlement_fill(
|
||||
option_inst_id=inst, qty_eth=qty, group_id=group_id
|
||||
)
|
||||
else:
|
||||
px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0
|
||||
if abs(cash) > 1e-12:
|
||||
self.ledger.apply_cash(
|
||||
cash,
|
||||
kind="close_option",
|
||||
group_id=group_id,
|
||||
note=f"LIVE oo settle {leg} {reason}",
|
||||
allow_negative=True,
|
||||
)
|
||||
with self.db._lock:
|
||||
self.db._conn.execute(
|
||||
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||
@@ -1082,11 +1112,11 @@ class OkxLiveExecutor(Matcher):
|
||||
inst,
|
||||
qty,
|
||||
contracts,
|
||||
px,
|
||||
px,
|
||||
fee,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
notional,
|
||||
now + i,
|
||||
"LIVE",
|
||||
),
|
||||
@@ -1108,7 +1138,7 @@ class OkxLiveExecutor(Matcher):
|
||||
int(time.time() * 1000),
|
||||
reason,
|
||||
net,
|
||||
f"oo full close {reason} exchange_flat_mirror",
|
||||
f"oo full close {reason} exchange_flat_or_settle",
|
||||
group_id,
|
||||
),
|
||||
)
|
||||
@@ -1336,7 +1366,7 @@ class OkxLiveExecutor(Matcher):
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
elif is_expiry:
|
||||
# 到期:交易所自动结算期权,本地只平永续;不卖期权、不用 intrinsic 发明 fill
|
||||
# 到期:交易所自动结算期权,本地只平永续;交割现金从账单镜像(禁止 intrinsic)
|
||||
ex_opt = exchange_option_abs_size(client, option_inst_id)
|
||||
if ex_opt is not None and ex_opt > 1e-8:
|
||||
logger.warning(
|
||||
@@ -1345,13 +1375,17 @@ class OkxLiveExecutor(Matcher):
|
||||
ex_opt,
|
||||
group_id,
|
||||
)
|
||||
of_px = 0.0
|
||||
of_fee = 0.0
|
||||
of_notional = 0.0
|
||||
of_px, of_fee, of_notional, settle_cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=opt_qty,
|
||||
group_id=group_id,
|
||||
)
|
||||
of_slip = 0.0
|
||||
option_apply_cash = False
|
||||
option_apply_cash = abs(settle_cash) > 1e-12
|
||||
logger.info(
|
||||
"expiry: skip option order, close perp only group=%s", group_id
|
||||
"expiry: skip option order, close perp only group=%s settle_cash=%.4f",
|
||||
group_id,
|
||||
settle_cash,
|
||||
)
|
||||
self._ensure_option_closed_perp_pending(
|
||||
group_id=group_id,
|
||||
@@ -1363,7 +1397,7 @@ class OkxLiveExecutor(Matcher):
|
||||
of_notional=of_notional,
|
||||
of_slip=of_slip,
|
||||
reason=reason,
|
||||
apply_cash=False,
|
||||
apply_cash=option_apply_cash,
|
||||
)
|
||||
pending_perp_only = True
|
||||
else:
|
||||
@@ -1534,6 +1568,49 @@ class OkxLiveExecutor(Matcher):
|
||||
settle_index_px=spot,
|
||||
)
|
||||
|
||||
def _live_option_settlement_fill(
|
||||
self,
|
||||
*,
|
||||
option_inst_id: str,
|
||||
qty_eth: float,
|
||||
group_id: str | None = None,
|
||||
begin_ms: int | None = None,
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""返回 (fill_px, fee, notional, cash_to_apply)。查不到交割则全 0。"""
|
||||
from .option_settle import fetch_option_settlement, settlement_to_fill
|
||||
|
||||
open_ms = begin_ms
|
||||
if open_ms is None and group_id:
|
||||
g = self.db.fetchone(
|
||||
"SELECT open_at_ms FROM groups WHERE group_id=?", (group_id,)
|
||||
)
|
||||
if g and g["open_at_ms"]:
|
||||
open_ms = int(g["open_at_ms"])
|
||||
try:
|
||||
ex_name = load_runtime_settings().exchange
|
||||
except Exception:
|
||||
ex_name = get_settings().exchange
|
||||
st = fetch_option_settlement(
|
||||
self._client(),
|
||||
exchange=str(ex_name or "okx"),
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=float(qty_eth),
|
||||
begin_ms=open_ms,
|
||||
)
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=float(qty_eth))
|
||||
cash = float(st.cash) if st.found else 0.0
|
||||
if st.found:
|
||||
logger.info(
|
||||
"option settlement %s source=%s px=%.6f fee=%.6f cash=%.6f (%s)",
|
||||
option_inst_id,
|
||||
st.source,
|
||||
px,
|
||||
fee,
|
||||
cash,
|
||||
st.detail,
|
||||
)
|
||||
return px, fee, notional, cash
|
||||
|
||||
def _ensure_option_closed_perp_pending(
|
||||
self,
|
||||
*,
|
||||
@@ -1838,16 +1915,32 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
if ex_sz <= 1e-8:
|
||||
now_ms = int(time.time() * 1000)
|
||||
qty_eth = float(row.get("option_qty_eth") or 0)
|
||||
begin = None
|
||||
try:
|
||||
begin = int(row.get("created_at_ms") or 0) or None
|
||||
except Exception:
|
||||
begin = None
|
||||
px, fee, notional, _cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=qty_eth,
|
||||
group_id=group_id,
|
||||
begin_ms=begin,
|
||||
)
|
||||
booked = self._book_residual_market_close(
|
||||
row,
|
||||
fill_px=0.0,
|
||||
fee=0.0,
|
||||
notional=0.0,
|
||||
fill_px=px,
|
||||
fee=fee,
|
||||
notional=notional,
|
||||
slip=0.0,
|
||||
now_ms=now_ms,
|
||||
note="LIVE residual already flat on exchange",
|
||||
note=(
|
||||
f"LIVE residual already flat; settlement px={px}"
|
||||
if notional > 0 or fee > 0
|
||||
else "LIVE residual already flat on exchange"
|
||||
),
|
||||
exec_mode="LIVE",
|
||||
filled_contracts=0.0,
|
||||
filled_contracts=float(row.get("option_qty_contracts") or 0) if (notional > 0 or fee > 0) else 0.0,
|
||||
remaining_contracts=0.0,
|
||||
close_reason="residual_premium_close",
|
||||
)
|
||||
@@ -2002,14 +2095,31 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
return None
|
||||
if ex_sz <= 1e-8:
|
||||
qty_eth = float(row.get("option_qty_eth") or 0)
|
||||
begin = None
|
||||
try:
|
||||
begin = int(row.get("created_at_ms") or 0) or None
|
||||
except Exception:
|
||||
begin = None
|
||||
gid = str(row.get("group_id") or "")
|
||||
px, fee, notional, _cash = self._live_option_settlement_fill(
|
||||
option_inst_id=option_inst_id,
|
||||
qty_eth=qty_eth,
|
||||
group_id=gid or None,
|
||||
begin_ms=begin,
|
||||
)
|
||||
return {
|
||||
"fill_px": 0.0,
|
||||
"fee": 0.0,
|
||||
"notional": 0.0,
|
||||
"fill_px": px,
|
||||
"fee": fee,
|
||||
"notional": notional,
|
||||
"slip": 0.0,
|
||||
"filled_contracts": 0.0,
|
||||
"filled_contracts": float(row.get("option_qty_contracts") or 0),
|
||||
"remaining_contracts": 0.0,
|
||||
"note": "LIVE residual flat on exchange before settle",
|
||||
"note": (
|
||||
f"LIVE residual flat on exchange; settlement px={px}"
|
||||
if notional > 0 or fee > 0
|
||||
else "LIVE residual flat on exchange before settle"
|
||||
),
|
||||
"exec_mode": "LIVE",
|
||||
"close_reason": "emergency" if force else "expiry",
|
||||
}
|
||||
|
||||
@@ -140,6 +140,47 @@ def reconcile_closed_group_pnl(
|
||||
float(local_perp) if local_perp is not None else 0.0
|
||||
)
|
||||
opt = float(option_pnl) if option_pnl is not None else 0.0
|
||||
|
||||
# 期权平仓 fill 为 0 价镜像时:尝试用交易所交割账单补期权腿
|
||||
try:
|
||||
zero_opt_close = False
|
||||
for fr in fills:
|
||||
if str(fr["leg"] or "") in ("option", "option2") and str(
|
||||
fr["action"] or ""
|
||||
) == "close":
|
||||
if abs(float(fr["notional"] or 0)) < 1e-12 and abs(
|
||||
float(fr["fill_px"] or 0)
|
||||
) < 1e-12:
|
||||
zero_opt_close = True
|
||||
break
|
||||
if zero_opt_close:
|
||||
from .option_settle import fetch_option_settlement
|
||||
|
||||
g = db.fetchone(
|
||||
"SELECT option_inst_id, option2_inst_id FROM groups WHERE group_id=?",
|
||||
(group_id,),
|
||||
)
|
||||
settle_cash = 0.0
|
||||
for inst_key in ("option_inst_id", "option2_inst_id"):
|
||||
inst = str((g[inst_key] if g else None) or "")
|
||||
if not inst:
|
||||
continue
|
||||
st = fetch_option_settlement(
|
||||
client,
|
||||
exchange=ex,
|
||||
option_inst_id=inst,
|
||||
qty_eth=1.0,
|
||||
begin_ms=begin,
|
||||
end_ms=end,
|
||||
)
|
||||
if st.found:
|
||||
settle_cash += float(st.cash)
|
||||
if abs(settle_cash) > 1e-12:
|
||||
# 用交割净现金替换本地 0 价期权盈亏近似:仍减 fees(交割费若已在 cash 内则可能双计,保守保留)
|
||||
opt = float(settle_cash)
|
||||
except Exception as e:
|
||||
logger.warning("reconcile option settlement overlay failed: %s", e)
|
||||
|
||||
net = perp_pnl + opt - fees + funding
|
||||
|
||||
if local_net is not None and exch_perp is None and abs(funding) < 1e-12:
|
||||
|
||||
@@ -394,6 +394,42 @@ class OkxTradeClient:
|
||||
total += to_usdt(float(raw), ccy)
|
||||
return total
|
||||
|
||||
def get_option_settlement_bills(
|
||||
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> list[dict] | None:
|
||||
"""期权交割/行权相关账单(近 7 日 bills;失败再试 archive)。"""
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
begin = int(begin_ms)
|
||||
# type=3 Delivery;再本地按 subType 170/171/172 过滤
|
||||
path = (
|
||||
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
|
||||
f"&type=3&begin={begin}&end={end}"
|
||||
)
|
||||
try:
|
||||
rows = self._request("GET", path)
|
||||
except Exception as e:
|
||||
logger.warning("okx option bills type=3 failed: %s; try all types", e)
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
|
||||
f"&begin={begin}&end={end}",
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.warning("okx option bills failed: %s; try archive", e2)
|
||||
try:
|
||||
rows = self._request(
|
||||
"GET",
|
||||
f"/api/v5/account/bills-archive?instType=OPTION&instId={inst_id}"
|
||||
f"&begin={begin}&end={end}",
|
||||
)
|
||||
except Exception as e3:
|
||||
logger.warning("okx option bills-archive failed: %s", e3)
|
||||
return None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
def get_closed_perp_pnl_usdt(
|
||||
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
|
||||
) -> float | None:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""LIVE 期权到期交割:从交易所账单/行权记录取结算现金,禁止本地 intrinsic 发明。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OKX bills subType:行权 / 对手行权 / 虚值到期
|
||||
_OKX_EXERCISE_SUBTYPES = frozenset({"170", "171", "172"})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OptionSettlement:
|
||||
"""qty_eth>0 时 fill_px = notional/qty_eth;cash 为入账净额(已扣费)。"""
|
||||
|
||||
found: bool
|
||||
notional: float = 0.0 # 结算毛额(入账前)
|
||||
fee: float = 0.0
|
||||
cash: float = 0.0 # notional - fee
|
||||
fill_px: float = 0.0
|
||||
source: str = ""
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def settlement_to_fill(
|
||||
st: OptionSettlement, *, qty_eth: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""返回 (fill_px, fee, notional)。未找到则全 0。"""
|
||||
if not st.found:
|
||||
return 0.0, 0.0, 0.0
|
||||
q = float(qty_eth)
|
||||
if q > 1e-12 and st.fill_px <= 0 and st.notional > 0:
|
||||
return float(st.notional) / q, float(st.fee), float(st.notional)
|
||||
return float(st.fill_px), float(st.fee), float(st.notional)
|
||||
|
||||
|
||||
def fetch_option_settlement(
|
||||
client: Any,
|
||||
*,
|
||||
exchange: str,
|
||||
option_inst_id: str,
|
||||
qty_eth: float,
|
||||
begin_ms: int | None,
|
||||
end_ms: int | None = None,
|
||||
) -> OptionSettlement:
|
||||
"""查交易所期权交割/行权入账。查不到 → found=False(调用方零价镜像、不发明)。"""
|
||||
inst = str(option_inst_id or "").strip()
|
||||
if not inst or client is None:
|
||||
return OptionSettlement(found=False, detail="no_inst_or_client")
|
||||
begin = int(begin_ms or 0)
|
||||
end = int(end_ms or int(time.time() * 1000))
|
||||
if begin <= 0:
|
||||
# 无开仓时间:收窄到近 48h,避免扫全量
|
||||
begin = end - 48 * 3600 * 1000
|
||||
ex = (exchange or "").strip().lower()
|
||||
try:
|
||||
if ex in ("binance", "bn"):
|
||||
return _bn_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
|
||||
return _okx_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
|
||||
except Exception as e:
|
||||
logger.warning("fetch_option_settlement failed %s %s: %s", ex, inst, e)
|
||||
return OptionSettlement(found=False, detail=str(e)[:160])
|
||||
|
||||
|
||||
def _okx_settlement(
|
||||
client: Any, inst_id: str, *, qty_eth: float, begin: int, end: int
|
||||
) -> OptionSettlement:
|
||||
from .money import to_usdt
|
||||
from ..exchange.okx.parse import safe_float
|
||||
|
||||
getter = getattr(client, "get_option_settlement_bills", None)
|
||||
if callable(getter):
|
||||
rows = getter(inst_id, begin_ms=begin, end_ms=end)
|
||||
else:
|
||||
rows = None
|
||||
if rows is None:
|
||||
return OptionSettlement(found=False, detail="okx_bills_unavailable")
|
||||
|
||||
cash = 0.0
|
||||
fee = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sub = str(row.get("subType") or "")
|
||||
typ = str(row.get("type") or "")
|
||||
if sub not in _OKX_EXERCISE_SUBTYPES and typ not in ("3",):
|
||||
continue
|
||||
ts = int(safe_float(row.get("ts")) or 0)
|
||||
if ts and (ts < begin - 120_000 or ts > end + 120_000):
|
||||
continue
|
||||
hit = True
|
||||
raw = safe_float(row.get("balChg"))
|
||||
if raw is None:
|
||||
raw = safe_float(row.get("pnl"))
|
||||
ccy = str(row.get("ccy") or "USDT")
|
||||
if raw is not None:
|
||||
cash += to_usdt(float(raw), ccy)
|
||||
fraw = safe_float(row.get("fee"))
|
||||
if fraw is not None:
|
||||
# OKX fee 常为负
|
||||
fee += abs(to_usdt(float(fraw), ccy))
|
||||
# 有些账单 pnl 已含费,fee 字段为 0
|
||||
|
||||
if not hit:
|
||||
return OptionSettlement(found=False, detail="okx_no_exercise_bill")
|
||||
|
||||
# balChg/pnl 视为账户净变动;fee 另计时用净额+费还原毛额作 fill notional
|
||||
net_cash = float(cash)
|
||||
fee = float(fee)
|
||||
if net_cash >= 0:
|
||||
notional = float(net_cash) + fee
|
||||
else:
|
||||
notional = 0.0
|
||||
|
||||
q = float(qty_eth)
|
||||
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
|
||||
return OptionSettlement(
|
||||
found=True,
|
||||
notional=float(notional),
|
||||
fee=float(fee),
|
||||
cash=float(net_cash),
|
||||
fill_px=float(fill_px),
|
||||
source="okx_bills",
|
||||
detail=f"subTypes exercise bills cash={net_cash:.6f}",
|
||||
)
|
||||
|
||||
|
||||
def _bn_settlement(
|
||||
client: Any, symbol: str, *, qty_eth: float, begin: int, end: int
|
||||
) -> OptionSettlement:
|
||||
from .money import to_usdt
|
||||
from ..exchange.okx.parse import safe_float
|
||||
|
||||
getter = getattr(client, "get_option_exercise_records", None)
|
||||
if not callable(getter):
|
||||
return OptionSettlement(found=False, detail="bn_exercise_api_missing")
|
||||
rows = getter(symbol, begin_ms=begin, end_ms=end)
|
||||
if rows is None:
|
||||
return OptionSettlement(found=False, detail="bn_exercise_unavailable")
|
||||
if not rows:
|
||||
return OptionSettlement(found=False, detail="bn_no_exercise_record")
|
||||
|
||||
amount = 0.0
|
||||
fee = 0.0
|
||||
hit = False
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
|
||||
continue
|
||||
hit = True
|
||||
ccy = str(row.get("currency") or row.get("quoteAsset") or "USDT")
|
||||
am = safe_float(row.get("amount"))
|
||||
if am is not None:
|
||||
amount += to_usdt(float(am), ccy)
|
||||
fr = safe_float(row.get("fee"))
|
||||
if fr is not None:
|
||||
fee += abs(to_usdt(float(fr), ccy))
|
||||
|
||||
if not hit:
|
||||
return OptionSettlement(found=False, detail="bn_no_matching_record")
|
||||
|
||||
notional = max(0.0, float(amount))
|
||||
net = float(amount) - float(fee)
|
||||
q = float(qty_eth)
|
||||
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
|
||||
return OptionSettlement(
|
||||
found=True,
|
||||
notional=notional,
|
||||
fee=float(fee),
|
||||
cash=float(net),
|
||||
fill_px=float(fill_px),
|
||||
source="binance_exerciseRecord",
|
||||
detail=f"amount={amount:.6f} fee={fee:.6f}",
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""期权交割账单镜像 + 期期开仓账本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.live.option_settle import (
|
||||
OptionSettlement,
|
||||
fetch_option_settlement,
|
||||
settlement_to_fill,
|
||||
)
|
||||
|
||||
|
||||
def test_settlement_to_fill_zero_when_not_found() -> None:
|
||||
st = OptionSettlement(found=False)
|
||||
assert settlement_to_fill(st, qty_eth=2.0) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_okx_settlement_from_exercise_bills() -> None:
|
||||
class _C:
|
||||
def get_option_settlement_bills(self, inst_id, *, begin_ms, end_ms=None):
|
||||
return [
|
||||
{
|
||||
"subType": "170",
|
||||
"type": "3",
|
||||
"ts": str(begin_ms + 1000),
|
||||
"balChg": "50.0",
|
||||
"fee": "-0.5",
|
||||
"ccy": "USDT",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="okx",
|
||||
option_inst_id="ETH-USDT-260808-2000-C",
|
||||
qty_eth=2.0,
|
||||
begin_ms=1_700_000_000_000,
|
||||
)
|
||||
assert st.found
|
||||
assert abs(st.cash - 50.0) < 1e-9
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
|
||||
assert fee == 0.5
|
||||
assert abs(notional - 50.5) < 1e-9
|
||||
assert abs(px - 25.25) < 1e-9
|
||||
|
||||
|
||||
def test_bn_settlement_from_exercise_record() -> None:
|
||||
class _C:
|
||||
def get_option_exercise_records(self, symbol, *, begin_ms, end_ms=None):
|
||||
return [
|
||||
{
|
||||
"symbol": symbol,
|
||||
"amount": "40",
|
||||
"fee": "0.2",
|
||||
"currency": "USDT",
|
||||
"quantity": "2",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="binance",
|
||||
option_inst_id="ETH-260808-2000-C",
|
||||
qty_eth=2.0,
|
||||
begin_ms=1,
|
||||
)
|
||||
assert st.found
|
||||
assert abs(st.cash - 39.8) < 1e-9
|
||||
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
|
||||
assert abs(notional - 40.0) < 1e-9
|
||||
assert abs(px - 20.0) < 1e-9
|
||||
assert abs(fee - 0.2) < 1e-9
|
||||
|
||||
|
||||
def test_okx_otm_expiry_bill_found_zero_cash() -> None:
|
||||
class _C:
|
||||
def get_option_settlement_bills(self, *_a, **_k):
|
||||
return [
|
||||
{
|
||||
"subType": "172",
|
||||
"type": "3",
|
||||
"ts": "1700000001000",
|
||||
"balChg": "0",
|
||||
"fee": "0",
|
||||
"ccy": "USDT",
|
||||
}
|
||||
]
|
||||
|
||||
st = fetch_option_settlement(
|
||||
_C(),
|
||||
exchange="okx",
|
||||
option_inst_id="ETH-OPT",
|
||||
qty_eth=1.0,
|
||||
begin_ms=1_700_000_000_000,
|
||||
)
|
||||
assert st.found
|
||||
assert st.cash == 0.0
|
||||
assert settlement_to_fill(st, qty_eth=1.0)[0] == 0.0
|
||||
|
||||
|
||||
def test_oo_open_applies_ledger_cash(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("MODE", "LIVE")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.live.executor import OkxLiveExecutor
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "oo.db")
|
||||
ex = OkxLiveExecutor(db)
|
||||
monkeypatch.setattr(ex, "_guard_live", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.claim_open_slot", lambda _db: (True, "ok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.assert_safe_to_open_live", lambda _e: (True, "ok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.stamp_opening_intent", lambda *_a, **_k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.live.executor.release_open_slot_if_opening", lambda *_a, **_k: None
|
||||
)
|
||||
monkeypatch.setattr(ex, "_ct_mult", lambda *_a, **_k: 0.01)
|
||||
|
||||
class _C:
|
||||
def place_market(self, *, inst_id, side, sz, **_k):
|
||||
return SimpleNamespace(
|
||||
avg_px=10.0 if "C" in inst_id or "call" in inst_id.lower() or inst_id.endswith("-C") or "CALL" in inst_id
|
||||
else 8.0,
|
||||
fee=0.1,
|
||||
sz=float(sz),
|
||||
)
|
||||
|
||||
# simpler fixed fills
|
||||
fills = [
|
||||
SimpleNamespace(avg_px=10.0, fee=0.1, sz=100.0),
|
||||
SimpleNamespace(avg_px=8.0, fee=0.05, sz=100.0),
|
||||
]
|
||||
|
||||
def place(**_k):
|
||||
return fills.pop(0)
|
||||
|
||||
monkeypatch.setattr(ex, "_client", lambda: SimpleNamespace(place_market=place))
|
||||
monkeypatch.setattr(
|
||||
ex.ledger,
|
||||
"get_setting_float",
|
||||
lambda k, d=0: 1.0 if "qty" in k else d,
|
||||
)
|
||||
|
||||
before = float(ex.ledger.snapshot()["available"])
|
||||
r = ex.open_oo_group(
|
||||
group_id="G-oo1",
|
||||
call_inst_id="ETH-CALL",
|
||||
put_inst_id="ETH-PUT",
|
||||
call_strike=2000.0,
|
||||
put_strike=1900.0,
|
||||
entry_index_px=1950.0,
|
||||
expiry_ymd="260810",
|
||||
)
|
||||
assert r.ok, r.detail
|
||||
after = float(ex.ledger.snapshot()["available"])
|
||||
# call 10*1 +0.1 + put 8*1 +0.05 = 18.15
|
||||
assert before - after > 18.0
|
||||
db.close()
|
||||
get_settings.cache_clear()
|
||||
@@ -40,6 +40,7 @@
|
||||
| repair/abandon 交易所 size | ✅ |
|
||||
| BN 余额资金门 | ✅ |
|
||||
| 半自动买一闸 | ✅ |
|
||||
| 交割账单拉价 | ⏳ 后续(零价镜像+note) |
|
||||
| 交割账单拉价 | ✅ OKX bills type=3/subType170-172;BN exerciseRecord |
|
||||
| 期期 LIVE 开仓账本 | ✅ 扣 Call/Put 权利金+手续费 |
|
||||
|
||||
再审计:无剩余 P0;剩交割账单与期期开仓账本对称为 P2/后续。
|
||||
再审计:仓位/成交/交割 SoT 主路径已闭环;无剩余 P0/P1。
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
| 期期到期 `live_sell_oo_both` | 跳过;`close_oo_full` 确认两腿空仓再镜像 |
|
||||
| recover 期权张数 | 以 `opt_sz` 回写 |
|
||||
|
||||
## 仍待(非本轮)
|
||||
## 续修(同日已落地)
|
||||
|
||||
- 期期盈利腿仍可能用报价镜像 fill(非到期路径)
|
||||
- 半自动部分成交改永续、armed 改参等
|
||||
- 币安余额接线
|
||||
- 交割:OKX bills / BN exerciseRecord → 到期与残仓空仓镜像
|
||||
- 期期 LIVE 开仓账本对称
|
||||
- closing 状态机、紧急期期路由、BN 余额门、半自动买一闸
|
||||
|
||||
## 残留低优先级
|
||||
|
||||
- 期期盈利腿崩溃恢复时若无成交回报,仍可能 0 价镜像(已禁止盘口发明价)
|
||||
- 半自动部分成交改永续等产品增强(非 SoT 硬伤)
|
||||
|
||||
+11
-1
@@ -5,6 +5,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 交割账单 SoT + 期期开仓账本
|
||||
|
||||
### 变更
|
||||
|
||||
1. 到期/残仓空仓:OKX `bills` 行权子类型、币安 `exerciseRecord` 镜像结算价与现金。
|
||||
2. 期期 LIVE 开仓:本地账本扣 Call/Put 权利金+手续费(与永期一致)。
|
||||
3. 平仓对账:期权 close 为 0 价时用交割净现金覆盖期权腿。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-08 — 实盘 SoT 续修(closing / 期期 / 资金门)
|
||||
|
||||
### 变更
|
||||
@@ -16,7 +26,7 @@
|
||||
|
||||
### 审计
|
||||
|
||||
见 `docs/审计修改方案-2026-08-08-交易所SoT续.md`;交割账单 API 未接,ITM 零价镜像 PnL 可能偏低(仓位 SoT 优先)。
|
||||
见 `docs/审计修改方案-2026-08-08-交易所SoT续.md`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user