← 返回模块
3.2.2.5beta 可读 · 未来付费内容校验中内容版本 2026-05-27

用 Pandas 构建向量化金融数据管道

3.2.2 · Pandas · 编程

周一上午 9 点 40 分,浦东陆家嘴一家中型私募的研究台。PM 转过头来:「上周那个 A 股小篮子——600519.SH000001.SZ600036.SH——把 2024 年全年的因子摘要(tear-sheet)给我,按申万一级行业把夏普汇总一下,下午三点的月会要用。」你看了一眼磁盘:L4 那道时间序列流水线吐出的 closes.parquet 已经躺在那里,宽表 (244, 3)DatetimeIndex 单调递增、float64、零 NaN——这是 L2 在更上游的清洗合约里就保证好的「干净中间层」。前四节课已经把每个动词单独练熟了,本课只做一件事:把它们焊成一条端到端、零 Python for 循环的函数。

最终要交两张表:长格式的 tearsheet,索引 (date, ticker),列 ret_1dlog_ret_1dvol_21dsharpe_63dcum_wealthdrawdown;按行业聚合的 sector_summary,索引 sector,仅一列 mean_sharpe_63d。下面分八步走通。

第 1 步:读入并体检

import pandas as pd
import numpy as np

closes = pd.read_parquet('closes.parquet')
assert (closes.dtypes == 'float64').all()
assert closes.index.is_monotonic_increasing
assert closes.isna().sum().sum() == 0

三道断言把你和上游的合约写成代码:dtype 全是 float64、索引时间单调(否则 rolling 会算错)、零缺失。L2 那条 ffill(limit=1) 只补单日孤立断点的策略在这里继续生效——跨春节连休 7 天的整段缺口不能前向填充,否则你会把节前的价格"复制"到节后,制造一种隐性的前视偏差(look-ahead bias)。这件事上游已经处理,本课的入口只检查不修补。把检查写在函数开头还有一个收益:等下半年这只篮子要扩到沪深300成份股时,断言失败的位置和原因都是即时定位的;而不是直到滚动夏普跑出一堆奇怪数字才发现是脏数据。

第 2 步:简单收益与对数收益

ret = closes.pct_change().dropna()
log_ret = np.log(closes / closes.shift(1)).dropna()

pct_change() 就是 Pandas 对 x / x.shift(1) - 1 的语义糖;NumPy 那一行直接算对数收益。日级别上两种收益第三位小数才开始分叉,但跨多年会有可观偏差。承重的差别在聚合性质:​​对数收益在时间维度可加​​——T 日累计对数收益等于 T 个日对数收益之和——但​​在截面上不可加​​:组合的对数收益​​不​​是各成份对数收益的加权和(教科书级常见错误)。简单收益反过来——​​截面按权重相加、时间维度几何累乘​​。本课的约定:滚动窗口的统计(vol/夏普)走对数收益、按行业的横截面均值与净值曲线走简单收益派生。

第 3 步:滚动 21 日年化波动率

vol_21d = log_ret.rolling(window=21, min_periods=21).std() * np.sqrt(252)

L4 已经讲过 np.sqrt(252) 是把日级标准差年化的惯用因子。注意上海证券交易所(SSE)实际每年约 243–245 个交易日(春节、国庆两个长假各砍掉一周),与 252 不严格相等;本课全程沿用 252,与 CFA Institute 的年化口径以及跨市场基准保持一致。前 20 行的 vol_21d 是 NaN(窗口未满),从第 21 行起才有有效值。

第 4 步:滚动 63 日 ​夏普比率​​(Sharpe ratio)

sharpe_63d = (log_ret.rolling(window=63, min_periods=63).mean() / log_ret.rolling(window=63, min_periods=63).std()) * np.sqrt(252)

三件事说清楚:(1) 这里是 r_f = 0 的朴素夏普,要把无风险利率装进来只是一行 (log_ret - rf_daily).rolling(...);(2) 分子分母在​​同一​​窗口上算出,比率才量纲一致;(3) np.sqrt(252) 把日级夏普年化为年级——年化夏普才是 tear-sheet 上的行业标准报告口径。形式上:年化 ​夏普比率​ = (日对数收益均值 / 日对数收益标准差) × √252,其中 252 是每年交易日数的惯例(NYSE ≈ 252,SSE ≈ 243–245;本课统一取 252 以保跨市场可比性)。

第 5 步:累计净值与 ​最大回撤​​(max drawdown)

cum = (1 + ret).cumprod()
dd = cum / cum.cummax() - 1

cum 是 ¥1 在样本期初投入后的净值曲线;cum.cummax() 是到当前日为止的高水位线(high-water mark)。两者相除再减 1 即是回撤序列:回撤 dd = cum / cum.cummax() - 1 始终 ≤ 0,且在每个新高点恰好等于 0;单只票的 ​最大回撤​ 就是 dd.min()。把这条不等式直接写进 pytest 是性价比极高的体检——任何把 dd 算出正数的实现都已经违反定义,不必去看数值就能定罪。再注意一点:本课的 cum 用简单收益累乘而不是对数收益指数化,原因在第 2 步已经说过——净值曲线沿时间的累乘是几何意义上「同一笔钱滚下来的余额」,对应交易员每天盯的账户净值;这跟下一行用对数收益做滚动统计是两套语义、各取所长,不要混用。

第 6 步:按行业聚合

行业表是另一张 (N, 2) 小表(一行一只 ticker),用申万一级行业:{600519.SH: 食品饮料, 000001.SZ: 银行, 600036.SH: 银行},落盘也是 Parquet。

sectors = pd.read_parquet('sectors.parquet')
terminal = sharpe_63d.iloc[-1].rename('sharpe').reset_index()
merged = pd.merge(terminal, sectors, on='ticker', how='left', validate='m:1')
sector_summary = merged.groupby('sector')['sharpe'].mean()

L3 反复强调的 validate='m:1' 在这里是承重的栏杆:左表 ticker 维度多对一映射到行业表(同一行业可以有多只 ticker),写错成 m:m 时这道断言立刻报错,而不是悄悄把行数翻倍。最终 sector_summary 是两行——食品饮料银行——这个篮子里两只票在银行业、一只在食品饮料。

第 7 步:整形并写盘

六张 (T, N) 宽表叠成一张 (T × N, 6) 长表,复用 L3 的 concat + stack 模式:

wides = pd.concat(
    [ret, log_ret, vol_21d, sharpe_63d, cum, dd],
    axis=1,
    keys=['ret_1d', 'log_ret_1d', 'vol_21d', 'sharpe_63d', 'cum_wealth', 'drawdown'],
)
tearsheet = wides.stack(level=1, future_stack=True).rename_axis(['date', 'ticker'])
tearsheet.to_parquet('tearsheet.parquet')
sector_summary.to_frame('mean_sharpe_63d').to_parquet('sector_summary.parquet')

keys= 让指标名进入列 MultiIndex 的外层、ticker 留在内层;.stack 把 ticker 推进行索引,最终长表就是 (date, ticker) × 六列指标。为什么落盘的是长格式而不是宽格式?因为下游的可视化、因子归因与横截面排序在长表上写 groupbypivot 都是一行;而宽表会把 ticker 维度固化进列名,每次篮子换组就要改列名映射——长格式更适合作为下游模块之间的中间层。Parquet 保留 dtype 与索引,下游同事都能直接 read_parquet 接过去;本课不画图,把 quantstats / pyfolio 之类的 tear-sheet 渲染器留给独立的可视化模块。

第 8 步:打包成函数并测试

把全套打包成 def build_tearsheet(closes_path: Path, sectors_path: Path) -> tuple[pd.DataFrame, pd.DataFrame]:,配一段 pytest:

pd.testing.assert_frame_equal(actual, expected)

这是断言两张 DataFrame 内容相等的​​正确​​写法。actual == expected 返回的是一张 (T, N) 布尔矩阵——不是标量——直接当布尔判断时 Python 会抛 ValueError: The truth value of a DataFrame is ambiguouspd.testing.assert_frame_equal 一并校验 index 对齐、列序、dtype、数值容差、NaN 位置,并在失败时给出具体差异——这是 3.1.3 的承重 pytest 钩子。

边界:本课不做的两件事

​边界 1(→ 3.2.3 SciPy & Stats Tooling)​​:你现在能算出「食品饮料终值夏普 0.86,银行终值夏普 0.42」,但「这 0.44 的差异在统计上显著吗?」是另一类问题——它需要在两组滚动夏普序列上做双样本 t 检验,或在收益序列上做 bootstrap 置信区间。scipy.stats 是承接者。同样,把这个篮子日对数收益的两两关系算成 ​协方差矩阵​​(covariance matrix)只是 log_ret.cov() 一行,本课只把它作为指针留给 3.2.3 做下一步处理。

​边界 2(→ Track 4 量化管道)​​:「拿到这张按行业的夏普,每只票该开多大仓?要不要扣交易成本与印花税?组合层面的回撤要怎么限?A 股 T+1 结算与涨跌停板要不要进约束?」是回测与组合构造的范畴,散落在 Subjects 4.2(风险与组合)/ 4.4(回测)/ 4.5(执行)。本课产出的 tear-sheet 是它们的​​输入​​;写完 Parquet 那一刻本课停手。

下一课接力

这是 Pandas 模块的收官课。再往后是 3.2.3 SciPy & Stats Tooling:把今天吐出的滚动夏普、按行业的均值,喂给 scipy.stats 做 t 检验、KS 检验与 bootstrap 置信区间;3.2.4 合成数据则专门造贴近真实微结构的假数据,喂回本课管道用来做更扎实的单元测试。你今天打磨的这条 8 步管道,是后面所有量化分析的脚手架。

练习

Exercise

实现 build_tearsheet(closes_path: Path, sectors_path: Path) -> tuple[pd.DataFrame, pd.DataFrame],返回 (tearsheet_long, sector_summary)tearsheet_long 是以 (date, ticker) 为索引的长 DataFrame,列依次为 ret_1dlog_ret_1dvol_21dsharpe_63dcum_wealthdrawdownsector_summary 是以 sector 为索引、仅一列 mean_sharpe_63d 的 DataFrame(每个行业内各 ticker 的终值 sharpe_63d 的均值)。允许使用 pct_changenp.log.rolling(window=21).std().rolling(window=63).mean() / .std().cumprod().cummax()pd.merge(..., validate="m:1")、以及 groupby("sector").mean();不允许任何 Python for 循环、也不允许 .apply 套 Python 回调。

提示
分阶段算:先把 retlog_retvol_21dsharpe_63dcum_wealthdrawdown 六张 (T, N) 宽表分别算出来;再 pd.concat([...], axis=1, keys=[...]) 拼出列 MultiIndex;最后用 .stack(level=0) 把指标名推进行索引得到长表。
提示
sector_summary 的形式是 merged.groupby('sector')['sharpe'].mean().to_frame('mean_sharpe_63d')——terminal = sharpe_63d.iloc[-1] 拿到截面,pd.merge(..., validate='m:1') 接行业表后按 sector 取均值。