统计已实现盈亏扣手续费,并按平仓时指数冻结为 U。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-08 22:50:21 +08:00
parent e8f8a76753
commit 5e8b578f75
12 changed files with 726 additions and 54 deletions
+5
View File
@@ -1147,6 +1147,8 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
if avg is None or avg <= 0:
avg = fill_px
state = str(o.get("state") or "").strip().lower()
fee = _safe_float(o.get("fee"))
fee_ccy = str(o.get("feeCcy") or o.get("fee_ccy") or "").strip().upper()
return {
"ok": True,
"ord_id": str(o.get("ordId") or ord_id),
@@ -1157,6 +1159,9 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
"avg_px": avg,
"side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""),
# OKX fee 扣费多为负;此处保留原始值,结算侧取绝对值
"fee": fee,
"fee_ccy": fee_ccy,
"raw": o,
}
except Exception as e:
+13 -2
View File
@@ -284,12 +284,13 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_history_lib import load_options_history
from lib.options.options_db import init_options_tables
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_stats_lib import (
compute_options_stats_for_window,
history_pnl_to_usdt,
load_local_options_stats_history,
normalize_stats_window,
)
@@ -301,7 +302,17 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
raw_live = cfg["fetch_option_positions"](ex)
if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败", "version": 1})
history = load_options_history(ex, cfg)
# 与实例统计一致:本地已平(扣费+冻结 U),不跟实时指数漂移
conn = cfg["get_db"]()
try:
init_options_tables(conn)
history = load_local_options_stats_history(conn, ex)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
stats = compute_options_stats_for_window(history_pnl_to_usdt(history, ex), window)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
net_realized = stats.get("net_realized_pnl")
+112 -6
View File
@@ -377,41 +377,145 @@ def close_option_by_bid1(
open_rows = conn.execute(
"""
SELECT id, premium_paid FROM options_trades
SELECT id, premium_paid, open_fee, premium_ccy, underlying,
exchange_ord_id
FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
""",
(inst_id,),
).fetchall()
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
settle_closed_pnl,
sum_option_order_fees_in_premium,
)
from lib.options.options_time_lib import options_app_now_str
row_ccy = premium_ccy
if open_rows:
row_ccy = str(open_rows[0]["premium_ccy"] or premium_ccy or "").strip() or premium_ccy
underly = str((open_rows[0]["underlying"] if open_rows else "") or "").strip()
close_idx = resolve_close_index_px(
ex, inst_id=inst_id, underlying=underly, hint=index_px
)
# 平仓手续费:优先累计订单号查单;查不到再用本笔下单 info
close_fee_total = 0.0
try:
close_fee_total = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=merged_ord,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
close_fee_total = 0.0
if close_fee_total <= 0:
fee_hint, fee_hint_ccy = extract_order_fee(
order if isinstance(order, dict) else None
)
if fee_hint > 0:
close_fee_total = fee_to_premium_ccy(
fee_hint,
fee_hint_ccy or row_ccy,
premium_ccy=row_ccy,
index_px=close_idx,
)
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
total_open_fee = sum(float(r["open_fee"] or 0) for r in open_rows)
# 开仓费未落库时按开仓单号补查
if total_open_fee <= 0:
open_oids = [
str(r["exchange_ord_id"] or "").strip()
for r in open_rows
if str(r["exchange_ord_id"] or "").strip()
]
try:
total_open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=open_oids,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
total_open_fee = 0.0
allocated = 0.0
allocated_cfee = 0.0
allocated_ofee = 0.0
for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1:
recv = round_premium_amount(
total_recv - allocated, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
row_cfee = round(close_fee_total - allocated_cfee, 8)
stored_ofee = float(row["open_fee"] or 0)
if stored_ofee > 0:
row_ofee = stored_ofee
elif total_paid > 0 and total_open_fee > 0:
row_ofee = round(total_open_fee - allocated_ofee, 8)
else:
row_ofee = round(total_open_fee - allocated_ofee, 8) if total_open_fee > 0 else 0.0
elif total_paid > 0:
share = paid / total_paid
recv = round_premium_amount(
total_recv * (paid / total_paid), decimals=PREMIUM_DECIMALS_COIN
total_recv * share, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
row_cfee = round(close_fee_total * share, 8)
allocated_cfee += row_cfee
stored_ofee = float(row["open_fee"] or 0)
if stored_ofee > 0:
row_ofee = stored_ofee
else:
row_ofee = round(total_open_fee * share, 8)
allocated_ofee += row_ofee
else:
recv = round_premium_amount(
total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
pnl = round_premium_amount(recv - paid, decimals=PREMIUM_DECIMALS_COIN) or 0.0
note_sql = ""
from lib.options.options_time_lib import options_app_now_str
row_cfee = round(close_fee_total / len(open_rows), 8)
allocated_cfee += row_cfee
stored_ofee = float(row["open_fee"] or 0)
row_ofee = stored_ofee if stored_ofee > 0 else round(
total_open_fee / len(open_rows), 8
)
if stored_ofee <= 0:
allocated_ofee += row_ofee
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=recv,
open_fee=row_ofee,
close_fee=row_cfee,
premium_ccy=str(row["premium_ccy"] or row_ccy or premium_ccy),
index_px=close_idx,
)
pnl = settled.get("realized_pnl")
pnl_usdt = settled.get("realized_pnl_usdt")
note_sql = ""
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/idx/pnl_u/[note]/id
params: list[Any] = [
close_px,
recv,
pnl,
merged_ord,
options_app_now_str(),
row_ofee,
row_cfee,
str(row["premium_ccy"] or row_ccy or premium_ccy or ""),
settled.get("close_index_px"),
pnl_usdt,
]
if signal_note and i == len(open_rows) - 1:
note_sql = """,
@@ -426,6 +530,8 @@ def close_option_by_bid1(
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = ?,
open_fee = ?, close_fee = ?, fee_ccy = ?,
close_index_px = ?, realized_pnl_usdt = ?,
close_recv_accum = 0, close_sheets_accum = 0,
close_notional_accum = 0
{note_sql}
+44 -1
View File
@@ -424,6 +424,41 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
# 开仓手续费(折权利金单位);优先成交回包,否则查单
open_fee = 0.0
try:
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
sum_option_order_fees_in_premium,
)
idx_open = resolve_close_index_px(
ex,
inst_id=inst_id,
underlying=underlying,
hint=(q.get("index_px") if isinstance(q, dict) else None),
)
fee_hint, fee_ccy_hint = extract_order_fee(order if isinstance(order, dict) else None)
if fee_hint <= 0 and isinstance(fill, dict):
fee_hint, fee_ccy_hint = extract_order_fee(fill.get("order") if isinstance(fill.get("order"), dict) else fill)
if fee_hint > 0:
open_fee = fee_to_premium_ccy(
fee_hint, fee_ccy_hint or premium_ccy, premium_ccy=premium_ccy, index_px=idx_open
)
if open_fee <= 0:
open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=[ord_id],
premium_ccy=premium_ccy,
index_px=idx_open,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
open_fee = 0.0
# 部分成交/减半张数后卖掉多余买币(按 bridge coin_bought 封顶)
need_coin = float(premium_paid)
try:
@@ -464,6 +499,7 @@ def open_coin_option_buy_full(
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
open_fee=open_fee,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
@@ -533,14 +569,19 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
open_fee = float(kwargs.get("open_fee") or 0.0)
if open_fee < 0:
open_fee = abs(open_fee)
fee_ccy = str(kwargs.get("premium_ccy") or "ETH")
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
open_fee, fee_ccy,
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
@@ -557,6 +598,8 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
open_fee,
fee_ccy,
pe,
pe_mult,
pe_state,
+6
View File
@@ -109,6 +109,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
# 手续费(权利金单位)与平仓冻结 U 口径
"ALTER TABLE options_trades ADD COLUMN open_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN fee_ccy TEXT",
"ALTER TABLE options_trades ADD COLUMN close_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL",
):
try:
conn.execute(ddl)
+61 -5
View File
@@ -187,7 +187,8 @@ def backfill_closed_options_realized_pnl_from_history(
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
created_at, closed_at, close_ord_id
created_at, closed_at, close_ord_id, realized_pnl_usdt,
open_fee, close_fee, premium_ccy, underlying
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
@@ -198,8 +199,13 @@ def backfill_closed_options_realized_pnl_from_history(
if not rows:
return 0
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
groups: dict[str, list[Any]] = {}
for row in rows:
# 已冻结 U 的本地结算不再被交易所历史覆盖
if _safe_float(row["realized_pnl_usdt"]) is not None:
continue
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
@@ -233,6 +239,8 @@ def backfill_closed_options_realized_pnl_from_history(
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
underly = str(group[0]["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst, underlying=underly)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
@@ -255,16 +263,37 @@ def backfill_closed_options_realized_pnl_from_history(
if pnl_ok and quote_ok and ord_ok:
continue
prem_recv = round(paid + share, 4)
ofee = float(_safe_float(r["open_fee"]) or 0.0)
cfee = float(_safe_float(r["close_fee"]) or 0.0)
prem_ccy = str(r["premium_ccy"] or "").strip() or "ETH"
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv,
open_fee=ofee,
close_fee=cfee,
premium_ccy=prem_ccy,
index_px=close_idx,
)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id)
WHERE id = ?
close_ord_id = COALESCE(?, close_ord_id),
close_index_px = COALESCE(?, close_index_px),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt)
WHERE id = ? AND realized_pnl_usdt IS NULL
""",
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
(
settled.get("realized_pnl"),
prem_recv,
close_quote,
matched_pos,
settled.get("close_index_px"),
settled.get("realized_pnl_usdt"),
int(r["id"]),
),
)
updated += 1
return updated
@@ -283,7 +312,8 @@ def sync_open_options_trades(
"""
rows = conn.execute(
"""
SELECT id, inst_id, premium_paid, exp_time, created_at
SELECT id, inst_id, premium_paid, exp_time, created_at,
open_fee, premium_ccy, underlying, exchange_ord_id
FROM options_trades
WHERE status = 'open'
"""
@@ -291,6 +321,8 @@ def sync_open_options_trades(
updated = 0
closed_inst_ids: list[str] = []
now_ms = int(time.time() * 1000)
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
for row in rows:
inst_id = str(row["inst_id"] or "")
if not inst_id or inst_id in live_inst_ids:
@@ -331,6 +363,24 @@ def sync_open_options_trades(
if not closed_at:
closed_at = options_app_now_str()
open_fee = float(_safe_float(row["open_fee"]) or 0.0)
prem_ccy = str(row["premium_ccy"] or "").strip() or "ETH"
underly = str(row["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst_id, underlying=underly)
# 交易所 pnl 多为权利金差;再扣本地开仓费后冻结(平仓费此处无单号,记 0)
if prem_recv is None and realized_pnl is not None:
prem_recv = round(paid + float(realized_pnl), 4)
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv if prem_recv is not None else (paid + float(realized_pnl or 0)),
open_fee=open_fee,
close_fee=0,
premium_ccy=prem_ccy,
index_px=close_idx,
)
realized_pnl = settled.get("realized_pnl")
pnl_usdt = settled.get("realized_pnl_usdt")
conn.execute(
"""
UPDATE options_trades
@@ -340,6 +390,9 @@ def sync_open_options_trades(
realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at),
close_index_px = COALESCE(?, close_index_px),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt),
fee_ccy = COALESCE(fee_ccy, ?),
signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算'
@@ -353,6 +406,9 @@ def sync_open_options_trades(
realized_pnl,
close_ord_id,
closed_at,
settled.get("close_index_px"),
pnl_usdt,
prem_ccy,
close_reason,
int(row["id"]),
),
+185
View File
@@ -0,0 +1,185 @@
"""已实现盈亏结算:扣手续费,并在平仓时按指数冻结 U 口径."""
from __future__ import annotations
from typing import Any
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 fee_amount_abs(fee: Any) -> float:
"""OKX 扣费多为负;统一为正数「已付手续费」."""
f = _safe_float(fee)
if f is None:
return 0.0
return abs(float(f))
def fee_to_premium_ccy(
fee_amt: Any,
fee_ccy: Any,
*,
premium_ccy: str,
index_px: float | None,
) -> float:
"""把手续费折成权利金币种数量(与 premium_paid/received 同单位)."""
amt = fee_amount_abs(fee_amt)
if amt <= 0:
return 0.0
fc = str(fee_ccy or "").strip().upper()
pc = str(premium_ccy or "").strip().upper()
if not fc or not pc or fc == pc:
return round(amt, 8)
# 稳定币互兑近似 1:1
stables = {"USDT", "USDC", "USD"}
if fc in stables and pc in stables:
return round(amt, 8)
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return 0.0
if fc in stables and pc in ("ETH", "BTC"):
return round(amt / float(idx), 8)
if fc in ("ETH", "BTC") and pc in stables:
return round(amt * float(idx), 8)
if fc in ("ETH", "BTC") and pc in ("ETH", "BTC") and fc != pc:
# 跨币种无可靠汇率时放弃折算,避免脏数据
return 0.0
return round(amt, 8)
def extract_order_fee(order: dict[str, Any] | None) -> tuple[float, str]:
"""从下单/查单结果取手续费(原始币种,正数)."""
if not isinstance(order, dict):
return 0.0, ""
raw = order.get("raw") if isinstance(order.get("raw"), dict) else {}
info = order.get("info") if isinstance(order.get("info"), dict) else {}
fee = order.get("fee")
if fee is None:
fee = raw.get("fee")
if fee is None:
fee = info.get("fee")
ccy = (
order.get("fee_ccy")
or raw.get("feeCcy")
or raw.get("fee_ccy")
or info.get("fee_ccy")
or info.get("feeCcy")
or ""
)
return fee_amount_abs(fee), str(ccy or "").strip().upper()
def sum_option_order_fees_in_premium(
ex: Any,
*,
inst_id: str,
ord_ids: list[str] | str | None,
premium_ccy: str,
index_px: float | None,
fetch_order_fn: Any = None,
) -> float:
"""按订单号汇总手续费并折成权利金单位."""
if isinstance(ord_ids, str):
ids = [p.strip() for p in ord_ids.split(",") if p.strip()]
else:
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
if not ids:
return 0.0
if fetch_order_fn is None:
from lib.exchange.okx_options_lib import fetch_option_order
fetch_order_fn = fetch_option_order
total = 0.0
seen: set[str] = set()
for oid in ids:
if oid in seen:
continue
seen.add(oid)
try:
info = fetch_order_fn(ex, inst_id=inst_id, ord_id=oid)
except Exception:
continue
if not isinstance(info, dict) or not info.get("ok"):
continue
fee_amt, fee_ccy = extract_order_fee(info)
total += fee_to_premium_ccy(
fee_amt, fee_ccy, premium_ccy=premium_ccy, index_px=index_px
)
return round(total, 8)
def settle_closed_pnl(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
premium_ccy: str,
index_px: float | None,
) -> dict[str, float | None]:
"""
净盈亏 = 收回 − 已付 − 开/平手续费(权利金单位).
币本位再乘平仓指数得到冻结 U;USDC/USDT 直接冻结.
"""
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = float(_safe_float(open_fee) or 0.0)
cfee = float(_safe_float(close_fee) or 0.0)
if ofee < 0:
ofee = abs(ofee)
if cfee < 0:
cfee = abs(cfee)
net = round(recv - paid - ofee - cfee, 8)
pc = str(premium_ccy or "").strip().upper()
idx = _safe_float(index_px)
if pc in ("ETH", "BTC"):
if idx is None or idx <= 0:
return {
"realized_pnl": net,
"close_index_px": None,
"realized_pnl_usdt": None,
"total_fee": round(ofee + cfee, 8),
}
return {
"realized_pnl": net,
"close_index_px": float(idx),
"realized_pnl_usdt": round(net * float(idx), 4),
"total_fee": round(ofee + cfee, 8),
}
# USDC / USDT 权利金
return {
"realized_pnl": net,
"close_index_px": float(idx) if idx is not None and idx > 0 else None,
"realized_pnl_usdt": round(net, 4),
"total_fee": round(ofee + cfee, 8),
}
def resolve_close_index_px(
ex: Any,
*,
inst_id: str = "",
underlying: str = "",
hint: Any = None,
) -> float | None:
"""平仓冻结用指数:优先调用方传入,否则拉标的近似价."""
h = _safe_float(hint)
if h is not None and h > 0:
return float(h)
underly = (underlying or "").strip().upper()
if not underly and inst_id:
underly = str(inst_id).split("-")[0].strip().upper() or "ETH"
if not underly:
underly = "ETH"
try:
from lib.options.options_stats_lib import _underlying_index_usdt
return _underlying_index_usdt(ex, underly)
except Exception:
return None
+5 -8
View File
@@ -93,6 +93,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
format_position_row,
options_api_ready,
cancel_option_order,
fetch_option_order,
fetch_option_pending_orders,
place_option_limit_order,
place_option_market_order,
@@ -132,6 +133,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"fetch_option_book_depth": fetch_option_book_depth,
"place_option_limit_order": place_option_limit_order,
"place_option_market_order": place_option_market_order,
"fetch_option_order": fetch_option_order,
"fetch_option_pending_orders": fetch_option_pending_orders,
"cancel_option_order": cancel_option_order,
"fetch_option_positions": fetch_option_positions,
@@ -1276,20 +1278,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
from lib.options.options_history_lib import load_options_history
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_stats_lib import (
compute_options_stats_from_history,
history_pnl_to_usdt,
)
from lib.options.options_stats_lib import compute_options_stats
raw_live = cfg["fetch_option_positions"](ex)
if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
history = load_options_history(ex, cfg)
# 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致
stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex))
# 已平仓走本地库:扣手续费 + 平仓时冻结 U,不跟实时指数漂移
stats = compute_options_stats(cfg["get_db"], ex)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0
total_pnl = None
+122 -15
View File
@@ -61,7 +61,8 @@ def _underlying_index_usdt(ex: Any, underly: str) -> float | None:
def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
"""
统计用:币本位 realized_pnl(ETH/BTC) 按指数折成 U;USDC 原样.
统计用:优先已冻结的 realized_pnl_usdt(平仓当时口径,含手续费后净额);
否则币本位用 close_index_px(再否则实时指数)折 U;USDC 原样.
折算失败的币仓剔除盈亏字段,避免把「币数量」当成 U.
"""
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
@@ -73,6 +74,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
if r.get("status") == "open":
out.append(r)
continue
frozen = _safe_float(r.get("realized_pnl_usdt"))
if 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)
@@ -83,6 +89,8 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
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)
@@ -278,6 +286,8 @@ def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]:
{
"status": "closed",
"realized_pnl": _row_get(row, "realized_pnl"),
"realized_pnl_usdt": _row_get(row, "realized_pnl_usdt"),
"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 "",
@@ -288,26 +298,118 @@ def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]:
return out
def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]:
conn = get_db()
try:
init_options_tables(conn)
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at, inst_id, underlying, premium_ccy
_CLOSED_STATS_SELECT = """
SELECT realized_pnl, realized_pnl_usdt, close_index_px,
created_at, closed_at, inst_id, underlying, premium_ccy
FROM options_trades
WHERE status = 'closed' AND realized_pnl IS NOT NULL
WHERE status = 'closed'
AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL)
"""
def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变."""
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px,
inst_id, underlying, premium_ccy, premium_paid, premium_received,
open_fee, close_fee
FROM options_trades
WHERE status = 'closed'
AND realized_pnl_usdt IS NULL
AND realized_pnl IS NOT NULL
ORDER BY id DESC
LIMIT 300
"""
).fetchall()
if not rows:
return 0
from lib.options.options_pnl_settle_lib import settle_closed_pnl, 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 ""),
)
# 有手续费列时按净额重算;否则用已有 realized_pnl 冻结
ofee = _safe_float(_row_get(row, "open_fee")) or 0.0
cfee = _safe_float(_row_get(row, "close_fee")) or 0.0
paid = _row_get(row, "premium_paid")
recv = _row_get(row, "premium_received")
if paid is not None and recv is not None and (ofee > 0 or cfee > 0):
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
premium_ccy=ccy or "USDC",
index_px=idx,
)
pnl = settled.get("realized_pnl")
usdt = settled.get("realized_pnl_usdt")
close_idx = settled.get("close_index_px")
else:
pnl = _safe_float(_row_get(row, "realized_pnl"))
if pnl is None:
continue
settled = settle_closed_pnl(
premium_paid=0,
premium_received=pnl,
open_fee=0,
close_fee=0,
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, ?),
realized_pnl = COALESCE(?, realized_pnl)
WHERE id = ? AND realized_pnl_usdt IS NULL
""",
(float(usdt), close_idx, pnl, int(row["id"])),
)
updated += 1
return updated
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
"""本地已平(含冻结 U/手续费净额) + 当前 open,供统计/中控窗口复用."""
init_options_tables(conn)
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'
"""
"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")})
# 币本位盈亏按指数折 U,再算胜率/盈亏比(与 /api/options/stats 一致)
return history
def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]:
conn = get_db()
try:
history = load_local_options_stats_history(conn, ex)
try:
conn.commit()
except Exception:
pass
# 优先冻结 U;缺省再按指数折(与 /api/options/stats 一致)
return compute_options_stats_from_history(history_pnl_to_usdt(history, ex))
finally:
conn.close()
@@ -337,12 +439,17 @@ def header_options_stats_for_window(
start_bj, end_bj = utc_window_to_bj_sql_strings(
list_window["start_utc"], list_window["end_utc"], app_tz
)
try:
freeze_missing_realized_pnl_usdt(conn, ex)
except Exception:
pass
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at, inst_id, underlying, premium_ccy
SELECT realized_pnl, realized_pnl_usdt, 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 (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL)
AND COALESCE(closed_at, created_at) >= ?
AND COALESCE(closed_at, created_at) <= ?
""",
+48 -9
View File
@@ -359,16 +359,30 @@ class SimBroker:
side: str,
sheets: float,
avg_px: float,
fee: float = 0.0,
fee_ccy: str | None = None,
) -> None:
conn = self.get_db()
try:
ccy = str(fee_ccy or _premium_ccy_for_inst(inst_id) or "").strip().upper()
conn.execute(
"""
INSERT OR REPLACE INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz,
fee, fee_ccy, created_at
) VALUES (?,?,?,?,?,'filled',?,?,?,?)
""",
(ord_id, inst_id, side, float(sheets), float(avg_px), float(sheets), _now()),
(
ord_id,
inst_id,
side,
float(sheets),
float(avg_px),
float(sheets),
float(fee or 0),
ccy,
_now(),
),
)
conn.commit()
finally:
@@ -383,6 +397,11 @@ class SimBroker:
).fetchone()
if not row:
return None
keys = set(row.keys()) if hasattr(row, "keys") else set()
fee = float(row["fee"]) if "fee" in keys and row["fee"] is not None else 0.0
fee_ccy = str(row["fee_ccy"] or "").strip().upper() if "fee_ccy" in keys else ""
if not fee_ccy:
fee_ccy = _premium_ccy_for_inst(row["inst_id"])
return {
"ok": True,
"ord_id": row["ord_id"],
@@ -391,7 +410,10 @@ class SimBroker:
"state": row["state"],
"acc_fill_sz": float(row["acc_fill_sz"]),
"avg_px": float(row["avg_px"]),
"fee": -abs(fee) if fee else 0.0,
"fee_ccy": fee_ccy,
"sim": True,
"raw": {"fee": -abs(fee) if fee else 0.0, "feeCcy": fee_ccy, "sim": True},
}
finally:
conn.close()
@@ -469,15 +491,23 @@ class SimBroker:
ord_id = f"sim-opt-{uuid.uuid4().hex[:16]}"
self._store_option_order(
ord_id=ord_id, inst_id=inst_id, side="buy", sheets=n, avg_px=pr.fill_px
ord_id=ord_id,
inst_id=inst_id,
side="buy",
sheets=n,
avg_px=pr.fill_px,
fee=float(pr.fee or 0),
fee_ccy=prem_ccy,
)
return {
"ok": True,
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
"raw": {"sim": True},
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
"px": pr.fill_px,
"ord_type": "ioc",
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
"fee": float(pr.fee or 0),
"fee_ccy": prem_ccy,
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
}
def sell_option_close(
@@ -540,16 +570,25 @@ class SimBroker:
note=f"sell {inst_id} x{close_n}@{pr.fill_px}",
)
ord_id = f"sim-opt-close-{uuid.uuid4().hex[:16]}"
prem_ccy = _premium_ccy_for_inst(inst_id)
self._store_option_order(
ord_id=ord_id, inst_id=inst_id, side="sell", sheets=close_n, avg_px=pr.fill_px
ord_id=ord_id,
inst_id=inst_id,
side="sell",
sheets=close_n,
avg_px=pr.fill_px,
fee=float(pr.fee or 0),
fee_ccy=prem_ccy,
)
return {
"ok": True,
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
"raw": {"sim": True},
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
"px": pr.fill_px,
"ord_type": "ioc",
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
"fee": float(pr.fee or 0),
"fee_ccy": prem_ccy,
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
}
def convert_usdt_usdc(
+8
View File
@@ -103,6 +103,14 @@ def init_sim_tables(conn: sqlite3.Connection) -> None:
)
"""
)
try:
conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee REAL DEFAULT 0")
except Exception:
pass
try:
conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee_ccy TEXT")
except Exception:
pass
row = conn.execute("SELECT id FROM sim_wallets WHERE id=1").fetchone()
if row is None:
equity = max(0.0, _env_float("SIM_INITIAL_EQUITY_USDT", 10000.0))
+109
View File
@@ -0,0 +1,109 @@
"""期权已实现盈亏:扣手续费 + 平仓冻结 U 不跟实时指数变."""
from __future__ import annotations
import unittest
from lib.options.options_pnl_settle_lib import (
fee_to_premium_ccy,
settle_closed_pnl,
)
from lib.options.options_stats_lib import (
compute_options_stats_from_history,
history_pnl_to_usdt,
)
class FeeConvertTests(unittest.TestCase):
def test_fee_same_ccy(self):
self.assertAlmostEqual(
fee_to_premium_ccy(0.001, "ETH", premium_ccy="ETH", index_px=3000),
0.001,
places=8,
)
def test_fee_usdt_to_eth(self):
self.assertAlmostEqual(
fee_to_premium_ccy(3.0, "USDT", premium_ccy="ETH", index_px=3000),
0.001,
places=8,
)
class SettleClosedPnlTests(unittest.TestCase):
def test_net_of_fees_and_freeze_usdt(self):
# 收回 0.02 ETH,已付 0.01,开+平手续费各 0.0005 → 净 0.009
# × 3000 = 27 U
s = settle_closed_pnl(
premium_paid=0.01,
premium_received=0.02,
open_fee=0.0005,
close_fee=0.0005,
premium_ccy="ETH",
index_px=3000,
)
self.assertAlmostEqual(float(s["realized_pnl"]), 0.009, places=8)
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4)
self.assertAlmostEqual(float(s["close_index_px"]), 3000.0, places=4)
def test_usdc_freeze_is_net(self):
s = settle_closed_pnl(
premium_paid=10,
premium_received=12,
open_fee=0.1,
close_fee=0.1,
premium_ccy="USDC",
index_px=None,
)
self.assertAlmostEqual(float(s["realized_pnl"]), 1.8, places=4)
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 1.8, places=4)
class FrozenStatsTests(unittest.TestCase):
def test_frozen_usdt_ignores_live_index(self):
history = [
{
"status": "closed",
"inst_id": "ETH-USD-260701-2500-C",
"underlying": "ETH",
"premium_ccy": "ETH",
"realized_pnl": 0.01, # 币数量
"realized_pnl_usdt": 25.0, # 平仓时冻结
"close_index_px": 2500.0,
"created_at": "2026-10-01 10:00:00",
"closed_at": "2026-10-01 12:00:00",
}
]
class _Ex:
def public_get_market_ticker(self, params):
return {"data": [{"last": "99999"}]} # 实时价再变也不影响
converted = history_pnl_to_usdt(history, _Ex())
self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 25.0, places=4)
stats = compute_options_stats_from_history(converted)
self.assertAlmostEqual(float(stats["net_realized_pnl"]), 25.0, places=4)
def test_close_index_used_when_no_frozen_usdt(self):
history = [
{
"status": "closed",
"inst_id": "ETH-USD-260701-2500-C",
"underlying": "ETH",
"premium_ccy": "ETH",
"realized_pnl": 0.01,
"close_index_px": 2500.0,
"created_at": "2026-10-01 10:00:00",
"closed_at": "2026-10-01 12:00:00",
}
]
class _Ex:
def public_get_market_ticker(self, params):
return {"data": [{"last": "99999"}]}
converted = history_pnl_to_usdt(history, _Ex())
self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 25.0, places=4)
if __name__ == "__main__":
unittest.main()