Persist options review deletes across local resync.
Hide deleted rows by history key and contract/close fingerprint so local options_trades imports no longer resurrect them. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 @@
|
||||
"<td>" + optHistoryStatusHtml(h) + "</td>" +
|
||||
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
|
||||
'<td class="opt-hist-time">' + timeTxt + "</td>" +
|
||||
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-key="' + histKey + '" data-status="' + (h.status || "") + '">删除</button></td>';
|
||||
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-key="' +
|
||||
histKey +
|
||||
'" data-status="' +
|
||||
(h.status || "") +
|
||||
'" data-inst="' +
|
||||
(h.inst_id || "") +
|
||||
'" data-closed="' +
|
||||
(h.closed_at || h.created_at || "") +
|
||||
'">删除</button></td>';
|
||||
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")
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
function loadTrades() {
|
||||
var tbody = $("or-trades-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载中…</td></tr>';
|
||||
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 = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
var rows = data.trades || [];
|
||||
tradesCache = {};
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="muted">暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)</td></tr>';
|
||||
'<tr><td colspan="8" class="muted">暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows
|
||||
@@ -159,18 +159,29 @@
|
||||
"<td>" +
|
||||
(t.reviewed ? "已复盘" : "待复盘") +
|
||||
"</td>" +
|
||||
'<td><button type="button" class="btn-secondary or-hide-btn" data-id="' +
|
||||
t.id +
|
||||
'" style="font-size:.72rem;padding:2px 8px">删除</button></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载失败</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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/<int:trade_id>", 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/<int:trade_id>", methods=["DELETE"])
|
||||
@lr
|
||||
def api_options_review_entry_delete(trade_id: int):
|
||||
|
||||
@@ -274,4 +274,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=38"></script>
|
||||
<script src="/static/options_panel.js?v=39"></script>
|
||||
|
||||
@@ -157,14 +157,15 @@
|
||||
<th>持有</th>
|
||||
<th>策略</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="or-trades-tbody">
|
||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
||||
<tr><td colspan="8" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/options_review.js?v=4"></script>
|
||||
<script src="/static/options_review.js?v=6"></script>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user