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 @@
-
+