Fix OO expiry settle index: persist spot and never invent strike from OTM fill.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+39
-14
@@ -25,6 +25,7 @@ def _is_oo_group(g: dict) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _infer_settle_index(g: dict, fills: list) -> float | None:
|
def _infer_settle_index(g: dict, fills: list) -> float | None:
|
||||||
|
"""优先库内 settle_index_px;否则用「实值腿」成交反推。虚值 fill≈0 时禁止推成行权价。"""
|
||||||
settle_index = g.get("settle_index_px")
|
settle_index = g.get("settle_index_px")
|
||||||
if settle_index is not None:
|
if settle_index is not None:
|
||||||
try:
|
try:
|
||||||
@@ -33,25 +34,44 @@ def _infer_settle_index(g: dict, fills: list) -> float | None:
|
|||||||
return v
|
return v
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
strike = g.get("strike")
|
|
||||||
side = str(g.get("option_side") or "").lower()
|
candidates: list[float] = []
|
||||||
if strike is None:
|
|
||||||
return None
|
|
||||||
for raw in fills:
|
for raw in fills:
|
||||||
f = dict(raw) if not isinstance(raw, dict) else raw
|
f = dict(raw) if not isinstance(raw, dict) else raw
|
||||||
if str(f.get("leg")) != "option" or str(f.get("action")) != "close":
|
if str(f.get("action") or "") != "close":
|
||||||
|
continue
|
||||||
|
leg = str(f.get("leg") or "")
|
||||||
|
if leg not in ("option", "option2"):
|
||||||
continue
|
continue
|
||||||
if abs(float(f.get("slip") or 0)) > 1e-12:
|
if abs(float(f.get("slip") or 0)) > 1e-12:
|
||||||
continue
|
continue
|
||||||
px = float(f.get("fill_px") or 0)
|
try:
|
||||||
k = float(strike)
|
px = float(f.get("fill_px") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
# 虚值到期 fill=0:k+0 / k-0 会得到行权价,不是真实结算指数
|
||||||
|
if px <= 1e-9:
|
||||||
|
continue
|
||||||
|
if leg == "option":
|
||||||
|
strike = g.get("strike")
|
||||||
|
side = str(g.get("option_side") or "").lower()
|
||||||
|
else:
|
||||||
|
strike = g.get("strike2")
|
||||||
|
side = str(g.get("option2_side") or "put").lower()
|
||||||
|
if strike is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
k = float(strike)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
if side in ("call", "c"):
|
if side in ("call", "c"):
|
||||||
return k + px
|
candidates.append(k + px)
|
||||||
if side in ("put", "p"):
|
elif side in ("put", "p"):
|
||||||
return k - px
|
candidates.append(k - px)
|
||||||
break
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
# 多腿一致时取平均;实值腿通常只有一条
|
||||||
|
return round(sum(candidates) / len(candidates), 4)
|
||||||
|
|
||||||
def _intrinsic(side: str, settle_index: float, strike: float) -> float:
|
def _intrinsic(side: str, settle_index: float, strike: float) -> float:
|
||||||
s = str(side or "").lower()
|
s = str(side or "").lower()
|
||||||
@@ -112,7 +132,7 @@ def _expiry_settle_info(g: dict, fills: list) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _close_index_px(g: dict, fills: list) -> float | None:
|
def _close_index_px(g: dict, fills: list) -> float | None:
|
||||||
"""平仓时标的指数:优先 settle_index_px,否则用永续平仓价近似。"""
|
"""平仓时标的指数:优先库内 settle;到期才用实值腿反推;否则永续平仓价。"""
|
||||||
raw = g.get("settle_index_px")
|
raw = g.get("settle_index_px")
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
@@ -121,6 +141,11 @@ def _close_index_px(g: dict, fills: list) -> float | None:
|
|||||||
return v
|
return v
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
# 仅到期:期权平仓价=内在价值,可反推指数;中途卖出的权利金不能当指数
|
||||||
|
if str(g.get("close_reason") or "") == "expiry":
|
||||||
|
inferred = _infer_settle_index(g, fills)
|
||||||
|
if inferred is not None and inferred > 0:
|
||||||
|
return inferred
|
||||||
for row in fills:
|
for row in fills:
|
||||||
f = dict(row) if not isinstance(row, dict) else row
|
f = dict(row) if not isinstance(row, dict) else row
|
||||||
if str(f.get("leg") or "") == "perp" and str(f.get("action") or "") == "close":
|
if str(f.get("leg") or "") == "perp" and str(f.get("action") or "") == "close":
|
||||||
|
|||||||
@@ -1082,16 +1082,23 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
summary = summarize_fills_pnl(list(fill_rows))
|
summary = summarize_fills_pnl(list(fill_rows))
|
||||||
net = float(summary.get("net_pnl") or 0.0)
|
net = float(summary.get("net_pnl") or 0.0)
|
||||||
|
settle_px = None
|
||||||
|
if reason == "expiry":
|
||||||
|
try:
|
||||||
|
settle_px = self._close_spot_px(get_session().snapshot())
|
||||||
|
except Exception:
|
||||||
|
settle_px = None
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
note=? WHERE group_id=?""",
|
note=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""",
|
||||||
(
|
(
|
||||||
"closed",
|
"closed",
|
||||||
int(time.time() * 1000),
|
int(time.time() * 1000),
|
||||||
reason,
|
reason,
|
||||||
net,
|
net,
|
||||||
f"oo full close {reason} exchange_flat_or_settle",
|
f"oo full close {reason} exchange_flat_or_settle",
|
||||||
|
float(settle_px) if settle_px is not None else None,
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1129,16 +1129,23 @@ class OkxLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
summary = summarize_fills_pnl(list(fill_rows))
|
summary = summarize_fills_pnl(list(fill_rows))
|
||||||
net = float(summary.get("net_pnl") or 0.0)
|
net = float(summary.get("net_pnl") or 0.0)
|
||||||
|
settle_px = None
|
||||||
|
if reason == "expiry":
|
||||||
|
try:
|
||||||
|
settle_px = self._close_spot_px(get_session().snapshot())
|
||||||
|
except Exception:
|
||||||
|
settle_px = None
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
note=? WHERE group_id=?""",
|
note=?, settle_index_px=COALESCE(?, settle_index_px) WHERE group_id=?""",
|
||||||
(
|
(
|
||||||
"closed",
|
"closed",
|
||||||
int(time.time() * 1000),
|
int(time.time() * 1000),
|
||||||
reason,
|
reason,
|
||||||
net,
|
net,
|
||||||
f"oo full close {reason} exchange_flat_or_settle",
|
f"oo full close {reason} exchange_flat_or_settle",
|
||||||
|
float(settle_px) if settle_px is not None else None,
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1000,9 +1000,15 @@ class Matcher:
|
|||||||
now += 1
|
now += 1
|
||||||
|
|
||||||
with self.db._lock:
|
with self.db._lock:
|
||||||
|
settle_px = None
|
||||||
|
if reason == "expiry":
|
||||||
|
try:
|
||||||
|
settle_px = self._close_spot_px(get_session().snapshot())
|
||||||
|
except Exception:
|
||||||
|
settle_px = None
|
||||||
self.db._conn.execute(
|
self.db._conn.execute(
|
||||||
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
fees=COALESCE(fees,0)+?, note=?
|
fees=COALESCE(fees,0)+?, note=?, settle_index_px=COALESCE(?, settle_index_px)
|
||||||
WHERE group_id=?""",
|
WHERE group_id=?""",
|
||||||
(
|
(
|
||||||
"closed",
|
"closed",
|
||||||
@@ -1011,6 +1017,7 @@ class Matcher:
|
|||||||
float(total_pnl),
|
float(total_pnl),
|
||||||
float(total_fees),
|
float(total_fees),
|
||||||
f"oo full close {reason}",
|
f"oo full close {reason}",
|
||||||
|
float(settle_px) if settle_px is not None else None,
|
||||||
group_id,
|
group_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""期期到期结算指数:虚值 Call fill=0 不得反推成行权价。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.api.trades import _infer_settle_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_otm_call_zero_fill_does_not_become_strike() -> None:
|
||||||
|
g = {
|
||||||
|
"hedge_mode": "option_option",
|
||||||
|
"option_side": "call",
|
||||||
|
"option2_side": "put",
|
||||||
|
"strike": 1920.0,
|
||||||
|
"strike2": 1890.0,
|
||||||
|
"settle_index_px": None,
|
||||||
|
}
|
||||||
|
fills = [
|
||||||
|
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||||
|
{"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||||
|
]
|
||||||
|
assert _infer_settle_index(g, fills) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_itm_put_fill_infers_settle_near_1875() -> None:
|
||||||
|
g = {
|
||||||
|
"hedge_mode": "option_option",
|
||||||
|
"option_side": "call",
|
||||||
|
"option2_side": "put",
|
||||||
|
"strike": 1920.0,
|
||||||
|
"strike2": 1920.0,
|
||||||
|
"settle_index_px": None,
|
||||||
|
}
|
||||||
|
fills = [
|
||||||
|
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||||
|
{"leg": "option2", "action": "close", "fill_px": 45.0, "slip": 0},
|
||||||
|
]
|
||||||
|
# put intrinsic 45 → settle = 1920 - 45 = 1875
|
||||||
|
assert _infer_settle_index(g, fills) == 1875.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_settle_wins() -> None:
|
||||||
|
g = {
|
||||||
|
"option_side": "call",
|
||||||
|
"strike": 1920.0,
|
||||||
|
"settle_index_px": 1875.2,
|
||||||
|
}
|
||||||
|
fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}]
|
||||||
|
assert _infer_settle_index(g, fills) == 1875.2
|
||||||
@@ -5,6 +5,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-11 — 期期到期结算指数修正
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
1. 期期 `close_oo_full`(SIM/OKX/BN)到期写入真实 `settle_index_px`(快照指数)。
|
||||||
|
2. 交易详情反推结算价:虚值 Call fill=0 不再误显示为行权价;优先实值 Put 腿反推。
|
||||||
|
|
||||||
|
### 审计
|
||||||
|
|
||||||
|
今天下午约 1875 到期,详情曾把结算指数显示成行权价(如 1920):因未落库 settle,且用 Call 内在价值 0 反推成 `strike+0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-11 — 期期交易记录展示与盈亏汇总
|
## 2026-08-11 — 期期交易记录展示与盈亏汇总
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
Reference in New Issue
Block a user