diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 93749bc..0c85550 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3727,6 +3727,11 @@ html[data-theme="light"] .options-estimate-row { .opt-history-table td:nth-child(6) { width: 20%; } +.opt-history-table th:nth-child(7), +.opt-history-table td:nth-child(7) { + width: 8%; + text-align: center; +} .opt-hist-time { font-size: 0.64rem; white-space: nowrap; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index d69c532..c12b3ad 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -215,7 +215,7 @@ function fmtUsdc(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; - return Number(v).toFixed(4).replace(/\.?0+$/, "") || "0"; + return Number(v).toFixed(2); } function fmtClosePreview(preview, premiumPaid) { @@ -525,7 +525,7 @@ const closePreview = p.close_preview || {}; const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; const tickSz = p.tick_sz; - const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmt(p.premium_paid, 4).replace(/\.?0+$/, "") : null); + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); const avgTxt = fmtDisplay(p.avg_px_fmt, fmtOptionPx(p.avg_px, tickSz)); const markTxt = fmtDisplay(p.mark_px_fmt, fmtOptionPx(p.mark_px, tickSz)); return ( @@ -899,6 +899,19 @@ } } + async function deleteHistoryRow(key, status) { + const warn = status === "open" + ? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?" + : "确认从列表隐藏该条历史记录?"; + if (!confirm(warn)) return; + const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { method: "DELETE" }); + if (!r.ok) { + alert(r.msg || "删除失败"); + return; + } + refreshAllPositions(); + } + function optHistoryStatus(h) { if (h.status_label) return h.status_label; if (h.status === "open") return "持仓中"; @@ -921,26 +934,33 @@ tbody.innerHTML = ""; const list = (d.ok && d.history) || []; if (!list.length) { - tbody.innerHTML = '暂无历史记录'; + tbody.innerHTML = '暂无历史记录'; return; } list.forEach(function (h) { const tr = document.createElement("tr"); - const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmt(h.premium_paid, 2) : null); + const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); const isOpen = h.status === "open"; const pnl = isOpen ? null : h.realized_pnl; const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; 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 || ""; tr.innerHTML = '' + (h.inst_id || "") + "" + "" + fmt(h.sheets, 0) + "" + "" + premTxt + "" + "" + optHistoryStatusHtml(h) + "" + '' + pnlTxt + "" + - '' + timeTxt + ""; + '' + timeTxt + "" + + ''; tbody.appendChild(tr); }); + tbody.querySelectorAll(".opt-history-del").forEach(function (btn) { + btn.addEventListener("click", function () { + deleteHistoryRow(btn.getAttribute("data-key"), btn.getAttribute("data-status")); + }); + }); } function refreshAllPositions() { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index d615ee3..8efc2f8 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -117,10 +117,32 @@ def format_option_px(px: float, tick_sz: Any) -> str: def format_usdc_amount(v: float | None) -> str | None: - """USDC 金额展示(与交易所持仓/历史一致,最多 4 位小数).""" + """USDC 金额展示(权利金/回收等,固定 2 位小数).""" if v is None: return None - return f"{float(v):.4f}".rstrip("0").rstrip(".") or "0" + return f"{float(v):.2f}" + + +def is_option_full_close_history(raw: dict[str, Any]) -> bool: + """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" + close_type = str(raw.get("type") or "").strip() + return close_type in ("2", "3", "6") + + +def option_history_row_key( + *, + source: str, + inst_id: str = "", + pos_id: str | None = None, + close_ms: int | None = None, +) -> str: + inst_id = (inst_id or "").strip() + pos_id = (pos_id or "").strip() + if source == "live": + return f"live:{inst_id}:{pos_id or close_ms or '0'}" + if pos_id: + return f"ex:{pos_id}" + return f"ex:{inst_id}:{close_ms or 0}" def _ms_to_iso(ms: Any) -> str | None: @@ -815,6 +837,7 @@ def fetch_all_option_positions_history( if after is not None and str(oldest) == after: break after = str(oldest) + out = [r for r in out if is_option_full_close_history(r)] out.sort(key=lambda r: int(_safe_float(r.get("uTime")) or 0), reverse=True) return out[:cap] @@ -854,9 +877,17 @@ def format_option_history_row( status_label = "强平" else: status_label = "已平" + pos_id = str(raw.get("posId") or "").strip() or None + close_ms = int(utime) if utime is not None else None return { "source": "exchange", - "pos_id": str(raw.get("posId") or "").strip() or None, + "history_key": option_history_row_key( + source="exchange", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, "inst_id": inst_id, "underlying": uly, "opt_type": opt_type, @@ -876,7 +907,7 @@ def format_option_history_row( "close_type": close_type, "created_at": _ms_to_iso(ctime), "closed_at": _ms_to_iso(utime), - "close_ms": int(utime) if utime is not None else None, + "close_ms": close_ms, "tick_sz": tick_sz, "raw": raw, } @@ -889,9 +920,17 @@ def format_live_option_history_row( ) -> dict[str, Any]: """将当前持仓格式化为历史列表中的「持仓中」行.""" inst_id = str(row.get("inst_id") or "").strip() + pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None + close_ms = open_ms return { "source": "live", - "pos_id": str((row.get("raw") or {}).get("posId") or "").strip() or None, + "history_key": option_history_row_key( + source="live", + inst_id=inst_id, + pos_id=pos_id, + close_ms=close_ms, + ), + "pos_id": pos_id, "inst_id": inst_id, "underlying": str(row.get("underlying") or inst_id.split("-")[0] or ""), "opt_type": row.get("opt_type"), diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 64ec61e..2ec91f3 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -46,6 +46,14 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_history_hidden ( + history_key TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS options_transfer_log ( diff --git a/lib/options/options_register.py b/lib/options/options_register.py index b526fa2..7528c7a 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -915,6 +915,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: finally: conn.close() + hidden_keys: set[str] = set() + conn = cfg["get_db"]() + try: + init_options_tables(conn) + hidden_keys = { + str(r["history_key"]) + for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() + } + finally: + conn.close() + hist_raw = fetch_all_option_positions_history(ex, limit=200) for raw in hist_raw: inst_id = str(raw.get("instId") or "").strip() @@ -925,7 +936,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: closed = [x for x in items if x.get("status") != "open"] closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) - history = open_rows + closed + history = [ + x + for x in (open_rows + closed) + if str(x.get("history_key") or "") not in hidden_keys + ] live_ids = {str(x.get("inst_id") or "") for x in open_rows} return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) @@ -939,13 +954,26 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])}) - @app.route("/api/options/history/", methods=["DELETE"]) + @app.route("/api/options/history/", methods=["DELETE"]) @lr - def api_options_history_delete(trade_id: str): + def api_options_history_delete(history_key: str): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) - return jsonify({"ok": False, "msg": "历史仓位来自交易所,不支持本地删除"}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index cb898df..ff1d4f1 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -198,10 +198,11 @@ 状态 盈亏 时间 + 操作 - 加载中… + 加载中… @@ -211,4 +212,4 @@ - + diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py index a38b300..358ebe5 100644 --- a/tests/test_options_sync.py +++ b/tests/test_options_sync.py @@ -4,6 +4,7 @@ import sqlite3 from lib.exchange.okx_options_lib import ( format_option_history_row, format_usdc_amount, + is_option_full_close_history, resolve_option_close_from_history, ) from lib.options.options_db import init_options_tables @@ -11,9 +12,16 @@ from lib.options.options_monitor_lib import sync_open_options_trades def test_format_usdc_amount(): - assert format_usdc_amount(4.896) == "4.896" - assert format_usdc_amount(4.90) == "4.9" - assert format_usdc_amount(4.0) == "4" + assert format_usdc_amount(4.896) == "4.90" + assert format_usdc_amount(4.9) == "4.90" + assert format_usdc_amount(4.0) == "4.00" + + +def test_is_option_full_close_history(): + assert is_option_full_close_history({"type": "2"}) + assert is_option_full_close_history({"type": "3"}) + assert not is_option_full_close_history({"type": "1"}) + assert not is_option_full_close_history({"type": "5"}) def test_format_option_history_row(): @@ -36,7 +44,8 @@ def test_format_option_history_row(): assert row["realized_pnl"] == -3.99 assert row["status_label"] == "已平" assert row["open_avg_px_fmt"] == "380" - assert row["premium_paid_fmt"] == "3.8" + assert row["premium_paid_fmt"] == "3.80" + assert row["history_key"] == "ex:pos-btc" def test_resolve_option_close_from_history_picks_latest():