From 49186992761ce553ac1ecb62a49a79710c506ad4 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 12:20:29 +0800 Subject: [PATCH] feat: show review fields in symbol archive trade table Co-authored-by: Cursor --- hub_symbol_archive_lib.py | 48 +++++++++- hub_trades_lib.py | 121 +++++++++++++++++++++++-- manual_trading_hub/static/app.css | 19 ++++ manual_trading_hub/static/archive.js | 60 +++++++++++- tests/test_hub_trades_review_fields.py | 101 +++++++++++++++++++++ 5 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 tests/test_hub_trades_review_fields.py diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index edfb796..245aac1 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -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( diff --git a/hub_trades_lib.py b/hub_trades_lib.py index 5351f6b..65102ba 100644 --- a/hub_trades_lib.py +++ b/hub_trades_lib.py @@ -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), } diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index c32bcdd..6e98f66 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 277202d..0743f40 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -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 = '' + - "" + + "" + + "" + "" + 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 ( '' + - "" + + '" + + '" + + '" + "
平仓方向结果盈亏标签备注开仓类型开仓时间平仓时间持仓时长方向结果盈亏标签备注
" + - (t.closed_at || "—") + + '" + + (rev ? '' + rev + "" : "") + + fmtEntryType(t) + + "" + + (rev ? '' + rev + "" : "") + + fmtDt(t.opened_at) + + "" + + (rev ? '' + rev + "" : "") + + fmtDt(t.closed_at) + + "" + + (rev ? '' + rev + "" : "") + + fmtHoldMinutes(t) + "" + (t.direction || "—") + diff --git a/tests/test_hub_trades_review_fields.py b/tests/test_hub_trades_review_fields.py new file mode 100644 index 0000000..c792aa3 --- /dev/null +++ b/tests/test_hub_trades_review_fields.py @@ -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()