feat: archive entry type from review, prune stale trades on sync, manual delete

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-08 12:39:27 +08:00
parent e68e29629e
commit 46963a4498
7 changed files with 260 additions and 19 deletions
+115 -8
View File
@@ -15,7 +15,11 @@ from hub_ohlcv_lib import (
aggregate_ohlcv_bars, aggregate_ohlcv_bars,
normalize_chart_timeframe, normalize_chart_timeframe,
) )
from hub_trades_lib import effective_entry_type, effective_hold_minutes, format_hold_minutes from hub_trades_lib import (
display_entry_type_label,
effective_hold_minutes,
format_hold_minutes,
)
ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"}) ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"})
ARCHIVE_DEFAULT_TIMEFRAME = "15m" ARCHIVE_DEFAULT_TIMEFRAME = "15m"
@@ -160,18 +164,114 @@ def _parse_dt_ms(raw: Any) -> int | None:
return None return None
def _trade_entry_reason_for_cache(t: dict[str, Any]) -> str:
for key in ("entry_type", "entry_reason", "reviewed_entry_reason"):
raw = t.get(key)
if raw is not None and str(raw).strip():
return str(raw).strip()
return display_entry_type_label(t) if isinstance(t, dict) else ""
def purge_stale_trades_cache(
exchange_key: str,
active_trade_ids: list[int] | set[int],
*,
db_path: Path | None = None,
) -> int:
"""删除该所缓存中已不在复盘/交易记录里的条目。"""
ex_k = (exchange_key or "").strip().lower()
if not ex_k:
return 0
ids: list[int] = []
for raw in active_trade_ids or []:
try:
ids.append(int(raw))
except (TypeError, ValueError):
continue
conn = _connect(db_path)
try:
if not ids:
rows = conn.execute(
"SELECT trade_id FROM archive_trade_cache WHERE exchange_key=?",
(ex_k,),
).fetchall()
stale_ids = [int(r["trade_id"]) for r in rows]
cur = conn.execute(
"DELETE FROM archive_trade_cache WHERE exchange_key=?",
(ex_k,),
)
else:
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"""
SELECT trade_id FROM archive_trade_cache
WHERE exchange_key=? AND trade_id NOT IN ({placeholders})
""",
(ex_k, *ids),
).fetchall()
stale_ids = [int(r["trade_id"]) for r in rows]
cur = conn.execute(
f"""
DELETE FROM archive_trade_cache
WHERE exchange_key=? AND trade_id NOT IN ({placeholders})
""",
(ex_k, *ids),
)
removed = int(cur.rowcount or 0)
if stale_ids:
ph2 = ",".join("?" * len(stale_ids))
conn.execute(
f"""
DELETE FROM trade_overlay
WHERE exchange_key=? AND trade_id IN ({ph2})
""",
(ex_k, *stale_ids),
)
return removed
finally:
conn.close()
def delete_trade_from_archive(
exchange_key: str,
trade_id: int,
*,
db_path: Path | None = None,
) -> bool:
ex_k = (exchange_key or "").strip().lower()
tid = int(trade_id)
conn = _connect(db_path)
try:
cur = conn.execute(
"""
DELETE FROM archive_trade_cache
WHERE exchange_key=? AND trade_id=?
""",
(ex_k, tid),
)
conn.execute(
"DELETE FROM trade_overlay WHERE exchange_key=? AND trade_id=?",
(ex_k, tid),
)
return int(cur.rowcount or 0) > 0
finally:
conn.close()
def upsert_trades_cache( def upsert_trades_cache(
exchange_key: str, exchange_key: str,
trades: list[dict[str, Any]], trades: list[dict[str, Any]],
*, *,
db_path: Path | None = None, db_path: Path | None = None,
) -> int: prune_missing: bool = True,
) -> dict[str, int]:
init_db(db_path) init_db(db_path)
ex_k = (exchange_key or "").strip().lower() ex_k = (exchange_key or "").strip().lower()
if not ex_k: if not ex_k:
return 0 return {"upserted": 0, "removed": 0}
now = _now_ms() now = _now_ms()
n = 0 n = 0
active_ids: list[int] = []
conn = _connect(db_path) conn = _connect(db_path)
try: try:
for t in trades or []: for t in trades or []:
@@ -182,7 +282,9 @@ def upsert_trades_cache(
sym = (t.get("symbol") or "").strip().upper() sym = (t.get("symbol") or "").strip().upper()
if not sym: if not sym:
continue continue
active_ids.append(tid)
payload = {k: t.get(k) for k in t.keys()} payload = {k: t.get(k) for k in t.keys()}
entry_label = _trade_entry_reason_for_cache(t)
conn.execute( conn.execute(
""" """
INSERT INTO archive_trade_cache ( INSERT INTO archive_trade_cache (
@@ -216,7 +318,7 @@ def upsert_trades_cache(
t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")), t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")),
t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")), t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")),
t.get("monitor_type"), t.get("monitor_type"),
t.get("entry_reason"), entry_label,
json.dumps(payload, ensure_ascii=False, default=str), json.dumps(payload, ensure_ascii=False, default=str),
now, now,
), ),
@@ -224,7 +326,10 @@ def upsert_trades_cache(
n += 1 n += 1
finally: finally:
conn.close() conn.close()
return n removed = 0
if prune_missing:
removed = purge_stale_trades_cache(ex_k, active_ids, db_path=db_path)
return {"upserted": n, "removed": removed}
def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]: def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]:
@@ -243,8 +348,8 @@ def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]:
out["closed_at"] = datetime.fromtimestamp(int(closed_ms) / 1000).strftime( out["closed_at"] = datetime.fromtimestamp(int(closed_ms) / 1000).strftime(
"%Y-%m-%d %H:%M:%S" "%Y-%m-%d %H:%M:%S"
) )
entry_type = (out.get("entry_type") or effective_entry_type(out) or "").strip() entry_type = display_entry_type_label(out)
if entry_type: if entry_type and entry_type != "":
out["entry_type"] = entry_type out["entry_type"] = entry_type
out["entry_reason"] = entry_type out["entry_reason"] = entry_type
hold_m = out.get("hold_minutes") hold_m = out.get("hold_minutes")
@@ -961,7 +1066,7 @@ def sync_exchange_symbol_archives(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""同步单所:交易缓存 + 各币种 K 线种子/增量。""" """同步单所:交易缓存 + 各币种 K 线种子/增量。"""
ex_k = (exchange_key or "").strip().lower() ex_k = (exchange_key or "").strip().lower()
upsert_trades_cache(ex_k, trades, db_path=db_path) cache_stats = upsert_trades_cache(ex_k, trades, db_path=db_path, prune_missing=True)
by_sym: dict[str, int] = {} by_sym: dict[str, int] = {}
for t in trades or []: for t in trades or []:
@@ -997,6 +1102,8 @@ def sync_exchange_symbol_archives(
"ok": True, "ok": True,
"exchange_key": ex_k, "exchange_key": ex_k,
"symbols": len(by_sym), "symbols": len(by_sym),
"trades_upserted": int(cache_stats.get("upserted") or 0),
"trades_removed": int(cache_stats.get("removed") or 0),
"seed_bars": seeded, "seed_bars": seeded,
"appended_bars": appended, "appended_bars": appended,
"trades": len(trades or []), "trades": len(trades or []),
+12 -1
View File
@@ -119,9 +119,20 @@ def effective_entry_type(d: dict) -> str:
kst = str(d.get("key_signal_type") or "").strip() kst = str(d.get("key_signal_type") or "").strip()
if kst: if kst:
return kst return kst
legacy = str(d.get("entry_type") or "").strip()
if legacy and legacy not in ("trend_pullback", "roll", "trend"):
return _normalize_monitor_type_label(legacy) or legacy
return mt return mt
def display_entry_type_label(d: dict) -> str:
"""档案/列表展示用开仓类型(不回落为「下单监控」若已有复盘或建档类型)。"""
label = effective_entry_type(d).strip()
if not label:
return ""
return _normalize_monitor_type_label(label) or label
def effective_hold_minutes( def effective_hold_minutes(
d: dict, d: dict,
*, *,
@@ -281,7 +292,7 @@ def _normalize_archive_trade_row(
opened_ms_i = int(opened_ms) if opened_ms else None opened_ms_i = int(opened_ms) if opened_ms else None
closed_ms_i = int(closed_ms) if closed_ms else None closed_ms_i = int(closed_ms) if closed_ms else None
hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i) hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i)
entry_type = effective_entry_type(d) entry_type = display_entry_type_label(d)
reviewed = bool( reviewed = bool(
d.get("reviewed_at") d.get("reviewed_at")
or d.get("reviewed_result") or d.get("reviewed_result")
+14
View File
@@ -1835,6 +1835,20 @@ def api_archive_trade_overlay(
return {"ok": True, "overlay": out} return {"ok": True, "overlay": out}
@app.delete("/api/archive/trade/{exchange_key}/{trade_id}")
def api_archive_trade_delete(exchange_key: str, trade_id: int):
from hub_symbol_archive_lib import delete_trade_from_archive
ex_k = (exchange_key or "").strip().lower()
if not ex_k:
raise HTTPException(status_code=400, detail="缺少 exchange_key")
init_archive_db()
removed = delete_trade_from_archive(ex_k, int(trade_id))
if not removed:
raise HTTPException(status_code=404, detail="档案中无该笔交易")
return {"ok": True, "exchange_key": ex_k, "trade_id": int(trade_id)}
@app.post("/api/archive/sync") @app.post("/api/archive/sync")
async def api_archive_sync(): async def api_archive_sync():
body = await _run_archive_sync_once() body = await _run_archive_sync_once()
+12
View File
@@ -4063,6 +4063,18 @@ body.hub-page-ai #page-ai {
.archive-trades-table td.neg { .archive-trades-table td.neg {
color: #ef4444; color: #ef4444;
} }
.archive-del-btn {
padding: 3px 8px;
font-size: 0.72rem;
border-radius: 6px;
border: 1px solid rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: #f87171;
cursor: pointer;
}
.archive-del-btn:hover {
background: rgba(239, 68, 68, 0.16);
}
.archive-tag-select, .archive-tag-select,
.archive-note-input { .archive-note-input {
width: 100%; width: 100%;
+66 -9
View File
@@ -109,14 +109,25 @@
return mins + "分钟"; return mins + "分钟";
} }
const ENTRY_TYPE_LABELS = {
trend_pullback: "趋势回调",
roll: "顺势加仓",
trend: "趋势回调",
};
function fmtEntryType(tr) { function fmtEntryType(tr) {
if (!tr) return "—"; if (!tr) return "—";
return ( const raw = String(
tr.entry_type || tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || ""
tr.entry_reason || ).trim();
tr.monitor_type || if (raw) {
"—" return ENTRY_TYPE_LABELS[raw] || raw;
); }
const mt = String(tr.monitor_type || "").trim();
if (mt && mt !== "下单监控") {
return ENTRY_TYPE_LABELS[mt] || mt;
}
return "—";
} }
function reviewMark(tr) { function reviewMark(tr) {
@@ -551,7 +562,7 @@
elTrades.innerHTML = elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' + '<table class="archive-trades-table"><thead><tr>' +
"<th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" + "<th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
"<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th>" + "<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" +
"</tr></thead><tbody>" + "</tr></thead><tbody>" +
trades trades
.map(function (t) { .map(function (t) {
@@ -618,15 +629,30 @@
'" value="' + '" value="' +
String(t.note || "").replace(/"/g, "&quot;") + String(t.note || "").replace(/"/g, "&quot;") +
'" placeholder="备注" /></td>' + '" placeholder="备注" /></td>' +
'<td><button type="button" class="archive-del-btn" data-id="' +
tid +
'" title="从档案移除(不影响实例复盘库)">删除</button></td>' +
"</tr>" "</tr>"
); );
}) })
.join("") + .join("") +
"</tbody></table>"; "</tbody></table>";
elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.stopPropagation();
void deleteTrade(btn.getAttribute("data-id"));
});
});
elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) {
row.addEventListener("click", function (ev) { row.addEventListener("click", function (ev) {
if (ev.target.closest("select") || ev.target.closest("input")) return; if (
ev.target.closest("select") ||
ev.target.closest("input") ||
ev.target.closest(".archive-del-btn")
) {
return;
}
selectedTradeId = row.getAttribute("data-id"); selectedTradeId = row.getAttribute("data-id");
renderTrades(); renderTrades();
applyChartMarkers(); applyChartMarkers();
@@ -650,6 +676,32 @@
}); });
} }
async function deleteTrade(tradeId) {
if (!selected || tradeId == null) return;
if (!window.confirm("从币种档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return;
const r = await apiFetch(
"/api/archive/trade/" + selected.exchange_key + "/" + tradeId,
{ method: "DELETE" }
);
if (!r.ok) {
const j = await r.json().catch(function () {
return {};
});
setStatus(j.detail || j.msg || "删除失败");
return;
}
if (String(selectedTradeId) === String(tradeId)) {
selectedTradeId = null;
}
trades = trades.filter(function (t) {
return String(t.trade_id || t.id) !== String(tradeId);
});
renderTrades();
applyChartMarkers();
await loadList();
setStatus("已移除 1 笔档案记录");
}
async function saveOverlay(tradeId, tag, note) { async function saveOverlay(tradeId, tag, note) {
if (!selected) return; if (!selected) return;
const body = { behavior_tag: tag || "", note: note != null ? note : undefined }; const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
@@ -742,7 +794,12 @@
if (row.ok === false) { if (row.ok === false) {
parts.push(label + " 失败: " + (row.msg || "未知错误")); parts.push(label + " 失败: " + (row.msg || "未知错误"));
} else { } else {
parts.push(label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"); let line =
label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔";
if (row.trades_removed > 0) {
line += " 清" + row.trades_removed;
}
parts.push(line);
} }
}); });
return parts.join(" · "); return parts.join(" · ");
+26
View File
@@ -9,6 +9,7 @@ from hub_ohlcv_lib import aggregate_ohlcv_bars
from hub_symbol_archive_lib import ( from hub_symbol_archive_lib import (
_fill_missing_bars, _fill_missing_bars,
init_db, init_db,
load_symbol_trades,
resolve_archive_chart, resolve_archive_chart,
upsert_bars_5m, upsert_bars_5m,
upsert_trade_overlay, upsert_trade_overlay,
@@ -134,6 +135,31 @@ def test_resolve_archive_chart_history_range():
assert len(out["candles"]) >= 40 assert len(out["candles"]) >= 40
def test_sync_prunes_missing_trades():
with tempfile.TemporaryDirectory() as td:
db = Path(td) / "archive.db"
init_db(db)
upsert_trades_cache(
"gate",
[
{"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1},
{"id": 2, "symbol": "BNB/USDT", "result": "止盈", "pnl_amount": 1},
],
db_path=db,
prune_missing=False,
)
stats = upsert_trades_cache(
"gate",
[{"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1}],
db_path=db,
prune_missing=True,
)
rows = load_symbol_trades("gate", "BNB/USDT", db_path=db)
assert len(rows) == 1
assert rows[0]["trade_id"] == 1
assert stats["removed"] == 1
def test_list_with_overlay_filters(): def test_list_with_overlay_filters():
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
db = Path(td) / "archive.db" db = Path(td) / "archive.db"
+15 -1
View File
@@ -8,10 +8,24 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from hub_symbol_archive_lib import init_db, load_symbol_trades, upsert_trades_cache from hub_symbol_archive_lib import init_db, load_symbol_trades, upsert_trades_cache
from hub_trades_lib import _normalize_archive_trade_row, effective_entry_type, effective_hold_minutes from hub_trades_lib import (
_normalize_archive_trade_row,
display_entry_type_label,
effective_entry_type,
effective_hold_minutes,
)
class TestHubTradesReviewFields(unittest.TestCase): class TestHubTradesReviewFields(unittest.TestCase):
def test_display_entry_type_for_manual_monitor_review(self):
d = {
"monitor_type": "下单监控",
"entry_reason": "",
"reviewed_entry_reason": "突破回踩",
"reviewed_at": "2026-06-08 10:00:00",
}
self.assertEqual(display_entry_type_label(d), "突破回踩")
def test_effective_entry_type_prefers_reviewed(self): def test_effective_entry_type_prefers_reviewed(self):
d = { d = {
"entry_reason": "突破回踩", "entry_reason": "突破回踩",