diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 565183b..42b609c 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1831,12 +1831,19 @@ } } - async function deleteHistoryRow(key, status) { + async function deleteHistoryRow(key, status, instId, closedAt) { const warn = status === "open" ? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?" - : "确认从列表隐藏该条历史记录?"; + : "确认从列表隐藏该条历史记录?(期权复盘页也会同步隐藏)"; if (!confirm(warn)) return; - const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { method: "DELETE" }); + const body = {}; + if (instId) body.inst_id = instId; + if (closedAt) body.closed_at = closedAt; + const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); if (!r.ok) { alert(r.msg || "删除失败"); return; @@ -1885,12 +1892,25 @@ "" + optHistoryStatusHtml(h) + "" + '' + pnlTxt + "" + '' + 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")); + deleteHistoryRow( + btn.getAttribute("data-key"), + btn.getAttribute("data-status"), + btn.getAttribute("data-inst"), + btn.getAttribute("data-closed") + ); }); }); } diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index ca9a7c5..20727f7 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -94,7 +94,7 @@ function loadTrades() { var tbody = $("or-trades-tbody"); if (!tbody) return; - tbody.innerHTML = '加载中…'; + tbody.innerHTML = '加载中…'; fetch("/api/options/review/trades?" + qs(), { credentials: "same-origin" }) .then(function (r) { return r.json(); @@ -102,14 +102,14 @@ .then(function (data) { setSyncStatus("本地记录已加载"); if (!data.ok) { - tbody.innerHTML = '加载失败'; + tbody.innerHTML = '加载失败'; return; } var rows = data.trades || []; tradesCache = {}; if (!rows.length) { tbody.innerHTML = - '暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)'; + '暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)'; return; } tbody.innerHTML = rows @@ -159,18 +159,29 @@ "" + (t.reviewed ? "已复盘" : "待复盘") + "" + + '' + "" ); }) .join(""); tbody.querySelectorAll(".or-trade-row").forEach(function (tr) { - tr.addEventListener("click", function () { + tr.addEventListener("click", function (ev) { + if (ev.target && ev.target.closest && ev.target.closest(".or-hide-btn")) return; openJournalForm(Number(tr.getAttribute("data-id"))); }); }); + tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + hideTrade(Number(btn.getAttribute("data-id"))); + }); + }); }) .catch(function () { - tbody.innerHTML = '加载失败'; + tbody.innerHTML = '加载失败'; }); } @@ -491,6 +502,29 @@ } } + function hideTrade(tradeId) { + if (!tradeId) return; + if (!confirm("从复盘列表删除并隐藏?刷新后也不会再出现.")) return; + fetch("/api/options/review/trades/" + tradeId, { + method: "DELETE", + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ok) { + alert(data.msg || "删除失败"); + return; + } + if (currentTradeId === tradeId) hideJournalForm(); + reloadAll(); + }) + .catch(function () { + alert("删除失败"); + }); + } + function saveEntry() { var tradeId = Number(($("or-trade-id") || {}).value || 0); if (!tradeId) { diff --git a/lib/options/options_register.py b/lib/options/options_register.py index ccfe1f2..408516a 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1096,6 +1096,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: key = (history_key or "").strip() if not key: return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None conn = cfg["get_db"]() try: init_options_tables(conn) @@ -1103,6 +1106,59 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", (key,), ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass conn.commit() finally: conn.close() diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py index 5a31c88..7123a1d 100644 --- a/lib/options/options_review_db.py +++ b/lib/options/options_review_db.py @@ -109,6 +109,22 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_review_hidden ( + history_key TEXT PRIMARY KEY, + inst_id TEXT, + closed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_review_hidden_inst + ON options_review_hidden(inst_id, closed_at) + """ + ) _ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER") _ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0") _ensure_column(conn, "options_review_trades", "target_price_up", "REAL") diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 8da85ad..6208e55 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -83,11 +83,35 @@ def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None: ) +def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool: + """删除已导入的复盘快照(含复盘内容).""" + key = str(history_key or "").strip() + if not key: + return False + existing = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key=?", (key,) + ).fetchone() + if not existing: + return False + tid = int(existing["id"]) + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (tid,)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (tid,)) + return True + + def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: - """幂等写入纯期权快照;不触碰 options_review_entries.""" + """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" history_key = str(row.get("history_key") or "").strip() if not history_key: return "skip" + if is_review_hidden( + conn, + history_key, + inst_id=str(row.get("inst_id") or "").strip() or None, + closed_at=row.get("closed_at") or row.get("created_at"), + ): + # 若此前已导入,清掉,避免列表残留 + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" opened_at = row.get("created_at") or row.get("opened_at") closed_at = row.get("closed_at") pnl = _safe_float(row.get("realized_pnl")) @@ -133,6 +157,120 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> return "inserted" +def _close_fingerprint(inst_id: Any, closed_at: Any) -> str | None: + inst = str(inst_id or "").strip() + if not inst: + return None + closed = str(closed_at or "").strip() + if not closed: + return f"inst:{inst}" + # 精确到分钟,避免秒差导致漏匹配 + return f"inst_close:{inst}:{closed[:16]}" + + +def is_review_hidden( + conn: sqlite3.Connection, + history_key: str, + *, + inst_id: str | None = None, + closed_at: Any = None, +) -> bool: + init_options_review_tables(conn) + key = str(history_key or "").strip() + if key and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + fp = _close_fingerprint(inst_id, closed_at) + if fp and conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 期权历史页删除:options_history_hidden,按合约指纹或原 key + try: + if key and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (key,) + ).fetchone(): + return True + if fp and conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (fp,) + ).fetchone(): + return True + # 仅隐藏了 ex:posId 时,用合约+平仓时间在历史隐藏表无直接命中; + # 若指纹已写入 options_review_hidden(新删除路径)上面已覆盖. + # 兼容:inst 级隐藏 + if inst_id: + inst_fp = f"inst:{str(inst_id).strip()}" + if conn.execute( + "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + if conn.execute( + "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", + (inst_fp,), + ).fetchone(): + return True + except Exception: + pass + return False + + +def hide_review_keys( + conn: sqlite3.Connection, + *, + history_key: str, + inst_id: str | None = None, + closed_at: Any = None, +) -> None: + init_options_review_tables(conn) + keys = [str(history_key or "").strip()] + fp = _close_fingerprint(inst_id, closed_at) + if fp: + keys.append(fp) + for k in keys: + if not k: + continue + conn.execute( + """ + INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) + VALUES (?, ?, ?) + """, + (k, (inst_id or None), str(closed_at or "")[:19] or None), + ) + try: + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden(history_key) VALUES (?)", + (k,), + ) + except Exception: + pass + + +def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: + """从复盘列表删除并持久隐藏,刷新本地源也不会再回来.""" + init_options_review_tables(conn) + row = conn.execute( + "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) + ).fetchone() + if not row: + return {"ok": False, "msg": "记录不存在"} + d = _row_to_dict(row) + hide_review_keys( + conn, + history_key=str(d.get("history_key") or ""), + inst_id=str(d.get("inst_id") or "").strip() or None, + closed_at=d.get("closed_at") or d.get("opened_at"), + ) + entry = conn.execute( + "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) + ).fetchone() + conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) + conn.execute("DELETE FROM options_review_trades WHERE id=?", (int(trade_id),)) + return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")} + + + def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" init_options_review_tables(conn) @@ -282,6 +420,13 @@ def upsert_hedge_plan_row( return "skip" opened_at = plan.get("opened_at") or plan.get("created_at") closed_at = plan.get("closed_at") + if is_review_hidden( + conn, + history_key, + inst_id=None, + closed_at=closed_at, + ): + return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" total = _safe_float(plan.get("realized_pnl_total")) hold = _hold_seconds(opened_at, closed_at) fields = { diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py index 2b3f4f6..58a145d 100644 --- a/lib/options/options_review_register.py +++ b/lib/options/options_review_register.py @@ -22,6 +22,7 @@ from lib.options.options_review_lib import ( delete_review_entry, ensure_local_review_synced, get_review_trade, + hide_review_trade, list_review_trades, save_review_entry, sync_all_review_sources, @@ -168,6 +169,26 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s finally: conn.close() + @app.route("/api/options/review/trades/", methods=["DELETE"]) + @lr + def api_options_review_trade_hide(trade_id: int): + """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" + conn = cfg["get_db"]() + try: + out = hide_review_trade(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + @app.route("/api/options/review/entry/", methods=["DELETE"]) @lr def api_options_review_entry_delete(trade_id: int): diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 7d22ea1..dea3d9d 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -274,4 +274,4 @@ - + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index 47b3634..c32bac6 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -157,14 +157,15 @@ 持有 策略 状态 + 操作 - 加载中… + 加载中… - + diff --git a/tests/test_options_review_lib.py b/tests/test_options_review_lib.py index e8f2952..268a64d 100644 --- a/tests/test_options_review_lib.py +++ b/tests/test_options_review_lib.py @@ -226,6 +226,38 @@ class OptionsReviewTests(unittest.TestCase): ).fetchone() self.assertEqual(float(row["realized_pnl_total"]), 1.23) + def test_hide_trade_persists_across_local_sync(self): + conn = _conn() + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import ( + hide_review_trade, + sync_options_from_local_trades, + ) + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-1-C','ETH','C',2000,1,0.01,0.01,0.2,'closed',1.0, + '2026-03-01 10:00:00','2026-03-01 11:00:00') + """ + ) + sync_options_from_local_trades(conn) + tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"] + out = hide_review_trade(conn, tid) + self.assertTrue(out["ok"]) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + sync_options_from_local_trades(conn) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + def test_local_options_trades_import(self): conn = _conn() from lib.options.options_db import init_options_tables