diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 6b45016..4eeb5be 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3254,6 +3254,102 @@ html[data-theme="light"] .options-estimate-row { font-size: 0.76rem; margin-bottom: 8px; } +.opt-pos-cards--accordion { + display: flex; + flex-direction: column; + gap: 6px; +} +.opt-pos-accordion-item { + display: flex; + flex-direction: column; +} +.opt-pos-bar { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + background: #141923; + border: 1px solid #2a3348; + border-radius: 8px; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + transition: border-color 0.15s, background 0.15s; +} +.opt-pos-bar:hover { + border-color: #3d4d6e; + background: #171d2a; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar { + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; + background: #171d2a; +} +.opt-pos-bar-main { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} +.opt-pos-bar-side { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +.opt-pos-bar-title { + font-size: 0.72rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 11rem; +} +.opt-pos-bar-meta { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; +} +.opt-pos-bar-cd { + font-size: 0.66rem; + color: #8b95b0; + white-space: nowrap; +} +.opt-pos-bar-pnl, +.opt-pos-bar-roi { + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} +.opt-pos-bar-chevron { + display: inline-block; + font-size: 0.58rem; + color: #8b95b0; + transition: transform 0.15s ease; + flex-shrink: 0; +} +.opt-pos-accordion-item.is-expanded .opt-pos-bar-chevron { + transform: rotate(90deg); +} +.opt-pos-accordion-body { + border: 1px solid #2a3348; + border-top: none; + border-radius: 0 0 8px 8px; + overflow: hidden; + background: #141923; +} +.opt-pos-card--nested { + margin-bottom: 0 !important; + border: none !important; + border-radius: 0 !important; + background: transparent !important; +} .options-page-wrap .opt-pos-card .pos-card-symbol strong { font-size: 0.78rem; } @@ -3441,6 +3537,29 @@ html[data-theme="light"] .options-settings-subtitle { color: #142232 !important; } +html[data-theme="light"] .opt-pos-bar { + background: #f6f9fc; + border-color: #c8d4e0; + color: #142232; +} +html[data-theme="light"] .opt-pos-bar:hover, +html[data-theme="light"] .opt-pos-accordion-item.is-expanded .opt-pos-bar { + background: #eef3f8; + border-color: #9eb0c4; +} +html[data-theme="light"] .opt-pos-bar-title { + color: #142232; +} +html[data-theme="light"] .opt-pos-bar-meta, +html[data-theme="light"] .opt-pos-bar-cd, +html[data-theme="light"] .opt-pos-bar-chevron { + color: #5a6d82; +} +html[data-theme="light"] .opt-pos-accordion-body { + background: #f6f9fc; + border-color: #c8d4e0; +} + html[data-theme="light"] .options-page-wrap .options-hint, html[data-theme="light"] .options-page-wrap #opt-index-line, html[data-theme="light"] .options-order-grid .k, diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index edbb64f..a8cf35b 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -14,6 +14,7 @@ chain: panelCache.chain || null, selectedInst: null, orderQuote: null, + expandedPosInst: null, }; function fmt(v, d) { @@ -427,14 +428,13 @@ } } - function renderPositionCard(p) { + function renderPositionCardInner(p) { const upl = p.upl; const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; return ( - '
' + '
' + '
' + (p.inst_id || "") + '' + '' + optTypeLabel(p.opt_type) + "
" + @@ -458,10 +458,77 @@ '
浮盈亏' + fmt(p.upl, 2) + "
" + '
收益率' + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "
" + - "
" + "" ); } + function renderPositionCard(p) { + return ( + '
' + + renderPositionCardInner(p) + + "
" + ); + } + + function renderPositionAccordionItem(p, expanded) { + const upl = p.upl; + const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : ""; + const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; + const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; + const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; + const inst = p.inst_id || ""; + return ( + '
' + + '" + + '
" + + '
' + + renderPositionCardInner(p) + + "
" + ); + } + + function bindPositionActions(container) { + if (!container) return; + container.querySelectorAll(".opt-close-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + closePosition(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-pos-bar").forEach(function (bar) { + bar.addEventListener("click", function () { + const item = bar.closest(".opt-pos-accordion-item"); + if (!item) return; + const inst = item.getAttribute("data-inst"); + state.expandedPosInst = state.expandedPosInst === inst ? null : inst; + const wrap = document.getElementById("opt-pos-cards"); + wrap.querySelectorAll(".opt-pos-accordion-item").forEach(function (el) { + const open = el.getAttribute("data-inst") === state.expandedPosInst; + el.classList.toggle("is-expanded", open); + const body = el.querySelector(".opt-pos-accordion-body"); + const btn = el.querySelector(".opt-pos-bar"); + if (body) body.hidden = !open; + if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false"); + }); + }); + }); + } + async function closePosition(inst, btn) { const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1"); if (!q.ok) { @@ -504,16 +571,27 @@ return; } empty.style.display = "none"; - list.forEach(function (p) { - const div = document.createElement("div"); - div.innerHTML = renderPositionCard(p); - wrap.appendChild(div.firstChild); - }); - wrap.querySelectorAll(".opt-close-btn").forEach(function (btn) { - btn.addEventListener("click", function () { - closePosition(btn.getAttribute("data-inst"), btn); + const multi = list.length >= 2; + wrap.classList.toggle("opt-pos-cards--accordion", multi); + if (multi) { + const ids = list.map(function (p) { return p.inst_id; }); + if (!state.expandedPosInst || ids.indexOf(state.expandedPosInst) < 0) { + state.expandedPosInst = list[0].inst_id || null; + } + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst); + wrap.appendChild(div.firstChild); }); - }); + } else { + state.expandedPosInst = null; + list.forEach(function (p) { + const div = document.createElement("div"); + div.innerHTML = renderPositionCard(p); + wrap.appendChild(div.firstChild); + }); + } + bindPositionActions(wrap); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } @@ -560,6 +638,15 @@ refreshAllPositions(); } + function optHistoryStatus(h) { + if (h.status !== "closed") return "持仓中"; + if ((h.signal_note || "").indexOf("到期结算") >= 0) return "到期"; + if (h.premium_received === 0 && h.realized_pnl != null && h.realized_pnl < 0 && !h.close_ord_id) { + return "到期"; + } + return "已平"; + } + async function refreshHistory() { const d = await apiJson("/api/options/history"); const tbody = document.getElementById("opt-history-tbody"); @@ -579,7 +666,7 @@ "" + (h.inst_id || "") + "" + "" + fmt(h.sheets, 0) + "" + "" + fmt(prem, 4) + "" + - "" + (h.status === "closed" ? "已平" : "持仓中") + "" + + "" + optHistoryStatus(h) + "" + '' + pnlTxt + "" + "" + (h.closed_at || h.created_at || "—") + "" + ''; diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 2e7b22b..63fc51f 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -644,6 +644,60 @@ def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]: return [] +def fetch_option_position_history( + ex: ccxt.okx, + inst_id: str, + *, + limit: int = 20, +) -> list[dict[str, Any]]: + """OKX 期权历史仓位(含到期结算/平仓).""" + inst_id = (inst_id or "").strip() + if not inst_id: + return [] + try: + resp = ex.private_get_account_positions_history( + { + "instType": "OPTION", + "instId": inst_id, + "limit": str(max(1, min(int(limit), 100))), + } + ) + rows = (resp or {}).get("data") or [] + return [r for r in rows if isinstance(r, dict)] + except Exception: + return [] + + +def resolve_option_close_from_history( + hist_rows: list[dict[str, Any]], + *, + open_ms: int | None = None, +) -> dict[str, Any] | None: + """从 positions-history 中选取最近一条有效平仓/结算记录.""" + best: dict[str, Any] | None = None + best_utime = -1 + for row in hist_rows: + u_ms = _safe_float(row.get("uTime")) + if u_ms is None or u_ms <= 0: + continue + if open_ms is not None and u_ms < int(open_ms) - 60_000: + continue + if u_ms > best_utime: + best = row + best_utime = int(u_ms) + if not best: + return None + realized = _safe_float(best.get("realizedPnl")) + if realized is None: + realized = _safe_float(best.get("pnl")) + return { + "close_quote": _safe_float(best.get("closeAvgPx")), + "realized_pnl": realized, + "close_ms": best_utime, + "pos_id": str(best.get("posId") or "").strip() or None, + } + + def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None: """期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1).""" total = 0.0 diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index ea98a46..e587863 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -1,10 +1,13 @@ -"""期权持仓监控:浮盈翻倍微信提醒.""" +"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" from __future__ import annotations import sqlite3 import time +from datetime import datetime, timezone from typing import Any, Callable +from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history + def _safe_float(v: Any) -> float | None: if v is None: @@ -101,6 +104,109 @@ def run_options_profit_alerts( return sent +def _created_at_ms(created_at: Any) -> int | None: + if not created_at: + return None + raw = str(created_at).strip() + if not raw: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"): + try: + dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +def sync_open_options_trades( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], + fetch_history_fn: Callable[[str], list[dict[str, Any]]], +) -> int: + """ + 交易所已无持仓时,将本地 open 记录同步为 closed. + 优先用 positions-history 回填盈亏;否则到期后按归零处理. + """ + rows = conn.execute( + """ + SELECT id, inst_id, premium_paid, exp_time, created_at + FROM options_trades + WHERE status = 'open' + """ + ).fetchall() + updated = 0 + now_ms = int(time.time() * 1000) + for row in rows: + inst_id = str(row["inst_id"] or "") + if not inst_id or inst_id in live_inst_ids: + continue + paid = _safe_float(row["premium_paid"]) or 0.0 + open_ms = _created_at_ms(row["created_at"]) + exp_ms = normalize_option_exp_ms(row["exp_time"], inst_id) + close_quote: float | None = None + prem_recv: float | None = None + realized_pnl: float | None = None + close_ord_id: str | None = None + closed_at: str | None = None + close_reason = "exchange" + + close_info = resolve_option_close_from_history( + fetch_history_fn(inst_id), + open_ms=open_ms, + ) + if close_info: + close_quote = close_info.get("close_quote") + realized_pnl = close_info.get("realized_pnl") + close_ord_id = close_info.get("pos_id") + if realized_pnl is not None: + prem_recv = round(paid + float(realized_pnl), 4) + close_ms = close_info.get("close_ms") + if close_ms: + closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + elif exp_ms is not None and now_ms >= int(exp_ms): + close_reason = "expired" + close_quote = 0.0 + prem_recv = 0.0 + realized_pnl = round(-paid, 4) + if exp_ms: + closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', + close_quote = ?, + premium_received = ?, + realized_pnl = ?, + close_ord_id = COALESCE(?, close_ord_id), + closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP), + signal_note = CASE + WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') + THEN '到期结算' + ELSE signal_note + END + WHERE id = ? + """, + ( + close_quote, + prem_recv, + realized_pnl, + close_ord_id, + closed_at, + close_reason, + int(row["id"]), + ), + ) + updated += 1 + return updated + + def options_monitor_loop( *, enabled: bool, @@ -111,6 +217,7 @@ def options_monitor_loop( send_wechat: Callable[[str], None], account_label: str, profit_ratio: float, + sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, stop_event: Any = None, ) -> None: if not enabled: @@ -130,6 +237,8 @@ def options_monitor_loop( account_label=account_label, ticker_bid_fn=ticker_bid_fn, ) + if sync_trades_fn is not None: + sync_trades_fn(conn) conn.commit() finally: conn.close() diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 76f3e77..97045d1 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -132,6 +132,38 @@ def _require_options_ex(cfg: dict[str, Any]): return ex, "" +def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量).""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + return float(raw), "" + + +def _sync_options_trades(cfg: dict[str, Any]) -> None: + ex = cfg.get("exchange_options") + if ex is None: + return + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import sync_open_options_trades + + raw = cfg["fetch_option_positions"](ex) + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) + conn.commit() + finally: + conn.close() + + def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: lr = cfg["login_required"] @@ -177,6 +209,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: min_sz = q.get("min_sz") or 1 mode = (request.args.get("mode") or "budget_full").strip() budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + available_usdc = None + if mode == "budget_full": + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) eth_amount = None sheet_count = None try: @@ -199,7 +241,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount if mode == "eth_amount" else None, sheets=sheet_count if mode == "sheets" else None, - budget_cap=cfg["trade_budget"], + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, ) return jsonify( { @@ -207,6 +249,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "quote_per_unit": ask, "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), "sizing": sizing, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, } ) @@ -242,15 +286,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: sheet_count = int(data.get("sheets")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "张数无效"}) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + if mode == "budget_full": + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget sizing = calc_order_size( quote_per_unit=float(ask), ct_mult=ct_mult, min_sz=min_sz, - budget_usdc=cfg["trade_budget"] if mode == "budget_full" else None, + budget_usdc=budget if mode == "budget_full" else None, budget_buffer=cfg["budget_buffer"], eth_amount=eth_amount, sheets=sheet_count, - budget_cap=cfg["trade_budget"], + budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, ) if not sizing.get("ok"): return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) @@ -304,6 +355,7 @@ 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}) + _sync_options_trades(cfg) raw = cfg["fetch_option_positions"](ex) rows = [cfg["format_position_row"](p) for p in raw] conn = cfg["get_db"]() @@ -554,6 +606,7 @@ 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}) + _sync_options_trades(cfg) conn = cfg["get_db"]() try: init_options_tables(conn) @@ -660,6 +713,21 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: raw = cfg["fetch_option_positions"](ex) return [cfg["format_position_row"](p) for p in raw] + def _sync(conn): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + ) + t = threading.Thread( target=options_monitor_loop, kwargs={ @@ -671,6 +739,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: "send_wechat": cfg["send_wechat"], "account_label": cfg["account_label"], "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, }, daemon=True, name="options-monitor", diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 298e2b0..f0bb0c3 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -62,7 +62,7 @@
- + diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py new file mode 100644 index 0000000..154902f --- /dev/null +++ b/tests/test_options_sync.py @@ -0,0 +1,86 @@ +"""期权平仓/到期状态同步单测.""" +import sqlite3 + +from lib.exchange.okx_options_lib import resolve_option_close_from_history +from lib.options.options_db import init_options_tables +from lib.options.options_monitor_lib import sync_open_options_trades + + +def test_resolve_option_close_from_history_picks_latest(): + rows = [ + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"}, + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"}, + ] + got = resolve_option_close_from_history(rows, open_ms=500) + assert got is not None + assert got["realized_pnl"] == -1.24 + assert got["pos_id"] == "9" + + +def test_sync_open_options_trades_marks_expired_closed(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=lambda _inst: [], + ) + assert n == 1 + row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone() + assert row["status"] == "closed" + assert row["premium_received"] == 0.0 + assert row["realized_pnl"] == -1.24 + assert "到期结算" in (row["signal_note"] or "") + + +def test_sync_open_options_trades_uses_exchange_history(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, created_at) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + def _hist(_inst): + return [ + { + "instId": "ETH-USD_UM-260709-1700-P", + "uTime": "1784000000000", + "realizedPnl": "-0.5", + "closeAvgPx": "0.1", + "posId": "pos-1", + } + ] + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=_hist, + ) + assert n == 1 + row = conn.execute( + "SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades" + ).fetchone() + assert row["status"] == "closed" + assert row["realized_pnl"] == -0.5 + assert row["premium_received"] == 0.74 + assert row["close_ord_id"] == "pos-1"