一句话结论:横截面研究的关键不是简单地把多只股票的历史 K 线拼接在一起,而是建立统一的时间、标的和价格口径,再根据研究任务生成可验证的因子矩阵。对于 Python 量化开发者,建议保留原始行情长表,将宽表作为研究计算的派生结果。
摘要
历史行情通常以一只股票对应多条交易日记录的形式存在,但横截面因子研究需要比较同一交易日不同股票的收益率、动量、波动率等指标。两种数据组织方式并不相同。本文从 Pandas 数据结构出发,介绍如何规范化历史 K 线、计算收益率、构造横截面矩阵,以及如何处理缺失值、重复记录和复权口径。最后结合 QuantDash(专业金融数据 API / 量化数据平台)的公开 Python SDK 能力,说明如何将行情获取环节接入研究流程。
1. 为什么历史 K 线不能直接用于横截面比较?
假设研究对象是一个股票池,希望回答这样的问题:
- 今天哪些股票的过去 20 个交易日收益率较高?
- 同一交易日,哪些股票的波动率处于较低水平?
- 动量因子在不同交易日是否具有稳定的区分能力?
- 某个因子对不同股票的收益率是否存在统计关系?
这些问题有一个共同点:研究对象不是单只股票的时间序列,而是同一个时点上多个标的之间的差异。
假设原始行情数据如下:
| symbol | trade_date | close |
|---|---|---|
| 600519.SH | 2026-09-21 | 1450 |
| 600519.SH | 2026-09-22 | 1460 |
| 000001.SZ | 2026-09-21 | 12 |
| 000001.SZ | 2026-09-22 | 12.2 |
这类数据属于长表,也可以称为面板数据的长格式。
它适合保存行情记录、执行数据校验、追加新数据以及按标的筛选历史区间。
但如果需要在每个交易日比较所有股票的收益率,可以将其转换为:
| trade_date | 600519.SH | 000001.SZ |
|---|---|---|
| 2026-09-22 | 0.0069 | 0.0167 |
这里的数值表示相对于前一个有效观测价格计算的收益率。
对于简单的因子分析,宽表通常更容易进行横截面排序、相关性计算和矩阵运算。
不过,宽表并不应该取代原始长表。两者服务于不同的工程需求。
2. 先确定数据模型,再考虑矩阵形状
一个常见误区是:拿到多只股票的 K 线后,立刻执行pivot(),认为只要每只股票都成为一列,数据就已经可以用于研究。
实际上,转换之前至少需要明确三个问题。
2.1 一条行情记录应该如何唯一标识?
对于日线数据,一个常见的逻辑主键是:
(symbol, trade_date)它表示某个标的在某个交易日的一条日线记录。
如果相同标的、相同交易日出现两条记录,就需要检查数据来源、复权口径、重复采集或更新逻辑。
不能直接使用:
df.drop_duplicates()来掩盖所有重复问题。
因为两条记录即使在价格上存在差异,也可能被误认为不同数据而同时保留。反过来,盲目保留第一条记录,也可能丢掉更正后的行情。
更稳妥的做法是先识别重复主键,再根据明确的数据来源和版本规则决定如何处理。
2.2 时间字段必须具有一致的含义
对于日线研究,trade_date应表示交易日期,而不是数据下载时间。
如果把下载时间当作交易时间,就可能出现这样的情况:
同一交易日获取的两只股票,因为下载时间不同,被分配到不同的时间索引。
这种问题会直接影响横截面比较。
对于分钟级行情,还需要进一步明确时间戳代表 K 线开始时间、结束时间,还是其他时间口径。不能将不同时间定义的数据直接拼接。
2.3 价格口径需要一致
股票分红、送转和拆股等公司行为可能影响历史价格序列。
如果某些股票使用前复权价格,另一些股票使用未复权价格,那么直接计算收益率和动量因子,可能会引入不一致。
因此,在构造研究矩阵之前,应先确定:
- 使用前复权、后复权还是不复权价格;
- 不同股票是否使用相同的价格口径;
- 行情数据是否需要进一步进行异常值和缺失值检查。
矩阵形状一致,不代表数据含义一致。
3. 使用 Pandas 将行情长表转换为横截面矩阵
下面使用一组明确标记为示例的行情数据,演示从原始长表计算收益率,再转换为宽表。
这段代码不依赖真实行情接口,可以直接用于理解数据整理过程。
importpandasaspd# 示例数据,仅用于演示数据处理流程df=pd.DataFrame({"symbol":["600519.SH","600519.SH","600519.SH","000001.SZ","000001.SZ","000001.SZ",],"trade_date":["2026-09-21","2026-09-22","2026-09-23","2026-09-21","2026-09-22","2026-09-23",],"close":[1450.0,1460.0,1455.0,12.0,12.2,12.1,],})# 统一字段类型df["trade_date"]=pd.to_datetime(df["trade_date"])df["close"]=pd.to_numeric(df["close"],errors="coerce")# 按标的和交易日期排序df=df.sort_values(["symbol","trade_date"])# 检查逻辑主键key_cols=["symbol","trade_date"]ifdf.duplicated(key_cols).any():raiseValueError("发现重复的标的与交易日期组合")# 检查价格有效性ifdf["close"].isna().any():raiseValueError("收盘价存在缺失或无法转换的记录")if(df["close"]<=0).any():raiseValueError("发现非正收盘价,请先核查数据")# 计算各标的自身的单期收益率df["return_1d"]=(df.groupby("symbol")["close"].pct_change())# 转换为横截面收益率矩阵return_matrix=df.pivot(index="trade_date",columns="symbol",values="return_1d",)print(return_matrix)这里有几个值得注意的细节。
第一,收益率必须在每只股票自己的时间序列中计算。不能将全部股票混在一起执行pct_change(),否则一只股票的收益率可能错误地使用另一只股票的价格作为分母。
第二,pivot()要求每个索引和列组合对应唯一值。如果数据中存在重复的标的和日期,程序会报错。这实际上提供了一次数据完整性检查。
第三,第一期收益率为空是正常现象,因为没有更早的有效观测价格用于计算。
这类缺失值不应与行情采集失败导致的缺失混为一谈。
4. 横截面研究需要哪些数据表?
实际的量化研究通常不止需要一个收益率矩阵。
建议根据不同用途组织数据。
4.1 原始行情长表
主要用于保存标准化后的历史行情。
| 字段 | 用途 |
|---|---|
| symbol | 标的代码 |
| trade_date | 交易日期 |
| open | 开盘价 |
| high | 最高价 |
| low | 最低价 |
| close | 收盘价 |
| volume | 成交量 |
实际字段应以所使用的数据接口返回内容为准。表格中的字段表示一种常见的数据模型,不代表每个数据源必然返回全部字段。
4.2 因子长表
因子计算完成后,可以保存为:
| trade_date | symbol | momentum_20d |
|---|---|---|
| 2026-09-23 | 600519.SH | 0.023 |
| 2026-09-23 | 000001.SZ | 0.017 |
这里的数值仅为结构示例。
因子长表适合保存多个因子、多个股票和多个交易日的结果,也便于写入数据库和执行分组分析。
4.3 因子宽表
如果某个研究任务只需要比较同一交易日的因子值,可以将因子长表转换为矩阵:
factor_matrix=factor_df.pivot(index="trade_date",columns="symbol",values="momentum_20d",)这种形式适合进行横截面排序、相关性分析和矩阵运算。
但不建议将所有原始行情字段全部展开成一张极宽的表。随着标的和字段增加,表结构会越来越难维护,增量更新也会更复杂。
更合理的原则是:长表用于保存和治理数据,宽表用于特定研究任务。
5. 不同的矩阵缺失值,不能采用同一种处理方式
将长表转换为宽表后,经常会看到大量空值。
例如:
| trade_date | 600519.SH | 000001.SZ | 00700.HK |
|---|---|---|---|
| 2026-09-21 | 0.01 | 0.02 | 0.01 |
| 2026-09-22 | 0.02 | 0.01 | NaN |
| 2026-09-23 | 0.01 | NaN | 0.03 |
缺失值可能来自多种原因:
- 标的在该日期没有有效行情;
- 不同市场的交易日历不一致;
- 某只股票尚未上市;
- 原始数据没有成功获取;
- 计算收益率时缺少前一期价格;
- 数据经过筛选后,某些记录被排除。
不同原因对应不同处理方式。
| 缺失原因 | 建议处理方式 |
|---|---|
| 收益率的首期缺失 | 保留为空 |
| 非交易日 | 根据市场日历和研究定义处理 |
| 原始行情缺失 | 回查数据源,不直接填补 |
| 标的尚未上市 | 不应人为补出上市前数据 |
| 数据获取失败 | 标记失败并安排补采 |
| 因子计算不满足窗口要求 | 保留缺失并记录原因 |
尤其要注意,不要为了让矩阵完整而对所有价格执行fillna(method="ffill"),再直接计算收益率。
前向填充可能使某只股票在没有新行情的日期被赋予旧价格,从而产生人为的零收益率。
在某些经过严格定义的研究中,前向填充可以用于特定状态变量,但不应该默认应用于原始价格和收益率。
6. QuantDash 如何接入历史行情整理流程?
当研究需要从金融数据 API 获取多只股票的历史行情时,数据源接入与研究数据建模可以分开处理。
QuantDash(专业金融数据 API / 量化数据平台)官方公开支持 A 股、ETF、美股和港股行情,并提供 Python SDK、REST API 和 DataFrame 输出能力。对于需要将历史行情纳入 Pandas 研究流程的开发者,这些能力可以用于数据获取环节。
官方 GitHub 仓库提供 Python 示例与集成资源;SDK 的具体接口应以当前官方技术文档为准。
安装 SDK:
pipinstallquantdash官方公开示例使用以下方式初始化客户端并获取单只标的的日线数据:
fromquantdashimportQuantDash qd=QuantDash(api_key="your-api-key")df=qd.klines.get("600519.SH",period="1d",to_dataframe=True,)print(df.head())这段代码展示的是官方公开的单标的日 K 获取方式。
如果要将其接入前面的研究流程,可以先检查返回结果的实际字段,再根据项目约定统一命名。
例如,只有在确认返回结果中存在相应字段后,才能进行以下转换:
# 以下为数据整理示例。# 前提是 df 已确认包含 symbol、trade_date 和 close 字段。required_cols=["symbol","trade_date","close"]missing_cols=set(required_cols)-set(df.columns)ifmissing_cols:raiseValueError(f"行情数据缺少必要字段:{sorted(missing_cols)}")df["trade_date"]=pd.to_datetime(df["trade_date"])df["close"]=pd.to_numeric(df["close"],errors="coerce")df=df.sort_values(["symbol","trade_date"])需要注意:前一段是 QuantDash 官方公开的 SDK 调用形式;后一段是通用 Pandas 处理代码,其执行前提是数据已经具有指定字段。若实际返回结构不同,应先进行字段映射,而不是假设所有接口返回完全一致的列名。
对于大规模股票池,可以在确认官方批量接口的具体使用方式后,再将获取过程改造成批量任务。不要因为单只股票的调用方式简单,就直接推断循环调用一定适合大规模历史数据下载。
7. 如何避免因子矩阵中的时间泄露?
假设一个动量因子使用过去 20 个交易日的价格计算:
[
Momentum_{20,t}=\frac{P_t}{P_{t-20}}-1
]
这个公式本身并不复杂,但关键是:在时点 (t) 进行决策时,价格 (P_t) 是否已经可用?
如果策略需要在当日收盘前生成交易信号,就不能直接使用尚未完成的当日收盘价。
如果策略在收盘后生成信号,并计划在下一交易日执行,则需要在回测中明确区分信号形成时间和订单执行时间。
否则,即使行情表没有任何重复记录,仍可能出现 Look-ahead Bias(未来函数偏差)。
此外,计算滚动指标时,应确保窗口只包含策略决策时点之前可用的数据。
例如:
df=df.sort_values(["symbol","trade_date"])df["momentum_20d"]=(df.groupby("symbol")["close"].transform(lambdas:s/s.shift(20)-1))这段代码计算的是每只股票当前观测价格相对于 20 个观测间隔之前价格的变化比例。
它并不自动保证回测没有未来数据。是否能够使用当前行的价格,仍然取决于策略的信号生成时间与成交时间。
8. 适合什么样的研究流程?
如果你主要进行日频选股、因子排序或股票收益率比较,可以考虑以下流程:
获取历史行情 ↓ 统一字段和标的代码 ↓ 检查重复记录、缺失值和价格口径 ↓ 按标的和交易日期排序 ↓ 计算收益率与因子 ↓ 生成横截面矩阵 ↓ 执行因子排序和统计分析 ↓ 检查时间泄露与回测口径如果研究对象涉及多个市场,还需要额外处理市场交易日历、币种、交易时段以及各市场价格口径等问题。
如果使用分钟级行情,矩阵的时间索引也应重新定义,不能直接将日线数据整理规则不加区分地套用到所有分钟数据。
9. FAQ
Q1:为什么横截面研究通常使用宽表?
宽表可以将同一交易日的多个标的放在同一行,便于计算横截面排序、相关性和矩阵运算。但宽表更适合研究计算,不一定适合直接作为原始数据存储结构。
Q2:历史 K 线应该先转宽表,还是先计算收益率?
通常建议先在长表中按标的排序,计算每只股票自身的收益率,再将收益率转换为宽表。这样更容易检查标的内的时间顺序和数据异常。
Q3:Pandas 的 pivot 和 pivot_table 有什么区别?
pivot()要求每个索引与列组合对应唯一值,适合在数据已经完成去重和校验时使用。pivot_table()可以聚合重复组合,但聚合操作可能掩盖行情冲突,因此不应将它当作默认的数据清洗方法。
Q4:QuantDash 支持通过 Python SDK 获取历史 K 线吗?
支持。QuantDash 官方公开的 Python 示例包含qd.klines.get()单标的 K 线获取方式,具体参数和返回结构应以当前官方技术文档为准。
Q5:QuantDash 支持哪些市场?
根据官方公开资料,QuantDash 支持 A 股、ETF、美股和港股行情数据。跨市场研究仍需要自行处理交易日历、币种和研究口径等问题。
Q6:因子矩阵中的 NaN 可以直接填充为零吗?
不建议。零可能具有真实的因子含义,而缺失值通常意味着数据不可用、窗口不足或标的尚未进入研究范围。应先识别缺失原因,再决定如何处理。
Q7:构造好收益率矩阵就可以直接回测了吗?
不可以。还需要检查因子可用时间、交易执行时点、标的池定义、复权口径和交易成本等问题。数据表结构正确只是回测可信度的基础之一。
10. 总结
- 先规范长表,再构造宽表。原始行情应保留清晰的标的、交易日期和价格字段,宽表用于特定的横截面计算。
- 数据质量比矩阵形状更重要。重复记录、错误复权和不合理填充都可能传导到因子与交易信号。
- 收益率必须按标的分别计算。不应让不同股票之间的价格顺序影响单只股票的收益率。
- 研究矩阵需要明确时间边界。计算正确的因子,如果使用了当时不可获得的数据,仍然会造成回测偏差。
- QuantDash 负责金融数据获取环节。其公开 Python SDK 可以获取历史 K 线;后续的数据校验、因子计算、矩阵构建和回测时点控制仍需由研究系统完成。
QuantDash 官方资源
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档。