From 55a979eee51ca1a3d2b4fa2bb27b49d2a37b2553 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 16:47:09 +0800 Subject: [PATCH] fix: show archive chart times in UTC+8 and parse Beijing wall clock Co-authored-by: Cursor --- hub_symbol_archive_lib.py | 28 +++++++---- manual_trading_hub/hub.py | 15 +----- manual_trading_hub/static/archive.js | 74 ++++++++++++++++++++++++---- manual_trading_hub/static/index.html | 2 +- tests/test_hub_symbol_archive_lib.py | 16 ++++++ 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index f48a597..c3e671d 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -6,9 +6,12 @@ import json import os import sqlite3 import time -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +CHART_DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai")) from hub_ohlcv_lib import ( TIMEFRAME_MS, @@ -143,7 +146,8 @@ def _now_ms() -> int: return int(time.time() * 1000) -def _parse_dt_ms(raw: Any) -> int | None: +def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None: + """将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8)。""" if raw in (None, ""): return None try: @@ -158,12 +162,22 @@ def _parse_dt_ms(raw: Any) -> int | None: for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): try: dt = datetime.strptime(s[:ln], fmt) - return int(dt.timestamp() * 1000) + aware = dt.replace(tzinfo=tz) + return int(aware.timestamp() * 1000) except ValueError: continue return None +def ms_to_wall_clock_str(ms: int, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> str: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_dt_ms(raw: Any) -> int | None: + return parse_wall_clock_ms(raw) + + 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) @@ -341,13 +355,9 @@ def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]: 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" - ) + out["opened_at"] = ms_to_wall_clock_str(int(opened_ms)) 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" - ) + out["closed_at"] = ms_to_wall_clock_str(int(closed_ms)) entry_type = display_entry_type_label(out) if entry_type and entry_type != "—": out["entry_type"] = entry_type diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 92df1b2..3adf80c 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -48,6 +48,7 @@ from hub_symbol_archive_lib import ( init_db as init_archive_db, list_symbol_rows, load_symbol_trades, + parse_wall_clock_ms, resolve_archive_chart, sync_exchange_symbol_archives, upsert_trade_overlay, @@ -1903,19 +1904,7 @@ def _parse_anchor_ms(at: str = "", anchor_ms: str = "") -> int | None: raw = (anchor_ms or at or "").strip() if not raw: return None - if raw.isdigit(): - v = int(raw) - return v if v > 1_000_000_000_000 else v * 1000 - s = raw.replace("Z", "").replace("T", " ") - for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): - try: - from datetime import datetime - - dt = datetime.strptime(s[:ln], fmt) - return int(dt.timestamp() * 1000) - except ValueError: - continue - return None + return parse_wall_clock_ms(raw) @app.get("/api/archive/meta") diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index d5f47fd..c367acd 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -33,6 +33,7 @@ "1h": 60 * 60_000, "4h": 4 * 60 * 60_000, }; + const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; let meta = null; let listRows = []; @@ -92,6 +93,59 @@ return s; } + function pad2(n) { + return n < 10 ? "0" + n : String(n); + } + + function utcSecToBjDate(utcSec) { + return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000); + } + + function formatChartTimeBj(utcSec, withDate) { + const d = utcSecToBjDate(utcSec); + const h = pad2(d.getUTCHours()); + const mi = pad2(d.getUTCMinutes()); + if (!withDate) return h + ":" + mi; + return ( + d.getUTCFullYear() + + "-" + + pad2(d.getUTCMonth() + 1) + + "-" + + pad2(d.getUTCDate()) + + " " + + h + + ":" + + mi + ); + } + + function chartLocalizationBj() { + return { + locale: "zh-CN", + dateFormat: "yyyy-MM-dd", + timeFormatter: function (time) { + if (typeof time === "number") return formatChartTimeBj(time, true); + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + }, + tickMarkFormatter: function (time, tickMarkType) { + if (typeof time !== "number") { + if (time && typeof time === "object" && time.year) { + return time.year + "-" + pad2(time.month) + "-" + pad2(time.day); + } + return ""; + } + const d = utcSecToBjDate(time); + if (tickMarkType === 0) return String(d.getUTCFullYear()); + if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1); + if (tickMarkType === 2) return pad2(d.getUTCDate()); + return formatChartTimeBj(time, false); + }, + }; + } + function fmtDt(raw) { if (raw == null || raw === "") return "—"; return String(raw).replace("T", " ").slice(0, 16); @@ -246,15 +300,16 @@ if (!s) return null; const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/); if (!m) return null; - const dt = new Date( - Number(m[1]), - Number(m[2]) - 1, - Number(m[3]), - Number(m[4] || 0), - Number(m[5] || 0), - Number(m[6] || 0) - ); - const ms = dt.getTime(); + const ms = + Date.UTC( + Number(m[1]), + Number(m[2]) - 1, + Number(m[3]), + Number(m[4] || 0), + Number(m[5] || 0), + Number(m[6] || 0) + ) - + CHART_TZ_OFFSET_SEC * 1000; return Number.isFinite(ms) ? ms : null; } @@ -447,6 +502,7 @@ horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, }, rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true }, + localization: chartLocalizationBj(), timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true, diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 86c8f0d..67e75e0 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -418,7 +418,7 @@ - + diff --git a/tests/test_hub_symbol_archive_lib.py b/tests/test_hub_symbol_archive_lib.py index c8302a1..ddd74bd 100644 --- a/tests/test_hub_symbol_archive_lib.py +++ b/tests/test_hub_symbol_archive_lib.py @@ -6,10 +6,16 @@ import tempfile from pathlib import Path from hub_ohlcv_lib import aggregate_ohlcv_bars +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + from hub_symbol_archive_lib import ( + CHART_DISPLAY_TZ, _fill_missing_bars, init_db, load_symbol_trades, + ms_to_wall_clock_str, + parse_wall_clock_ms, resolve_archive_chart, upsert_bars_5m, upsert_trade_overlay, @@ -200,3 +206,13 @@ def test_list_with_overlay_filters(): assert len(sick_only) == 1 profit_only = list_symbol_rows(filter_profit=True, db_path=db) assert len(profit_only) == 1 + + +def test_parse_wall_clock_ms_uses_utc_plus_8(): + ms = parse_wall_clock_ms("2026-06-07 20:30:00") + assert ms is not None + dt_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc) + dt_bj = dt_utc.astimezone(CHART_DISPLAY_TZ) + assert dt_bj.strftime("%Y-%m-%d %H:%M:%S") == "2026-06-07 20:30:00" + assert ms_to_wall_clock_str(ms) == "2026-06-07 20:30:00" + assert parse_wall_clock_ms("2026-06-07 20:30") == ms