feat: show review fields in symbol archive trade table

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-08 12:20:29 +08:00
parent 1dcf62bb08
commit 4918699276
5 changed files with 338 additions and 11 deletions
+47 -1
View File
@@ -15,6 +15,7 @@ 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
ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"})
ARCHIVE_DEFAULT_TIMEFRAME = "15m"
@@ -226,6 +227,51 @@ def upsert_trades_cache(
return n
def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]:
"""缓存行补齐复盘优先的展示字段(兼容旧同步数据)。"""
opened_ms = out.get("opened_at_ms") or _parse_dt_ms(out.get("opened_at"))
closed_ms = out.get("closed_at_ms") or _parse_dt_ms(out.get("closed_at"))
if opened_ms:
out["opened_at_ms"] = int(opened_ms)
if closed_ms:
out["closed_at_ms"] = int(closed_ms)
if not out.get("opened_at") and opened_ms:
out["opened_at"] = datetime.fromtimestamp(int(opened_ms) / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
)
if not out.get("closed_at") and closed_ms:
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:
out["entry_type"] = entry_type
out["entry_reason"] = entry_type
hold_m = out.get("hold_minutes")
if hold_m in (None, ""):
hold_m = effective_hold_minutes(
out,
opened_ms=out.get("opened_at_ms"),
closed_ms=out.get("closed_at_ms"),
)
try:
hold_m = max(0, int(hold_m or 0))
except (TypeError, ValueError):
hold_m = 0
out["hold_minutes"] = hold_m
out["hold_minutes_text"] = out.get("hold_minutes_text") or format_hold_minutes(hold_m)
if "reviewed" not in out:
out["reviewed"] = bool(
out.get("reviewed_at")
or out.get("reviewed_result")
or out.get("reviewed_opened_at")
or out.get("reviewed_closed_at")
or out.get("reviewed_entry_reason")
or out.get("reviewed_hold_minutes")
)
return out
def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[str, Any]:
d = dict(row)
payload = {}
@@ -240,7 +286,7 @@ def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[st
out["behavior_tag"] = ov.get("behavior_tag") or ""
out["note"] = ov.get("note") or ""
out["trade_id"] = out.get("trade_id") or out.get("id")
return out
return _enrich_trade_display_fields(out)
def load_overlays(
+114 -7
View File
@@ -4,6 +4,12 @@ from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from strategy_trade_labels import (
MONITOR_TYPE_ROLL,
MONITOR_TYPE_TREND_PULLBACK,
entry_reason_for_monitor_type,
)
TRADE_COMPLETED_RESULTS = (
"止盈",
"止损",
@@ -78,6 +84,74 @@ def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = N
return default
def format_hold_minutes(minutes: Any) -> str:
try:
total = int(minutes or 0)
except (TypeError, ValueError):
return "0分钟"
if total <= 0:
return "0分钟"
hours = total // 60
mins = total % 60
if hours:
return f"{hours}小时{mins}分钟"
return f"{mins}分钟"
def _normalize_monitor_type_label(raw: Any) -> str:
mt = str(raw or "").strip()
if mt in ("trend_pullback", "trend"):
return MONITOR_TYPE_TREND_PULLBACK
if mt in ("roll",):
return MONITOR_TYPE_ROLL
return mt
def effective_entry_type(d: dict) -> str:
"""复盘开仓类型优先,与实例交易记录 effective_entry_reason 一致。"""
er = _effective_field(d, "reviewed_entry_reason", "entry_reason")
if er is not None and str(er).strip():
return str(er).strip()
mt = _normalize_monitor_type_label(d.get("monitor_type"))
er2 = entry_reason_for_monitor_type(mt)
if er2:
return er2
kst = str(d.get("key_signal_type") or "").strip()
if kst:
return kst
return mt
def effective_hold_minutes(
d: dict,
*,
opened_ms: int | None = None,
closed_ms: int | None = None,
) -> int:
hm = _effective_field(d, "reviewed_hold_minutes", "hold_minutes")
if hm is not None and str(hm).strip() != "":
try:
return max(0, int(hm))
except (TypeError, ValueError):
pass
hs = _effective_field(d, "reviewed_hold_seconds", "hold_seconds")
if hs is not None and str(hs).strip() != "":
try:
return max(0, int(int(hs) // 60))
except (TypeError, ValueError):
pass
oms = opened_ms if opened_ms is not None else d.get("opened_at_ms")
cms = closed_ms if closed_ms is not None else d.get("closed_at_ms")
try:
oms_i = int(oms) if oms not in (None, "") else None
cms_i = int(cms) if cms not in (None, "") else None
except (TypeError, ValueError):
oms_i = cms_i = None
if oms_i and cms_i and cms_i > oms_i:
return max(0, int((cms_i - oms_i) // 60_000))
return 0
def _effective_pnl(d: dict) -> float:
reviewed = d.get("reviewed_pnl_amount")
if reviewed is not None and str(reviewed).strip() != "":
@@ -204,6 +278,18 @@ def _normalize_archive_trade_row(
trade_id = int(d.get("id"))
except (TypeError, ValueError):
return None
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)
reviewed = bool(
d.get("reviewed_at")
or d.get("reviewed_result")
or d.get("reviewed_opened_at")
or d.get("reviewed_closed_at")
or d.get("reviewed_entry_reason")
or d.get("reviewed_hold_minutes")
)
return {
"id": trade_id,
"exchange_key": (exchange_key or "").strip().lower(),
@@ -213,17 +299,20 @@ def _normalize_archive_trade_row(
"pnl_amount": round(pnl, 4),
"closed_at": closed_at,
"opened_at": opened_at,
"opened_at_ms": int(opened_ms) if opened_ms else None,
"closed_at_ms": int(closed_ms) if closed_ms else None,
"monitor_type": d.get("monitor_type"),
"opened_at_ms": opened_ms_i,
"closed_at_ms": closed_ms_i,
"monitor_type": _normalize_monitor_type_label(d.get("monitor_type")),
"entry_type": entry_type,
"entry_reason": entry_type,
"hold_minutes": hold_m,
"hold_minutes_text": format_hold_minutes(hold_m),
"actual_rr": d.get("actual_rr"),
"planned_rr": d.get("planned_rr"),
"trade_style": d.get("trade_style"),
"entry_reason": d.get("entry_reason"),
"trigger_price": d.get("trigger_price"),
"stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"),
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
"reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")),
"reviewed": reviewed,
"trading_day": trading_day_from_dt(close_dt, reset_hour),
}
@@ -278,10 +367,16 @@ def _archive_trade_select_sql(cols: set[str]) -> str:
"closed_at_ms",
"created_at",
"monitor_type",
"key_signal_type",
"actual_rr",
"planned_rr",
"trade_style",
"entry_reason",
"reviewed_entry_reason",
"hold_minutes",
"reviewed_hold_minutes",
"hold_seconds",
"reviewed_hold_seconds",
"trigger_price",
"stop_loss",
"take_profit",
@@ -341,7 +436,15 @@ def _normalize_snapshot_archive_row(
except (TypeError, ValueError):
pnl = 0.0
st = str(snap.get("strategy_type") or "").strip()
monitor_type = "trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st)
monitor_type = _normalize_monitor_type_label(
"trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st)
)
hold_m = effective_hold_minutes(
{},
opened_ms=opened_ms,
closed_ms=closed_ms,
)
entry_type = entry_reason_for_monitor_type(monitor_type) or monitor_type
return {
"id": -snap_id,
"symbol": (snap.get("symbol") or "").strip().upper(),
@@ -353,10 +456,14 @@ def _normalize_snapshot_archive_row(
"opened_at_ms": opened_ms,
"closed_at_ms": closed_ms,
"monitor_type": monitor_type,
"entry_reason": "trend_pullback" if st == "trend_pullback" else monitor_type,
"entry_type": entry_type,
"entry_reason": entry_type,
"hold_minutes": hold_m,
"hold_minutes_text": format_hold_minutes(hold_m),
"from_snapshot": True,
"snapshot_id": snap_id,
"trend_plan_id": snap.get("source_id"),
"reviewed": False,
"trading_day": trading_day_from_dt(close_dt, reset_hour),
}
+19
View File
@@ -4016,9 +4016,28 @@ body.hub-page-ai #page-ai {
}
.archive-trades-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
font-size: 0.78rem;
}
.archive-trades-table .archive-dt {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.archive-trades-table .archive-hold {
white-space: nowrap;
}
.archive-review-mark {
display: inline-block;
margin-right: 4px;
padding: 0 4px;
border-radius: 4px;
font-size: 0.62rem;
line-height: 1.4;
color: #6ab88a;
background: rgba(106, 184, 138, 0.12);
vertical-align: middle;
}
.archive-trades-table th,
.archive-trades-table td {
padding: 6px 8px;
+57 -3
View File
@@ -92,6 +92,37 @@
return s;
}
function fmtDt(raw) {
if (raw == null || raw === "") return "—";
return String(raw).replace("T", " ").slice(0, 16);
}
function fmtHoldMinutes(tr) {
if (!tr) return "—";
const text = tr.hold_minutes_text;
if (text) return text;
const n = Number(tr.hold_minutes);
if (!Number.isFinite(n) || n <= 0) return "0分钟";
const hours = Math.floor(n / 60);
const mins = Math.floor(n % 60);
if (hours) return hours + "小时" + mins + "分钟";
return mins + "分钟";
}
function fmtEntryType(tr) {
if (!tr) return "—";
return (
tr.entry_type ||
tr.entry_reason ||
tr.monitor_type ||
"—"
);
}
function reviewMark(tr) {
return tr && tr.reviewed ? "复" : "";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return "";
@@ -519,21 +550,44 @@
}
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) {
const tid = t.trade_id || t.id;
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
const tag = t.behavior_tag || "";
const rev = reviewMark(t);
return (
'<tr class="archive-trade-row' +
active +
'" data-id="' +
tid +
'">' +
"<td>" +
(t.closed_at || "") +
'<td' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtEntryType(t) +
"</td>" +
'<td class="archive-dt"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtDt(t.opened_at) +
"</td>" +
'<td class="archive-dt"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtDt(t.closed_at) +
"</td>" +
'<td class="archive-hold"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtHoldMinutes(t) +
"</td>" +
"<td>" +
(t.direction || "—") +
+101
View File
@@ -0,0 +1,101 @@
"""档案交易:复盘字段优先(开仓类型、持仓时长、开平仓时间)。"""
from __future__ import annotations
import tempfile
import unittest
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
class TestHubTradesReviewFields(unittest.TestCase):
def test_effective_entry_type_prefers_reviewed(self):
d = {
"entry_reason": "突破回踩",
"reviewed_entry_reason": "趋势回调",
"monitor_type": "下单监控",
}
self.assertEqual(effective_entry_type(d), "趋势回调")
def test_effective_hold_minutes_prefers_reviewed(self):
d = {
"hold_minutes": 30,
"reviewed_hold_minutes": 95,
"opened_at_ms": 1_700_000_000_000,
"closed_at_ms": 1_700_001_800_000,
}
self.assertEqual(effective_hold_minutes(d), 95)
def test_normalize_archive_trade_row_review_fields(self):
closed = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
opened = (datetime.now() - timedelta(days=2, hours=2)).strftime("%Y-%m-%d %H:%M:%S")
row = _normalize_archive_trade_row(
{
"id": 9,
"symbol": "ONDO/USDT",
"direction": "short",
"result": "止损",
"reviewed_result": "手动平仓",
"pnl_amount": -2.5,
"reviewed_pnl_amount": -2.58,
"opened_at": opened,
"reviewed_opened_at": "2026-06-07 14:30:00",
"closed_at": closed,
"reviewed_closed_at": "2026-06-08 08:44:21",
"opened_at_ms": 1_700_000_000_000,
"closed_at_ms": 1_700_007_200_000,
"entry_reason": "突破回踩",
"reviewed_entry_reason": "趋势回调",
"hold_minutes": 30,
"reviewed_hold_minutes": 1080,
"monitor_type": "趋势回调",
"reviewed_at": closed,
},
exchange_key="gate",
)
self.assertIsNotNone(row)
assert row is not None
self.assertEqual(row["entry_type"], "趋势回调")
self.assertEqual(row["hold_minutes"], 1080)
self.assertEqual(row["opened_at"], "2026-06-07 14:30:00")
self.assertEqual(row["closed_at"], "2026-06-08 08:44:21")
self.assertTrue(row["reviewed"])
def test_archive_cache_enriches_review_display_fields(self):
with tempfile.TemporaryDirectory() as td:
db = Path(td) / "archive.db"
init_db(db)
upsert_trades_cache(
"gate",
[
{
"id": 3,
"symbol": "ONDO/USDT",
"direction": "short",
"result": "手动平仓",
"pnl_amount": -2.58,
"opened_at": "2026-06-07 14:30:00",
"closed_at": "2026-06-08 08:44:21",
"opened_at_ms": 1_781_000_000_000,
"closed_at_ms": 1_781_065_000_000,
"entry_type": "趋势回调",
"hold_minutes": 1080,
"hold_minutes_text": "18小时0分钟",
"reviewed": True,
}
],
db_path=db,
)
rows = load_symbol_trades("gate", "ONDO/USDT", db_path=db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["entry_type"], "趋势回调")
self.assertEqual(rows[0]["hold_minutes"], 1080)
self.assertTrue(rows[0]["opened_at"].startswith("2026-06-07"))
self.assertTrue(rows[0]["closed_at"].startswith("2026-06-08"))
if __name__ == "__main__":
unittest.main()