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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user