diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index abe727b..24ce948 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -3303,9 +3303,9 @@ html[data-theme="light"] .options-estimate-row {
.opt-pos-bar-main {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 8px;
min-width: 0;
- flex: 0 1 auto;
+ flex: 1 1 auto;
overflow: hidden;
}
.opt-pos-bar-id-group {
@@ -3313,7 +3313,7 @@ html[data-theme="light"] .options-estimate-row {
align-items: center;
gap: 6px;
min-width: 0;
- max-width: 100%;
+ flex-shrink: 1;
overflow: hidden;
}
.opt-pos-bar .pos-side-badge {
@@ -3325,17 +3325,19 @@ html[data-theme="light"] .options-estimate-row {
font-size: 0.62rem;
line-height: 1;
}
-.opt-pos-cards--accordion .opt-pos-bar-meta {
- display: none;
+.opt-pos-bar-meta {
+ font-size: 0.66rem;
+ color: #8b95b0;
+ white-space: nowrap;
+ flex-shrink: 0;
}
.opt-pos-bar-side {
display: flex;
- flex-direction: column;
- align-items: flex-end;
- justify-content: center;
- gap: 1px;
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
flex: 0 0 auto;
- margin-left: auto;
+ margin-left: 8px;
font-variant-numeric: tabular-nums;
}
.opt-pos-bar-title {
@@ -3348,11 +3350,6 @@ html[data-theme="light"] .options-estimate-row {
flex: 0 1 auto;
min-width: 0;
}
-.opt-pos-bar-meta {
- font-size: 0.66rem;
- color: #8b95b0;
- white-space: nowrap;
-}
.opt-pos-bar-cd {
font-size: 0.66rem;
color: #8b95b0;
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index f3b5822..1dbf6d3 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -485,12 +485,14 @@
'' +
'' + inst + "" +
'' + optTypeLabel(p.opt_type) + "" +
- "" +
+ "" +
+ '行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张" +
+ "" +
'' +
(expAttr
- ? '—'
+ ? '到期 —'
: "") +
- '' + fmt(p.upl, 2) + "" +
+ '' + fmt(p.upl, 2) + " USDC" +
'' +
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "" +
"" +
@@ -679,8 +681,7 @@
}
list.forEach(function (h) {
const tr = document.createElement("tr");
- const prem = h.status === "closed" ? h.premium_received : h.premium_paid;
- const premTxt = prem != null ? fmt(prem, 2) : "—";
+ const premTxt = h.premium_paid != null ? fmt(h.premium_paid, 2) : "—";
const pnl = h.realized_pnl;
const pnlTxt = pnl != null ? fmt(pnl, 2) : "—";
const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : "";
diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py
index e587863..c11f7f0 100644
--- a/lib/options/options_monitor_lib.py
+++ b/lib/options/options_monitor_lib.py
@@ -176,6 +176,8 @@ def sync_open_options_trades(
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
+ else:
+ continue
conn.execute(
"""
@@ -207,6 +209,57 @@ def sync_open_options_trades(
return updated
+def reconcile_live_open_trades(
+ conn: sqlite3.Connection,
+ *,
+ live_inst_ids: set[str],
+) -> int:
+ """交易所有持仓但本地误标 closed 时恢复为 open."""
+ fixed = 0
+ for inst_id in live_inst_ids:
+ if not inst_id:
+ continue
+ open_row = conn.execute(
+ "SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' LIMIT 1",
+ (inst_id,),
+ ).fetchone()
+ if open_row:
+ continue
+ row = conn.execute(
+ """
+ SELECT id, close_ord_id, realized_pnl
+ FROM options_trades
+ WHERE inst_id = ? AND status = 'closed'
+ ORDER BY id DESC LIMIT 1
+ """,
+ (inst_id,),
+ ).fetchone()
+ if not row:
+ continue
+ if row["close_ord_id"]:
+ continue
+ if row["realized_pnl"] is not None:
+ continue
+ conn.execute(
+ """
+ UPDATE options_trades
+ SET status = 'open',
+ close_quote = NULL,
+ premium_received = NULL,
+ realized_pnl = NULL,
+ closed_at = NULL,
+ signal_note = CASE
+ WHEN signal_note = '到期结算' THEN NULL
+ ELSE signal_note
+ END
+ WHERE id = ?
+ """,
+ (int(row["id"]),),
+ )
+ fixed += 1
+ return fixed
+
+
def options_monitor_loop(
*,
enabled: bool,
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index 97045d1..162fb04 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -147,7 +147,7 @@ def _sync_options_trades(cfg: dict[str, Any]) -> None:
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
+ from lib.options.options_monitor_lib import reconcile_live_open_trades, 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 "")}
@@ -158,6 +158,7 @@ def _sync_options_trades(cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]()
try:
init_options_tables(conn)
+ reconcile_live_open_trades(conn, live_inst_ids=live_ids)
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
conn.commit()
finally:
@@ -715,13 +716,14 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
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
+ from lib.options.options_monitor_lib import reconcile_live_open_trades, 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 "")}
+ reconcile_live_open_trades(conn, live_inst_ids=live_ids)
return sync_open_options_trades(
conn,
live_inst_ids=live_ids,
diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html
index 2d116e6..b7b488e 100644
--- a/lib/options/templates/options_panel.html
+++ b/lib/options/templates/options_panel.html
@@ -137,4 +137,4 @@
-
+
diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py
index 154902f..53c7bb7 100644
--- a/tests/test_options_sync.py
+++ b/tests/test_options_sync.py
@@ -45,7 +45,54 @@ def test_sync_open_options_trades_marks_expired_closed():
assert "到期结算" in (row["signal_note"] or "")
-def test_sync_open_options_trades_uses_exchange_history():
+def test_sync_open_options_trades_skips_without_close_evidence():
+ 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 (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'open', '2026-07-09 08:00:00')
+ """,
+ ("BTC-USD_UM-260710-62000-P",),
+ )
+ conn.commit()
+
+ n = sync_open_options_trades(
+ conn,
+ live_inst_ids=set(),
+ fetch_history_fn=lambda _inst: [],
+ )
+ assert n == 0
+ row = conn.execute("SELECT status FROM options_trades").fetchone()
+ assert row["status"] == "open"
+
+
+def test_reconcile_live_open_trades_reopens_sync_artifact():
+ 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, closed_at)
+ VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'closed', '2026-07-09 09:10:34')
+ """,
+ ("BTC-USD_UM-260710-62000-P",),
+ )
+ conn.commit()
+
+ from lib.options.options_monitor_lib import reconcile_live_open_trades
+
+ n = reconcile_live_open_trades(conn, live_inst_ids={"BTC-USD_UM-260710-62000-P"})
+ assert n == 1
+ row = conn.execute("SELECT status, closed_at FROM options_trades").fetchone()
+ assert row["status"] == "open"
+ assert row["closed_at"] is None
+
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)