feat: show review fields in symbol archive trade table
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || "—") +
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user