资讯详情 用Python做股票历史数据统计分析:从清洗到回归
📅 2026/10/27 5:02:28
做股票市场分析这件事我踩过的坑比写过的代码多。刚入门那会儿我也迷信看图形找感觉盯着K线猜明天涨还是跌猜对了归功于盘感猜错了归咎于运气。后来才明白真正能沉淀下来的东西不是某一次的判断而是一套用 Python 处理历史数据、做统计分析的可复现流程——同样的数据喂进去谁跑都得出同样的数结论站不站得住脚一目了然。这篇内容就围绕使用 Python 进行股票市场分析基于历史数据的统计分析这个主题把我这几年从数据清洗、收益率计算、分布刻画到相关性回归的完整链路拆开讲。适合刚学完 pandas 想找个真实场景练手的朋友也适合做了几年分析、想回头把地基重新夯实一遍的从业者。所有代码和参数都可以直接抄去跑涉及的标的全部用虚构代称仅作技术练习与统计研究用途。1. 为什么用 Python 做历史数据的统计分析整体设计与选型1.1 从看图猜到用数据说话的思维切换我得先把这件事的定位说清楚。用 Python 做股票市场分析核心不是预测明天涨跌而是回答一类可量化的问题这段历史里这个标的的日均收益是多少、波动有多大、最惨的时候回撤了多少、它和另一个标的的联动性强不强、这些统计特征在过去三年里有没有发生结构性变化。这类问题的共同点是答案必须是可复现、可量化、可证伪的。可复现意味着换个人、换台机器、隔半年再跑一遍结果一致可量化意味着结论落在一个具体的数上而不是感觉偏强可证伪意味着你能提前说清楚什么情况下这个结论就不成立了。看图猜最大的问题不是准确率低而是无法归因。你判断错了是因为数据有问题、逻辑有漏洞还是纯粹运气差分不清。而统计分析把每个环节都摊开在代码里错在哪一行、哪个假设不成立一查就知道。这也是我坚持用代码而不是表格软件做这件事的根本原因不是工具崇拜是可审计性。一份分析报告如果别人无法复算那它就只是一篇个人随笔。另外要提前打个预防针统计分析给出的结论绝大多数是描述性和关联性的不是因果性和预测性的。历史波动率低不代表未来波动率低两只标的过去相关性高不代表未来还高。把描述性结论当成预测依据是新手最容易犯的错。我在后面每一节都会强调这个边界。1.2 技术栈选型为什么是这套组合做历史数据的统计分析Python 生态里有太多选择但真正高频用到的其实就那么几个。我把它们的分工和选型理由列成一张表方便你判断自己需不需要装全套。库承担的角色选它的理由什么情况下可以不用pandas时间序列容器、对齐、分组、滚动窗口带 DatetimeIndex 的 DataFrame 天生适配行情数据rolling/resample 一行搞定基本绕不开numpy向量化数值计算、矩阵运算比 Python 原生循环快一到两个数量级写起来还短只用 pandas 也能跑但慢matplotlib静态图表可控性最强任何一张图都能精细调想交互式看图可换别的scipy.stats分布检验、分位数、相关性正态性检验、JB 检验、秩相关都在这里只算均值和标准差可以省statsmodels回归、残差诊断、时间序列模型输出完整的回归摘要系数、t 值、p 值、R²比手写省事只想算个相关系数不必装为什么不上更重型的量化框架我的看法是那些框架解决的是策略回测和组合管理的问题而这篇内容聚焦的是数据本身的统计刻画。在你还没搞清楚自己手里的数据干不干净、收益率算法对不对的时候套一个复杂框架上去只会让错误被包装得更漂亮。先用最朴素的工具把数据搞对再考虑上框架这个顺序我建议别颠倒。还有一个现实考量这几个库的文档和社区问答极其丰富你遇到问题基本都能搜到答案。用冷门工具省下来的那点代码量远不够弥补排查问题花掉的时间。1.3 完整分析链路的拆解我把整个流程拆成六段每一段都有明确的输入输出这样任何一步出错都能快速定位。获取拿到原始行情数据包含日期、开高低收、成交量、成交额等字段。清洗处理缺失交易日、重复行、停牌零成交、异常极值、字段类型错误。对齐多标的分析时把各自的交易日历对齐成统一索引处理某一方停牌造成的空洞。统计计算收益率、波动率、回撤、偏度峰度、分位数、VaR、相关系数、回归系数。可视化把统计结果画成净值曲线、直方图、滚动窗口曲线、热力图。结论把数字翻译成人能看懂的描述并明确写出这个结论的适用边界。我最想强调第 4 步之前的那三步。很多人拿到数据直接df[close].pct_change().std()一行出结果然后拿去写报告。如果原始数据没做复权、中间缺了几天、有一行重复这个标准差就是错的而且错得毫无提示。统计分析最贵的地方从来不是算是确认算的东西是对的。提示给每个步骤都留一个中间产物清洗后的 CSV、对齐后的 parquet出问题时能快速回滚到上一步对比比一口气跑通全流程要省时间得多。2. 数据获取与清洗整个分析最脏也最值钱的部分2.1 三种常见数据来源的取舍历史行情数据的获取路径大致有三条各有各的坑我按实际使用体验说说取舍。第一条是公开接口。优点是省事写几行代码就能拉到几年数据缺点是字段口径不统一、历史长度受限、偶尔限频或改版导致脚本挂掉。我一般只在做快速验证时用它真要沉淀分析结果会先把拉下来的数据落成本地文件后续分析全部读本地。这样做的理由是隔离不确定性数据源变了我的历史分析结果不会跟着变。第二条是从券商或行情软件导出 CSV。优点是字段齐全、复权方式可选、历史长缺点是文件名和格式五花八门有的用 GBK 编码有的价格字段带千分位逗号有的日期写成20240105有的写成2024/1/5。处理这类文件读取阶段就要显式指定encoding、dtype、parse_dates不要指望自动推断。我吃过一次亏某个文件的价格字段被自动识别成字符串后面所有算术运算全变成长度拼接跑出来的均值离谱到我自己都不信。第三条是自建本地数据库。适合需要长期跟踪几十上百个标的的场景用一份统一 schema 存起来查询快、可版本化。代价是前期要写同步脚本、处理增量更新和去重。如果你只是做单次分析没必要上这套。不管走哪条路我建议的第一步都是把原始数据原样存档一份只读不写。清洗永远在副本上做这样即使清洗逻辑写错了原始数据还在重来一遍成本很低。2.2 复权处理不处理这一步后面全是错的这是我最想敲黑板的一节。行情数据分不复权、前复权、后复权三种口径选错了你的收益率统计会凭空多出一堆根本不存在的暴涨暴跌。原理不复杂标的分红派息或送转股时交易所会在除权除息日把价格按比例下调。假设某标的除息前收在 100 元每股派 5 元除息日开盘参考价就变成 95 元。如果你拿不复权的收盘价序列算收益率这一天会算出 -5% 的跌幅——但实际上持有人的总资产没变那 5 元是拿到手的现金。这个 -5% 是纯粹的统计噪声不是真实亏损。如果一只标的每年分红两次连续十年你的收益率序列里就凭空多出二十个假的暴跌点波动率被显著高估最大回撤也会失真。三种口径怎么选后复权以最早的价格为基准把后续分红再投资的收益累加上去价格序列单调可比适合做长期收益率和波动率的统计。我默认用这个。前复权以最新价格为基准往回调整图上看起来和当前报价连续适合看图形和算短期指标。缺点是每次除权后整个历史序列都会变不利于结果存档复现。不复权只在需要研究真实成交价的场景用比如算涨跌停、算最小变动价位、算持仓市值。我的做法是统计用后复权展示用前复权原始价另存一份。三份数据各司其职不要混用。注意如果你在数据源那边勾选了前复权然后把结果存成本地 CSV 长期复用那么半年后该标的再次除权你的历史数据和最新的前复权数据就对不上了。这种错位极难发现表现出来就是某一天收益率突然异常。所以长期存档一定要用后复权。2.3 数据清洗的六个必查项清洗这活儿没有标准答案但有六个项我每次必查检查一遍大概五分钟能挡掉后面八成的问题。第一缺失的交易日。看实际行数和理论交易日数的差额。缺失有两种一种是数据源漏了一种是当天停牌。前者要补后者不能补——补出来的是假数据。判断方法如果某天没有行情记录但前后都有先查是不是停牌日。第二重复行。同一个日期出现两条记录通常来自增量更新的重复写入。去重时保留哪一条要想清楚我一般保留最后一条假设是最新修正的数据并在日志里记录去重了多少行。第三停牌与零成交。成交量为 0 或价格全天不变的日子说明当天几乎没有交易。这类日子的收益率算出来是 0会人为压低波动率。处理方式取决于目的算波动率时我倾向于把它们标记出来而不是直接删因为删了会让时间间隔变得不均匀。第四异常极值。单日涨跌幅超过合理范围超出当日涨跌幅限制的或者是负价格、零价格基本都是数据错误。要单独列出来逐条核对不要用clip一刀切那会把真实的极值也削掉。第五时区与交易日历。跨市场分析时这条特别致命。不同市场的交易日历不同节假日错开是常态直接按日期外连接会出现大量空值。正确做法是先确定一个主日历再用reindex或asof对齐并对齐后做缺失标记。第六字段类型。价格是不是浮点、成交量是不是整数、日期是不是真正的 datetime 类型。这三点看起来低级但dtype错了后面所有计算都白搭而且 pandas 有时不报错只是默默给你一个奇怪的结果。2.4 一段可以直接复用的加载与校验代码下面这段是我常用的模板把加载、类型转换、基础校验串在一起跑完会打印一份体检报告。import pandas as pd import numpy as np def load_price(path, date_coldate, price_colclose, vol_colvolume): df pd.read_csv( path, encodingutf-8, parse_dates[date_col], dtype{price_col: float64, vol_col: float64}, ) df df.rename(columns{date_col: date, price_col: close, vol_col: volume}) df df[[date, close, volume]].dropna(subset[date]) df df.sort_values(date).drop_duplicates(subsetdate, keeplast) df df.set_index(date).asfreq(B) # 按工作日重采样暴露缺口 # 基础体检 report { 行数: len(df), 缺失收盘价: int(df[close].isna().sum()), 零成交量天数: int((df[volume].fillna(0) 0).sum()), 非正价格行数: int((df[close] 0).sum()), 日期跨度: f{df.index.min().date()} ~ {df.index.max().date()}, } return df, report df, report load_price(demo_prices.csv) for k, v in report.items(): print(f{k}: {v})几个细节值得说。asfreq(B)会按工作日重建索引把缺失的日子显式变成NaN这比看起来连续实际有洞要安全得多因为后面任何聚合都会把NaN暴露出来。drop_duplicates(keeplast)假设后写入的是修正值如果你的数据源不是这个逻辑要改成first。体检报告里我特意保留了非正价格行数因为负价格几乎必然是错误数据早发现早处理。3. 收益率与波动率统计分析的起点3.1 简单收益率和对数收益率到底用哪个这是新手问得最多的一个问题我的回答是两个都要会看场景选。简单收益率定义是r_t P_t / P_{t-1} - 1直观能直接加总成组合收益按权重加权也容易向非技术背景的人解释。缺点是跨期不可加两个连续交易日的简单收益率不能直接相加得到两日收益必须先转成(1r1)*(1r2)-1。对数收益率定义是r_t ln(P_t / P_{t-1})最大优势是时间可加性日对数收益率直接相加就精确等于区间对数收益率。做多期统计、做滚动窗口累加、做蒙特卡洛路径模拟用对数收益率会简洁很多。另外它天然对称10% 和 -10% 在对数尺度上是等距的做分布拟合更舒服。我的默认习惯是单标的的时间序列统计用对数收益率跨标的加权、组合归因用简单收益率。两者在日频、小幅波动的情况下数值差异极小日收益 1% 以内差异在万分之一量级但在做严谨的年度统计时差异会显现出来。df[log_ret] np.log(df[close]).diff() df[simple_ret] df[close].pct_change()np.log(close).diff()和np.log(close / close.shift(1))等价但前者写法更短也避免了显式shift产生的临时列。注意diff()第一行必然是NaN这不是 bug。3.2 年化为什么乘的是 252 而不是 365年化波动率的公式是日波动率 × sqrt(N)其中 N 取 252因为 A 股和多数市场一年大约有 250 到 252 个交易日365 天减去约 104 天周末和约 11 天法定假日再考虑调休落在 242 到 250 之间。用 252 是行业惯例和实际交易日数的细微偏差可以接受关键是同一份分析里保持口径一致。年化收益率要小心有两种算法。算术年化是日均收益 × 252复利年化是(1 累计收益) ** (252 / 实际天数) - 1。前者高估长期收益后者更接近真实体验。我做短期一年内统计时用算术做长周期三年以上时用复利并在报告里写清楚用的哪一种。还要注意一个坑年化波动率对小样本极不稳定。只有 20 个交易日的数据算出来的年化波动率误差范围可能比数值本身还大。我一般要求至少 120 个交易日才输出年化波动率少于这个数就只报日频指标。3.3 最大回撤的计算细节最大回撤衡量的是从任意一个历史高点买入最惨的时候亏多少公式是max(1 - 当前净值 / 历史最高净值)。实现上先算累计净值再取累计最大值两者相除得到回撤序列取最小值。def max_drawdown(nav: pd.Series): running_max nav.cummax() drawdown nav / running_max - 1.0 mdd drawdown.min() trough_date drawdown.idxmin() return mdd, trough_date, drawdown nav (1 df[log_ret].fillna(0)).cumprod() mdd, trough, dd_series max_drawdown(nav) print(f最大回撤 {mdd:.2%}谷底日期 {trough.date()})两个容易忽略的地方。第一净值必须包含分红再投资所以这里用后复权价格用不复权价格算出的回撤会虚高。第二最大回撤是单点极值它不告诉你恢复用了多久。所以我通常会额外算一个回撤持续期从峰值日到净值重新回到前高的交易日数。这个指标比回撤本身更能反映持有体验有些标的回撤不深但三年没回来那一样难受。3.4 补偿性统计夏普类指标的注意事项很多人算完收益率和波动率就会顺手算一个夏普比率这里我要提醒一句夏普比率的分子必须用超额收益标的收益减去无风险利率不是直接用标的收益。无风险利率取多少、按什么频率折算都要写清楚。用 0 作为无风险利率算出来的数值严格说不叫夏普比率。另外夏普比率对收益分布的正态假设很敏感。如果收益率序列是尖峰肥尾的后面会讲金融数据几乎都是夏普比率会高估风险调整后的表现。这时候用 Sortino 比率分母只算下行波动会更合理。我一般两个都算差值大的时候说明下行风险是主要矛盾。4. 分布特征与风险度量均值之外的世界4.1 正态性检验金融收益率从来不是正态的教科书喜欢用正态分布描述收益率但只要你真的画一张日收益率的直方图就会发现它的形状完全不是钟形中间特别尖两头特别厚。这个现象叫尖峰肥尾leptokurtic是所有金融时间序列的共同特征。为什么会这样直白地说大部分日子风平浪静收益率集中在 0 附近但偶尔会出现极端行情一天跌掉平时一个月的波动。正态分布假设极端事件出现的概率极低3 个标准差以外只有 0.27%而实际数据里 3 个标准差以外的日子可能占 2% 到 3%差了一个数量级。这个差异在做风险度量时是致命的。检验方法我用三个Jarque-Bera 检验同时检验偏度和峰度是否为 0 和 3。原假设是数据服从正态分布p 值小于 0.05 就拒绝。Shapiro-Wilk 检验小样本5000 以内更灵敏但样本太大时会过度拒绝。KS 检验和指定分布比较需要先估参数再检验两步走时要小心。实践中几乎必然拒绝正态假设所以结论不是拒绝就完了而是接下来所有指标都要考虑这个偏离。4.2 偏度、峰度与分位数三个描述形状的抓手偏度衡量分布的不对称性。负偏度意味着左尾更长也就是极端下跌比极端上涨更常见、幅度更大。峰度衡量尾部厚度正态分布峰度为 3金融数据的峰度经常在 5 到 15 之间。我习惯报超额峰度峰度减 3这样正态时正好是 0读起来方便。分位数是更直接的风险刻画方式。5% 分位数的意思是历史上最差的 5% 的日子里跌幅至少是多少。这个数比标准差直观得多因为它是一个真实发生过的收益水平不依赖任何分布假设。我通常同时报 1%、5%、25%、50%、75% 分位数组成一个分布快照。指标正常参考区间含义异常时提示什么日均对数收益接近 0万分之几长期漂移显著为正/负需检查复权是否处理日波动率1% ~ 3%日常波动幅度低于 0.5% 或高于 5% 都要查数据超额峰度1 ~ 10尾部厚度大于 20 大概率有异常值污染偏度-1 ~ 1不对称性绝对值大于 2 先怀疑数据最大回撤视周期而定最惨持有体验超过 80% 要核复权这张表我放在分析脚本的打印输出里每次跑完扫一眼能快速发现异常。4.3 VaR 的三种算法以及它们的边界VaR在险价值回答的问题是在给定置信水平下未来某段时间的最大可能损失是多少。比如 95% 置信水平的日 VaR 是 3%意思是有 95% 的把握单日损失不超过 3%。三种常见算法历史模拟法直接把历史收益率排序取对应分位数。优点是简单、不假设分布缺点是假设历史会重演如果样本期没经历过极端行情VaR 会严重低估风险。样本量太小也别用这个方法。参数法方差-协方差法假设收益率服从正态分布公式是VaR -(μ z * σ)z 取 1.64595%或 2.32699%。优点是计算快、参数少缺点就是上节说的正态假设不成立会低估尾部风险。蒙特卡洛模拟用历史数据估计参数或经验分布模拟出上千条路径取模拟结果的分位数。灵活性最高可以塞进各种假设但计算量大且结果取决于模型设定——模型错了模拟出来的数再好看也是假的。我实际用的时候是三个都算然后对比。如果参数法算出来的 VaR 明显低于历史模拟法说明尾部很肥正态假设不适用后续风险度量要以历史法或 CVaR 为准。另外VaR 有个数学上很讨厌的性质它不满足次可加性两个组合合并后的 VaR 可能大于各自 VaR 之和。所以我通常会补一个CVaR条件 VaR也叫期望损失即超过 VaR 阈值后那些最差日子的平均损失这个指标满足一致性风险度量的要求。from scipy import stats def var_report(ret: pd.Series, level0.95): r ret.dropna() hist_var -np.percentile(r, (1 - level) * 100) mu, sigma r.mean(), r.std(ddof1) param_var -(mu stats.norm.ppf(1 - level) * sigma) tail r[r -hist_var] cvar -tail.mean() if len(tail) else np.nan return {历史法VaR: hist_var, 参数法VaR: param_var, CVaR: cvar, 样本量: len(r)} for k, v in var_report(df[log_ret], 0.95).items(): print(f{k}: {v:.4f} if isinstance(v, float) else f{k}: {v})这里ddof1用的是样本标准差和 pandas 默认一致stats.norm.ppf(1-level)在 level0.95 时是 -1.645前面加负号后整个式子的符号约定是损失为正写报告时要在表头注明否则正负号很容易被误读。4.4 一句话概括统计结论的边界我特别想说这些分布统计量描述的是过去那段样本期的特征它们的有效期取决于市场结构是否发生改变。一个标的的波动率在政策环境、交易规则、参与者结构发生变化后历史统计量的参考价值会快速衰减。所以我每次输出结论都会附上样本区间、样本量、以及这段区间内有无明显结构性变化的说明。这不是免责声明是分析本身的一部分。5. 多标的之间的统计关系相关与回归5.1 相关系数的三个陷阱单标的分析做完下一步通常是比较两个或多个标的。相关系数是最常用的工具但有三个坑我踩过不止一次。第一个坑皮尔逊相关系数对异常值极度敏感。一个极端值就能把 0.2 拉到 0.6。判断方法很简单算一遍皮尔逊再算一遍斯皮尔曼秩相关两者差得远就说明有关系非线性或异常值在捣鬼。斯皮尔曼用的是排名抗干扰能力强代价是丢掉了幅度信息。第二个坑滚动相关比静态相关有用得多。整段样本算一个相关系数会把所有时间变化都平均掉。用 60 日或 120 日滚动窗口算你会看到相关性在市场压力期明显上升——这个现象在业内叫相关性在下跌中聚集是分散化策略失效的主要原因。图上看一眼就明白比文字解释直观。第三个坑相关不等于因果甚至相关也不等于可交易。两个标的相关系数 0.9不代表一个涨另一个一定跟着涨更不代表存在套利空间。可能存在第三个共同驱动因素也可能只是某段特殊行情造成的假象。我见过太多把高相关当成稳定规律的方案换个时间段就失效。def rolling_corr(a: pd.Series, b: pd.Series, window60): joined pd.concat([a, b], axis1, joininner).dropna() return joined.iloc[:, 0].rolling(window).corr(joined.iloc[:, 1])joininner很关键外连接会引入大量空值滚动窗口就废了。但内连接也会丢数据所以我在函数里额外打印一下对齐后的行数和原始行数对比差距大就说明两边的交易日历对不上。5.2 伪回归为什么两个无关序列能算出高 R²这是时间序列分析里最经典的陷阱值得单独说。如果你拿两条都带明显趋势的序列做回归比如两个长期上涨的价格序列即使它们毫无关系回归出来的 R² 也会很高、t 值也很显著。原因是趋势这个共同成分把两条序列绑在了一起。通俗的比喻是两个人都喝醉了往同一个方向走看起来步调一致其实是各自被地面坡度带着走不是互相影响。解决思路是先去掉趋势再研究关系。具体做法有两种一是对价格取对数差分也就是用收益率而不是价格做回归二是先做单位根检验确认序列是否平稳。如果两个序列都非平稳但它们的某个线性组合是平稳的就称这两个序列协整。协整关系比单纯的高相关更可靠因为它意味着长期存在一个稳定的均衡关系短期偏离会被拉回来。检验方法我常用 Engle-Granger 两步法先回归再对残差做平稳性检验。要注意的是这个方法在小样本下功效不高结论只能作为参考。5.3 用 OLS 拆解收益来源多标的分析里我最常用的回归形式是市场模型r_i alpha beta * r_m epsilon其中r_i是标的收益r_m是参照基准的收益。beta 衡量这个标的对基准的敏感度beta 大于 1 说明波动被放大alpha 是剥离基准影响后的超额部分。这个模型的实用价值在于把涨了拆成两部分一部分是因为整体环境在涨另一部分是这个标的自身的特点。很多看起来表现很好的标的拆开一看 alpha 接近于零全是 beta 贡献的。import statsmodels.api as sm def market_model(r_i, r_m): data pd.concat([r_i, r_m], axis1, joininner).dropna() y, x data.iloc[:, 0], sm.add_constant(data.iloc[:, 1]) res sm.OLS(y, x).fit() return res res market_model(df[log_ret], benchmark[log_ret]) print(res.summary())看summary()的时候别只看 R²。要重点看三处系数的 p 值是否显著异于 0、Durbin-Watson 统计量残差是否存在自相关正常应在 2 附近、以及残差的标准差衡量未被模型解释的风险。R² 低不代表模型没用日频收益率的 R² 在 0.3 到 0.6 之间是常态追求高 R² 反而容易过拟合。注意回归前一定要检查两边的时间索引是否严格对齐。我遇到过一次两个序列各自从不同日期开始concat后前几十行是错位的跑出来的 beta 完全错误但因为 R² 很高差点没发现。现在我的固定动作是先print(len(a), len(b), len(joined))三个数对不上就先停下来查。5.4 多重比较别在噪音里找规律做多标的扫描时一个隐蔽的坑是多重比较。如果你同时检验 50 个标的的某个统计特征即使全都是纯随机数据按 5% 显著性水平也会有约 2 到 3 个显著。然后你把这三个人为挑出来写进报告结论就是假的。应对办法有两个方向一是降低单次检验的显著性门槛Bonferroni 校正把 0.05 除以检验次数二是控制错误发现率FDRBenjamini-Hochberg 方法。前者保守后者在检验数量多时更实用。我自己的习惯是任何批量扫描出显著结果的结论都要在样本外的时间段再验一遍过不了这一关的一律不进报告。6. 常见问题与排查技巧实录6.1 数据层面的四个高频故障收益率序列里出现单日暴跌 50% 以上。九成是复权没处理或者数据源在某天切换了复权口径。排查方法找到那一天看不复权价格和复权价格的比值有没有跳变。两个标的的相关性莫名变成 1 或 -1。通常是某一边在窗口内是常数停牌或数据缺失被填充常数序列的相关系数无定义pandas 会给出NaN或极端值。检查方法是看窗口内的标准差是否为 0。成交量数据全是整数但偶尔出现小数。说明数据源混用了不同口径有的按手有的按股。这种问题不会报错但会让你算的换手率差 100 倍。日期索引里有重复或时区混杂。用df.index.duplicated().sum()和df.index.tz两个检查就能发现。跨市场数据尤其要统一到同一时区再做运算。6.2 逻辑层面的两个致命错误未来函数。最典型的是用了当天收盘后才知道的信息去算当天的信号或者用了全样本的均值/标准差去做标准化。做描述性统计时这不是问题但一旦你的结论涉及历史上如果这样做会怎样未来函数会让回测结果好得离谱。检查方法把数据截断到某个日期用截断前的数据重算一遍看结果是否改变。如果变了就说明用到了未来信息。幸存者偏差。只拿当前还在交易的标的做历史分析会系统性高估收益、低估风险因为表现差被淘汰的样本不在你的数据里。这个问题在分析指数成分时尤其严重。缓解办法是尽量用包含已退市标的的全样本数据或者至少在报告中明确说明这个偏差的存在。6.3 常见问题速查表现象最可能的原因快速验证处理方式日收益率出现 ±50% 以上未复权 / 口径切换对比不复权与复权价格统一改用后复权年化波动率异常低停牌日被填充为 0 收益查零成交量天数占比标记停牌日剔除或单独处理相关系数矩阵出现 NaN某序列窗口内为常数检查窗口内 std缩短窗口或剔除该段回归 R² 高达 0.95用了价格而非收益率看输入是否为价格水平改对数收益率再做结果每次跑都不一样未固定随机种子检查是否用了蒙特卡洛设置np.random.seed样本外表现大幅下滑过拟合或未来函数截断重算重做特征与检验流程6.4 几条靠踩坑换来的实操习惯第一个习惯任何一个统计量先打印样本量和时间区间。没有样本量信息的均值是没有意义的。我现在的脚本里所有输出函数都强制返回样本量就是为了防止有一天我忘了这件事。第二个习惯关键结论至少用两种方法交叉验证。波动率用日频算一遍用周频再算一遍相关性用皮尔逊算一遍用斯皮尔曼再算一遍VaR 用历史法算一遍用参数法再算一遍。两个方法结论一致可信度上一个台阶不一致就找到了值得深挖的地方。第三个习惯把所有分析参数写进配置文件不硬编码在脚本里。样本区间、滚动窗口长度、年化天数、无风险利率、VaR 置信水平这些东西一旦散落在代码各处改一个参数要翻十个文件而且极容易漏改导致前后口径不一致。我现在用一个 YAML 存这些参数脚本启动时读进来报告开头自动打印一份参数清单复现的时候一目了然。第四个习惯给每个分析结果配上图。数字看多了会麻木一张净值曲线配回撤阴影或者一张滚动波动率曲线能让你在三秒内发现异常。图不需要好看能看出问题就行。第五个习惯结论要写成如果……那么……的形式。这个标的波动率是 2.1% 是描述在样本期内该标的日波动率 2.1%若未来市场结构不变则单日 95% 分位损失约为 3.5% 才是可用于决策的表述。前一句话谁都能说后一句话才体现了你对统计边界的理解。这套流程我用了几年最大的体会是把数据弄干净、把口径统一、把样本量记牢比学会任何复杂模型都值钱。我见过太多分析报告模型写得很花哨结果一查原始数据复权没做、日期错位、样本量只有三十个交易日。这种情况下算出来的东西精度再高也是精确的错误。所以我每次开工前的第一件事永远是打开清洗脚本跑一遍体检报告看一眼行数、区间、缺失和异常确认地基是实的再往上盖楼。