9d77a430e5
平仓卖光可卖币后再按账户差分结算复盘;数据统计改为历史币盈亏×卖回价;复盘展示初始权利金与盈利率,并附历史修复脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
865 lines
30 KiB
Python
865 lines
30 KiB
Python
"""期权本地交易统计(胜率 / 盈亏 / 持仓时长)."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||
from lib.options.options_db import init_options_tables
|
||
|
||
|
||
def _safe_float(v: Any) -> float | None:
|
||
if v is None or v == "":
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _underlying_index_usdt(ex: Any, underly: str) -> float | None:
|
||
"""取标的 USDT 近似指数(币本位已平盈亏折 U).优先公开 ticker,避免私钥失败."""
|
||
u = (underly or "ETH").strip().upper() or "ETH"
|
||
pubs: list[Any] = []
|
||
try:
|
||
from lib.sim.hooks import _APP_MODULE, _sim_public_exchange
|
||
|
||
pub = _sim_public_exchange(ex) if _APP_MODULE is not None else None
|
||
if pub is not None:
|
||
pubs.append(pub)
|
||
except Exception:
|
||
pass
|
||
if ex is not None and ex not in pubs:
|
||
pubs.append(ex)
|
||
from lib.exchange.okx_options_lib import fetch_index_price
|
||
|
||
for pub in pubs:
|
||
try:
|
||
if hasattr(pub, "public_get_market_ticker"):
|
||
rows = (pub.public_get_market_ticker({"instId": f"{u}-USDT"}) or {}).get("data") or []
|
||
if rows:
|
||
last = _safe_float(rows[0].get("last") or rows[0].get("lastPx"))
|
||
if last is not None and last > 0:
|
||
return float(last)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
px = fetch_index_price(pub, f"{u}-USD")
|
||
if px is not None and float(px) > 0:
|
||
return float(px)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
t = pub.fetch_ticker(f"{u}/USDT") or {}
|
||
last = _safe_float(t.get("last") or t.get("close"))
|
||
if last is not None and last > 0:
|
||
return float(last)
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def history_pnl_to_usdt(
|
||
history: list[dict[str, Any]],
|
||
ex: Any = None,
|
||
*,
|
||
prefer_frozen_usdt: bool = True,
|
||
) -> list[dict[str, Any]]:
|
||
"""折 U.
|
||
|
||
prefer_frozen_usdt=True:优先行内 realized_pnl_usdt(实盘 exchange freeze / 旧兼容).
|
||
prefer_frozen_usdt=False:口径 B — 币盈亏 × 平仓卖回价(close_index_px),忽略账户差分 A.
|
||
"""
|
||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
|
||
|
||
idx_cache: dict[str, float | None] = {}
|
||
out: list[dict[str, Any]] = []
|
||
for row in history:
|
||
r = dict(row)
|
||
if r.get("status") == "open":
|
||
out.append(r)
|
||
continue
|
||
frozen = _safe_float(r.get("realized_pnl_usdt"))
|
||
if prefer_frozen_usdt and frozen is not None:
|
||
r["realized_pnl"] = round(float(frozen), 4)
|
||
out.append(r)
|
||
continue
|
||
pnl = _safe_float(r.get("realized_pnl"))
|
||
if pnl is None:
|
||
out.append(r)
|
||
continue
|
||
inst = str(r.get("inst_id") or "")
|
||
underly = str(r.get("underlying") or (inst.split("-")[0] if inst else "ETH") or "ETH")
|
||
ccy = str(r.get("premium_ccy") or "").strip().upper()
|
||
if not ccy:
|
||
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
|
||
if ccy in ("ETH", "BTC"):
|
||
idx = _safe_float(r.get("close_index_px"))
|
||
if idx is None or idx <= 0:
|
||
if underly not in idx_cache:
|
||
idx_cache[underly] = _underlying_index_usdt(ex, underly)
|
||
idx = idx_cache.get(underly)
|
||
if idx is None or idx <= 0:
|
||
r["realized_pnl"] = None
|
||
else:
|
||
r["realized_pnl"] = round(float(pnl) * float(idx), 4)
|
||
else:
|
||
r["realized_pnl"] = round(float(pnl), 4)
|
||
out.append(r)
|
||
return out
|
||
|
||
|
||
def history_pnl_to_usdt_stats(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
|
||
"""数据统计口径 B:期权历史币盈亏 × 平仓卖回价."""
|
||
return history_pnl_to_usdt(history, ex, prefer_frozen_usdt=False)
|
||
|
||
|
||
def _parse_ts(raw: Any) -> datetime | None:
|
||
if raw is None or raw == "":
|
||
return None
|
||
s = str(raw).strip().replace(" ", "T", 1)
|
||
try:
|
||
return datetime.fromisoformat(s)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _hold_seconds(created_at: Any, closed_at: Any) -> float | None:
|
||
start = _parse_ts(created_at)
|
||
end = _parse_ts(closed_at)
|
||
if start is None or end is None:
|
||
return None
|
||
sec = (end - start).total_seconds()
|
||
return sec if sec >= 0 else None
|
||
|
||
|
||
def _avg_seconds(values: list[float]) -> float | None:
|
||
if not values:
|
||
return None
|
||
return round(sum(values) / len(values), 1)
|
||
|
||
|
||
def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""基于期权历史列表(交易所)计算统计."""
|
||
wins: list[float] = []
|
||
losses: list[float] = []
|
||
win_holds: list[float] = []
|
||
loss_holds: list[float] = []
|
||
all_holds: list[float] = []
|
||
open_holds: list[float] = []
|
||
now = datetime.now()
|
||
|
||
for row in history:
|
||
if row.get("status") == "open":
|
||
start = _parse_ts(row.get("created_at"))
|
||
if start is not None:
|
||
sec = (now - start).total_seconds()
|
||
if sec >= 0:
|
||
open_holds.append(sec)
|
||
continue
|
||
pnl_raw = row.get("realized_pnl")
|
||
if pnl_raw is None:
|
||
continue
|
||
try:
|
||
pnl = float(pnl_raw)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
hold = _hold_seconds(row.get("created_at"), row.get("closed_at"))
|
||
if hold is not None:
|
||
all_holds.append(hold)
|
||
if pnl > 0:
|
||
wins.append(pnl)
|
||
if hold is not None:
|
||
win_holds.append(hold)
|
||
elif pnl < 0:
|
||
losses.append(pnl)
|
||
if hold is not None:
|
||
loss_holds.append(hold)
|
||
|
||
total_closed = len(wins) + len(losses)
|
||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||
avg_win = sum(wins) / len(wins) if wins else None
|
||
avg_loss = sum(losses) / len(losses) if losses else None
|
||
|
||
total_profit = round(sum(wins), 4) if wins else 0.0
|
||
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
|
||
net_realized = round(sum(wins) + sum(losses), 4)
|
||
return {
|
||
"total_closed": total_closed,
|
||
"win_count": len(wins),
|
||
"loss_count": len(losses),
|
||
"win_rate": win_rate,
|
||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||
"avg_win": round(avg_win, 4) if avg_win is not None else None,
|
||
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
|
||
"total_profit": total_profit,
|
||
"total_loss": total_loss,
|
||
"net_realized_pnl": net_realized,
|
||
"avg_hold_sec": _avg_seconds(all_holds),
|
||
"avg_win_hold_sec": _avg_seconds(win_holds),
|
||
"avg_loss_hold_sec": _avg_seconds(loss_holds),
|
||
"open_count": len(open_holds),
|
||
"avg_open_hold_sec": _avg_seconds(open_holds),
|
||
}
|
||
|
||
|
||
_STATS_WINDOWS = ("today", "7d", "30d", "all")
|
||
|
||
|
||
def normalize_stats_window(raw: Any) -> str:
|
||
w = str(raw or "7d").strip().lower()
|
||
if w in ("1d", "day", "今日"):
|
||
return "today"
|
||
if w in ("7", "week"):
|
||
return "7d"
|
||
if w in ("30", "month"):
|
||
return "30d"
|
||
if w in ("0", "全部", "total"):
|
||
return "all"
|
||
return w if w in _STATS_WINDOWS else "7d"
|
||
|
||
|
||
def stats_window_bounds(window: str, *, now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
|
||
"""返回闭区间起止(本地/业务时区近似用本地 now).all → (None, None)."""
|
||
w = normalize_stats_window(window)
|
||
end = now or datetime.now()
|
||
if w == "all":
|
||
return None, None
|
||
if w == "today":
|
||
start = end.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return start, end
|
||
days = 7 if w == "7d" else 30
|
||
from datetime import timedelta
|
||
|
||
start = end - timedelta(days=days)
|
||
return start, end
|
||
|
||
|
||
def filter_history_for_stats_window(
|
||
history: list[dict[str, Any]],
|
||
window: str,
|
||
*,
|
||
now: datetime | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""窗口内已平仓 + 当前持仓(open)."""
|
||
start, end = stats_window_bounds(window, now=now)
|
||
out: list[dict[str, Any]] = []
|
||
for row in history or []:
|
||
if row.get("status") == "open":
|
||
out.append(row)
|
||
continue
|
||
if start is None:
|
||
out.append(row)
|
||
continue
|
||
ts = _parse_ts(row.get("closed_at")) or _parse_ts(row.get("created_at"))
|
||
if ts is None:
|
||
continue
|
||
if ts < start:
|
||
continue
|
||
if end is not None and ts > end:
|
||
continue
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def compute_options_stats_for_window(
|
||
history: list[dict[str, Any]],
|
||
window: str,
|
||
*,
|
||
now: datetime | None = None,
|
||
) -> dict[str, Any]:
|
||
w = normalize_stats_window(window)
|
||
filtered = filter_history_for_stats_window(history, w, now=now)
|
||
stats = compute_options_stats_from_history(filtered)
|
||
start, end = stats_window_bounds(w, now=now)
|
||
stats["window"] = w
|
||
stats["window_start"] = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
|
||
stats["window_end"] = end.strftime("%Y-%m-%d %H:%M:%S") if end else None
|
||
return stats
|
||
|
||
|
||
def _row_get(row: Any, key: str, default: Any = None) -> Any:
|
||
try:
|
||
if hasattr(row, "keys") and key in row.keys():
|
||
return row[key]
|
||
except Exception:
|
||
pass
|
||
if isinstance(row, dict):
|
||
return row.get(key, default)
|
||
return default
|
||
|
||
|
||
def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for row in closed_rows or []:
|
||
out.append(
|
||
{
|
||
"status": "closed",
|
||
"realized_pnl": _row_get(row, "realized_pnl"),
|
||
# 口径 B 不读账户 realized_pnl_usdt;保留键位兼容旧调用
|
||
"realized_pnl_usdt": None,
|
||
"close_index_px": _row_get(row, "close_index_px"),
|
||
"created_at": _row_get(row, "created_at"),
|
||
"closed_at": _row_get(row, "closed_at"),
|
||
"inst_id": _row_get(row, "inst_id") or "",
|
||
"underlying": _row_get(row, "underlying") or "",
|
||
"premium_ccy": _row_get(row, "premium_ccy") or "",
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
# 口径 B:凡有币盈亏的已平均计入;U 由 history_pnl_to_usdt_stats 按卖回价折算
|
||
_CLOSED_STATS_SELECT = """
|
||
SELECT realized_pnl, close_index_px,
|
||
created_at, closed_at, inst_id, underlying, premium_ccy
|
||
FROM options_trades
|
||
WHERE status = 'closed'
|
||
AND realized_pnl IS NOT NULL
|
||
"""
|
||
|
||
|
||
def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||
"""旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变.
|
||
|
||
注意:已有 realized_pnl 视为净额直接冻结,不再二次扣 open_fee/close_fee
|
||
(避免对 OKX realizedPnl 或已扣费本地净额重复减费).
|
||
"""
|
||
init_options_tables(conn)
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px,
|
||
inst_id, underlying, premium_ccy
|
||
FROM options_trades
|
||
WHERE status = 'closed'
|
||
AND realized_pnl_usdt IS NULL
|
||
AND realized_pnl IS NOT NULL
|
||
AND trading_usdt_before IS NULL
|
||
ORDER BY id DESC
|
||
LIMIT 300
|
||
"""
|
||
).fetchall()
|
||
if not rows:
|
||
return 0
|
||
from lib.options.options_pnl_settle_lib import (
|
||
freeze_pnl_to_usdt,
|
||
resolve_close_index_px,
|
||
)
|
||
|
||
updated = 0
|
||
for row in rows:
|
||
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper()
|
||
idx = _safe_float(_row_get(row, "close_index_px"))
|
||
if idx is None or idx <= 0:
|
||
idx = resolve_close_index_px(
|
||
ex,
|
||
inst_id=str(_row_get(row, "inst_id") or ""),
|
||
underlying=str(_row_get(row, "underlying") or ""),
|
||
)
|
||
pnl = _safe_float(_row_get(row, "realized_pnl"))
|
||
if pnl is None:
|
||
continue
|
||
settled = freeze_pnl_to_usdt(pnl, premium_ccy=ccy or "USDC", index_px=idx)
|
||
usdt = settled.get("realized_pnl_usdt")
|
||
close_idx = settled.get("close_index_px")
|
||
if usdt is None:
|
||
continue
|
||
conn.execute(
|
||
"""
|
||
UPDATE options_trades
|
||
SET realized_pnl_usdt = ?,
|
||
close_index_px = COALESCE(close_index_px, ?)
|
||
WHERE id = ? AND realized_pnl_usdt IS NULL
|
||
""",
|
||
(float(usdt), close_idx, int(row["id"])),
|
||
)
|
||
updated += 1
|
||
return updated
|
||
|
||
|
||
def recompute_closed_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||
"""强制按「平仓收回×平仓指数 − 开仓成本U − 手续费」重算已平冻结 U.
|
||
|
||
旧逻辑用 (收回−已付)×平仓指数,等于把开仓成本也按平仓价重估,ETH 上涨时会偏亏,
|
||
导致合计盈亏与余额对不上.有 budget_usdt/open_index_px 时优先用真实开仓成本.
|
||
"""
|
||
init_options_tables(conn)
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, open_index_px,
|
||
inst_id, underlying, premium_ccy, premium_paid, premium_received,
|
||
open_fee, close_fee, fee_ccy, budget_usdt, close_quote, eth_amount,
|
||
open_quote
|
||
FROM options_trades
|
||
WHERE status = 'closed'
|
||
AND premium_paid IS NOT NULL
|
||
ORDER BY id DESC
|
||
LIMIT 300
|
||
"""
|
||
).fetchall()
|
||
if not rows:
|
||
return 0
|
||
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
|
||
from lib.options.options_pricing_lib import total_premium
|
||
|
||
updated = 0
|
||
for row in rows:
|
||
paid = _safe_float(_row_get(row, "premium_paid"))
|
||
recv = _safe_float(_row_get(row, "premium_received"))
|
||
if recv is None:
|
||
cq = _safe_float(_row_get(row, "close_quote"))
|
||
eth = _safe_float(_row_get(row, "eth_amount"))
|
||
if cq is not None and eth is not None and eth > 0:
|
||
recv = float(total_premium(cq, eth))
|
||
if paid is None or recv is None:
|
||
continue
|
||
ofee = abs(_safe_float(_row_get(row, "open_fee")) or 0.0)
|
||
cfee = abs(_safe_float(_row_get(row, "close_fee")) or 0.0)
|
||
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() or "ETH"
|
||
close_idx = _safe_float(_row_get(row, "close_index_px"))
|
||
open_idx = _safe_float(_row_get(row, "open_index_px"))
|
||
budget = _safe_float(_row_get(row, "budget_usdt"))
|
||
if close_idx is None or close_idx <= 0:
|
||
close_idx = resolve_close_index_px(
|
||
ex,
|
||
inst_id=str(_row_get(row, "inst_id") or ""),
|
||
underlying=str(_row_get(row, "underlying") or ""),
|
||
)
|
||
if (open_idx is None or open_idx <= 0) and budget and paid > 1e-12:
|
||
open_idx = float(budget) / float(paid)
|
||
settled = settle_closed_pnl(
|
||
premium_paid=paid,
|
||
premium_received=recv,
|
||
open_fee=ofee,
|
||
close_fee=cfee,
|
||
premium_ccy=ccy,
|
||
index_px=close_idx,
|
||
open_index_px=open_idx,
|
||
budget_usdt=budget,
|
||
fee_ccy=str(_row_get(row, "fee_ccy") or ccy),
|
||
)
|
||
new_usdt = settled.get("realized_pnl_usdt")
|
||
if new_usdt is None:
|
||
continue
|
||
old_usdt = _safe_float(_row_get(row, "realized_pnl_usdt"))
|
||
new_pnl = settled.get("realized_pnl")
|
||
# 有变化才写,避免无意义刷库
|
||
if (
|
||
old_usdt is not None
|
||
and abs(float(old_usdt) - float(new_usdt)) < 1e-6
|
||
and _safe_float(_row_get(row, "realized_pnl")) is not None
|
||
and new_pnl is not None
|
||
and abs(float(_row_get(row, "realized_pnl")) - float(new_pnl)) < 1e-10
|
||
):
|
||
continue
|
||
conn.execute(
|
||
"""
|
||
UPDATE options_trades
|
||
SET realized_pnl = COALESCE(?, realized_pnl),
|
||
close_index_px = COALESCE(?, close_index_px),
|
||
open_index_px = COALESCE(open_index_px, ?),
|
||
realized_pnl_usdt = ?
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
new_pnl,
|
||
settled.get("close_index_px"),
|
||
settled.get("open_index_px"),
|
||
float(new_usdt),
|
||
int(row["id"]),
|
||
),
|
||
)
|
||
updated += 1
|
||
return updated
|
||
|
||
|
||
def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int:
|
||
"""兼容旧名:改为强制按开仓成本重算冻结 U."""
|
||
return recompute_closed_realized_pnl_usdt(conn, ex)
|
||
|
||
|
||
def _sim_net_usdt_converts_for_trade(
|
||
conn,
|
||
*,
|
||
underlying: str,
|
||
start_ts: str,
|
||
end_ts: str,
|
||
) -> float | None:
|
||
"""从 sim_ledger 汇总该标的现货兑换的 USDT 净变动(买币为负、卖回为正)."""
|
||
coin = (underlying or "").strip().upper()
|
||
if coin not in ("ETH", "BTC") or not start_ts or not end_ts:
|
||
return None
|
||
try:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT amount, note FROM sim_ledger_entries
|
||
WHERE kind = 'convert'
|
||
AND UPPER(ccy) = 'USDT'
|
||
AND account = 'trading'
|
||
AND ts >= ?
|
||
AND ts <= ?
|
||
""",
|
||
(str(start_ts), str(end_ts)),
|
||
).fetchall()
|
||
except Exception:
|
||
return None
|
||
total = 0.0
|
||
n = 0
|
||
for row in rows or []:
|
||
note = str(_row_get(row, "note") or "").upper()
|
||
amt = _safe_float(_row_get(row, "amount"))
|
||
if amt is None:
|
||
continue
|
||
related = (
|
||
f"{coin}/USDT" in note
|
||
or f"{coin}-USDT" in note
|
||
or note.strip() == f"FROM {coin}"
|
||
)
|
||
if not related:
|
||
continue
|
||
total += float(amt)
|
||
n += 1
|
||
if n < 1:
|
||
return None
|
||
return round(total, 4)
|
||
|
||
|
||
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||
"""兼容入口:已按交易账户结算(pnl_settled=1)的永不改写.
|
||
|
||
有 trading_usdt_before 的待结算单留给卖回结算;
|
||
仅无账户快照的旧单才回退权利金×指数冻结.
|
||
"""
|
||
del ex
|
||
init_options_tables(conn)
|
||
# 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖
|
||
return 0
|
||
|
||
|
||
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
|
||
"""本地已平(模拟盘口径) + 当前 open."""
|
||
init_options_tables(conn)
|
||
try:
|
||
# 权利金净盈亏冻结 U(纠正旧版把买币预算差额当盈利的数据)
|
||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
freeze_missing_realized_pnl_usdt(conn, ex)
|
||
except Exception:
|
||
pass
|
||
closed_rows = conn.execute(_CLOSED_STATS_SELECT).fetchall()
|
||
open_rows = conn.execute(
|
||
"SELECT created_at FROM options_trades WHERE status = 'open'"
|
||
).fetchall()
|
||
history = _closed_trade_dicts(closed_rows)
|
||
for row in open_rows:
|
||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||
return history
|
||
|
||
|
||
def _load_exchange_pnl_freeze_map(conn) -> dict[str, dict[str, Any]]:
|
||
init_options_tables(conn)
|
||
out: dict[str, dict[str, Any]] = {}
|
||
try:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT history_key, realized_pnl_usdt, close_index_px, realized_pnl
|
||
FROM options_exchange_pnl_freeze
|
||
"""
|
||
).fetchall()
|
||
except Exception:
|
||
return out
|
||
for row in rows or []:
|
||
key = str(_row_get(row, "history_key") or "").strip()
|
||
if not key:
|
||
continue
|
||
out[key] = {
|
||
"realized_pnl_usdt": _row_get(row, "realized_pnl_usdt"),
|
||
"close_index_px": _row_get(row, "close_index_px"),
|
||
"realized_pnl": _row_get(row, "realized_pnl"),
|
||
}
|
||
return out
|
||
|
||
|
||
def apply_exchange_pnl_freeze(
|
||
conn,
|
||
history: list[dict[str, Any]],
|
||
ex: Any = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""写冻结表.行内已有 close_index_px / _freeze_index_hint 时不打网.
|
||
|
||
若仍缺指数且传了 ex,会在持连接时补拉(仅兼容旧单测).生产请用
|
||
load_live_exchange_stats_history(先网后短事务).
|
||
"""
|
||
freeze_map = _load_exchange_pnl_freeze_map(conn)
|
||
enriched: list[dict[str, Any]] = []
|
||
need_idx: dict[str, str] = {}
|
||
for row in history or []:
|
||
r = dict(row)
|
||
if r.get("status") == "open":
|
||
enriched.append(r)
|
||
continue
|
||
key = str(r.get("history_key") or "").strip()
|
||
pnl = _safe_float(r.get("realized_pnl"))
|
||
if (
|
||
key
|
||
and pnl is not None
|
||
and not (
|
||
key in freeze_map
|
||
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
|
||
)
|
||
and _safe_float(r.get("close_index_px")) is None
|
||
and _safe_float(r.get("_freeze_index_hint")) is None
|
||
):
|
||
underly = str(r.get("underlying") or "").strip().upper()
|
||
inst = str(r.get("inst_id") or "")
|
||
if not underly and inst:
|
||
underly = inst.split("-")[0].strip().upper()
|
||
if underly:
|
||
need_idx[underly] = inst
|
||
enriched.append(r)
|
||
|
||
if need_idx and ex is not None:
|
||
from lib.options.options_pnl_settle_lib import resolve_close_index_px
|
||
|
||
idx_cache: dict[str, float | None] = {}
|
||
for underly, inst in need_idx.items():
|
||
idx_cache[underly] = resolve_close_index_px(
|
||
ex, inst_id=inst, underlying=underly
|
||
)
|
||
for r in enriched:
|
||
if r.get("status") == "open":
|
||
continue
|
||
underly = str(r.get("underlying") or "").strip().upper()
|
||
inst = str(r.get("inst_id") or "")
|
||
if not underly and inst:
|
||
underly = inst.split("-")[0].strip().upper()
|
||
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
|
||
r["_freeze_index_hint"] = idx_cache.get(underly)
|
||
|
||
return _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
|
||
|
||
|
||
def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]:
|
||
"""模拟盘数据统计:口径 B(历史币盈亏×卖回价)."""
|
||
conn = get_db()
|
||
try:
|
||
history = load_local_options_stats_history(conn, ex)
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def load_live_exchange_stats_history(cfg: dict[str, Any], ex: Any) -> list[dict[str, Any]]:
|
||
"""实盘统计用历史:先拉交易所,再无连接时取指数,最后短事务写冻结.
|
||
|
||
禁止在持有 DB 连接时打行情/历史网,否则会拖死持仓与中控轮询.
|
||
"""
|
||
from lib.options.options_history_lib import load_options_history
|
||
from lib.options.options_pnl_settle_lib import resolve_close_index_px
|
||
|
||
history = load_options_history(ex, cfg) or []
|
||
get_db = cfg["get_db"]
|
||
conn = get_db()
|
||
try:
|
||
freeze_map = _load_exchange_pnl_freeze_map(conn)
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
conn.close()
|
||
|
||
need_idx: dict[str, str] = {}
|
||
for row in history:
|
||
if row.get("status") == "open":
|
||
continue
|
||
key = str(row.get("history_key") or "").strip()
|
||
if not key or _safe_float(row.get("realized_pnl")) is None:
|
||
continue
|
||
if key in freeze_map and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None:
|
||
continue
|
||
underly = str(row.get("underlying") or "").strip().upper()
|
||
inst = str(row.get("inst_id") or "")
|
||
if not underly and inst:
|
||
underly = inst.split("-")[0].strip().upper()
|
||
if underly and _safe_float(row.get("close_index_px")) is None:
|
||
need_idx[underly] = inst
|
||
|
||
idx_cache: dict[str, float | None] = {}
|
||
for underly, inst in need_idx.items():
|
||
idx_cache[underly] = resolve_close_index_px(ex, inst_id=inst, underlying=underly)
|
||
|
||
enriched: list[dict[str, Any]] = []
|
||
for row in history:
|
||
r = dict(row)
|
||
if r.get("status") != "open":
|
||
underly = str(r.get("underlying") or "").strip().upper()
|
||
inst = str(r.get("inst_id") or "")
|
||
if not underly and inst:
|
||
underly = inst.split("-")[0].strip().upper()
|
||
key = str(r.get("history_key") or "").strip()
|
||
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
|
||
if not (
|
||
key in freeze_map
|
||
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
|
||
):
|
||
r["_freeze_index_hint"] = idx_cache.get(underly)
|
||
enriched.append(r)
|
||
|
||
conn = get_db()
|
||
try:
|
||
out = _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def compute_options_stats_live_exchange(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
|
||
"""实盘:与「期权历史」同一数据源;口径 B=币盈亏×卖回价(freeze)."""
|
||
history = load_live_exchange_stats_history(cfg, ex)
|
||
return compute_options_stats_from_history(
|
||
history_pnl_to_usdt(history, ex, prefer_frozen_usdt=True)
|
||
)
|
||
|
||
|
||
def _apply_exchange_pnl_freeze_with_hints(
|
||
conn,
|
||
history: list[dict[str, Any]],
|
||
freeze_map: dict[str, dict[str, Any]] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""短事务写冻结;指数只用来自行内 _freeze_index_hint / 已有 close_index_px."""
|
||
from lib.options.options_pnl_settle_lib import settle_exchange_realized_pnl
|
||
from lib.options.options_time_lib import options_app_now_str
|
||
|
||
init_options_tables(conn)
|
||
fmap = freeze_map if freeze_map is not None else _load_exchange_pnl_freeze_map(conn)
|
||
out: list[dict[str, Any]] = []
|
||
for row in history or []:
|
||
r = dict(row)
|
||
hint = r.pop("_freeze_index_hint", None)
|
||
if r.get("status") == "open":
|
||
out.append(r)
|
||
continue
|
||
key = str(r.get("history_key") or "").strip()
|
||
pnl = _safe_float(r.get("realized_pnl"))
|
||
if not key or pnl is None:
|
||
out.append(r)
|
||
continue
|
||
frozen = fmap.get(key)
|
||
if frozen is not None and _safe_float(frozen.get("realized_pnl_usdt")) is not None:
|
||
r["realized_pnl_usdt"] = frozen.get("realized_pnl_usdt")
|
||
r["close_index_px"] = frozen.get("close_index_px")
|
||
out.append(r)
|
||
continue
|
||
ccy = str(r.get("premium_ccy") or "").strip().upper()
|
||
idx = _safe_float(r.get("close_index_px")) or _safe_float(hint)
|
||
settled = settle_exchange_realized_pnl(
|
||
realized_pnl=pnl,
|
||
premium_ccy=ccy or "ETH",
|
||
index_px=idx,
|
||
)
|
||
usdt = settled.get("realized_pnl_usdt")
|
||
close_idx = settled.get("close_index_px")
|
||
if usdt is not None:
|
||
try:
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO options_exchange_pnl_freeze(
|
||
history_key, realized_pnl, realized_pnl_usdt, close_index_px,
|
||
premium_ccy, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(history_key) DO UPDATE SET
|
||
realized_pnl = excluded.realized_pnl,
|
||
realized_pnl_usdt = excluded.realized_pnl_usdt,
|
||
close_index_px = excluded.close_index_px,
|
||
premium_ccy = excluded.premium_ccy,
|
||
updated_at = excluded.updated_at
|
||
""",
|
||
(
|
||
key,
|
||
float(pnl),
|
||
float(usdt),
|
||
close_idx,
|
||
ccy or "",
|
||
options_app_now_str(),
|
||
),
|
||
)
|
||
except Exception:
|
||
pass
|
||
r["realized_pnl_usdt"] = float(usdt)
|
||
r["close_index_px"] = close_idx
|
||
out.append(r)
|
||
return out
|
||
|
||
|
||
def compute_options_stats_auto(cfg: dict[str, Any], ex: Any = None) -> dict[str, Any]:
|
||
"""模拟盘走本地结算;实盘走交易所期权历史原生盈亏."""
|
||
get_db = cfg.get("get_db")
|
||
try:
|
||
from lib.sim.mode_lib import is_sim_mode
|
||
|
||
if callable(get_db) and is_sim_mode(get_db):
|
||
return compute_options_stats(get_db, ex)
|
||
except Exception:
|
||
pass
|
||
if ex is None:
|
||
ex = cfg.get("exchange_options")
|
||
return compute_options_stats_live_exchange(cfg, ex)
|
||
|
||
|
||
def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> dict[str, Any]:
|
||
"""兼容旧调用;内部统一折 U 后走 compute_options_stats_from_history."""
|
||
history = _closed_trade_dicts(closed_rows)
|
||
for row in open_rows or []:
|
||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||
|
||
|
||
def header_options_stats_for_window(
|
||
conn,
|
||
list_window: dict[str, Any],
|
||
app_tz,
|
||
ex: Any = None,
|
||
) -> dict[str, Any]:
|
||
"""顶栏总交易/胜率/盈亏比:口径 B(历史币盈亏×卖回价)."""
|
||
from lib.common.history_window_lib import utc_window_to_bj_sql_strings
|
||
|
||
init_options_tables(conn)
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(
|
||
list_window["start_utc"], list_window["end_utc"], app_tz
|
||
)
|
||
closed_rows = conn.execute(
|
||
"""
|
||
SELECT realized_pnl, close_index_px,
|
||
created_at, closed_at, inst_id, underlying, premium_ccy
|
||
FROM options_trades
|
||
WHERE status = 'closed'
|
||
AND realized_pnl IS NOT NULL
|
||
AND COALESCE(closed_at, created_at) >= ?
|
||
AND COALESCE(closed_at, created_at) <= ?
|
||
""",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
open_rows = conn.execute(
|
||
"SELECT created_at FROM options_trades WHERE status = 'open'"
|
||
).fetchall()
|
||
history = _closed_trade_dicts(closed_rows)
|
||
for row in open_rows:
|
||
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
|
||
stats = compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
|
||
return {
|
||
"total": int(stats.get("total_closed") or 0),
|
||
"rate": float(stats.get("win_rate") or 0),
|
||
"profit_loss_ratio": stats.get("profit_loss_ratio"),
|
||
}
|