首页 / 资讯中心 / 文章详情

Python历史行情统计分析:从复权清洗到可复现报告

Python历史行情统计分析:从复权清洗到可复现报告 ★ FEATURED ARTICLE
很多人拿到一份行情 CSV第一反应是装个模型、调个参、跑个回测然后盯着那条漂亮得不像话的净值曲线沾沾自喜。我见过太多这样的开头也见过太多在第二次数据更新后就崩掉的结论。股票市场分析这件事真正难的部分从来不在模型那一层而在你动手算第一个数字之前这份数据是复权的吗停牌日怎么处理你算的收益率是算术平均还是几何平均你的样本里有多少只标的已经退市、被你无意中幸存掉了这篇文章聊的是用 Python 对历史行情做统计分析的一套完整做法——描述性统计、收益率分布、波动率与相关性、平稳性与假设检验、滚动窗口指标以及把这些东西组装成一份可复现报告的过程。适合已经会写一点 pandas、但总觉得自己算出来的数字哪里不对的人也适合想把分析流程从一次性脚本升级成可复用工程的人。1. 先把统计分析这四个字摆正位置1.1 描述、检验、归因三件事别混着做我给统计分析下的定义很朴素用一套有明确假设的方法把历史价格里能观察到的规律量化出来并诚实地标注这些规律在什么条件下可能失效。它包含三个层次很多人的混乱就来自把三者混为一谈。第一层是描述。均值、标准差、偏度、峰度、分位数、最大回撤——这些数字不解释任何因果只是把一段历史压缩成几个便于比较的量。描述性统计的价值在于建立基准直觉这个标的日常波动大概是几个百分点极端日子能极端到什么程度收益分布是不是明显偏斜。第二层是检验。你有了一句感觉比如这两个标的走势挺同步的或者这段行情的波动比去年大检验的作用是把它变成一个可以被反驳的命题。相关系数是不是显著不为零波动率差异在统计上是否站得住收益率序列能不能拒绝正态假设。检验给出的是概率意义上的判断不是真理。第三层是归因。把总收益拆成市场beta、行业暴露、风格因子、个股特质或者把一个策略的盈亏拆成选股贡献和择时贡献。归因最容易做假因为拆解方式有无数种换一套因子就能讲出另一个故事。我的建议是严格按顺序来前一层没做扎实不要跳到下一层。描述性统计都没跑通、连自己数据的量纲和频率都没确认就去拟合因子模型结果一定是自欺欺人。1.2 一个最小可用的分析骨架从工程角度看一套能反复用的统计分析流程至少要有五个环节取数、清洗、计算、检验、出报告。每个环节的产物要落盘中间结果不能只活在内存里。market_stats/ ├── data/ │ ├── raw/ 原始快照只读带日期戳 │ └── processed/ 清洗后的面板数据 ├── src/ │ ├── loader.py 读取与格式统一 │ ├── cleaner.py 复权、去重、对齐、异常标记 │ ├── metrics.py 收益率与风险指标 │ ├── tests.py 统计检验 │ └── report.py 图表与汇总表 ├── reports/ 每次运行的输出按 run_id 分目录 └── config.yaml 标的池、时间区间、窗口参数这个结构看着啰嗦但它解决了一个真实痛点三个月后你想复现某张图如果当时的数据没落盘、参数写死在脚本里你基本只能重来一遍。而行情数据是会变的——复权因子每天更新前复权价格历史会整体平移你不冻结快照结论就会漂移。配置与数据分离还有另一个好处换标的池只改 config不动代码跑不同时间窗只改参数不复制脚本。2. 复权与对齐分析结果失真的头号来源2.1 前复权、后复权、不复权选错的代价这是我最想强调的一点。同一只标的用不复权价格算出来的收益率在除权除息日会出现一个凭空的大阴线那不是市场行为是会计行为。拿这种数据算波动率波动率被系统性高估算动量信号被除权日污染。三种价格各有用途混用是灾难价格类型构造方式适用场景主要缺陷不复权交易所原始成交价计算真实成交金额、涨跌停判断、税费核算除权日收益率断裂前复权以最新价为基准向前调整看当前价格连续性、画走势图历史价格随每次除权变动不可复现后复权以上市首日为基准向后调整回测、长期收益率计算、可复现研究价格数值与真实成交价差距大关键结论是做统计分析和回测用后复权做展示和当前价位对照用前复权算成交和成本用不复权。前复权最大的问题是它不是一份稳定的历史记录——今天导出和三个月后导出的前复权序列历史段的数值不一样。你基于前复权数据做的任何回归、任何回测别人拿着新数据复现不出来。这不是小瑕疵是可复现性的根本问题。如果手头的原始数据只有不复权价格和除权除息信息复权因子需要自己推。简化公式是对每个除权事件调整因子 (除权前收盘价 - 每股派息) / (除权前收盘价 × (1 送转比例))累乘得到累积因子。实际实现里细节很多比如配股、增发的处理不建议手写用成熟数据源直接取后复权序列更稳妥。2.2 停牌、缺失值与交易日对齐停牌的处理是第二个大坑。行情数据在停牌期间通常直接缺失如果你用reindex补齐交易日再用前值填充就会生成一串收益率为零的假数据。这些零收益会把波动率压低、把自相关结构扭曲、把最大回撤算小。我的做法是分两步先补齐交易日历把缺失标记为NaN而不是填零再单独生成一个is_tradable布尔列记录当天是否真实有成交。所有统计计算只在is_tradable为真的样本上进行并且在报告里注明样本内剔除停牌日 N 个。import pandas as pd import numpy as np def align_to_calendar(df: pd.DataFrame, calendar: pd.DatetimeIndex) - pd.DataFrame: 把单标的行情对齐到统一交易日历缺失保持 NaN不填充 out df.reindex(calendar) out[is_tradable] out[close].notna() return out def clean_prices(df: pd.DataFrame) - pd.DataFrame: df df.copy() df.index pd.to_datetime(df.index) df df[~df.index.duplicated(keeplast)].sort_index() # 价格必须为正非正的直接置空并记录 bad df[close] 0 if bad.any(): df.loc[bad, [open, high, low, close]] np.nan return df多标的对齐时计算相关系数要用共同交易日内连接因为相关系数需要成对样本但计算各自的时间序列指标时要用并集日历否则不同标的的统计窗口长度不一致年化结果没法比。这个区别很小但错了以后数字会差得莫名其妙。2.3 用一份脏数据演练清洗链路我习惯在正式分析前先用一份故意做脏的样本跑一遍清洗确认每一类问题都能被识别。常见脏数据包括重复日期、价格为 0 或负数、成交量缺失、日期格式不统一有的是2023/1/5有的是20230105、时区混用。def audit(df: pd.DataFrame) - dict: return { rows: len(df), dup_dates: int(df.index.duplicated().sum()), non_positive_close: int((df[close] 0).sum()), missing_close: int(df[close].isna().sum()), zero_volume: int((df.get(volume, pd.Series(dtypefloat)) 0).sum()), nan_ratio: float(df.isna().mean().mean()), }清洗日志要落盘和结果一起存。理由很实际当某个指标看起来离谱时第一件事是翻清洗日志看是不是样本被剔得太多。我遇到过某标的因为长期停牌清洗后有效样本只剩三分之一算出来的年化波动率完全不可信但因为日志里写着剔除比例一眼就能发现问题。3. 收益率序列怎么造决定了后面所有数字3.1 简单收益率与对数收益率的取舍简单收益率r_t P_t / P_{t-1} - 1对数收益率l_t ln(P_t / P_{t-1})。两者在小幅波动下几乎相等在极端行情下差异明显。对数收益率的核心优势是可加性多期对数收益率等于各期之和这让跨期汇总和方差计算变得干净。简单收益率的优势是可解释性组合收益率是各资产简单收益率的加权和权重有明确的经济含义。我的习惯是算波动率、做时间序列建模、算长期累计收益用对数收益率算组合加权收益、算单期盈亏、做绩效归因用简单收益率。两者不要在同一张表里混着报否则读者会误读量级。def log_returns(prices: pd.Series) - pd.Series: p prices.dropna() return np.log(p).diff().dropna() def simple_returns(prices: pd.Series) - pd.Series: p prices.dropna() return p.pct_change().dropna()一个容易被忽略的细节pct_change()默认会把 NaN 按前值填充处理直接用在停牌数据上会造出假零收益。必须显式检查输入序列里有没有 NaN或者先 dropna 再算。这个坑我在早期项目里踩过整整一周都在怀疑数据源有问题最后发现是自己少写了一行dropna。3.2 四个数字读出分布的形状描述性统计我固定看一组样本数、均值、标准差、偏度、峰度、1% 和 99% 分位数、最小值最大值。前四个里均值标准差人人都会算真正有信息量的是偏度和峰度。偏度衡量不对称性。负偏意味着左尾更长大跌的日子比大涨的日子更极端这在权益类资产里非常常见。峰度高意味着尖峰厚尾极端值出现的频率远高于正态分布的预期。很多风控模型默认正态假设一遇到厚尾就系统性低估风险2008 年那类事件就是活教材。from scipy import stats def describe(r: pd.Series) - pd.Series: r r.dropna() return pd.Series({ n: r.shape[0], mean_bp: r.mean() * 1e4, std_bp: r.std(ddof1) * 1e4, skew: stats.skew(r, biasFalse), excess_kurt: stats.kurtosis(r, fisherTrue, biasFalse), q01: r.quantile(0.01), q99: r.quantile(0.99), min: r.min(), max: r.max(), })注意ddof1和无偏估计参数。样本标准差用ddof1是惯例但很多人用 numpy 默认的ddof0样本量小时会低估波动。偏度峰度同理biasFalse给的是无偏估计长尾数据下差异更明显。标准误也要顺手算一下。均值的标准误是std / sqrt(n)如果这个值和均值本身量级相当说明你的均值估计基本没有信息量——这在短样本高频数据上极其常见。3.3 年化换算里那些说不太清的规矩年化是最容易算错的地方因为它依赖一个隐含假设一年有多少个周期。日频数据按 252 个交易日算周频按 52月频按 12。用 365 去换算是把自然日和交易日搞混了。波动率年化是日波动率 × sqrt(252)这个没问题前提是收益率近似独立。收益率有自相关时这个换算会低估长期波动严谨做法要加自协方差修正项但实务里通常忽略代价是长周期风险被低估。收益率年化有两个版本别混算术年化日平均收益 × 252适合做期望收益的估计但会系统性高估长期复利效果。几何年化(1 累计收益) ** (252 / n) - 1反映真实的复利增长。两个数在波动大的标的上能差出好几个百分点。报告里如果只写一个年化收益一定要注明是哪种算法。def annualized(r: pd.Series, periods: int 252) - dict: r r.dropna() n len(r) geo (1 r).prod() ** (periods / n) - 1 return { ann_return_arith: r.mean() * periods, ann_return_geo: geo, ann_vol: r.std(ddof1) * np.sqrt(periods), ann_sharpe: r.mean() / r.std(ddof1) * np.sqrt(periods), }夏普比率的计算同样依赖一个选择无风险利率取什么。取零最省事但会让不同时期的夏普不可比。如果要横向对比多年最好统一用一个明确的短期利率序列并且在报告里写清楚取值来源。4. 滚动指标静态数字骗人滚动数字才诚实4.1 滚动窗口长度背后的权衡单一样本期内算出的一个波动率数字掩盖了波动率本身随时间变化这件事。滚动波动率能把这一点摊开。窗口长度的选择是偏差与方差的老问题窗口越短对近期变化越敏感但估计噪声越大窗口越长估计越稳但对结构性变化反应越迟钝。实践中常用的几个档位20 日约一个月敏感适合观察短期情绪但噪声大不适合作为风控阈值。60 日约一个季度平衡性最好是我默认用的档位。250 日约一年稳定适合做长期风险预算但对突变的反应会滞后好几个月。def rolling_vol(r: pd.Series, window: int 60, periods: int 252) - pd.Series: return r.rolling(window, min_periodswindow).std(ddof1) * np.sqrt(periods)min_periods一定要显式设置默认行为在序列开头会产生不稳定的估计值。另外滚动窗口会消耗前 window-1 个样本如果总样本只有 300 天、窗口取 250你能看到的滚动序列只有 50 个点任何基于它的结论都要打问号。4.2 相关系数的两个陷阱第一个陷阱是静态相关掩盖时变结构。两个资产十年相关系数 0.3听着挺分散但可能在最近两年是 0.8。危机期间相关性普遍上升这是分散化策略在最需要它的时候失效的根本原因。解决办法是画滚动相关并且同时看相关性的分布而不是只看均值。第二个陷阱是样本量不足导致的伪相关。两个独立的随机游走序列在短样本上算出 0.6 的相关性毫不稀奇。判断显著性可以用 Fisher z 变换构造置信区间def corr_ci(x: pd.Series, y: pd.Series, alpha: float 0.05) - tuple: pair pd.concat([x, y], axis1).dropna() n pair.shape[0] if n 10: return (np.nan, np.nan) r pair.iloc[:, 0].corr(pair.iloc[:, 1]) z np.arctanh(r) se 1 / np.sqrt(n - 3) z_crit stats.norm.ppf(1 - alpha / 2) lo, hi np.tanh(z - z_crit * se), np.tanh(z z_crit * se) return r, (lo, hi)区间宽到跨越零点的时候就别拿这个相关系数去讲配置故事了。还有一个更隐蔽的问题滞后相关。资产 A 的收益和资产 B 的下一期收益相关可能是领先滞后关系也可能是两个序列都受同一个因素驱动造成的伪结构。检查方法是把相关系数在不同滞后阶数上算一遍画出滞后相关图。如果只在滞后 0 附近有峰基本是同期共动如果峰出现在非零滞后且衰减很慢要警惕序列本身的趋势性。4.3 回撤与风险调整收益的实际算法最大回撤的计算很多人写错在峰值的定义上。正确做法是维护一个累计最高净值序列逐点计算当前净值相对历史峰值的跌幅。def drawdown_series(nav: pd.Series) - pd.DataFrame: peak nav.cummax() dd nav / peak - 1 return pd.DataFrame({nav: nav, peak: peak, drawdown: dd}) def max_drawdown(nav: pd.Series) - float: return drawdown_series(nav)[drawdown].min()除了最大回撤的数值我更关心三件事回撤持续了多久从峰到谷再到收复的天数、回撤发生的频率比如年度内超过 10% 回撤的次数、回撤的分布形态。一个策略最大回撤 20% 但三年只发生一次和最大回撤 20% 但每年发生两次风险特征完全不同单看一个数字看不出来。净值曲线的构造也要注意用简单收益率累乘(1r).cumprod()得到的是以 1 为起点的净值如果中途有资金进出必须按时点加权不能简单累乘。这一点在自建组合上特别容易出错。5. 检验环节让结论站得住脚5.1 正态性检验的正确用法金融收益率几乎从不服从正态分布这是常识。但检验本身仍有价值因为它能告诉你偏离有多严重、偏离在哪个方向。常用的三个检验检验关注点样本量敏感度适用场景Jarque-Bera偏度与峰度联合偏离极高大样本快速筛查Shapiro-Wilk整体分布拟合中等小样本n 5000Kolmogorov-Smirnov与指定分布的最大偏差中等需要指定参照分布关键提醒样本量大的时候任何微小偏离都会显著。你有 5000 个日频样本JB 检验的 p 值基本必然是 0这不代表数据有问题只代表正态假设不精确。这时候应该看效应量——偏度和峰度的具体数值而不是盯着 p 值。def normality_tests(r: pd.Series) - dict: r r.dropna() jb_stat, jb_p stats.jarque_bera(r) sw_stat, sw_p stats.shapiro(r) if len(r) 5000 else (np.nan, np.nan) return { jb_stat: jb_stat, jb_p: jb_p, skew: stats.skew(r, biasFalse), excess_kurt: stats.kurtosis(r, fisherTrue, biasFalse), sw_stat: sw_stat, sw_p: sw_p, }如果结论是显著非正态后续建模就要考虑厚尾分布比如用 t 分布拟合或者用经验分位数做风险估计而不是套正态的 1.96 倍标准差。5.2 平稳性与自相关时间序列的基本体检价格序列通常非平稳有趋势、方差随时间变化收益率序列通常近似平稳。这条规律决定了做回归前要先检验否则会出现伪回归——两条各自独立上升的序列回归出来的 R² 高达 0.9t 值吓人但毫无意义。ADF 检验的原假设是存在单位根即非平稳。p 值小于 0.05 才能说拒绝非平稳。要注意 ADF 对滞后阶数和是否含趋势项很敏感至少要跑含常数项和不含常数项两个版本对照。from statsmodels.tsa.stattools import adfuller def adf_report(r: pd.Series, regression: str c) - dict: r r.dropna() stat, p, used_lag, nobs, crit, _ adfuller(r.values, regressionregression, autolagAIC) return {adf_stat: stat, p_value: p, used_lag: used_lag, nobs: nobs, crit_5pct: crit[5%]}自相关用 Ljung-Box 检验它检验的是前 m 阶自相关联合为零这个假设。日频收益率通常在 1 阶上没有显著自相关但其绝对值波动率代理往往有很强的自相关这就是波动率聚集现象。from statsmodels.stats.diagnostic import acorr_ljungbox def lb_test(r: pd.Series, lags(5, 10, 20)) - pd.DataFrame: return acorr_ljungbox(r.dropna(), lagslist(lags), return_dfTrue)把收益率和收益率的绝对值分别跑一遍 Ljung-Box对比结果就能看出这个序列是收益可预测还是波动可预测。这两者的区别对后续建模方向有决定性影响。5.3 多重比较p 值不是护身符这是我见过最普遍的方法论错误。你试了 20 个参数组合每个都做一次显著性检验按 5% 的显著性水平平均会有一个显著的结果纯属偶然。试的参数越多找到假阳性的概率越大。这就是数据窥探偏差。实务上的缓解手段预先注册分析方案把要检验的假设和参数写死在跑数据之前。用 Bonferroni 或 Benjamini-Hochberg 做多重比较校正虽然保守但能挡住大部分的假阳性。把样本切成探索集和验证集所有发现必须在验证集上重新确认。报告效应量不要只报 p 值。统计显著和经济显著的差别在金融数据里尤其大。我个人的经验是任何在参数扫描中胜出的配置都要用一套完全没参与扫描的样本再做一次确认。通不过这一步的结论哪怕 p 值小于 0.001也不要写进报告。6. 从 CSV 到一份能交付的统计报告6.1 依赖与目录环境不需要多复杂几个基础库就够pip install pandas numpy scipy statsmodels matplotlib pyyamlstatsmodels负责 ADF 和 Ljung-Boxscipy负责分布检验matplotlib负责出图。我不建议在这里引入任何重型框架统计分析需要的是透明和可检查不是自动化流水线。配置文件把所有可变参数集中管理universe: - ASSET_A - ASSET_B date_range: [2018-01-01, 2024-12-31] windows: [20, 60, 250] risk_free_annual: 0.02 output_dir: reports标的用代号而非真实名称好处是报告本身可以脱敏也避免有人把示例数据当成真实结论。6.2 核心流程串联把前面几段拼成一个可执行的流程import numpy as np import pandas as pd from pathlib import Path def build_panel(raw: dict[str, pd.DataFrame]) - dict: raw: {asset: 后复权行情df}返回清洗后的面板 calendar pd.DatetimeIndex(sorted(set().union(*[df.index for df in raw.values()]))) panel, flags {}, {} for name, df in raw.items(): df df.reindex(calendar) flags[name] df[close].notna() panel[name] df[close].dropna() return panel, flags, calendar def returns_table(panel: dict, method: str log) - pd.DataFrame: out {} for name, s in panel.items(): out[name] np.log(s).diff() if method log else s.pct_change() return pd.DataFrame(out).dropna(howall) def summary_table(rets: pd.DataFrame, periods: int 252) - pd.DataFrame: rows {} for col in rets.columns: r rets[col].dropna() nav (1 r).cumprod() rows[col] { n: len(r), ann_ret_geo: nav.iloc[-1] ** (periods / len(r)) - 1, ann_vol: r.std(ddof1) * np.sqrt(periods), skew: r.skew(), kurt: r.kurtosis(), max_dd: (nav / nav.cummax() - 1).min(), } return pd.DataFrame(rows).T注意build_panel返回了三个对象以并集日历对齐的价格、可交易标记、完整日历。后面的计算里并集日历用来保证滚动窗口时间一致可交易标记用来过滤无效样本。6.3 报告里应该出现什么一份合格的统计报告我认为至少包含这几块样本说明时间区间、标的数量、有效样本数、剔除的停牌日数、复权方式。收益率分布表均值、标准差、偏度、峰度、分位数。风险指标表年化波动、最大回撤、回撤持续时间、夏普。滚动波动率图至少两个窗口对照。相关性矩阵与滚动相关图。检验结果表ADF、Ljung-Box、正态性检验的统计量与 p 值并附一句人话解释。数据质量问题清单缺失比例、异常值处理方式、极端值的缩尾处理说明。最后一项常被省略但它决定了报告的可信度。没有数据质量说明的报告读者无法判断结论的可靠性边界。出图的时候我习惯把参数写进标题比如60 日滚动年化波动率后复权2018-2024这样任何时候翻回来看图都不用猜上下文。7. 那些只有跑过才会知道的坑7.1 前视偏差与幸存者偏差前视偏差指的是你在 t 日用了 t 日之后才知道的信息。最隐蔽的形式不是数据泄露而是样本筛选你从当前还在交易的标的池里选样本等于提前知道了谁活下来、谁退市了。用这个池子回看历史收益会被系统性高估因为退市的、被并购的、长期停牌的都被你排除在外了。处理方式只有一个构造时点可用池在每个历史时点只纳入当时真实存在且可交易的标的。这需要一个包含退市记录和上市日期的数据库工作量不小但没有它长期收益的统计结论基本不可信。另一个隐蔽形式是指标计算用了未来数据。比如用整个样本期的均值做标准化再用标准化后的数据算滚动指标这就是用了未来信息。正确做法是每个时点只用截止到该时点的样本估算参数。7.2 交易成本与纸上收益统计分析与策略回测的边界很模糊。只要你在报告里写了如果按这个信号操作年化能到多少就必须扣成本。佣金、滑点、冲击成本、融券费用这些加起来对高频信号的杀伤力巨大。一个粗糙但实用的估算方法是按换手率乘以单边成本率从收益里扣。如果你的策略年换手 20 倍单边成本按万分之三算光成本就吃掉 6% 的年化收益。很多看着年化 15% 的策略扣完成本只剩 9%扣完风险和回撤后优势消失。停牌还会带来一个更麻烦的问题信号在停牌期间无法执行。如果回测里假设每天都能按收盘价成交那就高估了策略的可执行性。严谨的做法是引入可交易标记信号只在可交易日生效。7.3 代码层面的可复现与性能可复现这件事我总结成三个固定数据快照固定、参数固定、随机种子固定。行情数据每天更新今天跑的结果和下周跑的不一样这是常态不是你代码写错了。解决办法是每次运行都记录数据快照的哈希值写进报告头。性能方面pandas 的rolling在几十个标的、几千个交易日这个量级完全够用。真正拖慢速度的通常是两件事在循环里反复调用.loc做逐行赋值以及用apply对每行做本可以向量化的计算。我的经验是先把所有指标都写成向量化形式只有当某个自定义指标实在没法向量化时才退回rolling().apply()并且接受它慢十倍的事实。另外注意 dtype。行情数据读进来如果价格列被识别成object所有计算都会慢一个数量级而且结果可能是错的。加载完第一件事就是df df.astype({close: float64})并且检查一遍df.dtypes。还有一个小习惯所有输出文件按run_id分目录run_id用时间戳加参数哈希。三个月后要复现某张图直接按目录找不用翻命令行历史。这个习惯帮我省下的时间远超当初搭建它的成本。最后分享一个我自己的体会。做这类统计分析最容易犯的错不是数学用错而是把描述当成预测。你算出的年化波动、最大回撤、相关系数全部只描述了过去那段样本里的表现它们没有义务在未来重演。报告里把这句话写清楚比多加十个指标重要得多。真正有用的分析是让读者知道在什么条件下这个结论可能不成立而不是给出一个看起来很确定的数字。我自己的做法是每份报告结尾留一段失效条件列出三个能让上述结论崩塌的情形比如样本期结构变化、极端行情缺失、标的池调整。这段话写起来不讨喜但它是分析里最诚实的一部分。
阅读完成 · 觉得有帮助?
咨询建站