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:
+115
-8
@@ -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 []),
|
||||
|
||||
+12
-1
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 =
|
||||
'<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>" +
|
||||
"</tr></thead><tbody>" +
|
||||
trades
|
||||
.map(function (t) {
|
||||
@@ -618,15 +629,30 @@
|
||||
'" value="' +
|
||||
String(t.note || "").replace(/"/g, """) +
|
||||
'" placeholder="备注" /></td>' +
|
||||
'<td><button type="button" class="archive-del-btn" data-id="' +
|
||||
tid +
|
||||
'" title="从档案移除(不影响实例复盘库)">删除</button></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
"</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) {
|
||||
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(" · ");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "突破回踩",
|
||||
|
||||
Reference in New Issue
Block a user