分钟K线时区契约与午休处理:tick-stock-panel 分钟回测不出错的 3 个关键细节
【免费下载链接】tick-stock-panelTSP自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台 | LLM能力驱使策略定制+个股分析+复盘 | 自由接入第三方数据源与个性化扩展数据 | 个人开源项目地址: https://gitcode.com/GitHub_Trending/ti/tick-stock-panel
tick-stock-panel 是一款自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台。很多人做分钟级回测时踩过同一个坑:数据源给的是 UTC 时间戳、服务器容器默认时区又是 UTC,再叠加 A 股特有的午休缺口,分钟K线时间对不齐,回测信号就悄悄错了。这篇文章带你拆解 tick-stock-panel 如何用一条「时区契约」和一套「午休口径」,把这类错误挡在数据入库之前。
问题从哪来:Docker 里默认时区是 UTC
A 股连续竞价时段是北京时间 9:30-11:30 与 13:00-15:00。而常见的python:slim镜像默认时区是 UTC——北京的 9:15-15:05,换算成 UTC 只有 1:15-7:05。
如果轮询、落盘用服务器本地时间判断"现在是不是交易时段",Docker 部署下窗口会和真实交易时段完全错开。tick-stock-panel 的解法是不依赖系统时区,固定使用北京时间常量,所有时段判断都走 market_time.py:
CN_TZ = UTC+8(无夏令时,不会漂移)- 上午 120 分钟 + 下午 120 分钟 = 全天 240 分钟
- 已交易分钟数在盘中实时累计,收盘后封顶 240
时区契约:分钟K线统一为"北京墙钟"
项目的核心约定写在 CONTRIBUTING.md 的「日期、交易日和时区」一节(§3.3):
kline_minute.datetime必须是北京时间墙钟(naive,不带时区),如09:35:00。
围绕这条契约,kline_sync.py 实现了守卫函数_enforce_minute_beijing_wallclock,在两个源头入口强制执行:TickFlow 数据帧和插件/自定义数据源帧。它的处理规则可以概括为一张表:
| 输入形态 | 守卫行为 |
|---|---|
| 带时区(tz-aware) | 转换为 Asia/Shanghai 后去掉时区标记 |
| 无时区,时刻落在交易时段(9/10/11/13/14/15 点) | 判定为北京墙钟,直接放行 |
| 无时区,整体呈"交易时段 -8 小时"的 UTC 特征(1/2/3/5/6/7 点) | 自动 +8 小时纠偏,并记录日志 |
| 无法识别的口径 | 拒收:抛出异常,绝不让脏时间入库 |
几个设计细节值得新手学习:
- 先显式转换再守卫:TickFlow 的毫秒时间戳按 UTC 基准,_normalize_minute 会先换算成北京墙钟再进守卫,
01:30 UTC一定变成09:30入库; - 幂等:纠偏后的帧再次通过守卫会直接放行,不会二次 +8;
- fail-closed:宁可报错拒收,也不猜一个"看起来合理"的时间写进库。
对应测试 test_minute_timezone_contract.py 覆盖了显式转换、UTC 特征自愈、混合口径、拒收等全部分支,是理解契约的最佳阅读材料。
自定义数据源:契约违规就回退,不硬扛
tick-stock-panel 支持自由接入第三方数据源与插件。自定义源返回的分钟帧同样要过守卫(见 kline_sync.py 的_try_custom_minute):
- 插件返回 UTC 墙钟 → 守卫自动 +8 纠偏后正常下发;
- 插件返回无法识别的时间口径 → 直接回退到 TickFlow 主数据源。
对自托管用户来说,这意味着接一个新数据源时不需要自己操心时区换算——要么被自动纠偏,要么被安全回退,本地库里的分钟K线始终保持同一种口径。
午休处理:11:31-12:00 不该多出来的 30 分钟
A 股午间休市(11:30-13:00)是分钟数据的第二个暗坑。前端分时图会按当前时刻估算"今天应有多少根分钟K",本地数据够了就用本地,不够才去实时补拉。
曾经的实现里,上午分支写成h < 12 or (h == 12 and m == 0),导致11:31-12:00 期间期望根数被继续累加到 121~150 根,而实际上午最多只有 120 根(09:31-11:30)。后果是本地明明完整的上午数据被误判为"不完整":
- 个股分时图:触发实时拉取,拉空后响应为空,轮询直接停止,分时图空白直到重新打开;
- 自选列表分时:被归为"尾部落后",每轮都对所有自选标的发起增量补拉,白白消耗限流配额。
修复后的口径与 market_time.py 的已交易分钟数完全一致:午休期间期望值保持上午累计的 120 根封顶,见 kline.py:
- 开盘前(9:30 之前)→ 0
- 上午时段 → 0~120 随时间增长
- 11:30-13:00 午休 → 恒定 120
- 下午时段 → 120~240 随时间增长
- 收盘后 / 历史交易日 → 240
数据完整性检测也把午休当作合法结构:相邻K线的间隔要么是 1 分钟,要么是午休缺口91 分钟(11:30 → 13:01),见 kline.py 中的_LUNCH_GAP_MIN = 91。其他任何间隔都视为缺洞,触发全天重拉回填。
回归测试 test_kline_minute_lunch_expected.py 把 11:35、11:50、12:00 三个午休时刻全部钉死为"本地 120 根、不触发拉取",防止这个 bug 复活。
这些细节如何保证分钟回测正确
分钟回测的正确性建立在一条链路上:
- 入口收口——所有数据源(官方 + 自定义插件)在源头统一为北京墙钟,脏数据 fail-closed 拒收;
- 口径一致——已交易分钟数、分时期望根数、午休缺口识别共用同一套 240 分钟模型,前端口径与回测口径不打架;
- 测试兜底——时区契约、午休期望、缺口检测各有专项测试,口径变更会立刻被回归抓住。
对新手来说,这套"契约 + 守卫 + 测试"的组合拳是处理时间敏感金融数据的通用范式:不要在各处各自换算时区,而是定一条全库契约,在边界处强制校验。
关键源码与文档索引
- 时区工具与交易时段模型:backend/app/market_time.py
- 分钟K归一化与时区守卫:backend/app/services/kline_sync.py
- 分时/自选分时期望根数与午休缺口检测:backend/app/api/kline.py
- 时区契约回归测试:backend/tests/test_minute_timezone_contract.py
- 午休期望根数回归测试:backend/tests/test_kline_minute_lunch_expected.py
- 契约原文:CONTRIBUTING.md §3.3「日期、交易日和时区」
掌握这两组细节,你再接入任何分钟级数据源,都能先问对两个问题:时间是什么口径?午休缺口放不放心?
【免费下载链接】tick-stock-panelTSP自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台 | LLM能力驱使策略定制+个股分析+复盘 | 自由接入第三方数据源与个性化扩展数据 | 个人开源项目地址: https://gitcode.com/GitHub_Trending/ti/tick-stock-panel
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考