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 os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional
|
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 (
|
from hub_ohlcv_lib import (
|
||||||
TIMEFRAME_MS,
|
TIMEFRAME_MS,
|
||||||
@@ -143,7 +146,8 @@ def _now_ms() -> int:
|
|||||||
return int(time.time() * 1000)
|
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, ""):
|
if raw in (None, ""):
|
||||||
return None
|
return None
|
||||||
try:
|
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)):
|
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(s[:ln], fmt)
|
dt = datetime.strptime(s[:ln], fmt)
|
||||||
return int(dt.timestamp() * 1000)
|
aware = dt.replace(tzinfo=tz)
|
||||||
|
return int(aware.timestamp() * 1000)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
return None
|
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:
|
def _trade_entry_reason_for_cache(t: dict[str, Any]) -> str:
|
||||||
for key in ("entry_type", "entry_reason", "reviewed_entry_reason"):
|
for key in ("entry_type", "entry_reason", "reviewed_entry_reason"):
|
||||||
raw = t.get(key)
|
raw = t.get(key)
|
||||||
@@ -341,13 +355,9 @@ def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if closed_ms:
|
if closed_ms:
|
||||||
out["closed_at_ms"] = int(closed_ms)
|
out["closed_at_ms"] = int(closed_ms)
|
||||||
if not out.get("opened_at") and opened_ms:
|
if not out.get("opened_at") and opened_ms:
|
||||||
out["opened_at"] = datetime.fromtimestamp(int(opened_ms) / 1000).strftime(
|
out["opened_at"] = ms_to_wall_clock_str(int(opened_ms))
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
if not out.get("closed_at") and closed_ms:
|
if not out.get("closed_at") and closed_ms:
|
||||||
out["closed_at"] = datetime.fromtimestamp(int(closed_ms) / 1000).strftime(
|
out["closed_at"] = ms_to_wall_clock_str(int(closed_ms))
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
entry_type = display_entry_type_label(out)
|
entry_type = display_entry_type_label(out)
|
||||||
if entry_type and entry_type != "—":
|
if entry_type and entry_type != "—":
|
||||||
out["entry_type"] = entry_type
|
out["entry_type"] = entry_type
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from hub_symbol_archive_lib import (
|
|||||||
init_db as init_archive_db,
|
init_db as init_archive_db,
|
||||||
list_symbol_rows,
|
list_symbol_rows,
|
||||||
load_symbol_trades,
|
load_symbol_trades,
|
||||||
|
parse_wall_clock_ms,
|
||||||
resolve_archive_chart,
|
resolve_archive_chart,
|
||||||
sync_exchange_symbol_archives,
|
sync_exchange_symbol_archives,
|
||||||
upsert_trade_overlay,
|
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()
|
raw = (anchor_ms or at or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
if raw.isdigit():
|
return parse_wall_clock_ms(raw)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/archive/meta")
|
@app.get("/api/archive/meta")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"1h": 60 * 60_000,
|
"1h": 60 * 60_000,
|
||||||
"4h": 4 * 60 * 60_000,
|
"4h": 4 * 60 * 60_000,
|
||||||
};
|
};
|
||||||
|
const CHART_TZ_OFFSET_SEC = 8 * 60 * 60;
|
||||||
|
|
||||||
let meta = null;
|
let meta = null;
|
||||||
let listRows = [];
|
let listRows = [];
|
||||||
@@ -92,6 +93,59 @@
|
|||||||
return s;
|
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) {
|
function fmtDt(raw) {
|
||||||
if (raw == null || raw === "") return "—";
|
if (raw == null || raw === "") return "—";
|
||||||
return String(raw).replace("T", " ").slice(0, 16);
|
return String(raw).replace("T", " ").slice(0, 16);
|
||||||
@@ -246,15 +300,16 @@
|
|||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const dt = new Date(
|
const ms =
|
||||||
|
Date.UTC(
|
||||||
Number(m[1]),
|
Number(m[1]),
|
||||||
Number(m[2]) - 1,
|
Number(m[2]) - 1,
|
||||||
Number(m[3]),
|
Number(m[3]),
|
||||||
Number(m[4] || 0),
|
Number(m[4] || 0),
|
||||||
Number(m[5] || 0),
|
Number(m[5] || 0),
|
||||||
Number(m[6] || 0)
|
Number(m[6] || 0)
|
||||||
);
|
) -
|
||||||
const ms = dt.getTime();
|
CHART_TZ_OFFSET_SEC * 1000;
|
||||||
return Number.isFinite(ms) ? ms : null;
|
return Number.isFinite(ms) ? ms : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +502,7 @@
|
|||||||
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||||
},
|
},
|
||||||
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
|
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
|
||||||
|
localization: chartLocalizationBj(),
|
||||||
timeScale: {
|
timeScale: {
|
||||||
borderColor: isDark ? "#2a3348" : "#d0d7e2",
|
borderColor: isDark ? "#2a3348" : "#d0d7e2",
|
||||||
timeVisible: true,
|
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="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_draw.js?v=20260608-market-vol-rank"></script>
|
||||||
<script src="/assets/chart.js?v=20260608-market-vol-rank-v5"></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/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hub_ohlcv_lib import aggregate_ohlcv_bars
|
from hub_ohlcv_lib import aggregate_ohlcv_bars
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from hub_symbol_archive_lib import (
|
from hub_symbol_archive_lib import (
|
||||||
|
CHART_DISPLAY_TZ,
|
||||||
_fill_missing_bars,
|
_fill_missing_bars,
|
||||||
init_db,
|
init_db,
|
||||||
load_symbol_trades,
|
load_symbol_trades,
|
||||||
|
ms_to_wall_clock_str,
|
||||||
|
parse_wall_clock_ms,
|
||||||
resolve_archive_chart,
|
resolve_archive_chart,
|
||||||
upsert_bars_5m,
|
upsert_bars_5m,
|
||||||
upsert_trade_overlay,
|
upsert_trade_overlay,
|
||||||
@@ -200,3 +206,13 @@ def test_list_with_overlay_filters():
|
|||||||
assert len(sick_only) == 1
|
assert len(sick_only) == 1
|
||||||
profit_only = list_symbol_rows(filter_profit=True, db_path=db)
|
profit_only = list_symbol_rows(filter_profit=True, db_path=db)
|
||||||
assert len(profit_only) == 1
|
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