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"