diff --git a/hub_bridge.py b/hub_bridge.py index 02b27df..3805917 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -446,6 +446,7 @@ def register_hub_routes(app): conn, trading_day, row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, ) finally: conn.close() diff --git a/hub_trades_lib.py b/hub_trades_lib.py index ba0bc37..829bac1 100644 --- a/hub_trades_lib.py +++ b/hub_trades_lib.py @@ -4,6 +4,16 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Callable, Optional +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", +) + def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str: """与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日。""" @@ -16,6 +26,28 @@ def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> return trading_day_from_dt(now or datetime.now(), reset_hour) +def parse_dt_for_trading_day(raw: Any) -> datetime | None: + if raw is None: + return None + s = str(raw).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def trading_day_window_bounds(trading_day: str, reset_hour: int = 8) -> tuple[str, str]: + """交易日 [reset_hour, 次日 reset_hour) 对应的北京时间字符串区间(闭区间)。""" + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(seconds=1) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict: if row is None: return {} @@ -36,62 +68,111 @@ def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict: return {} +def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = None) -> Any: + rv = d.get(reviewed_key) + if rv is not None and str(rv).strip() != "": + return rv + bv = d.get(base_key) + if bv is not None and str(bv).strip() != "": + return bv + return default + + +def _effective_pnl(d: dict) -> float: + reviewed = d.get("reviewed_pnl_amount") + if reviewed is not None and str(reviewed).strip() != "": + try: + return float(reviewed) + except (TypeError, ValueError): + pass + ex = d.get("exchange_realized_pnl") + if ex is not None and str(ex).strip() != "": + try: + return float(ex) + except (TypeError, ValueError): + pass + try: + return float(d.get("pnl_amount") or 0) + except (TypeError, ValueError): + return 0.0 + + +def _trade_close_dt(d: dict) -> datetime | None: + raw = _effective_field(d, "reviewed_closed_at", "closed_at") + if raw is None or str(raw).strip() == "": + raw = d.get("created_at") or d.get("opened_at") + return parse_dt_for_trading_day(raw) + + +def _normalize_trade_row( + d: dict, + *, + trading_day: str, + reset_hour: int, +) -> dict[str, Any] | None: + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + if trading_day_from_dt(close_dt, reset_hour) != trading_day: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + return { + "symbol": d.get("symbol"), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "monitor_type": d.get("monitor_type"), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "entry_reason": d.get("entry_reason"), + "reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")), + } + + def fetch_trades_for_trading_day( conn, trading_day: str, *, row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, limit: int = 200, ) -> list[dict[str, Any]]: - """返回指定交易日的已平仓记录(优先 session_date,否则 closed_at 日期)。""" + """返回指定交易日的已平仓记录(与 /records 交易记录一致,复盘字段优先)。""" day = (trading_day or "").strip()[:10] if not day: return [] lim = max(1, min(int(limit or 200), 500)) + start_bj, end_bj = trading_day_window_bounds(day, reset_hour) + ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')" rows = conn.execute( f""" - SELECT symbol, exchange_symbol, direction, result, pnl_amount, - closed_at, opened_at, session_date, monitor_type, - actual_rr, planned_rr, trade_style, entry_reason + SELECT symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount, + exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at, + created_at, monitor_type, actual_rr, planned_rr, trade_style, entry_reason, + reviewed_at FROM trade_records - WHERE ( - (session_date IS NOT NULL AND TRIM(session_date) != '' AND session_date = ?) - OR ( - (session_date IS NULL OR TRIM(session_date) = '') - AND closed_at IS NOT NULL AND TRIM(closed_at) != '' - AND substr(closed_at, 1, 10) = ? - ) - ) - AND result IS NOT NULL AND TRIM(result) != '' - ORDER BY COALESCE(closed_at, opened_at) ASC + WHERE {ts_expr} >= ? AND {ts_expr} <= ? + ORDER BY {ts_expr} ASC LIMIT ? """, - (day, day, lim), + (start_bj, end_bj, lim * 3), ).fetchall() out: list[dict[str, Any]] = [] for row in rows: d = _row_dict(row, row_to_dict_fn) - try: - pnl = float(d.get("pnl_amount") or 0) - except (TypeError, ValueError): - pnl = 0.0 - out.append( - { - "symbol": d.get("symbol"), - "exchange_symbol": d.get("exchange_symbol"), - "direction": d.get("direction"), - "result": d.get("result"), - "pnl_amount": round(pnl, 4), - "closed_at": d.get("closed_at"), - "opened_at": d.get("opened_at"), - "session_date": d.get("session_date"), - "monitor_type": d.get("monitor_type"), - "actual_rr": d.get("actual_rr"), - "planned_rr": d.get("planned_rr"), - "trade_style": d.get("trade_style"), - "entry_reason": d.get("entry_reason"), - } - ) + norm = _normalize_trade_row(d, trading_day=day, reset_hour=reset_hour) + if norm: + out.append(norm) + if len(out) >= lim: + break return out diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 64ebabc..b320e8f 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -3534,27 +3534,66 @@ body.hub-page-ai #page-ai { color: var(--text); font-weight: 600; } -.ai-result-md h2 { +.ai-md-body.ai-result-md h2 { font-size: 1.02rem; - color: var(--accent-2, var(--accent)); + color: var(--red); margin: 14px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border-soft); + -webkit-text-stroke: 0.45px #fff; + paint-order: stroke fill; + text-shadow: + 0 0 1px #fff, + 0 0 2px rgba(255, 255, 255, 0.9), + 1px 0 0 #fff, + -1px 0 0 #fff, + 0 1px 0 #fff, + 0 -1px 0 #fff; } .ai-md-body.ai-result-md h2:first-child { margin-top: 0; } .ai-md-body.ai-result-md h3 { font-size: 0.92rem; - color: var(--accent-2, var(--accent)); + color: var(--red); margin: 16px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border-soft); font-weight: 600; + -webkit-text-stroke: 0.45px #fff; + paint-order: stroke fill; + text-shadow: + 0 0 1px #fff, + 0 0 2px rgba(255, 255, 255, 0.9), + 1px 0 0 #fff, + -1px 0 0 #fff, + 0 1px 0 #fff, + 0 -1px 0 #fff; } .ai-md-body.ai-result-md h3:first-of-type { margin-top: 4px; } +.ai-md-body.ai-result-md h4 { + font-size: 0.92rem; + color: var(--red); + margin: 10px 0 6px; + -webkit-text-stroke: 0.45px #fff; + paint-order: stroke fill; + text-shadow: + 0 0 1px #fff, + 0 0 2px rgba(255, 255, 255, 0.9), + 1px 0 0 #fff, + -1px 0 0 #fff, + 0 1px 0 #fff, + 0 -1px 0 #fff; +} +.ai-result-md h2 { + font-size: 1.02rem; + color: var(--accent-2, var(--accent)); + margin: 14px 0 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-soft); +} .ai-result-md h3, .ai-result-md h4 { font-size: 0.92rem; @@ -3624,7 +3663,16 @@ body.hub-page-ai #page-ai { } .ai-ac-name { min-width: 9rem; - font-weight: 500; + font-weight: 600; + color: var(--red); + -webkit-text-stroke: 0.35px #fff; + paint-order: stroke fill; + text-shadow: + 0 0 1px #fff, + 1px 0 0 #fff, + -1px 0 0 #fff, + 0 1px 0 #fff, + 0 -1px 0 #fff; } .ai-ac-remark { color: var(--muted); @@ -3726,7 +3774,16 @@ body.hub-page-ai #page-ai { margin: 0 0 6px; font-size: 0.82rem; font-weight: 600; - color: var(--accent-2, var(--accent)); + color: var(--red); + -webkit-text-stroke: 0.45px #fff; + paint-order: stroke fill; + text-shadow: + 0 0 1px #fff, + 0 0 2px rgba(255, 255, 255, 0.9), + 1px 0 0 #fff, + -1px 0 0 #fff, + 0 1px 0 #fff, + 0 -1px 0 #fff; } .ai-msg-attachments { display: flex; diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f853bc2..dcba08b 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + diff --git a/tests/test_hub_trades_lib.py b/tests/test_hub_trades_lib.py index fe68c45..5d13a2f 100644 --- a/tests/test_hub_trades_lib.py +++ b/tests/test_hub_trades_lib.py @@ -3,10 +3,15 @@ from __future__ import annotations import sqlite3 import unittest - -from hub_trades_lib import fetch_trades_for_trading_day, summarize_trades, trading_day_from_dt from datetime import datetime +from hub_trades_lib import ( + fetch_trades_for_trading_day, + summarize_trades, + trading_day_from_dt, + trading_day_window_bounds, +) + class HubTradesLibTest(unittest.TestCase): def test_trading_day_reset(self): @@ -15,32 +20,44 @@ class HubTradesLibTest(unittest.TestCase): dt2 = datetime(2026, 6, 6, 8, 0, 0) self.assertEqual(trading_day_from_dt(dt2, 8), "2026-06-06") + def test_trading_day_window_bounds(self): + start, end = trading_day_window_bounds("2026-06-06", 8) + self.assertEqual(start, "2026-06-06 08:00:00") + self.assertEqual(end, "2026-06-07 07:59:59") + def test_fetch_and_summarize(self): conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row conn.execute( """CREATE TABLE trade_records ( - symbol TEXT, exchange_symbol TEXT, direction TEXT, result TEXT, - pnl_amount REAL, closed_at TEXT, opened_at TEXT, session_date TEXT, - monitor_type TEXT, actual_rr REAL, planned_rr REAL, trade_style TEXT, entry_reason TEXT + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT )""" ) conn.execute( - "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( "ONDO/USDT", - "ONDO/USDT:USDT", "short", "止损", + None, -0.5, + None, + None, "2026-06-06 10:00:00", + None, "2026-06-06 09:00:00", - "2026-06-06", + None, + "2026-06-06 10:00:00", "趋势回调", None, None, "trend", "", + None, ), ) conn.commit() @@ -52,6 +69,89 @@ class HubTradesLibTest(unittest.TestCase): self.assertAlmostEqual(stats["total_pnl_u"], -0.5) conn.close() + def test_early_morning_belongs_prev_trading_day(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "BTC/USDT", + "long", + "止盈", + None, + 1.2, + None, + None, + "2026-06-07 07:30:00", + None, + "2026-06-07 06:00:00", + None, + "2026-06-07 07:30:00", + "关键位", + None, + None, + "trend", + "", + None, + ), + ) + conn.commit() + self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-07")), 0) + self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-06")), 1) + conn.close() + + def test_reviewed_fields_preferred(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + """CREATE TABLE trade_records ( + symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT, + pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL, + closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT, + created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL, + trade_style TEXT, entry_reason TEXT, reviewed_at TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "ETH/USDT", + "long", + "止损", + "止盈", + -0.5, + 2.0, + None, + "2026-06-06 09:00:00", + "2026-06-06 11:00:00", + "2026-06-06 08:00:00", + None, + "2026-06-06 11:00:00", + "趋势回调", + None, + None, + "trend", + "", + "2026-06-06 12:00:00", + ), + ) + conn.commit() + rows = fetch_trades_for_trading_day(conn, "2026-06-06") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["result"], "止盈") + self.assertAlmostEqual(rows[0]["pnl_amount"], 2.0) + self.assertTrue(rows[0]["reviewed"]) + conn.close() + if __name__ == "__main__": unittest.main()