资讯动态

多周期时间序列超帧构建指南:pandas实现与防泄漏实战

发布时间:2026/10/15 13:26:18 来源:尧图企业网站定制
先交代一个场景如果你手上同时有 tick 级、分钟级、小时级和日线数据以前是怎么处理的我最早的做法很粗暴——把四张表各自算好特征再在模型入口处横向拼接。第一次看这方案好像能跑但数据量越大越吃力而且调试时根本不知道“某一行的 hour 数据到底是用哪一时刻算出来的”。后来我索性把多周期数据按统一时间轴“压”成一张宽表这才算真正解决了问题。这个结构现在被人叫作 hyperframes听起来新名词其实本质是把多周期时间序列对齐成一张可复用的数据框架。这篇文章我会把构建 hyperframes 的核心机制、pandas 实操代码、以及我在落地时踩过的四个坑全部讲透适合做量化研究、时序特征工程、多尺度数据分析的读者参考。1. 什么是 hyperframes把“多周期”压成“单时间轴”的数据结构设计1.1 三张表拼成一张表的直觉做法为什么很快就不够用了假设你有三张表分钟表、小时表、日线表。最简单的思路是分别算好各自的特征比如分钟表里放分钟涨跌幅小时表里放小时均线日线表里放昨日成交量然后把三张表按时间字段横向拼起来。问题出现在两个地方。第一时间戳对不齐。分钟表的索引是“2023-01-03 10:30:00”小时表的索引可能是“2023-01-03 10:00:00”直接 join 时取不到值你只能先对小时表做 ffill才能把上一根小时线状态填到当前分钟上。第二这种 ffill 很容易把“还没发生的数据”也填进来。如果小时表里已经包含“10:00-11:00 这根 K 线的收盘价”你在 10:30 的分钟行里去 ffill拿到的是未来信息。策略回测时不觉得实盘一跑就把模型打回原形。所以我理解 hyperframes 不是一个新库而是一套设计约定以某个基础周期通常是最小周期为索引把所有更大周期的特征按照“截至当前时刻已知”的规则映射到这张表上形成一张宽表。这张表的好处是模型训练、特征可视化、回测线程都能直接消费不用在几十张中间表之间来回切换。1.2 超帧的三个关键设计点基准周期、已知边界、对齐方式构建超帧先要想清楚三件事。基准周期也就是最终表的索引粒度。通常选你要预测的最小时间粒度比如分钟级预测用分钟表小时级预测用小时表。已知边界每个字段写入超帧时必须保证这个值在当前时刻是已知的、且不再变化的。这是超帧的灵魂也是大多数人栽跟头的地方。对齐方式不是“同一时刻的对应值”而是“截至当前时刻最近一个可用的周期值”。一般用 as-of 合并而不是普通 join。举个例子。10:30 的分钟行可以带上“09:00-10:00 这根已完成小时线的收盘价”但不能带“10:00-11:00 这根正在形成的小时线的任何聚合值”。同理日线上10:30 只能带“昨天收盘价”不能带“今天盘中实时价”。我用过的最直观类比是看天气预报你现在能看到的是“昨晚发布的今日天气”不是“今天实际天气”。超帧里存的永远是前者。1.3 多周期数据为什么不能强行对齐有人会想把小时线重采样成分钟线不就行了每分钟都复制同一根小时线的值。行是能行但对齐方式必须讲究。普通 join 要求 key 完全相等。小时表的时间戳是 10:00分钟表是 10:30两者不相等你就得把小时表改造成每行都有时间戳这通常通过 ffill/reindex 完成。问题在于reindex 会把小时表映射到它曾经存在过的时间区间但不会自动处理“这个值什么时候开始’生效’”。小时 K 线的标签是 10:00它代表的是从 10:00 开始到 11:00 结束的一小时窗口。10:30 时这根 K 线还没结束因此不应该对外暴露完整聚合。要让 10:00 这根小时线在 11:00 之后才变得可见正确做法是把它的“有效时间”改成 11:00再做 as-of 合并。这就是我在下一节要重点展开的机制。2. 先把“已完成信号”做对重采样参数与 as-of 对齐机制2.1 重采样不只是分组聚合还得带“完成状态”pandas 的 resample 是把时间桶聚合的标准工具但默认行为只负责“算出桶内统计值”完全不管“这个桶什么时候才算完成”。hourly minute_df.resample(1h, closedleft, labelleft).agg({ open: first, high: max, low: min, close: last, volume: sum })这段代码会按小时切桶产出 10:00-11:00 这一桶的 open/high/low/close/volume。问题是如果你在 10:30 用这个结果给分钟行赋值就等于把未来半小时的价格数据也塞进去了。要解决“完成状态”核心操作是把桶标签从“桶起点”改成“桶结束之后才生效”。hourly_effective hourly.copy() hourly_effective.index hourly_effective.index pd.Timedelta(hours1)这样代表 10:00-11:00 的小时线其有效时间变成 11:00。在 10:30 的分钟行做 as-of 合并时它只找得到 10:00 之前已完成的小时线也就是 09:00-10:00 那根10:30 拿到的就是“最近的已完成小时值”。逻辑瞬间就安全了。2.2 为什么 merge_asof 比 join 更适合超帧普通 join 要求左右两边 key 严格匹配但“最近可用值”天然是一对多、且时间不完全对齐的操作。pandas 的 merge_asof 正是做这个的对左表每一行在右表里找“最后一个小于等于当前时间戳的键对应的值”。hyperframe pd.merge_asof( minute_df.sort_values(minute_ts), hourly_effective.sort_values(hour_effective), left_onminute_ts, right_onhour_effective, directionbackward )这里 directionbackward 的意思就是“向前找最近那条”。它天然解决了两个问题时间戳不相等时自动找最近的可用历史值新一根小时线未生效时旧值继续传递不会留空洞。2.3 closed 与 label 参数到底该怎么设resample 的 closed 和 label 是两个容易让人懵的参数。我用一句话总结closed 决定“桶的边界算谁”label 决定“结果标签写在桶的哪头”。对于期货、股票这类从 9:30 开始的连续分钟数据我习惯用 closedleft, labelleft也就是桶包含左边界、不包含右边界标签取桶起点。比如 10:00 桶包含 10:00-10:59.59 的分钟数据标签为 10:00。但要注意光设这两个参数还不够你仍然需要加上面说的“完成状态切换”。closed/label 解决的是桶归属完成状态解决的是“值什么时候可以见人”两者是不同维度的问题。3. 手把手搭一个分钟/小时/日线超帧含完整代码与结果校验3.1 准备一份分钟级基础数据为了说清楚流程我们模拟 3 个交易日、每天 390 分钟的分钟线数据。import pandas as pd import numpy as np np.random.seed(42) trade_dates pd.bdate_range(2023-01-03, periods3) # 剔除周末后的3个交易日这里用bdate_range生成工作日 all_ts [] for d in trade_dates: day_ts pd.date_range(d pd.Timedelta(hours9, minutes30), periods390, freq1min) all_ts.append(day_ts) idx pd.DatetimeIndex(sorted(pd.DatetimeIndex(np.concatenate(all_ts)))) n len(idx) close 100 np.cumsum(np.random.randn(n) * 0.05) minute_df pd.DataFrame({ open: close - 0.02, high: close np.abs(np.random.randn(n) * 0.03), low: close - np.abs(np.random.randn(n) * 0.03), close: close, volume: np.random.randint(100, 500, sizen), }, indexidx) minute_df.index.name minute_ts这份数据的索引就是标准业务时间戳。实际场景里你从数据库或行情接口拿到的数据也应该是这种形态。3.2 生成“已完成”的小时线、日线表关键一步是生成大周期表然后把索引切到“生效时间点”。# 小时线 hourly_raw minute_df.resample(1h, closedleft, labelleft).agg({ open: first, high: max, low: min, close: last, volume: sum }) hourly_raw.index.name hour_effective hourly_raw hourly_raw.reset_index() hourly_raw[hour_effective] hourly_raw[hour_effective] pd.Timedelta(hours1) # 日线 daily_raw minute_df.resample(1D, closedleft, labelleft).agg({ open: first, high: max, low: min, close: last, volume: sum }) daily_raw.index.name daily_effective daily_raw daily_raw.reset_index() daily_raw[daily_effective] daily_raw[daily_effective] pd.Timedelta(days1)需要注意日线这里把生效时间设为“下一个自然日零点”。这样在当天盘中任意时刻merge_asof 只会匹配到昨天的日线从而避免盘中泄露今日收盘价。3.3 用 merge_asof 合并成超帧现在把小时线、日线都合并到分钟表上。minute_work minute_df.reset_index().sort_values(minute_ts) hourly_work hourly_raw.rename(columns{ open: h_open, high: h_high, low: h_low, close: h_close, volume: h_volume }).sort_values(hour_effective) daily_work daily_raw.rename(columns{ open: d_open, high: d_high, low: d_low, close: d_close, volume: d_volume }).sort_values(daily_effective) hyperframe pd.merge_asof( minute_work, hourly_work, left_onminute_ts, right_onhour_effective, directionbackward ) hyperframe pd.merge_asof( hyperframe, daily_work, left_onminute_ts, right_ondaily_effective, directionbackward ) hyperframe hyperframe.set_index(minute_ts).sort_index()合并完成后hyperframe 就是一张以分钟为索引的宽表每一行同时带有当前分钟数据、最近已完成小时线数据、最近已完成日线数据。3.4 结果校验关键时间点的取值是否符合预期这一步很重要别急着往下走。我们取一个具体时间点验证。sample hyperframe.loc[2023-01-03 10:30:00] print(sample[[h_close, d_close]])预期结果h_close 是 09:00-10:00 这根已完成小时线的收盘价d_close 是 2023-01-02 的日线收盘价而不是 1 月 3 日的实时值。这个校验逻辑在真实项目中可以固化成单元测试每次改完数据管道就自动跑一遍。我见过太多线上事故最后都定位到“某行特征引用了未来值”而这种测试能提前拦住。3.5 最终数据结构形态构建完成的超帧大概是这样的minute_tsopenhighlowclosevolumeh_openh_closeh_volumed_opend_closed_volume2023-01-03 10:30:00...............09:00小时线open09:00小时线close09:00小时线volume1月2日open1月2日close1月2日volume2023-01-03 11:00:00...............10:00小时线open10:00小时线close10:00小时线volume1月2日open1月2日close1月2日volume可以看到11:00 这一行开始用上了 10:00-11:00 这根新小时线的值因为这一刻它刚好完成并“生效”。4. 构建超帧最容易踩的四个坑泄漏、NaN、索引与内存4.1 坑一特征引用了未完成的 K 线回测曲线很漂亮实盘直接崩这是超帧构建里最大的坑没有之一。症状单因子回测年化收益高得离谱但换成样本外数据后效果断崖式下滑。原因直接用 hourly_raw 去 join 分钟表没有做“生效时间”位移。比如在 10:30 就把 10:00-11:00 这根的收盘价拿去做信号等于开卷考试。解决记住两个动作——先对桶标签加一个周期时长再用 merge_asof 的 backward 方向取最近值。这是我在每个项目里都强制执行的规范。这类泄漏在单周期数据里相对容易发现但在多周期超帧里很隐蔽因为它藏在几十个特征列里。我建议做特征时统一后缀已完成的周期特征加_ok后缀比如 h_close_ok、d_close_ok实时计算的特征加_rt后缀比如 minute_rt_ma5。4.2 坑二开盘初期的 NaN 断层症状每天早上第一根分钟数据附近h_close、d_close 出现 NaN下游模型直接报错或者 fillna(0) 后引入垃圾信号。原因merge_asof 找“最近一条历史记录”如果历史记录里第一条生效时间都在当前时间之后就自然匹配不到。比如 9:30 开盘按“小时生效时间桶起点1小时”的规则第一根可用的已完成小时线要到 10:00 才出现9:30-9:59 之间的分钟行就拿不到 h_close。解决给分钟表加一个“盘前/昨日数据”的兜底。最直接的做法是先用 merge_asof 合并再对小时和日线字段做 ffillhyperframe[[h_open, h_high, h_low, h_close, h_volume]] ( hyperframe[[h_open, h_high, h_low, h_close, h_volume]].ffill() )这里 ffill 是安全的因为 merge_asof 已经保证了“不会拿到未来值”ffill 只是把缺失的历史状态延续下来。但要注意ffill 前必须确认 merge_asof 已经生效否则原本的“未来值”会被 ffill 进一步扩散。4.3 坑三索引不唯一与 concat 的隐形复制症状构建超帧时用了 concat莫名其妙内存翻了三倍而且 groupby 结果出现重复行。原因分钟表索引本身是唯一的但如果你把小时线、日线直接 concat 进来就会把右表索引整段复制造成大量重复索引。之后做 rolling 或 groupby这些重复索引会让窗口计算产生非常隐蔽的错误。解决超帧只能以基准周期为索引。大周期数据一律通过 merge_asof 映射进来不要用 concat 去堆。如果确实需要堆叠多张同周期表务必先 reset_index让所有行都带上唯一的业务主键。4.4 坑四多周期字段导致的内存膨胀症状分钟表 5000 万行加上小时、日线、周线各几十个特征内存直接到 30 多 GB。原因超帧是宽表宽表的每行都要重复保存大周期的值。日线特征只有几百行数据但被复制到 5000 万分钟行里一下子放大了十万倍。解决三个方向。先压缩再复制。大周期特征只在生成特征时计算不用把最终宽表全部落盘。如果一定要持久化用 Parquet 的分区存储按日期切分。数值类型降到 float32。分钟 OHLC 数据用 float32 几乎无损内存减半。对于日线/周线这种低频特征不放进超帧而是单独维护一张低频表在模型入口时单独 join。超帧只保留“模型真正需要逐行输入”的高频特征。我最近一个项目里把 5000 万行的超帧从 22 GB 压到了 4 GB主要就是靠 float32 只保留必要列 低频特征外置。内存问题大多数时候不是机器不够而是数据结构设计冗余。5. 超帧喂给模型之前特征分组、标签搬移与时间序列交叉验证5.1 特征分组让超帧不再是“大杂烩”超帧建好之后特征列通常有几十上百个。直接丢给模型前我会先把特征分成三组方便后续做相关性分析和重要性排序。当前周期特征比如分钟线的收盘价、成交量、分钟涨跌幅反映“此刻发生了什么”。跨周期状态特征比如 h_close/d_close 的比值、h_volume 与当前分钟成交量的对比反映“大周期对小周期的压制或共振”。窗口统计特征比如过去 30 分钟均值、过去 5 根小时线的动量反映“近期状态”。这三组特征在模型里的作用其实不同。当前周期特征适合做短周期预测跨周期状态特征能捕捉趋势切换窗口统计特征则提供连续性。如果建模时间紧我会优先保第二组和第三组它们的增量信息通常更大。5.2 目标标签的搬移shift 方向和尾部截断超帧本身没有目标值。处理标签时最容易犯的错是直接用“下一分钟收盘价”做 y而没有做 shift。hyperframe[target] hyperframe[close].shift(-5) / hyperframe[close] - 1shift(-5) 表示未来 5 分钟收益率。问题是最后 5 行 target 会变成 NaN很多人在 fit 前直接 dropna这没问题但如果你用 fillna(0)最后 5 行会被模型当成“收益率为 0”的样本等于硬造了 5 条假数据。我通常在 dropna 之外再显式记录保留范围。valid_idx hyperframe[target].notna() train_data hyperframe.loc[valid_idx].copy()5.3 时间序列切分purge 与 embargo 的取舍普通交叉验证在时序数据上几乎必翻车。常见的做法是 TimeSeriesSplit它按时间顺序切出训练集和测试集。但超帧构建完成后特征之间已经存在“你中有我”的关系——比如小时特征是过去一小时聚合值测试集开头的样本里可能包含了训练集末尾的信息。这时我会做两件事。purge删掉训练集末尾与测试集开头重叠的样本embargo在训练集和测试集之间留一段“隔离带”比如隔离 10 根分钟线让窗口类特征不复用测试集开头的信息。代码上其实就是套一个时间范围过滤器。test_start test_idx.min() embargo_end test_start - pd.Timedelta(minutes10) train_data train_data[train_data.index embargo_end]这段逻辑虽然简单但实际项目里性价比极高。不做 purge/embargo模型在回测里的表现会稳定偏高而且偏高的程度随特征窗口长度变化很难用统一系数去校正。最后再分享一个实践经验超帧建成以后别急着丢给模型。先打印一行某时间点的完整特征值人肉核对三五个字段是否符合“当时已知”的逻辑再跑小型单特征相关性分析看看有没有柱状凸起似的异常相关性。这套检查花不了 20 分钟但能挡掉绝大多数回测陷阱。我的习惯是把它写成一个函数每改一次数据管道就自动跑一遍省下的调试时间远远超过构建超帧本身。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑