Python股票历史数据统计分析与核心指标实现 做股票历史数据的统计分析这件事我用 Python 前后折腾了好几年从最开始拿 Excel 手动拉数据、算个涨跌幅都要复制粘贴半天到后来能写出一套自己顺手的脚本中间踩的坑比想象中多得多。这篇文章想讲清楚的是使用 Python 进行股票市场分析、基于历史数据做统计分析这套东西到底是什么、能算出哪些有用的数字、适合谁上手。它不涉及任何交易决策系统也不承诺能预测涨跌本质是把一堆乱七八糟的行情数据整理干净算出收益率、波动率、最大回撤、夏普比率、相关性这些描述性统计量让你对一段历史走势有一个可量化、可复现的认知。适合三类人看刚学完 pandas 想找个真实数据集练手的新手、做量化研究但总被数据口径坑到的半熟手、以及需要用统计口径向别人解释这段行情到底怎么回事的从业者。我先把结论性的态度放在前面统计分析的价值不在于告诉你明天涨不涨而在于让你知道过去的分布长什么样、极端情况有多极端、你承受的波动到底值不值。很多人一上来就想做择时模型结果连复权都没处理算出来的收益率全是错的后面所有指标跟着一起崩。所以下面我会按整体设计思路—数据清洗—指标实现—完整实操—问题排查这条线走中间穿插我实际踩过的坑。1. 整体设计思路与技术栈选型拆解1.1 从想分析一只股票到一条可复现的分析链路大部分人一开始的想法很朴素我想看看这只标的过去几年表现怎么样。但这句话拆开之后其实包含五个完全不同的动作取数、对齐、清洗、计算、呈现。新手最常犯的错误是把这五步揉在一起写写成两百行的巨型脚本跑一次要重新联网拉一遍数据网络抖一下就得重来而且中间任何一步出错都很难定位。我比较推荐的链路是分层 落盘取数层负责把原始行情落到本地 CSV 或 Parquet清洗层负责复权对齐和异常值处理计算层只吃干净的 DataFrame呈现层最后才画图出报告。每一层的输出都物化成文件下一层从文件读。这样做的好处很直接——你在调指标算法的时候完全不用再碰网络某个指标算错了可以直接从中间文件重跑几秒钟出结果别人想复现你的结论把数据文件和脚本一起给他就行。另一个设计上的取舍是先做单标的的深度统计再做多标的的横向对比。单标的的指标体系相对闭合收益率、波动率、回撤、分布形态这些在一只票上就能全部跑通并验证正确性。等你确认自己的算法没问题了再用同一套函数去 map 到多只标的上做相关性矩阵、做分组对比。反过来先做多标的一旦某只票的数据有问题你会花大量时间在排查到底是算法错了还是数据脏了。注意所有基于历史数据的统计结论只描述过去不构成任何买卖依据。这一点不是免责套话而是统计方法本身的边界——样本内表现好和未来表现好是两个完全不同的问题。1.2 技术栈选型的取舍逻辑工具选择上我没有太多花哨的东西核心就四件pandas 做数据结构、numpy 做数值计算、matplotlib 做图、scipy 做统计检验。这四个库的组合足够覆盖 90% 的日常需求学习成本也低。有几个地方值得说清楚为什么这么选。为什么不用现成的量化框架市面上有一些回测框架开箱即用但对纯统计分析来说反而是负担。它们的抽象层次是策略—信号—撮合而你只是要算一个滚动波动率为了这个去理解一整套框架的账户模型、撮合规则、手续费配置投入产出比很低。等你哪天真要做回测了再上那时候你的数据处理能力已经练出来了。为什么数据落盘用 Parquet 而不是 CSVCSV 的问题是类型全部丢失读回来日期是字符串、数字可能有空值、列名可能带空格每次都要重新 parse_dates 和 astype慢而且容易出错。Parquet 保留 schema读取速度快好几倍文件还小。唯一的缺点是记事本打不开但这一点用一个 read_csv 就能看见预览不算障碍。数据量不大的话 CSV 也完全够用我个人日常还是 CSV 为主只有行数上百万才切 Parquet。为什么统计检验用 scipy 而不是自己写公式峰度、偏度、Jarque-Bera 这些自己推公式容易在样本量修正比如峰度的有偏/无偏估计上写错而 scipy 的实现是经过大量验证的。能用成熟库就不要手写除非你要理解原理——理解原理可以看公式实现还是用库。环节常用库/工具选它的理由什么情况下可以换掉数据容器pandas时间序列索引、滚动窗口、分组聚合都是原生能力极大数据量换 polars 或 duckdb数值计算numpy向量化快NaN 处理语义清晰基本不需要换取数开源财经数据接口库 / 本地导出文件本地文件最稳接口库负责补全内网环境只能用本地文件可视化matplotlib可控性最强出图能直接进报告交互式查看可用 plotly统计检验scipy.stats分布检验、相关系数实现可靠复杂模型才需要 statsmodels1.3 数据源与字段口径先把地基打平做统计最怕的不是算法难而是两个数据源的口径不一样。同样叫收盘价一个源给的是不复权价另一个给的是前复权价同样叫成交量一个单位是股一个是手。你把两份数据拼在一起算相关性算出来的数字没有任何意义。所以我在正式开算之前会先做一件事拿同一只标的、同一段日期用两个不同的数据源各拉一遍然后把收盘价相减看差异。如果差异是零或者极小说明口径一致可以放心用如果差异呈现明显的阶梯状跳变那基本可以确定是复权方式不同。这个检查花不了五分钟但能省掉后面几天的困惑。字段上我会统一成下面这套最小集合多一个都不要字段名含义类型要求常见坑trade_date交易日datetime64有的是字符串20240105有的是2024-01-05open / high / low / close四价float64可能带千分位逗号或空字符串volume成交量float64单位是股还是手要确认amount成交额float64有时缺失非必需adj_factor复权因子float64有它就能自由切换复权方式有了 adj_factor 这个字段你就掌握了主动权。前复权价 原始价 × 当日复权因子 / 最新复权因子后复权价 原始价 × 当日复权因子。前复权的价格和当前盘面一致看着舒服但缺点是每次除权后历史序列会整体变化你之前存的数就过期了后复权是历史锚定的永远不变适合长期存档。做统计我倾向于存原始价 复权因子计算时按需生成这样以后想换口径不用重新取数。2. 数据获取与清洗脏数据是最大的坑2.1 本地文件与接口取数的两种姿势取数这件事稳定性优先级高于便利性。在线接口的好处是方便、能补最新数据坏处是限频、可能变字段、可能某天突然不通。我的习惯是首次全量用接口拉之后每天增量补全部落本地。这样即使接口挂了本地数据还能继续做分析。增量更新的逻辑很简单读本地文件拿到最后一天日期只请求这一天之后的数据然后 concat 上去去重。这里有个细节不要用 appendpandas 早就废弃了也不要在循环里反复 concat效率极低。正确做法是把增量片段收集到列表循环结束后一次性 concat。import pandas as pd import os DATA_DIR data def update_local(symbol: str, fetch_fn) - pd.DataFrame: path os.path.join(DATA_DIR, f{symbol}.csv) if os.path.exists(path): old pd.read_csv(path, parse_dates[trade_date]) start old[trade_date].max() pd.Timedelta(days1) new fetch_fn(symbol, start) if not new.empty: df pd.concat([old, new], ignore_indexTrue) else: df old else: df fetch_fn(symbol, None) df (df.drop_duplicates(subset[trade_date]) .sort_values(trade_date) .reset_index(dropTrue)) df.to_csv(path, indexFalse) return df这个函数有两点值得注意。一是 drop_duplicates 必须在 sort 之前做否则重复行的保留顺序不确定二是 sort 之后一定要 reset_index不然索引是乱的后面用 iloc 定位会出错。2.2 复权处理不复权的收益率全是错的这是我最想强调的一节。假设一只票某天收盘 20 元第二天除权10 送 10开盘参考价变成 10 元。如果你直接用不复权价算收益率会得到一个 -50% 的日收益。但这个 -50% 是假的你手里的股票数量翻倍了总市值没变。这种假信号进了波动率计算会把波动率抬得离谱进了回撤计算会造出一个根本不存在的巨大回撤。判断你的数据是否需要复权有个简单办法看是否存在单日跌幅接近但又不完全等于某个整数比例的跳变同时当天成交量明显放大。更靠谱的办法是直接看有没有除权除息记录或者比较前复权和不复权序列在同一天的差异。处理方式上如果数据源直接提供了前复权价用它可以但要记住它是会变的历史如果只给原始价和复权因子就自己算def to_qfq(df: pd.DataFrame) - pd.DataFrame: 前复权以最新价格为锚历史价格按因子缩放 df df.copy() latest_factor df[adj_factor].iloc[-1] ratio df[adj_factor] / latest_factor for col in [open, high, low, close]: df[col] df[col] * ratio return df这里有个容易忽略的点成交量也需要反向调整。价格除以比例成交量就要乘以同样的比例否则量价关系失真你算出来的量价相关性会很奇怪。很多教程只调价格不调量属于典型的不完整处理。还有一个更隐蔽的坑现金分红。送股、转增、配股这些是股本变动分红是现金流出两者都体现在复权因子里。如果你的数据源只处理了送转没处理分红收益率会系统性偏高一点点长期累积下来差异不小。验证方法是对比你的复权序列和某个可信的前复权序列看长期累计收益是否一致。2.3 缺失值、停牌与异常价格的清洗规则真实数据里干净是不存在的。常见的问题有这么几类处理原则各不相同。交易日缺失停牌。停牌期间没有行情记录直接 resample 到日历日会产生 NaN。这里要区分两种情况如果只是取数区间内某几天缺说明是停牌此时不应该用前值填充因为填充会制造出零波动的假象人为压低波动率。正确做法是保留缺失在计算收益率时按交易日序号相邻计算pandas 的 pct_change 默认就是按行相邻天然适配。只有在需要画连续时间轴的时候才考虑 reindex且填充后必须标记。价格为 0 或负数。基本可以判定是脏数据直接置为 NaN 后按缺失处理。有些源在停牌日会给 0一定要过滤否则 pct_change 会算出无穷大。单日涨跌幅超过合理区间。不同市场涨跌幅限制不同如果你的数据里出现远超限制的单日变动优先怀疑复权问题其次怀疑数据错误。可以设一个阈值做标记但不要直接删除删数据是最危险的操作。def clean_ohlc(df: pd.DataFrame, max_abs_ret: float 0.5) - pd.DataFrame: df df.copy() price_cols [open, high, low, close] df[price_cols] df[price_cols].apply(pd.to_numeric, errorscoerce) df.loc[(df[price_cols] 0).any(axis1), price_cols] pd.NA df df.dropna(subset[close]).reset_index(dropTrue) df[ret] df[close].pct_change() df[ret_flag] df[ret].abs() max_abs_ret return dferrorscoerce这一句很关键。财经数据里经常混进 --、、N/A 这类占位符直接 astype(float) 会抛异常coerce 会把它们变成 NaN后面统一处理。我用这个参数救过无数次场。提示清洗阶段的所有删除动作都要留痕。我的习惯是加一列 flag记录这条记录被标记的原因最后打印一行共标记 N 条这样你能知道数据到底脏到什么程度而不是稀里糊涂删完就完事。3. 核心统计指标的手工实现与验证3.1 收益率简单收益率与对数收益率的区别收益率有两种算法很多文章混着用导致后面加总的时候出错。简单收益率r_t P_t / P_{t-1} - 1。优点是直观跨期可以直接加权平均按资金权重缺点是跨期不可直接相加两天的 10% 和 -10% 加起来不等于 0实际是 -1%。对数收益率r_t ln(P_t / P_{t-1})。优点是跨期可加两天对数收益相加等于两天累计对数收益做时间序列建模时也更符合统计假设缺点是不能直接解释为涨了多少。我的习惯是描述性统计和建模用对数收益率给非专业读者看报表用简单收益率。两者在数值小的时候差异极小日收益 ±2% 以内差异不到万分之五但累计起来区别就明显了。df[ret_simple] df[close].pct_change() df[ret_log] np.log(df[close] / df[close].shift(1))验证方式很简单把对数收益率累加后取 exp应该等于末价除以首价。如果不等就是中间有缺失值没处理干净。3.2 波动率与年化换算sqrt(252) 到底怎么来的波动率本质是收益率的标准差。日频数据算出来的叫日波动率要跟其他周期可比就得年化。年化的逻辑来自方差的可加性假设如果日收益率独立同分布那么 N 天的方差是日方差的 N 倍标准差就是 sqrt(N) 倍。一年交易日按 252 算365 减去周末约 104 天再减去节假日约 11 天实际取 250 到 252 都常见我固定用 252。ann_vol df[ret_log].std(ddof1) * np.sqrt(252)ddof1是样本标准差必须显式写。pandas 的 std 默认就是 1但 numpy 的 std 默认是 0混用会差一点点。样本量小的时候这个差异不能忽略。年化收益率要小心不能用日收益均值 × 252那是算术年化会高估。正确算法是几何年化n_days len(df) total_ret df[close].iloc[-1] / df[close].iloc[0] ann_ret total_ret ** (252 / n_days) - 1这里n_days用交易日数量252/n_days 就是这段区间相当于多少年。举个例子三年约 756 个交易日累计涨了 60%年化就是1.6 ** (252/756) - 1 ≈ 16.96%如果错用算术方法0.6 / 3 20%虚高了三个点。3.3 最大回撤、夏普比率与卡玛比率最大回撤衡量的是从任意历史高点买入后最坏能亏多少。实现上用一个滚动最大值def max_drawdown(series: pd.Series) - tuple: cummax series.cummax() dd series / cummax - 1 mdd dd.min() end_idx dd.idxmin() peak_idx series.loc[:end_idx].idxmax() return mdd, peak_idx, end_idx注意 idxmax 要限定在峰值之前的那一段里找否则可能找到后面才出现的更高点逻辑就错了。返回起止索引是为了画图时能把回撤区间标出来这个在报告里很有说服力。夏普比率是超额收益除以波动率公式(年化收益 - 无风险利率) / 年化波动率。它衡量的是每承担一单位波动能换到多少超额回报。无风险利率这个参数看起来小实际影响不小当无风险利率从 2% 变成 4% 时一个年化 12% 的标的夏普会从 0.35 掉到 0.28。国内常用一年期国债收益率或银行间同业拆借利率做代理学术界还有其他口径你用哪个要在报告里写清楚不然没法比较。卡玛比率是年化收益除以最大回撤绝对值ann_ret / abs(mdd)。它比夏普更贴近真实感受因为分母是实际可能发生的最大亏损而不是波动率。一个策略夏普 0.8 但卡玛只有 0.5意味着它的波动分布很不均匀大部分时间平稳、偶尔暴跌。指标公式数值含义常见陷阱年化波动率日收益std × √252波动的绝对幅度用不复权价会虚高年化收益(末/首)^(252/n) - 1几何平均增速用算术平均会高估最大回撤min(P/cummax - 1)最坏浮亏起止点定位错误夏普比率(年化收益 - rf) / 年化波动单位风险的超额回报无风险利率口径不统一卡玛比率年化收益 / 最大回撤单位回撤的回报回撤接近 0 时数值爆炸3.4 分布形态、相关性与正态性检验前面几个指标都在讲幅度分布形态讲的是形状。A 股这类市场的日收益率分布有两个稳定特征尖峰、左偏。尖峰意味着极端值出现的频率比正态分布高得多左偏意味着大跌的日子比大涨的日子更极端。from scipy import stats skew stats.skew(df[ret_log].dropna()) kurt stats.kurtosis(df[ret_log].dropna(), fisherTrue) # 超额峰度 jb_stat, jb_p stats.jarque_bera(df[ret_log].dropna())fisherTrue得到的是超额峰度正态分布为 0fisherFalse得到的是原始峰度正态为 3。这两个参数不写清楚两个人数出来的峰度可能差 3讨论半天其实说的不是一个东西。这个坑我踩过一次在一个内部交流里和人争了半天最后发现是参数没对齐。Jarque-Bera 检验的原假设是数据服从正态分布。p 值小于 0.05 就拒绝原假设。实测下来几乎任何一只个股的日收益率都会拒绝正态假设这不是你的数据有问题而是金融收益率本来就不正态。所以这个检验的意义不在于证明不正态那是必然的而在于量化偏离程度——两段区间谁的 JB 统计量更大谁的尾部风险更值得警惕。相关系数方面如果要算两只标的的联动性用收益率序列算不要用价格序列算。价格序列算出来的相关系数会因为共同趋势而虚高两只毫无关系的股票在长期上涨的市场里也能算出 0.9。收益率序列去掉了趋势才是真正的同涨同跌度量。4. 完整实操从原始数据到一份分析报告4.1 环境准备与项目目录用 conda 或者 venv 建一个干净环境装四个核心库即可pip install pandas numpy scipy matplotlib项目目录我会分成四层对应前面说的分层思路stock-analysis/ ├── data/ # 原始与清洗后数据 │ ├── raw/ │ └── clean/ ├── src/ │ ├── fetch.py # 取数与更新 │ ├── clean.py # 清洗与复权 │ ├── metrics.py # 指标计算 │ └── report.py # 出图与导出 ├── output/ # 图表与报告 └── run.py # 主流程把指标函数全部放进 metrics.py好处是可以在 notebook 里 import 单独测试。我习惯每写完一个函数就用一段已知答案的小数据验证一遍比如构造一个从 100 涨到 120 再跌到 90 的序列最大回撤应该是 -25%年化收益可以手算核对。先验证算法再跑真实数据这个顺序反了会让你在几百个数字里找 bug。4.2 核心指标计算的完整代码下面这一段是我日常用的核心函数集去掉了业务相关的部分保留了通用逻辑。import numpy as np import pandas as pd TRADING_DAYS 252 def calc_returns(close: pd.Series) - pd.DataFrame: out pd.DataFrame(indexclose.index) out[ret_simple] close.pct_change() out[ret_log] np.log(close / close.shift(1)) out[cum_log] out[ret_log].fillna(0).cumsum() return out def calc_volatility(ret: pd.Series, window: int None) - float: r ret.dropna() if window: return r.rolling(window).std(ddof1) * np.sqrt(TRADING_DAYS) return r.std(ddof1) * np.sqrt(TRADING_DAYS) def calc_annual_return(close: pd.Series) - float: n len(close) - 1 if n 0: return np.nan total close.iloc[-1] / close.iloc[0] return total ** (TRADING_DAYS / n) - 1 def calc_mdd(close: pd.Series) - dict: cummax close.cummax() dd close / cummax - 1 mdd dd.min() end dd.idxmin() peak close.loc[:end].idxmax() return {mdd: mdd, peak_date: peak, trough_date: end, duration: (end - peak).days} def calc_sharpe(ann_ret: float, ann_vol: float, rf: float 0.02) - float: if ann_vol 0 or np.isnan(ann_vol): return np.nan return (ann_ret - rf) / ann_vol def summarize(close: pd.Series, rf: float 0.02) - dict: close close.dropna() rets calc_returns(close) ann_ret calc_annual_return(close) ann_vol calc_volatility(rets[ret_log]) md calc_mdd(close) return { 起始日期: close.index[0].date(), 结束日期: close.index[-1].date(), 交易日数: len(close), 累计收益: close.iloc[-1] / close.iloc[0] - 1, 年化收益: ann_ret, 年化波动: ann_vol, 最大回撤: md[mdd], 回撤持续天数: md[duration], 夏普比率: calc_sharpe(ann_ret, ann_vol, rf), 卡玛比率: ann_ret / abs(md[mdd]) if md[mdd] ! 0 else np.nan, 日收益偏度: rets[ret_log].skew(), 日收益超额峰度: rets[ret_log].kurtosis(), }calc_annual_return里的n len(close) - 1这一点很容易写错成len(close)。收益率区间的数量永远比价格点少一用错了会系统性低估年化收益样本越短偏差越大。这个错误我在早期代码里存在了很久后来做交叉验证才发现。4.3 参数计算实例手算对照一遍才敢信光看代码没有体感我拿一组构造数据走一遍完整计算你可以跟着手算核对。假设一段区间共 504 个交易日约两年起始价 10 元结束价 14 元期间最高价出现在第 300 天为 15 元之后最低跌到 11.25 元再回升。累计收益 14/10 - 1 40%。 年化收益 1.4^(252/504) - 1 1.4^0.5 - 1 ≈ 18.32%。注意这里开平方就是几何年化如果错用 40%/2 20%会高估 1.68 个百分点。最大回撤 11.25/15 - 1 -25%。回撤持续天数 从第 300 天到最低点的自然日跨度。假设日对数收益标准差为 0.0181.8%年化波动 0.018 × √252 0.018 × 15.87 ≈ 28.57%。夏普 (0.1832 - 0.02) / 0.2857 ≈ 0.571。 卡玛 0.1832 / 0.25 ≈ 0.733。这几个数字放在一起就能读出一段画像两年涨了 40%年化不到 19%但中间要承受 25% 的回撤波动率接近 29%夏普 0.57属于波动不小、回报中等的类型。如果换成另一个标的夏普 1.2、卡玛 1.5那就是明显的不同画像了。这里我想强调一个容易被忽略的细节样本区间选择会显著改变所有指标。同一只标的取 2019-2021 和取 2021-2023年化收益可能一个正 30% 一个负 20%。所以报告里必须写清楚区间而且最好同时给出多个滚动窗口的结果比如 1 年、3 年、5 年各算一遍看指标是否稳定。只给一个区间数字读者没法判断这个结论有多稳健。def rolling_summary(close: pd.Series, windows(252, 756, 1260)): rows [] for w in windows: if len(close) w: sub close.iloc[-w:] s summarize(sub) s[窗口] f{w//252}年 rows.append(s) return pd.DataFrame(rows)4.4 出图与报告导出图我一般只出四张多了读者也看不进去价格与回撤叠加图、收益率分布直方图叠正态曲线、滚动波动率曲线、多标的累计收益对比图。import matplotlib.pyplot as plt def plot_price_dd(close: pd.Series, save_path: str): fig, (ax1, ax2) plt.subplots(2, 1, figsize(11, 7), sharexTrue, gridspec_kw{height_ratios: [3, 1]}) ax1.plot(close.index, close.values, lw1.2) ax1.set_ylabel(Price) ax1.grid(alpha0.3) dd close / close.cummax() - 1 ax2.fill_between(dd.index, dd.values, 0, alpha0.5) ax2.set_ylabel(Drawdown) ax2.grid(alpha0.3) plt.tight_layout() plt.savefig(save_path, dpi150) plt.close()fill_between画回撤比折线直观得多读者一眼就能看到阴影最深处在哪。另外plt.close()千万别漏批量出图时不关figure会吃满内存跑到后面直接卡死这个坑我踩过两次才养成习惯。收益率分布图可以把正态曲线叠上去视觉上直接能看出实际分布的中间更高、两边更长比讲一堆峰度数字有效。最后汇总成一个 Markdown 或者 HTML 表格配合几张图就是一份能直接发给别人看的分析报告。我倾向于导 HTML用df.to_html()加一点点内联样式浏览器打开就能看不需要装任何东西。5. 常见问题与排查技巧实录5.1 高频问题速查表下面这张表是我这几年被问得最多、以及自己踩得最惨的问题汇总基本上覆盖了新手 80% 的困惑。现象最可能的原因排查动作解决方式日收益率出现 -50%、-90% 的异常值未复权除权日跳空查该日是否有除权除息记录用复权因子重算或换前复权数据年化波动率异常高80%同上或混入了错误价格打印收益率序列前 10 大绝对值清洗异常值确认复权口径年化收益为 nan区间内交易日太少或首尾价缺失检查 len(df) 与首尾行补齐数据或换区间夏普比率为 inf波动率为 0全是缺失值填充检查 std 是否为 0剔除填充值保留真实缺失相关系数接近 1 但两只票明显不同步用了价格而非收益率确认输入序列改用 pct_change 后的序列最大回撤起点晚于终点idxmax 未限定范围打印 peak/trough 索引限定 loc[:end] 再取 argmax峰度算出来差 3fisher 参数不一致看调用处参数统一口径并在报告中注明滚动波动率图开头一段是空白rolling 窗口默认不填充正常现象若需填充用 min_periods 控制批量出图到一半内存溢出未调用 plt.close()观察内存占用每张图后显式关闭增量更新后数据重复未去重或日期未标准化检查 trade_date 唯一性统一转 datetime 后 drop_duplicates5.2 几个我踩过的坑与实操心得坑一以为数据源的收盘价是同一个东西。早期我把两个源的数据直接 concat算出来的波动率比两个源单独算的都高。查了两天才发现一个源是前复权、一个是不复权拼接处在除权日附近产生了一个虚假跳空。解决办法是永远只用一个源做时间序列需要多源就各算各的再对比结论不要在同一列里混。坑二rolling 的窗口单位搞混。rolling(20)是 20 行不是 20 个自然日。如果中间有停牌缺行20 行可能横跨两个月。做月度波动率时我一开始写的是rolling(20)结果算出来的是最近 20 个交易日跟月度完全不是一回事。正确做法是按时间重采样到月再算或者显式用rolling(30D)这种时间窗口写法。这个区别在停牌多的标的上尤其明显。坑三把统计显著当成经济显著。用 t 检验比较两段区间的平均收益率p 值 0.03看起来显著。但日收益均值差可能只有 0.02%年化差不到 5 个百分点扣掉交易成本什么都不剩。样本量大的时候任何微小差异都会统计显著所以看到 p 值小先别激动去看效应量有多大。坑四忽略幸存者偏差。如果你想统计过去五年表现最好的板块只用当前还在的标的做样本那些退市的、被合并的根本不在你的数据里结论会系统性偏乐观。这个偏差在个股层面特别严重做横向对比时一定要意识到样本本身的筛选问题。坑五过度拟合历史区间。我曾经花了一周时间在一段三年数据上反复调参数把最大回撤压到了很漂亮的水平。换个区间一测全线崩盘。后来我给自己定了个规矩任何参数只在一段数据上确定必须在另一段完全没看过的数据上验证两段表现差异超过 50% 就说明是过拟合。注意以上所有讨论都是对历史数据的描述性统计不涉及任何对未来价格的判断。历史统计能做的是刻画风险特征、辅助理解波动来源不能替代任何形式的独立判断。6. 这套统计流程还能往哪延伸把基础指标跑通之后往下走有几个方向我按投入产出比排一下。第一个方向是横截面扩展。把单标的的 summarize 函数套到几十只标的上得到一张指标总表然后做排序、分组、画散点。比如把年化波动率做横轴、年化收益做纵轴每个点是一只标的你立刻能看出高波动是否真的带来了高回报这个问题在当前样本里的答案。再往下可以做相关性矩阵用一个热力图呈现找出哪些标的的走势高度同步。这一步几乎不需要新知识复用已有函数就能做性价比最高。第二个方向是分层统计。按月份、按星期、按涨跌状态分组看收益率分布是否有系统性差异。这类分析很容易做出看起来有规律的结果所以要格外小心多重比较问题——你测了 12 个月总有一两个月看起来显著那可能只是随机波动。我的做法是同时报告效应量和样本量并且用另一段数据交叉验证。第三个方向是滚动窗口分析。把年化收益、波动率、夏普做成 60 日滚动曲线能清楚看到风险特征随时间的变化。很多标的的问题不在于平均波动高而在于波动是阶段性爆发的滚动曲线能把这个结构显出来。这一步的代码量也很小主要是把已有函数用 rolling apply 包一层注意 apply 慢能用向量化就用向量化。第四个方向是引入基准做相对分析。算超额收益、跟踪误差、信息比率、上行/下行捕获率。这一步需要一条基准指数序列数据要求高一些但价值也大——绝对收益受市场整体涨跌影响太大相对指标才能看出这个标的本身怎么样。最后分享一个我个人的习惯每次做完一轮分析我会把关键结论写成三五句话附上区间、参数、数据源存到项目目录的一个 notes.md 里。过半年回头再看你会发现当时很多发现其实是区间选择带来的巧合而这个小本子能帮你避免重复踩同一个坑。统计这件事最贵的不是算力是记性。