diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 8802fbb..a500d9c 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2526,10 +2526,24 @@ } list.forEach(function (h) { const tr = document.createElement("tr"); - const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); + const premCcy = posPremiumCcy(h); + const unit = premCcy === "USDC" ? "U" : premCcy; + const premCore = fmtDisplay( + h.premium_paid_fmt, + h.premium_paid != null ? fmtPremiumAmt(h.premium_paid, premCcy) : null + ); + const premTxt = premCore === "—" ? "—" : premCore + " " + unit; const isOpen = h.status === "open"; const pnl = isOpen ? null : h.realized_pnl; - const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; + let pnlTxt = "—"; + if (pnl != null && !Number.isNaN(Number(pnl))) { + const n = Number(pnl); + const absCore = fmtDisplay( + null, + fmtPremiumAmt(Math.abs(n), premCcy) + ); + pnlTxt = (n > 0 ? "+" : n < 0 ? "-" : "") + absCore + " " + unit; + } const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); const histKey = h.history_key || ""; diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index 88d2540..bc80c7b 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -49,7 +49,7 @@ if (v == null || v === "") return "—"; var n = Number(v); if (Number.isNaN(n)) return "—"; - return (n >= 0 ? "+" : "") + n.toFixed(2); + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; } function fmtHold(sec) { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 4f86c3f..11998f2 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -1437,6 +1437,7 @@ def format_option_history_row( ct_mult: float = 0.01, ) -> dict[str, Any]: """标准化 OKX positions-history 单条记录供前端展示.""" + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode from lib.options.options_pricing_lib import total_premium inst_id = str(raw.get("instId") or "").strip() @@ -1447,11 +1448,14 @@ def format_option_history_row( sheets = _safe_float(raw.get("openMaxPos")) sheets_i = int(abs(sheets or 0)) eth_amount = round(abs(sheets or 0) * ct_mult, 8) if sheets else 0.0 - premium_paid = ( - round(total_premium(open_avg, eth_amount), 8) - if open_avg is not None and eth_amount > 0 - else None - ) + # 模拟盘可直接带权利金;否则用开仓均价×名义 + premium_paid = _safe_float(raw.get("_sim_premium_paid")) + if premium_paid is None: + premium_paid = ( + round(total_premium(open_avg, eth_amount), 8) + if open_avg is not None and eth_amount > 0 + else None + ) realized = _safe_float(raw.get("realizedPnl")) if realized is None: realized = _safe_float(raw.get("pnl")) @@ -1461,6 +1465,10 @@ def format_option_history_row( ctime = _safe_float(raw.get("cTime")) opt_type, strike = option_fields_from_inst_id(inst_id) uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") + row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc" + premium_ccy = str(raw.get("_sim_premium_ccy") or "").strip().upper() or premium_ccy_for_mode( + row_mode, uly or "ETH" + ) if close_type in ("3", "4"): status_label = "强平" else: @@ -1487,8 +1495,11 @@ def format_option_history_row( "close_avg_px": close_avg, "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, "premium_paid": premium_paid, - "premium_paid_fmt": format_usdc_amount(premium_paid), + "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy), + "premium_ccy": premium_ccy, + "margin_mode": row_mode, "realized_pnl": realized, + "realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy), "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, "status": "closed", "status_label": status_label, @@ -1531,7 +1542,12 @@ def format_live_option_history_row( "close_avg_px_fmt": None, "premium_paid": row.get("premium_paid"), "premium_paid_fmt": row.get("premium_paid_fmt"), + "premium_ccy": row.get("premium_ccy"), + "margin_mode": row.get("margin_mode"), "realized_pnl": row.get("upl"), + "realized_pnl_fmt": format_premium_amount( + _safe_float(row.get("upl")), ccy=str(row.get("premium_ccy") or "USDC") + ), "pnl_ratio_pct": row.get("upl_ratio_pct"), "status": "open", "status_label": "持仓中", diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index a57d88a..c92b7e5 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -271,28 +271,85 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] -def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: - """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" +def sync_options_from_local_trades( + conn: sqlite3.Connection, + ex: Any | None = None, +) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所). + + 币本位权利金/盈亏按指数折算成 USDT(U) 写入,复盘页统一按 U 展示. + """ init_options_review_tables(conn) from lib.options.options_db import init_options_tables + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode init_options_tables(conn) rows = conn.execute( """ SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, - open_quote, close_quote, premium_paid, realized_pnl, - created_at, closed_at, signal_note, status + open_quote, close_quote, premium_paid, premium_received, realized_pnl, + premium_ccy, created_at, closed_at, signal_note, status FROM options_trades WHERE status = 'closed' ORDER BY id DESC LIMIT 500 """ ).fetchall() + + def _index_px(underly: str) -> float | None: + u = (underly or "ETH").strip().upper() or "ETH" + pub = ex + if pub is None: + try: + from lib.sim.hooks import _APP_MODULE + + pub = getattr(_APP_MODULE, "exchange", None) if _APP_MODULE else None + except Exception: + pub = None + if pub is None: + return None + try: + t = pub.fetch_ticker(f"{u}/USDT") or {} + last = t.get("last") or t.get("close") + return float(last) if last is not None else None + except Exception: + return None + + def _to_usdt(amount: float | None, *, ccy: str, idx: float | None) -> float | None: + if amount is None: + return None + unit = (ccy or "USDC").strip().upper() + if unit in ("ETH", "BTC"): + if idx is None or idx <= 0: + return None + return round(float(amount) * float(idx), 4) + return round(float(amount), 4) + inserted = updated = skipped = 0 + idx_cache: dict[str, float | None] = {} for r in rows: trade_id = int(r["id"]) history_key = f"local_opt:{trade_id}" + inst = str(r["inst_id"] or "") + underly = str(r["underlying"] or (inst.split("-")[0] if inst else "ETH") or "ETH") + ccy = str(r["premium_ccy"] or "").strip().upper() + if not ccy: + ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) + if underly not in idx_cache: + idx_cache[underly] = _index_px(underly) + idx = idx_cache.get(underly) pnl = _safe_float(r["realized_pnl"]) + if pnl is None: + paid0 = _safe_float(r["premium_paid"]) + recv0 = _safe_float(r["premium_received"]) + if paid0 is not None and recv0 is not None: + pnl = recv0 - paid0 + prem = _safe_float(r["premium_paid"]) + pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) + prem_u = _to_usdt(prem, ccy=ccy, idx=idx) + if ccy in ("ETH", "BTC") and idx is None: + pnl_u = pnl + prem_u = prem opened_at = r["created_at"] closed_at = r["closed_at"] action = upsert_option_history_row( @@ -308,8 +365,8 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: "sheets": r["sheets"], "open_avg_px": r["open_quote"], "close_avg_px": r["close_quote"], - "premium_paid": r["premium_paid"], - "realized_pnl": pnl, + "premium_paid": prem_u if prem_u is not None else prem, + "realized_pnl": pnl_u if pnl_u is not None else pnl, "created_at": opened_at, "closed_at": closed_at, "status_label": "已平", @@ -552,7 +609,7 @@ def sync_all_review_sources( conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn ) else: - out["options"] = sync_options_from_local_trades(conn) + out["options"] = sync_options_from_local_trades(conn, ex=ex) out["hedge"] = sync_hedge_plans_closed(conn) return out @@ -579,7 +636,7 @@ def ensure_local_review_synced( backfill_hedge_option_legs_realized_pnl(conn, hist) except Exception: pass - return sync_all_review_sources(conn, from_exchange=False) + return sync_all_review_sources(conn, ex=ex, from_exchange=False) def _row_to_dict(row: Any) -> dict[str, Any]: diff --git a/lib/sim/broker_lib.py b/lib/sim/broker_lib.py index 0614127..9f788bf 100644 --- a/lib/sim/broker_lib.py +++ b/lib/sim/broker_lib.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import uuid from datetime import datetime from typing import Any, Callable @@ -888,3 +889,124 @@ class SimBroker: row["expTime"] = str(exp_ms) rows.append(row) return rows + + def option_positions_history_okx_rows( + self, + exchange: Any = None, + *, + inst_id: str | None = None, + limit: int = 200, + ) -> list[dict[str, Any]]: + """模拟盘历史仓位:从本地已平 options_trades 合成 OKX positions-history 字段. + + 实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据. + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + from lib.exchange.okx_options_lib import option_fields_from_inst_id + from lib.options.options_db import init_options_tables + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + tz = ZoneInfo( + (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" + ) + + def _to_ms(ts: Any) -> int | None: + if ts is None: + return None + raw = str(ts).strip() + if not raw: + return None + for fmt, ln in ( + ("%Y-%m-%d %H:%M:%S", 19), + ("%Y-%m-%d %H:%M:%S.%f", 26), + ("%Y-%m-%d %H:%M", 16), + ): + try: + dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=tz) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + conn = self.get_db() + try: + init_options_tables(conn) + sql = """ + SELECT id, inst_id, underlying, sheets, open_quote, close_quote, + premium_paid, premium_received, realized_pnl, premium_ccy, + created_at, closed_at + FROM options_trades + WHERE status = 'closed' + """ + params: list[Any] = [] + if inst_id: + sql += " AND inst_id = ?" + params.append(str(inst_id).strip()) + sql += " ORDER BY id DESC LIMIT ?" + params.append(int(max(1, min(int(limit), 500)))) + rows = conn.execute(sql, params).fetchall() + finally: + conn.close() + + out: list[dict[str, Any]] = [] + for r in rows: + iid = str(r["inst_id"] or "").strip() + if not iid: + continue + sheets = float(r["sheets"] or 0) + if sheets <= 0: + continue + open_px = float(r["open_quote"] or 0) or None + close_px = float(r["close_quote"] or 0) or None + paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None + recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None + pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None + if pnl is None and paid is not None and recv is not None: + pnl = round(recv - paid, 8) + if pnl is None: + pnl = 0.0 + if paid is None and open_px is not None: + paid = round(open_px * sheets * 0.01, 8) + ccy = str(r["premium_ccy"] or "").strip().upper() + if not ccy: + u = str(r["underlying"] or iid.split("-")[0] or "ETH") + ccy = premium_ccy_for_mode(margin_mode_from_inst_id(iid), u) + if close_px is None and exchange is not None: + try: + bid, _ask, _ = _option_bid_ask(exchange, iid) + if bid and float(bid) > 0: + close_px = float(bid) + except Exception: + pass + open_ms = _to_ms(r["created_at"]) + close_ms = _to_ms(r["closed_at"]) or open_ms + opt_type, strike = option_fields_from_inst_id(iid) + uly = str(r["underlying"] or iid.split("-")[0] or "").upper() + pnl_ratio = None + if paid is not None and abs(float(paid)) > 1e-12: + pnl_ratio = float(pnl) / float(paid) + out.append( + { + "instId": iid, + "uly": f"{uly}-USD" if margin_mode_from_inst_id(iid) == "coin" else f"{uly}-USD_UM", + "posId": f"sim-{int(r['id'])}", + "openAvgPx": str(open_px) if open_px is not None else "", + "closeAvgPx": str(close_px) if close_px is not None else "", + "closeTotalPos": str(sheets), + "openMaxPos": str(sheets), + "realizedPnl": str(round(pnl, 8)), + "pnl": str(round(pnl, 8)), + "pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "", + "cTime": str(open_ms or ""), + "uTime": str(close_ms or ""), + "type": "2", + "optType": opt_type or "", + "stk": str(strike) if strike is not None else "", + "_sim_premium_ccy": ccy, + "_sim_premium_paid": paid, + } + ) + return out diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 62ff676..f69835a 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -355,6 +355,31 @@ def _patch_okx_options_lib(app_module: Any) -> None: pass return _orig_fetch_pos(ex) + _orig_fetch_hist = getattr(opt_lib, "fetch_option_position_history", None) + _orig_fetch_all_hist = getattr(opt_lib, "fetch_all_option_positions_history", None) + + def fetch_option_position_history(ex, inst_id, limit=50): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return broker().option_positions_history_okx_rows( + ex, inst_id=inst_id, limit=limit + ) + except Exception: + pass + if callable(_orig_fetch_hist): + return _orig_fetch_hist(ex, inst_id, limit=limit) + return [] + + def fetch_all_option_positions_history(ex, *, limit=200): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return broker().option_positions_history_okx_rows(ex, limit=limit) + except Exception: + pass + if callable(_orig_fetch_all_hist): + return _orig_fetch_all_hist(ex, limit=limit) + return [] + opt_lib.options_header_balances = options_header_balances opt_lib.fetch_options_balances = fetch_options_balances opt_lib.options_api_ready = options_api_ready @@ -363,6 +388,8 @@ def _patch_okx_options_lib(app_module: Any) -> None: opt_lib.fetch_option_order = fetch_option_order opt_lib.wait_option_order_full_fill = wait_option_order_full_fill opt_lib.fetch_option_positions = fetch_option_positions + opt_lib.fetch_option_position_history = fetch_option_position_history + opt_lib.fetch_all_option_positions_history = fetch_all_option_positions_history opt_lib._sim_hooks_applied = True _patch_spot_bridge_lib() @@ -533,6 +560,8 @@ def patch_options_cfg(cfg: dict[str, Any]) -> dict[str, Any]: "fetch_option_positions", "fetch_option_order", "wait_option_order_full_fill", + "fetch_option_position_history", + "fetch_all_option_positions_history", ): if key in cfg and hasattr(opt_lib, key): cfg[key] = getattr(opt_lib, key)