From 46963a4498d6a718000a6cf0ce865380f00bcf59 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 12:39:27 +0800 Subject: [PATCH] feat: archive entry type from review, prune stale trades on sync, manual delete Co-authored-by: Cursor --- hub_symbol_archive_lib.py | 123 +++++++++++++++++++++++-- hub_trades_lib.py | 13 ++- manual_trading_hub/hub.py | 14 +++ manual_trading_hub/static/app.css | 12 +++ manual_trading_hub/static/archive.js | 75 +++++++++++++-- tests/test_hub_symbol_archive_lib.py | 26 ++++++ tests/test_hub_trades_review_fields.py | 16 +++- 7 files changed, 260 insertions(+), 19 deletions(-) diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 245aac1..f48a597 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -15,7 +15,11 @@ from hub_ohlcv_lib import ( aggregate_ohlcv_bars, 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_DEFAULT_TIMEFRAME = "15m" @@ -160,18 +164,114 @@ def _parse_dt_ms(raw: Any) -> int | 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( exchange_key: str, trades: list[dict[str, Any]], *, db_path: Path | None = None, -) -> int: + prune_missing: bool = True, +) -> dict[str, int]: init_db(db_path) ex_k = (exchange_key or "").strip().lower() if not ex_k: - return 0 + return {"upserted": 0, "removed": 0} now = _now_ms() n = 0 + active_ids: list[int] = [] conn = _connect(db_path) try: for t in trades or []: @@ -182,7 +282,9 @@ def upsert_trades_cache( sym = (t.get("symbol") or "").strip().upper() if not sym: continue + active_ids.append(tid) payload = {k: t.get(k) for k in t.keys()} + entry_label = _trade_entry_reason_for_cache(t) conn.execute( """ 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("closed_at_ms") or _parse_dt_ms(t.get("closed_at")), t.get("monitor_type"), - t.get("entry_reason"), + entry_label, json.dumps(payload, ensure_ascii=False, default=str), now, ), @@ -224,7 +326,10 @@ def upsert_trades_cache( n += 1 finally: 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]: @@ -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( "%Y-%m-%d %H:%M:%S" ) - entry_type = (out.get("entry_type") or effective_entry_type(out) or "").strip() - if entry_type: + entry_type = display_entry_type_label(out) + if entry_type and entry_type != "—": out["entry_type"] = entry_type out["entry_reason"] = entry_type hold_m = out.get("hold_minutes") @@ -961,7 +1066,7 @@ def sync_exchange_symbol_archives( ) -> dict[str, Any]: """同步单所:交易缓存 + 各币种 K 线种子/增量。""" 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] = {} for t in trades or []: @@ -997,6 +1102,8 @@ def sync_exchange_symbol_archives( "ok": True, "exchange_key": ex_k, "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, "appended_bars": appended, "trades": len(trades or []), diff --git a/hub_trades_lib.py b/hub_trades_lib.py index 65102ba..8e68cd9 100644 --- a/hub_trades_lib.py +++ b/hub_trades_lib.py @@ -119,9 +119,20 @@ def effective_entry_type(d: dict) -> str: kst = str(d.get("key_signal_type") or "").strip() if 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 +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( d: dict, *, @@ -281,7 +292,7 @@ def _normalize_archive_trade_row( opened_ms_i = int(opened_ms) if opened_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) - entry_type = effective_entry_type(d) + entry_type = display_entry_type_label(d) reviewed = bool( d.get("reviewed_at") or d.get("reviewed_result") diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index ef783c4..48d6292 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1835,6 +1835,20 @@ def api_archive_trade_overlay( 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") async def api_archive_sync(): body = await _run_archive_sync_once() diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 6e98f66..188f35b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -4063,6 +4063,18 @@ body.hub-page-ai #page-ai { .archive-trades-table td.neg { 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-note-input { width: 100%; diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 0743f40..d5f47fd 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -109,14 +109,25 @@ return mins + "分钟"; } + const ENTRY_TYPE_LABELS = { + trend_pullback: "趋势回调", + roll: "顺势加仓", + trend: "趋势回调", + }; + function fmtEntryType(tr) { if (!tr) return "—"; - return ( - tr.entry_type || - tr.entry_reason || - tr.monitor_type || - "—" - ); + const raw = String( + tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || "" + ).trim(); + 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) { @@ -551,7 +562,7 @@ elTrades.innerHTML = '' + "" + - "" + + "" + "" + trades .map(function (t) { @@ -618,15 +629,30 @@ '" value="' + String(t.note || "").replace(/"/g, """) + '" placeholder="备注" />' + + '' + "" ); }) .join("") + "
开仓类型开仓时间平仓时间持仓时长方向结果盈亏标签备注方向结果盈亏标签备注操作
"; + 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) { 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"); renderTrades(); 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) { if (!selected) return; const body = { behavior_tag: tag || "", note: note != null ? note : undefined }; @@ -742,7 +794,12 @@ if (row.ok === false) { parts.push(label + " 失败: " + (row.msg || "未知错误")); } 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(" · "); diff --git a/tests/test_hub_symbol_archive_lib.py b/tests/test_hub_symbol_archive_lib.py index 2d53f99..c8302a1 100644 --- a/tests/test_hub_symbol_archive_lib.py +++ b/tests/test_hub_symbol_archive_lib.py @@ -9,6 +9,7 @@ from hub_ohlcv_lib import aggregate_ohlcv_bars from hub_symbol_archive_lib import ( _fill_missing_bars, init_db, + load_symbol_trades, resolve_archive_chart, upsert_bars_5m, upsert_trade_overlay, @@ -134,6 +135,31 @@ def test_resolve_archive_chart_history_range(): 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(): with tempfile.TemporaryDirectory() as td: db = Path(td) / "archive.db" diff --git a/tests/test_hub_trades_review_fields.py b/tests/test_hub_trades_review_fields.py index c792aa3..f758167 100644 --- a/tests/test_hub_trades_review_fields.py +++ b/tests/test_hub_trades_review_fields.py @@ -8,10 +8,24 @@ from datetime import datetime, timedelta from pathlib import Path 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): + 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): d = { "entry_reason": "突破回踩",