ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

量化投资实战:Python对齐财务与行情数据,构建因子与回测框架

量化投资实战:Python对齐财务与行情数据,构建因子与回测框架 简介面向量化投资初学者与金融数据分析人员这份资源完整呈现了基于财务与交易数据的量化分析流程适合用于学习从数据清洗、技术指标计算、主成分分析到策略构建与回测的落地实现。压缩包共19个文件包含9个Excel格式的财务与交易数据集、7个Python分析脚本以及3个编译缓存文件整体体积仅1.42MB结构上覆盖数据获取、指标选择、数据处理与综合排名、投资组合构建、逻辑回归策略实现等关键环节。已有129人学习下载数据文件中包含股票价格、交易量、财务报表等常见字段脚本则演示了技术指标计算、主成分分析降维、投资组合权重构建和逻辑回归模型训练等具体步骤。读者可直接运行或改造其中的Python脚本快速搭建自己的量化分析框架并理解统计套利、因子模型、时间序列与机器学习等方法在真实数据上的工程化表达。1. 量化投资分析的第一步把财务与交易数据放进同一张表拿到一份同时包含资产负债表数据和每日行情数据的 CSV第一件该做的事不是直接跑回归而是先回答一个问题这一行财务数据到底该对齐到哪一天把报告期当成信号日期回测结果会好得离谱实盘却一塌糊涂——财报还没公告价格已经把预期走完。标题里的含数据集意味着这套流程可以脱离私有数据源在本地复现只要字段对齐清洗、因子、回测链路不依赖任何交易终端。这里讨论的完整方案就是以财务数据为低频基本面、以交易数据为高频市场情绪合并成一张因子宽表后做检验与组合回测适合想自己走过下载、清洗、计算到回测全流程的人。2. 数据对齐与合并先消灭前视偏差再谈信号2.1 用公告日还是报告期对齐一个策略有没有效先看它有没有看到未来。财务数据最容易引入未来信息的地方在「日期」的选择上。绝大多数数据源会同时给出两个日期报告期report_date和公告日期ann_date。某公司的 ROE 属于 2023 年四季度不代表 2023 年 12 月 31 日就能拿到这个数真实世界里投资者直到 2024 年 3 月底才可能看到年报。回测时若用报告期去对齐行情等于让策略在 2023 年 12 月就提前知道了三个月后的数字算出来的选股收益是假的。正确的对齐原则只有一条每个交易日只能使用当天及之前已经公告的财务数据。也就是把每条财务记录挂到它的公告日上然后对交易日做「向前找最近一条公告」的匹配。用 pandas 的 merge_asof 实现最顺手它可以按时间序列做方向性匹配比先交叉连接再过滤快一个量级。2.2 字段设计与合并的 pandas 实现无论数据从哪来第一步都统一改字段名之后所有代码只认这套命名字段含义合并时的作用ts_code股票代码合并键需与交易表一致trade_date交易日左表时间列ann_date公告日期右表时间列决定信号可用时间report_date报告期只用于去重识别不参与对齐roe / revenue_yoy净资产收益率 / 营收同比财务因子入参close / pct_chg收盘价 / 涨跌幅收益计算基础合并前先做两步清洗按公告日排序后对 (ts_code, report_date) 去重取每份财报最后一次公告的版本行情表按 ts_code trade_date 去重防止数据源重复行。import pandas as pd trading pd.read_csv(daily_price.csv, parse_dates[trade_date]) financial pd.read_csv(annual_fin.csv, parse_dates[ann_date, report_date]) # 同一份财报可能被审计调整后重新公告保留每个报告期最新公告 financial financial.sort_values(ann_date).drop_duplicates( [ts_code, report_date], keeplast ) # merge_asof 要求左右表都按时间列升序排序 trading trading.sort_values(trade_date) financial financial.sort_values(ann_date) # 每个交易日只关联到今天为止最近的一条已公告财报 aligned pd.merge_asof( trading, financial[[ts_code, ann_date, roe, revenue_yoy]], left_ontrade_date, right_onann_date, byts_code, directionbackward, )这段代码里三个参数值得展开。directionbackward 是核心它规定匹配方向为「不超过当前交易日的最近一条公告」从机制上堵死前视byts_code 让匹配在每个股票内部独立进行避免用股票 A 的财报去对齐股票 B左右表先 sort_values 是 merge_asof 的硬性要求顺序错了结果会乱。跑完后 aligned 里会多出一列 ann_date表示该交易日能看到的是哪一天公告的财报。如果策略需要「财报发布后再隔一天才可交易」再对 ann_date 做一次滞后过滤即可。这个字段建议一直保留到因子计算它是后续排查数据问题的重要线索。提示merge_asof 的 backward 方向对停牌股票不会自动跨日跳过。股票停牌期间没有交易记录复牌第一天的匹配结果仍然是停牌前最近公告该行为在 A 股场景下符合预期但你要知道它存在。2.3 复权与停牌缺失的处理交易数据进入回测前必须统一复权口径。一般用前复权数据计算因子和收益用后复权数据画长期累计曲线。不同数据源的前复权基准日不同同一段历史价格在不同时间下载会得到不同数值如果因子计算和收益统计混用了两套复权价选股逻辑与收益结果会互相矛盾且这种错误极难排查。停牌带来的常见问题是股票在一段时间内没有行情记录。合并宽表后如果直接对缺失日期不处理pct_change 会把复牌日的收益和停牌前的收盘价连起来算而中间可能隔了二十个交易日。稳妥做法是保留所有股票的全部交易日网格停牌日价格填充为 NaN收益记为 NaN宁可少一段数据也不要制造一个假单日收益。3. 因子构建从财务与交易字段里提取可计算信号3.1 财务因子ROE、营收增速与毛利率财务因子的信息量在横截面比较中体现同一时点下高 ROE、营收高增长、毛利有韧性的公司未来估值修复的概率更高。单看一家公司的绝对值意义不大因此必须把各公司对齐到同一报告期再比较否则会把季度因素误判成公司差异。这里有个容易踩的坑财报里的 ROE 是累计值。一季报、中报、三季报、年报覆盖的期间不同直接拿一季报 ROE 和三季报 ROE 做横截面比较会引入季节性误差。常见做法有两种只用年报数据做年度截面或者把报表科目转成 TTM最近四个季度加总口径。fin fin.sort_values([ts_code, year, quarter]).copy() # 利润表科目是累计值diff 得到相邻期差额即单季值 fin[np_single] fin.groupby(ts_code)[net_profit].diff() # 一季度没有上期可比单季值就是本期累计值 fin[np_single] fin[np_single].mask(fin[quarter] 1, fin[net_profit]) # 单季值滚动 4 期求和得到 TTM 净利润 fin[np_ttm] fin.groupby(ts_code)[np_single].transform( lambda x: x.rolling(4).sum() ) fin[roe_ttm] fin[np_ttm] / fin[equity]这段代码的逻辑链是diff 把累计净利润转成单季净利润Q1 的 diff 结果其实是去年四季度的值所以用 mask 把 Q1 覆盖为本期累计值rolling(4) 在时间序上滚动加总得到截至当前报告期的最近四个季度净利润。转 TTM 后再算 ROE不同季度披露的公司之间才可比。3.2 量价因子动量、波动率与换手率交易数据提供财务数据完全看不到的信息市场对这家公司的定价行为。动量因子捕捉收益延续性波动率因子刻画风险水平换手率反映流动性。计算这些因子时最常见的错误是忘记按股票分组导致 rolling 窗口跨股票串行算出来的因子值毫无意义。df df.sort_values([ts_code, trade_date]) df[mom_20] df.groupby(ts_code)[close].transform( lambda x: x / x.shift(20) - 1 ) df[vol_20] df.groupby(ts_code)[close].pct_change().rolling(20).std() df[turnover_ma5] df.groupby(ts_code)[turnover_rate].transform( lambda x: x.rolling(5).mean() )transform 保证结果与原始行一一对应不会改变行数rolling(20).std 在缺失日期缺口处会照常计算窗口如果缺口太多先对每只股票重采样到统一交易日网格再 rolling。动量窗口不是固定 20可以组合 5/20/60 多窗口但窗口越多过拟合风险越高一般先从一个窗口开始有显著 IC 再扩展。3.3 去极值、标准化与市值中性化截面因子值里总会混入极少数异常样本一个录入错误的 ROE300% 会把 z-score 整体拉偏。标准化前先做去极值中位数去极值MAD比分位数截断稳健对财务指标这种厚尾分布更合适。def winsorize_mad(s, n5): med s.median() mad (s - med).abs().median() return s.clip(med - n * mad, med n * mad) # 按交易日截面做而不是全历史一起做 df[factor] df.groupby(trade_date)[factor].transform( lambda x: winsorize_mad(x) ) df[factor_z] df.groupby(trade_date)[factor].transform( lambda x: (x - x.mean()) / x.std() )MAD 去极值的 n 参数取 5表示保留中位数加减 5 倍绝对中位差以内的样本对分布尾部约束比固定分位数更灵活。z-score 假设横截面因子大致服从正态分布如果不放心可以改用排名百分位。市值中性化用回归取残差实现对 factor 与 log_market_cap 做截面回归残差就是中性化后的因子。这一步在本地回测里经常被跳过但跳过它的代价是因子实际在选「小市值」而非选好公司尤其财务因子天然与市值相关最终收益曲线会混入市值风格。4. 用最小回测框架验证财务与交易因子的选股效果4.1 单因子检验IC 与分层收益因子值算出来之后先别急着上组合回测。第一步检验它和未来收益有没有稳定的截面相关信息系数IC就是某一天的因子值与未来 N 日收益的截面相关系数多个交易日的 IC 均值、IC 标准差、IRIC 均值除以 IC 标准差分别回答「有没有用」「稳不稳定」「扣掉波动还划不划算」。# 未来 20 个交易日的收益 df[ret_fwd] df.groupby(ts_code)[close].transform( lambda x: x.shift(-20) / x - 1 ) # 每个交易日做截面相关新版 pandas 需要 include_groupsFalse ic df.groupby(trade_date).apply( lambda d: d[factor].corr(d[ret_fwd]), include_groupsFalse ) print(fIC 均值: {ic.mean():.4f}, ICIR: {ic.mean() / ic.std():.4f})计算 IC 时要注意收益窗口与因子频率匹配。日频动量因子用 5 日收益计算 IC 比较稳定月度调仓的财务因子用 20 日收益计算。用日频因子配 20 日收益会导致 IC 序列自相关过高IR 被虚高看起来很好看实盘根本达不到。4.2 月度调仓组合的完整实现IC 通过后做「月度选 Top N」的组合回测。这个实现的核心思路是每月最后一个交易日按因子值排序选股持有到下月最后一个交易日组合内等权。收益归属的关键点在于——选股日的当日收益不计入组合真正的持有期从下个月第一个交易日开始。def monthly_top_portfolio(df, n_hold20): df df.sort_values([ts_code, trade_date]).copy() df[month] df[trade_date].dt.to_period(M) df[ret] df.groupby(ts_code)[close].pct_change() # 每月最后一天选股 month_end df.groupby(month)[trade_date].transform(max) selected ( df[df[trade_date] month_end] .sort_values(factor, ascendingFalse) .groupby(month) .head(n_hold)[[month, ts_code]] .rename(columns{month: signal_month}) ) # 关键下一个月持有所以持仓月的月份减 1 是对应信号月 df[holding_signal] df[month] - 1 port df.merge( selected, left_onholding_signal, right_onsignal_month, howinner ) daily port.groupby(trade_date)[ret].mean() return (1 daily).cumprod()这段代码里month 减去 1 是持仓映射的核心交易日属于 7 月时holding_signal 是 6 月它匹配的是 6 月底生成的选股信号因此 7 月每个交易日的收益都计入组合而 6 月最后一个交易日的 month 是 6 月holding_signal 是 5 月故信号日的当日收益不会混入组合。merge 用 inner 语义退市或缺失数据的股票自动被剔除不需要额外处理。4.3 手续费、滑点与涨跌停边界不考虑交易成本的回测曲线没有参考意义。财务因子这类低频换仓策略在 A 股的成本相对可控但动量和换手率因子会被成本严重打回原形。常用参数如下成本项参考值说明佣金万 2.5 双边实际可谈到万 1.5 左右印花税卖出千 1只在卖出时征收滑点0.1% - 0.3%流动性差的票取上限冲击成本随持仓市值上升小资金可近似为 0把以上折算成单边成本约 0.15%买入佣金加滑点、卖出约 0.35%佣金、印花税、滑点叠加。月度调仓一年 12 次往返成本接近 0.5% 乘以换手频率对年化收益的侵蚀在 5 个百分点以上。涨跌停是回测里最隐蔽的陷阱。选股信号恰好出现在涨停板时次日开盘大概率买不到。信号生成后加一步过滤若选股日 pct_chg 达到涨跌幅上限的 98% 以上直接剔除该股票。这个规则逻辑简单但能显著减少回测中的纸面收益尤其对小市值股票组合效果明显。5. 回测前的数据质量核查与幸存者偏差修正5.1 交易日历与横截面股票数检查回测跑出漂亮曲线之前先做一份数据体检交易日数量是否与交易日历一致、每只股票交易记录数是否合理、横截面股票数量是否随时间异常变化。第三项直接关系到幸存者偏差——如果数据源只保留了当前仍在上市的股票历史回测会高估收益因为退市股票的亏损段全部被删掉了。def data_health_check(df): per_day df.groupby(trade_date)[ts_code].nunique() print(f交易日数量: {df[trade_date].nunique()}) print(f股票总数: {df[ts_code].nunique()}) print(f横截面股票数: {per_day.min()} - {per_day.max()}) print(f横截面曲线单调递增: {per_day.is_monotonic_increasing})真实市场的横截面股票数不是单调递增的每年都有退市和新增。如果输出显示曲线严格单调递增或者历史某年的股票数明显小于当年应有数量说明退市样本缺失。判断标准是历史截面数应该包含「后来退市但当时还在」的股票因此曲线应有起伏单调递增基本等于只有存续股票。5.2 财务空值率与基本面异常过滤财务字段的空值比异常值更危险。某股票因营收为负导致同比增速为空直接 dropna 删行会让样本偏差到「活得好」的公司。常规做法是分开处理空值打标让模型感知缺失同时用行业内中位数填充保留截面位置另外把财务数据缺失超过两年的股票整体剔除。df[is_missing_roe] df[roe].isna().astype(int) df[roe] df.groupby([industry, trade_date])[roe].transform( lambda x: x.fillna(x.median()) ) # 超过 500 个交易日无有效财务数据的样本直接剔除 bad_stocks df.groupby(ts_code)[is_missing_roe].mean() df df[~df[ts_code].isin(bad_stocks[bad_stocks 0.5].index)]行业内按日期中位数填充能最大程度保留横截面信息同时给后续模型一个「该字段缺失」的显式信号。缺失比例超过 50% 的股票直接剔除这类公司基本面质量有问题已经不是数据缺失问题。把 data_health_check 的输出和回测净值曲线放进同一份报告样本数量变化与净值变化对照着看能省下大量排查数据错误的时间。本文还有配套的精品资源点击获取
返回列表