From 5e8b578f756f90439704aebc91b75d22fb85a737 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 8 Oct 2026 22:50:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=B7=B2=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=9B=88=E4=BA=8F=E6=89=A3=E6=89=8B=E7=BB=AD=E8=B4=B9=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E5=B9=B3=E4=BB=93=E6=97=B6=E6=8C=87=E6=95=B0?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E4=B8=BA=20U=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/exchange/okx_options_lib.py | 5 + lib/hub/hub_bridge_register.py | 15 ++- lib/options/options_close_exec_lib.py | 118 +++++++++++++++- lib/options/options_coin_open_lib.py | 45 ++++++- lib/options/options_db.py | 6 + lib/options/options_monitor_lib.py | 66 ++++++++- lib/options/options_pnl_settle_lib.py | 185 ++++++++++++++++++++++++++ lib/options/options_register.py | 13 +- lib/options/options_stats_lib.py | 153 +++++++++++++++++---- lib/sim/broker_lib.py | 57 ++++++-- lib/sim/db_lib.py | 8 ++ tests/test_options_pnl_settle.py | 109 +++++++++++++++ 12 files changed, 726 insertions(+), 54 deletions(-) create mode 100644 lib/options/options_pnl_settle_lib.py create mode 100644 tests/test_options_pnl_settle.py diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 4aa0001..71afbce 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -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: diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index ee173ac..8f5943c 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -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") diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index 5eef288..d6b39d8 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -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} diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index a546255..442c9f5 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -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, diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 9eaf86a..8faeee4 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -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) diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index 46f37b4..32cb5f7 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -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"]), ), diff --git a/lib/options/options_pnl_settle_lib.py b/lib/options/options_pnl_settle_lib.py new file mode 100644 index 0000000..8f972d6 --- /dev/null +++ b/lib/options/options_pnl_settle_lib.py @@ -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 diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 4694649..cc7455d 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -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 diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 158949d..3fcea27 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -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,9 +89,11 @@ 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"): - if underly not in idx_cache: - idx_cache[underly] = _underlying_index_usdt(ex, underly) - idx = idx_cache.get(underly) + 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: @@ -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 +_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 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'" + ).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 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 - FROM options_trades - WHERE status = 'closed' AND realized_pnl IS NOT NULL - """ - ).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")}) - # 币本位盈亏按指数折 U,再算胜率/盈亏比(与 /api/options/stats 一致) + 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) <= ? """, diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 01f1baa..806e6ce 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -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( diff --git a/lib/sim/db_lib.py b/lib/sim/db_lib.py index 9d0b7a4..f3c14c6 100644 --- a/lib/sim/db_lib.py +++ b/lib/sim/db_lib.py @@ -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)) diff --git a/tests/test_options_pnl_settle.py b/tests/test_options_pnl_settle.py new file mode 100644 index 0000000..95bdbc2 --- /dev/null +++ b/tests/test_options_pnl_settle.py @@ -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()