fix: show archive chart times in UTC+8 and parse Beijing wall clock

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-08 16:47:09 +08:00
parent 947b58084d
commit 55a979eee5
5 changed files with 103 additions and 32 deletions
+19 -9
View File
@@ -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
+2 -13
View File
@@ -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")
+59 -3
View File
@@ -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(
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)
);
const ms = dt.getTime();
) -
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,
+1 -1
View File
@@ -418,7 +418,7 @@
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart_draw.js?v=20260608-market-vol-rank"></script>
<script src="/assets/chart.js?v=20260608-market-vol-rank-v5"></script>
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
<script src="/assets/archive.js?v=20260608-hub-archive-tz8"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
</body>
+16
View File
@@ -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