diff --git a/Dockerfile b/Dockerfile index 36ec384..6665c19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,5 +72,8 @@ ENV STATIC_DIR=/app/static \ COPY --from=frontend-builder /build/dist ./static ENV PYTHONPATH=/app +# 兜底时区: 交易时段判断已在代码里显式用北京时间 (app/market_time.py), +# 此处让日志时间戳等其余 naive 时间也对齐北京时间。 +ENV TZ=Asia/Shanghai EXPOSE 3018 CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3018"] diff --git a/backend/app/market_time.py b/backend/app/market_time.py new file mode 100644 index 0000000..17895e2 --- /dev/null +++ b/backend/app/market_time.py @@ -0,0 +1,21 @@ +"""A股市场时间工具 — 固定北京时间 (UTC+8, 无夏令时)。 + +服务器/容器本地时区不可靠 (python:slim 镜像默认 UTC), 交易时段判断、 +实时行情落盘日期等必须显式使用北京时间, 否则 Docker 部署时轮询窗口 +与真实交易时段完全错开 (北京 9:15-15:05 = UTC 1:15-7:05)。 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +CN_TZ = timezone(timedelta(hours=8)) + + +def cn_now() -> datetime: + """当前北京时间 (带时区)。""" + return datetime.now(CN_TZ) + + +def cn_today() -> date: + """当前北京日期。""" + return datetime.now(CN_TZ).date() diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index 1e5c423..fa16803 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -24,7 +24,9 @@ import logging import math import threading import time -from datetime import date, datetime, time as dt_time +from datetime import date, time as dt_time + +from app.market_time import cn_now, cn_today from pathlib import Path import polars as pl @@ -89,7 +91,7 @@ class DepthService: if not self._has_capability(): logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑") return - today = date.today() + today = cn_today() if self._persisted_for_date(today): # parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet) self._restore_from_parquet(today) @@ -579,7 +581,8 @@ class DepthService: @staticmethod def _is_trading_hours() -> bool: - now = datetime.now() + # 显式北京时间: 容器/服务器本地时区可能是 UTC, 用 naive now() 会整体错开轮询窗口 + now = cn_now() t = now.time() morning = dt_time(9, 25) <= t <= dt_time(11, 35) afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 2948f9b..3d27857 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -26,10 +26,12 @@ from __future__ import annotations import logging import threading import time -from datetime import date, datetime, time as dt_time +from datetime import date, time as dt_time import polars as pl +from app.market_time import cn_now, cn_today + logger = logging.getLogger(__name__) @@ -584,7 +586,7 @@ class QuoteService: if not select_exprs: return pl.DataFrame() result = df.select(select_exprs).with_columns( - pl.lit(date.today()).cast(pl.Date).alias("date"), + pl.lit(cn_today()).cast(pl.Date).alias("date"), ) # 修复: API 在非交易时段可能返回 open/high/low=0 或 null, # 导致蜡烛从 0 开始。用 close 填充这些异常值。 @@ -643,7 +645,8 @@ class QuoteService: @staticmethod def _is_trading_hours() -> bool: - now = datetime.now() + # 显式北京时间: 容器/服务器本地时区可能是 UTC, 用 naive now() 会整体错开轮询窗口 + now = cn_now() t = now.time() morning = dt_time(9, 15) <= t <= dt_time(11, 35) afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) @@ -879,7 +882,7 @@ class QuoteService: 不写 daily, 直接传给 compute_enriched_today 避免重复计算。 """ try: - today = date.today() + today = cn_today() t0 = time.perf_counter() # ---- 尝试增量路径 ---- diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 99d92ca..d04a104 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -19,6 +19,7 @@ from typing import Any, Callable import polars as pl +from app.market_time import cn_today from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器 from app.strategy import config as _strategy_config @@ -555,7 +556,7 @@ class MonitorRuleEngine: # 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。 # loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。 run_kwargs: dict = { - "as_of": _dt.date.today(), + "as_of": cn_today(), "overrides": overrides, } if s.filter_history_fn: @@ -563,7 +564,7 @@ class MonitorRuleEngine: logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid) return [] try: - today = _dt.date.today() + today = cn_today() lookback = max(1, getattr(s, "lookback_days", 30)) hist_df = self._history_loader(today, lookback) if hist_df is None or hist_df.is_empty(): @@ -597,7 +598,7 @@ class MonitorRuleEngine: import math self._latest_strategy_results[sid] = { "total": result.total, - "as_of": str(_dt.date.today()), + "as_of": str(cn_today()), "rows": [ {k: (None if isinstance(v, float) and not math.isfinite(v) else v) for k, v in row.items()}