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