Backfill OO expiry settle display via public ETHUSDT and intrinsic overlay.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""公开行情辅助:补历史到期结算指数展示(不发明成交现金)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE: dict[int, float] = {}
|
||||
_CACHE_MAX = 256
|
||||
|
||||
|
||||
def looks_binance_option(inst_id: str | None) -> bool:
|
||||
return "USD_UM" in str(inst_id or "")
|
||||
|
||||
|
||||
def eth_usdt_close_at_ms(ts_ms: int | None) -> float | None:
|
||||
"""币安 ETHUSDT 1m K 线收盘价(近似期权结算指数)。失败返回 None。"""
|
||||
if ts_ms is None:
|
||||
return None
|
||||
try:
|
||||
ms = int(ts_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ms <= 0:
|
||||
return None
|
||||
minute = (ms // 60_000) * 60_000
|
||||
cached = _CACHE.get(minute)
|
||||
if cached is not None:
|
||||
return cached
|
||||
url = (
|
||||
"https://api.binance.com/api/v3/klines"
|
||||
f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=4) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
rows = json.loads(raw)
|
||||
if not rows:
|
||||
return None
|
||||
close_px = float(rows[0][4])
|
||||
if close_px <= 0:
|
||||
return None
|
||||
if len(_CACHE) >= _CACHE_MAX:
|
||||
_CACHE.clear()
|
||||
_CACHE[minute] = close_px
|
||||
return close_px
|
||||
except (urllib.error.URLError, TimeoutError, ValueError, TypeError, IndexError) as e:
|
||||
logger.debug("eth_usdt_close_at_ms failed ms=%s: %s", minute, e)
|
||||
return None
|
||||
|
||||
|
||||
def maybe_public_settle_index(g: dict[str, Any]) -> float | None:
|
||||
"""库内无结算价时,币安期权到期组用公开 ETHUSDT 收盘近似。"""
|
||||
if str(g.get("close_reason") or "") != "expiry":
|
||||
return None
|
||||
inst = g.get("option_inst_id") or g.get("option2_inst_id")
|
||||
if not looks_binance_option(str(inst) if inst else None):
|
||||
return None
|
||||
ts = g.get("close_at_ms") or g.get("hold_close_at_ms")
|
||||
return eth_usdt_close_at_ms(ts if ts is not None else None)
|
||||
+86
-14
@@ -25,7 +25,7 @@ def _is_oo_group(g: dict) -> bool:
|
||||
|
||||
|
||||
def _infer_settle_index(g: dict, fills: list) -> float | None:
|
||||
"""优先库内 settle_index_px;否则用「实值腿」成交反推。虚值 fill≈0 时禁止推成行权价。"""
|
||||
"""优先库内 settle_index_px;否则用「实值腿」成交反推;再否则公开指数近似。"""
|
||||
settle_index = g.get("settle_index_px")
|
||||
if settle_index is not None:
|
||||
try:
|
||||
@@ -68,10 +68,62 @@ def _infer_settle_index(g: dict, fills: list) -> float | None:
|
||||
candidates.append(k + px)
|
||||
elif side in ("put", "p"):
|
||||
candidates.append(k - px)
|
||||
if not candidates:
|
||||
return None
|
||||
# 多腿一致时取平均;实值腿通常只有一条
|
||||
return round(sum(candidates) / len(candidates), 4)
|
||||
if candidates:
|
||||
return round(sum(candidates) / len(candidates), 4)
|
||||
|
||||
try:
|
||||
from .public_index import maybe_public_settle_index
|
||||
|
||||
pub = maybe_public_settle_index(g)
|
||||
if pub is not None and pub > 0:
|
||||
return round(float(pub), 4)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _overlay_expiry_zero_fills(
|
||||
g: dict, fills: list, settle_index: float | None
|
||||
) -> list:
|
||||
"""到期 close 价为 0 且已有结算指数时,用内在价值覆盖展示(响应层,不写库)。"""
|
||||
if settle_index is None or settle_index <= 0:
|
||||
return fills
|
||||
if str(g.get("close_reason") or "") != "expiry":
|
||||
return fills
|
||||
out: list = []
|
||||
changed = False
|
||||
for raw in fills:
|
||||
f = dict(raw) if not isinstance(raw, dict) else dict(raw)
|
||||
if str(f.get("action") or "") == "close" and str(f.get("leg") or "") in (
|
||||
"option",
|
||||
"option2",
|
||||
):
|
||||
try:
|
||||
px = float(f.get("fill_px") or 0)
|
||||
except (TypeError, ValueError):
|
||||
px = 0.0
|
||||
if px <= 1e-9:
|
||||
leg = str(f.get("leg") or "")
|
||||
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 not None:
|
||||
try:
|
||||
intrinsic = _intrinsic(side, float(settle_index), float(strike))
|
||||
qty = float(f.get("qty_eth") or 0)
|
||||
f["fill_px"] = intrinsic
|
||||
f["base_px"] = intrinsic
|
||||
f["notional"] = intrinsic * qty
|
||||
f["slip"] = 0.0
|
||||
f["_overlay_intrinsic"] = True
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out.append(f)
|
||||
return out if changed else fills
|
||||
|
||||
def _intrinsic(side: str, settle_index: float, strike: float) -> float:
|
||||
s = str(side or "").lower()
|
||||
@@ -211,15 +263,26 @@ def _option_leverage_for_leg(
|
||||
def _enrich_group(g: dict, fills: list) -> dict:
|
||||
is_oo = _is_oo_group(g)
|
||||
g["is_oo"] = is_oo
|
||||
summary = summarize_fills_pnl(fills)
|
||||
# LIVE:优先 groups.realized_pnl(已按交易所回写,含资金费)
|
||||
if str(g.get("exec_mode") or "").upper() == "LIVE" and g.get("realized_pnl") is not None:
|
||||
settle = _infer_settle_index(g, fills)
|
||||
view_fills = _overlay_expiry_zero_fills(g, fills, settle)
|
||||
overlaid = any(
|
||||
isinstance(f, dict) and f.get("_overlay_intrinsic") for f in view_fills
|
||||
)
|
||||
summary = summarize_fills_pnl(view_fills)
|
||||
# LIVE 且未做内在价值覆盖:优先 groups.realized_pnl(含资金费)
|
||||
if (
|
||||
not overlaid
|
||||
and str(g.get("exec_mode") or "").upper() == "LIVE"
|
||||
and g.get("realized_pnl") is not None
|
||||
):
|
||||
summary = dict(summary)
|
||||
summary["net_pnl"] = float(g["realized_pnl"])
|
||||
if g.get("funding_usdt") is not None:
|
||||
summary["funding_usdt"] = float(g["funding_usdt"])
|
||||
summary["pnl_source"] = "live_exchange"
|
||||
# 期期 SIM:若成交汇总缺腿但组上已有 realized_pnl,用组值兜底
|
||||
elif overlaid:
|
||||
summary = dict(summary)
|
||||
summary["pnl_source"] = "expiry_intrinsic_overlay"
|
||||
elif (
|
||||
is_oo
|
||||
and g.get("realized_pnl") is not None
|
||||
@@ -240,17 +303,20 @@ def _enrich_group(g: dict, fills: list) -> dict:
|
||||
prem2 = float(g.get("initial_premium2") or 0) if is_oo else 0.0
|
||||
g["total_initial_premium"] = prem1 + prem2 if is_oo else prem1
|
||||
g.update(hold_timing(g, fills))
|
||||
info = _expiry_settle_info(g, fills)
|
||||
if settle is not None and g.get("settle_index_px") is None:
|
||||
g["settle_index_px"] = float(settle)
|
||||
info = _expiry_settle_info(g, view_fills)
|
||||
if info:
|
||||
g["expiry_settle"] = info
|
||||
if g.get("settle_index_px") is None and info.get("settle_index_px") is not None:
|
||||
g["settle_index_px"] = info["settle_index_px"]
|
||||
mp = _move_points(g, fills)
|
||||
mp = _move_points(g, view_fills)
|
||||
g["move_points"] = mp
|
||||
g["close_index_px"] = _close_index_px(g, fills)
|
||||
g["close_index_px"] = _close_index_px(g, view_fills)
|
||||
g["option_leverage"] = _option_leverage_for_leg(g, fills, leg="option")
|
||||
if is_oo:
|
||||
g["option2_leverage"] = _option_leverage_for_leg(g, fills, leg="option2")
|
||||
g["_view_fills"] = view_fills
|
||||
return g
|
||||
|
||||
|
||||
@@ -265,7 +331,9 @@ async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
|
||||
(g["group_id"],),
|
||||
)
|
||||
groups.append(_enrich_group(g, fills))
|
||||
gr = _enrich_group(g, fills)
|
||||
gr.pop("_view_fills", None)
|
||||
groups.append(gr)
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
@@ -281,9 +349,13 @@ async def group_detail(
|
||||
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||
)
|
||||
gr = _enrich_group(_row(g), fills)
|
||||
view_fills = gr.pop("_view_fills", None) or fills
|
||||
return {
|
||||
"group": gr,
|
||||
"fills": [_row(x) for x in fills],
|
||||
"fills": [
|
||||
{k: v for k, v in (dict(x) if not isinstance(x, dict) else x).items() if k != "_overlay_intrinsic"}
|
||||
for x in view_fills
|
||||
],
|
||||
"pnl_summary": gr.get("pnl_summary"),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.api.trades import _infer_settle_index
|
||||
from app.api.trades import _infer_settle_index, _overlay_expiry_zero_fills
|
||||
|
||||
|
||||
def test_otm_call_zero_fill_does_not_become_strike() -> None:
|
||||
def test_otm_call_zero_fill_does_not_become_strike(monkeypatch) -> None:
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"option_inst_id": "ETH-USD-260811-1920-C", # OKX 样式:不走公开回退
|
||||
"strike": 1920.0,
|
||||
"strike2": 1890.0,
|
||||
"settle_index_px": None,
|
||||
"close_reason": "expiry",
|
||||
"close_at_ms": 1,
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||
{"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"app.api.public_index.maybe_public_settle_index", lambda _g: None
|
||||
)
|
||||
assert _infer_settle_index(g, fills) is None
|
||||
|
||||
|
||||
@@ -34,7 +40,6 @@ def test_itm_put_fill_infers_settle_near_1875() -> None:
|
||||
{"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
|
||||
|
||||
|
||||
@@ -46,3 +51,28 @@ def test_stored_settle_wins() -> None:
|
||||
}
|
||||
fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}]
|
||||
assert _infer_settle_index(g, fills) == 1875.2
|
||||
|
||||
|
||||
def test_public_fallback_and_overlay(monkeypatch) -> None:
|
||||
g = {
|
||||
"hedge_mode": "option_option",
|
||||
"option_side": "call",
|
||||
"option2_side": "put",
|
||||
"option_inst_id": "ETH-USD_UM-260811-1940-C",
|
||||
"strike": 1940.0,
|
||||
"strike2": 1920.0,
|
||||
"settle_index_px": None,
|
||||
"close_reason": "expiry",
|
||||
"close_at_ms": 1786435200000,
|
||||
}
|
||||
fills = [
|
||||
{"leg": "option", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
|
||||
{"leg": "option2", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"app.api.public_index.maybe_public_settle_index", lambda _g: 1877.8
|
||||
)
|
||||
assert _infer_settle_index(g, fills) == 1877.8
|
||||
view = _overlay_expiry_zero_fills(g, fills, 1877.8)
|
||||
assert view[0]["fill_px"] == 0.0 # call OTM
|
||||
assert abs(view[1]["fill_px"] - (1920 - 1877.8)) < 1e-9
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — 期期到期结算指数:公开行情回退 + 零成交覆盖
|
||||
|
||||
### 变更
|
||||
|
||||
1. 币安期期到期若库内无 `settle_index_px`、成交也为 0:用到期时刻 ETHUSDT 1m 收盘近似结算指数(约 1877)。
|
||||
2. 详情用内在价值覆盖展示零价到期成交,并重算 Call/Put 盈亏。
|
||||
3. `scripts/repair_oo_settle_index.py` 可写回库(需 `DEPLOY_PASS`)。
|
||||
|
||||
### 审计
|
||||
|
||||
G-20260810-01 更新后仍显示「结算指数 —」:旧记录未落库 settle,且两腿 close=0,仅禁止误推后变成空。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — 期期到期结算指数修正
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""回填期期到期组的 settle_index(及可选内在价值成交)。
|
||||
|
||||
用法:
|
||||
$env:DEPLOY_PASS='...'
|
||||
$env:REPAIR_GROUP_ID='G-20260810-01'
|
||||
# 可选: $env:SETTLE_INDEX='1877.8' 不设则用币安 ETHUSDT 1m 收盘
|
||||
python scripts/repair_oo_settle_index.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HOST = os.environ.get("DEPLOY_HOST", "47.236.184.99")
|
||||
USER = os.environ.get("DEPLOY_USER", "root")
|
||||
PASSWORD = os.environ.get("DEPLOY_PASS", "")
|
||||
ROOT = os.environ.get("DEPLOY_ROOT", "/opt/eth_hedge_sim")
|
||||
DB = f"{ROOT}/backend/data/hedge.db"
|
||||
GROUP = os.environ.get("REPAIR_GROUP_ID", "G-20260810-01")
|
||||
SETTLE = os.environ.get("SETTLE_INDEX", "").strip()
|
||||
REPAIR_FILLS = os.environ.get("REPAIR_FILLS", "1").strip() not in ("0", "false", "no")
|
||||
|
||||
|
||||
def _eth_close(ms: int) -> float | None:
|
||||
minute = (int(ms) // 60_000) * 60_000
|
||||
url = (
|
||||
"https://api.binance.com/api/v3/klines"
|
||||
f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1"
|
||||
)
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
rows = json.loads(resp.read().decode())
|
||||
if not rows:
|
||||
return None
|
||||
return float(rows[0][4])
|
||||
|
||||
|
||||
REMOTE = r'''
|
||||
import sqlite3
|
||||
DB = %(db)r
|
||||
GROUP = %(group)r
|
||||
SETTLE = %(settle)s
|
||||
REPAIR_FILLS = %(repair_fills)s
|
||||
|
||||
con = sqlite3.connect(DB)
|
||||
con.row_factory = sqlite3.Row
|
||||
g = con.execute("SELECT * FROM groups WHERE group_id=?", (GROUP,)).fetchone()
|
||||
if not g:
|
||||
print("missing", GROUP)
|
||||
raise SystemExit(1)
|
||||
if str(g["close_reason"] or "") != "expiry":
|
||||
print("not expiry", GROUP)
|
||||
raise SystemExit(1)
|
||||
settle = float(SETTLE)
|
||||
strike = float(g["strike"] or 0)
|
||||
strike2 = float(g["strike2"] or 0) if g["strike2"] is not None else None
|
||||
side = str(g["option_side"] or "call").lower()
|
||||
side2 = str(g["option2_side"] or "put").lower()
|
||||
|
||||
def intrinsic(side, spot, k):
|
||||
if k is None:
|
||||
return None
|
||||
if side in ("call", "c"):
|
||||
return max(spot - k, 0.0)
|
||||
if side in ("put", "p"):
|
||||
return max(k - spot, 0.0)
|
||||
return 0.0
|
||||
|
||||
c_iv = intrinsic(side, settle, strike)
|
||||
p_iv = intrinsic(side2, settle, strike2) if strike2 is not None else None
|
||||
con.execute("BEGIN")
|
||||
con.execute(
|
||||
"UPDATE groups SET settle_index_px=?, note=COALESCE(note,'') || ? WHERE group_id=?",
|
||||
(settle, f" | settle_backfill={settle}", GROUP),
|
||||
)
|
||||
updated = []
|
||||
if REPAIR_FILLS:
|
||||
for leg, iv in (("option", c_iv), ("option2", p_iv)):
|
||||
if iv is None:
|
||||
continue
|
||||
row = con.execute(
|
||||
"SELECT id, qty_eth, fill_px FROM fills WHERE group_id=? AND leg=? AND action='close'",
|
||||
(GROUP, leg),
|
||||
).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
if abs(float(row["fill_px"] or 0)) > 1e-9:
|
||||
continue
|
||||
qty = float(row["qty_eth"] or 0)
|
||||
con.execute(
|
||||
"UPDATE fills SET base_px=?, fill_px=?, notional=?, slip=0 WHERE id=?",
|
||||
(iv, iv, iv * qty, row["id"]),
|
||||
)
|
||||
updated.append((leg, iv, qty))
|
||||
# 重算 realized
|
||||
fills = list(con.execute("SELECT * FROM fills WHERE group_id=?", (GROUP,)))
|
||||
opt = {}
|
||||
for f in fills:
|
||||
key = (f["leg"], f["action"])
|
||||
opt[key] = f
|
||||
net = 0.0
|
||||
fees = 0.0
|
||||
for leg in ("option", "option2"):
|
||||
o = opt.get((leg, "open"))
|
||||
c = opt.get((leg, "close"))
|
||||
if not o or not c:
|
||||
continue
|
||||
qty = float(o["qty_eth"] or c["qty_eth"] or 0)
|
||||
net += (float(c["fill_px"]) - float(o["fill_px"])) * qty
|
||||
fees += float(o["fee"] or 0) + float(c["fee"] or 0)
|
||||
net_after = net - fees
|
||||
con.execute(
|
||||
"UPDATE groups SET realized_pnl=? WHERE group_id=?",
|
||||
(net_after, GROUP),
|
||||
)
|
||||
con.commit()
|
||||
print({
|
||||
"group": GROUP,
|
||||
"settle": settle,
|
||||
"call_iv": c_iv,
|
||||
"put_iv": p_iv,
|
||||
"fills_updated": updated,
|
||||
"realized_pnl": net_after,
|
||||
})
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not PASSWORD:
|
||||
print("Set DEPLOY_PASS", file=sys.stderr)
|
||||
return 2
|
||||
import paramiko
|
||||
|
||||
settle = SETTLE
|
||||
if not settle:
|
||||
# peek close_at from remote first
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
_, stdout, _ = client.exec_command(
|
||||
f"sqlite3 {DB} \"SELECT close_at_ms FROM groups WHERE group_id='{GROUP}'\""
|
||||
)
|
||||
raw = stdout.read().decode().strip()
|
||||
client.close()
|
||||
if not raw:
|
||||
print("cannot read close_at_ms", file=sys.stderr)
|
||||
return 1
|
||||
px = _eth_close(int(raw))
|
||||
if px is None:
|
||||
print("cannot fetch ETHUSDT close", file=sys.stderr)
|
||||
return 1
|
||||
settle = f"{px:.4f}"
|
||||
print("using public ETHUSDT close", settle)
|
||||
|
||||
body = REMOTE % {
|
||||
"db": DB,
|
||||
"group": GROUP,
|
||||
"settle": settle,
|
||||
"repair_fills": "True" if REPAIR_FILLS else "False",
|
||||
}
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
_, stdout, stderr = client.exec_command("python3 - <<'PY'\n" + body + "\nPY")
|
||||
print(stdout.read().decode("utf-8", "replace"))
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
if err:
|
||||
print(err, file=sys.stderr)
|
||||
code = stdout.channel.recv_exit_status()
|
||||
client.close()
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user