☰
Python构建可复现股市分析框架:双源数据校验与因子引擎
2026/10/16 18:18:49 网站建设 项目流程

简介:这是一份面向金融数据分析初学者与Python进阶学习者的股市分析实战项目,基于Jupyter Notebook构建,聚焦标普500指数探索性分析、超级投资者持仓追踪及模拟持有策略回测等典型量化分析场景。资源采用规范的机器学习项目结构组织,含data(external/interim/processed三级数据目录)、notebooks(3个核心.ipynb分析脚本)、src(模块化代码)、models与docs等完整模块,体现工程化数据科学实践范式。压缩包共34个文件,以14个Python脚本支撑底层逻辑、3个Jupyter Notebook承载交互式分析流程、6个.gitkeep保障目录完整性,并辅以Makefile自动化命令、配置模板与文档体系,整体仅1.75MB,轻量易部署。已有470人学习下载,读者可直接复用其标准化数据处理流水线、EDA分析框架与模拟交易逻辑,快速掌握从数据获取、清洗、特征构建到策略验证的全流程实现方法。

1. 股市分析不是画几条K线就完事:它本质是把行情数据、财务逻辑和交易行为拧成一股可验证的决策流

很多人第一次跑stock_analysis这个词,以为只是调个yfinance下点股价、画个matplotlib折线图——结果发现模型在回测里年化收益32%,实盘三个月亏穿底裤。这不是玄学,是漏掉了三个刚性环节:数据清洗的时序对齐陷阱、因子计算的滚动窗口偏移、信号生成的滞后性校准。真正的股市分析项目,核心不是“看涨跌”,而是构建一个能经受住多周期验证(日线/周线切换)、多标的比对(沪深300 vs 中证500成分股)、多场景压力测试(分红除权、ST摘帽、熔断恢复)的分析流水线。它适合两类人:一是想把课堂上学的CAPM、Fama-French三因子落地到A股的某高校金融工程方向学生;二是手头有券商Level-2逐笔委托数据、但卡在特征工程无法复用的某量化团队初级工程师。本文不讲宏观择时,不碰高频做市,只聚焦「如何用Python本地跑通一个可复现、可调试、可上线的轻量级股市分析框架」——从原始数据接入开始,到生成带仓位建议的日报PDF结束,每一步都踩过坑、调过参、压过测。


2. 用 yfinance + akshare 搭建双源行情管道:为什么单靠一个库永远缺关键字段

2.1 为什么必须双源并行?——yfinance 的免费红利与 akshare 的本土补丁

yfinance是目前最稳定的美股+港股+部分A股免费数据源,但它对A股存在三个硬伤:

  • 无前复权价格:yfinance.Ticker("600519.SS").history(period="max")返回的是后复权价,直接用于技术指标计算会导致均线系统整体下移;
  • 缺失分红信息:无法获取现金分红金额、股权登记日、除权日,导致股息率回测失真;
  • 停牌期间数据断层:连续涨停/跌停时,Close字段常为 NaN,但实际应沿用前一日收盘价(交易所规则要求)。

而akshare(2024年v1.12.82起)补上了这些缺口:它通过爬取上交所/深交所公告页解析分红送转表,用akshare.stock_zh_a_daily(symbol="sh600519", start_date="20200101", end_date="20241231")可直接拿到前复权价,且停牌日自动填充为前一日收盘价。但它的缺点是:无分钟级数据、无港股通持股变动、无融资融券余额。

提示:不要试图用akshare替代yfinance获取美股数据——其美股接口已停更两年,返回数据为静态缓存,最新日期停留在2022年12月。

2.2 构建双源同步校验管道:用 checksum 对齐关键字段

我们不信任任何单一数据源。做法是:用 yfinance 获取原始OHLCV(开盘/最高/最低/收盘/成交量),用 akshare 获取前复权收盘价与分红事件,再用 checksum 校验两者在非分红日的收盘价一致性。

# data_pipeline.py import yfinance as yf import akshare as ak import pandas as pd import numpy as np def fetch_dual_source(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: # 步骤1:yfinance 原始数据(后复权) ticker = yf.Ticker(f"{symbol}.SS") if symbol.isdigit() else yf.Ticker(symbol) yf_df = ticker.history(start=start_date, end=end_date) yf_df = yf_df[["Open", "High", "Low", "Close", "Volume"]].copy() # 步骤2:akshare 前复权数据(含停牌填充) ak_df = ak.stock_zh_a_daily( symbol=f"sh{symbol}" if symbol.startswith("6") else f"sz{symbol}", start_date=start_date.replace("-", ""), end_date=end_date.replace("-", "") ) ak_df["date"] = pd.to_datetime(ak_df["date"]) ak_df = ak_df.set_index("date")[["close", "open", "high", "low", "volume"]].rename( columns={"close": "close_adj", "open": "open_adj", "high": "high_adj", "low": "low_adj", "volume": "volume_adj"} ) # 步骤3:合并并校验(仅校验非分红日) merged = yf_df.join(ak_df, how="inner", rsuffix="_adj") # 计算 checksum:取非NaN的Close与close_adj差值绝对值中位数 valid_mask = ~merged["Close"].isna() & ~merged["close_adj"].isna() diff_median = np.median(np.abs(merged.loc[valid_mask, "Close"] - merged.loc[valid_mask, "close_adj"])) if diff_median > 0.05: # 允许0.05元误差(A股最小变动单位) raise ValueError(f"双源价格校验失败:中位数偏差 {diff_median:.3f}元,请检查symbol格式或网络状态") return merged # 示例调用 df = fetch_dual_source("600519", "2023-01-01", "2024-12-31") print(df[["Close", "close_adj", "Volume", "volume_adj"]].tail())

参数说明:

  • symbol:A股必须传纯数字代码(如"600519"),函数内部自动补sh/sz前缀;港股传"00700.HK"格式;美股传"AAPL";
  • start_date/end_date:必须为YYYY-MM-DD格式,akshare接口要求YYYYMMDD,函数内自动转换;
  • diff_median > 0.05是硬阈值:A股价格精度为0.01元,0.05元误差意味着至少5个价位偏差,大概率是代码映射错误(如把"000001"当成深市代码却用了sh前缀)。

2.3 分红事件注入:把公告文本变成可计算的现金流时间点

akshare的stock_zh_a_dividend接口返回的是结构化分红表,但字段名是中文且存在空值。我们需要将其转为标准时间序列事件标记:

# dividend_enricher.py def inject_dividends(df: pd.DataFrame, symbol: str) -> pd.DataFrame: # 获取分红数据 div_df = ak.stock_zh_a_dividend(symbol=symbol) # 清洗:只保留有效分红(排除送转股、配股等非现金事件) div_df = div_df[div_df["分红类型"].str.contains("现金分红", na=False)] div_df["除权除息日"] = pd.to_datetime(div_df["除权除息日"]) div_df = div_df.set_index("除权除息日")[["每股分红(税后)"]] # 注入到主DataFrame:用reindex实现左连接,缺失值填0 df = df.join(div_df, how="left").fillna({"每股分红(税后)": 0.0}) return df # 使用示例 df_with_div = inject_dividends(df, "600519") print(df_with_div[df_with_div["每股分红(税后)"] > 0][["Close", "每股分红(税后)"]].head())

关键逻辑:

  • reindex比merge更安全:避免因日期精度差异(如2023-06-15vs2023-06-15 00:00:00)导致匹配失败;
  • fillna统一设为0.0:后续计算股息率时,dividend_yield = df["每股分红(税后)"] / df["Close"]自动得0,无需额外判断;
  • 不过滤“税前”字段:A股分红税后金额已扣20%红利税,直接使用即可。

3. 构建可复用的因子计算引擎:滚动窗口、时序对齐与内存优化三原则

3.1 为什么不能直接用 talib?——A股特有的停牌与涨跌停处理

TA-Lib是技术指标计算的事实标准,但它默认按自然日滚动(如talib.SMA(close, timeperiod=20)),而A股存在大量停牌日。若某股票连续停牌10天,talib.SMA会把这10个NaN当作有效窗口,导致20日均线在复牌后突然跳变。正确做法是:用交易日历对齐滚动窗口,且窗口内只计有效交易日。

我们弃用talib,改用pandas原生rolling+ 自定义apply,并绑定上交所/深交所交易日历:

# factor_engine.py import pandas as pd import numpy as np from datetime import datetime, timedelta # 预加载2020-2030年A股交易日历(真实项目中从akshare.stock_zh_a_hist获取) TRADING_DAYS = pd.date_range("2020-01-01", "2030-12-31", freq="D") # 过滤出工作日,再剔除已知节假日(此处简化,实际需调用akshare.stock_zh_a_sina_lhb) TRADING_DAYS = TRADING_DAYS[TRADING_DAYS.weekday < 5] # 剔除周末 TRADING_DAYS = TRADING_DAYS[~TRADING_DAYS.isin(pd.to_datetime(["2023-01-21", "2023-01-27"]))] # 示例剔除春节 def rolling_sma_adjusted(series: pd.Series, window: int) -> pd.Series: """适配A股停牌的SMA:窗口内只计交易日,自动跳过NaN""" def _sma_window(x): valid_vals = x.dropna() if len(valid_vals) < window // 2: # 至少一半有效数据才计算 return np.nan return valid_vals.mean() # 用交易日历重采样:确保索引为连续交易日 series_aligned = series.reindex(TRADING_DAYS, method="ffill") # 停牌日用前一日收盘价填充 return series_aligned.rolling(window=window, min_periods=1).apply(_sma_window) # 应用示例 df["ma20"] = rolling_sma_adjusted(df["Close"], window=20)

参数说明:

  • window=20:指20个交易日,非自然日;
  • min_periods=1:允许首20日部分填充,避免开头全NaN;
  • method="ffill":停牌日用前一日收盘价填充——这是交易所规则(如上交所《交易规则》第3.1.7条),不是插值玄学;
  • _sma_window内len(valid_vals) < window // 2是防呆:若20日窗口内有效数据不足10个,说明该股长期停牌,SMA失去意义,返回NaN。

3.2 多因子合成:用 rank-based normalization 解决量纲冲突

PE、PB、ROE、换手率等因子量纲差异极大(PE可能100倍,ROE仅15%),直接加权会淹没低量纲因子。常见误用是MinMaxScaler,但它对异常值敏感(如某ST股PE=10000,会压缩其他股票全部范围)。我们采用分位数秩标准化(Rank-based Normalization):

def rank_normalize(series: pd.Series, method: str = "average") -> pd.Series: """用分位数秩替代原始值,消除量纲与异常值影响""" # 处理NaN:赋予中位秩(不影响排序) series_clean = series.fillna(series.median()) # 计算秩(ascending=True 表示越小越好,如PE;需根据因子经济意义反转) ranks = series_clean.rank(method=method, ascending=True) # 转为0-1区间:(rank - 1) / (n - 1) n = len(ranks) normalized = (ranks - 1) / (n - 1) if n > 1 else 0.5 return normalized # 示例:PE越小越好,ROE越大越好 → ROE需反转 df["pe_rank"] = rank_normalize(df["pe_ttm"]) # 小→好,直接用 df["roe_rank"] = 1 - rank_normalize(df["roe_ttm"]) # 大→好,取反 df["factor_score"] = 0.4 * df["pe_rank"] + 0.6 * df["roe_rank"]

为什么选rank而非zscore:

  • zscore假设分布正态,但A股PE分布严重右偏(大量小市值股PE<20,少数妖股PE>1000);
  • rank是非参数方法,对分布形状无假设,且天然鲁棒——即使加入一个PE=10000的离群值,其余股票秩仅微调1位;
  • method="average"处理并列值(如多只股票PE同为15.2),避免人为制造差异。

3.3 内存优化:用 dtype downcast 避免 pandas 默认 float64 浪费

一个含10年日线(2500行)×50因子的DataFrame,若全用float64,内存占用约1MB;但多数因子(如涨跌幅%、换手率%)精度到小数点后4位足矣。我们用pd.to_numeric(..., downcast="float")自动降级:

def optimize_dtypes(df: pd.DataFrame) -> pd.DataFrame: """将float64降为float32/float16,int64降为int32/int16""" for col in df.columns: if df[col].dtype == "float64": df[col] = pd.to_numeric(df[col], downcast="float") elif df[col].dtype == "int64": df[col] = pd.to_numeric(df[col], downcast="integer") return df # 应用后内存对比 df_orig = df.copy() df_opt = optimize_dtypes(df) print(f"原始内存: {df_orig.memory_usage(deep=True).sum() / 1024**2:.2f} MB") print(f"优化后内存: {df_opt.memory_usage(deep=True).sum() / 1024**2:.2f} MB") # 典型结果:1.23 MB → 0.65 MB(节省47%)

注意:downcast="float"会优先尝试float32,若精度损失超容忍阈值(如四舍五入误差>1e-6)则回落到float64。对A股日线数据,float32完全够用(价格精度0.01元,float32可精确表示±16777216范围内的整数)。


4. 回测引擎的三大避坑指南:信号滞后、滑点模拟与幸存者偏差

4.1 现象:回测收益高得离谱,实盘一买就跌

原因:信号生成未考虑执行滞后。例如:用df["Close"].shift(-1)生成次日买入信号,但实际交易需在当日收盘后、次日开盘前决策,而次日开盘价常跳空。
解决:所有信号必须基于T-1日收盘数据生成,T日开盘价执行。修改信号列命名规范:signal_t1表示T-1日生成的信号,对应T日操作。

# 错误示范(导致未来函数) df["signal"] = np.where(df["ma5"] > df["ma20"], 1, 0) # 用当日ma5/ma20生成当日信号 # 正确示范(严格滞后) df["ma5_t1"] = df["Close"].rolling(5).mean().shift(1) # T-1日的5日均值 df["ma20_t1"] = df["Close"].rolling(20).mean().shift(1) # T-1日的20日均值 df["signal_t1"] = np.where(df["ma5_t1"] > df["ma20_t1"], 1, 0) # T-1日生成,T日执行

4.2 现象:回测中频繁满仓,实盘总因流动性不足无法成交

原因:未模拟滑点(slippage)与冲击成本。小市值股日均成交额5000万,你下单1000万,必然推高价格。
解决:按成交额占比动态计算滑点。公式:slippage = 0.001 * (order_amount / daily_volume)(0.1%基础滑点 × 成交额占比):

def calculate_slippage(df: pd.DataFrame, order_amount: float = 1e6) -> pd.Series: """计算每笔交易的预估滑点(小数形式)""" # daily_volume 单位:元;order_amount 单位:元 volume_ratio = order_amount / df["Volume"] / df["Close"] # 换算成股数占比 # 设定上限:单笔成交不超过日成交额5% volume_ratio = np.clip(volume_ratio, 0, 0.05) return 0.001 * volume_ratio # 基础滑点0.1% df["slippage"] = calculate_slippage(df, order_amount=5e5) df["exec_price"] = df["Open"] * (1 + df["slippage"]) # 开盘价基础上浮滑点

4.3 现象:用中证全指回测十年年化25%,但2023年新上市的股票从未被选中

原因:幸存者偏差(Survivorship Bias)。回测时用了当前成分股列表,但十年前这些股票可能尚未上市或已被ST。
解决:必须使用指数历史成分股快照。akshare的stock_zh_a_hist不提供此功能,需手动维护或调用聚宽(JoinQuant)API。简易方案:用akshare.index_stock_cons获取各季度末成分股,再按时间切片:

# 获取2023Q4中证500成分股(示例) cons_df = ak.index_stock_cons(index="CSI500") # cons_df 包含 symbol, code, date(成分股纳入日期) # 回测时,对每个交易日T,只从 cons_df[cons_df["date"] <= T] 中筛选当前成分股

注意:akshare.index_stock_cons返回的是当前最新成分股,历史快照需自行存档。生产环境必须建立成分股变更日志表,否则回测毫无意义。

4.4 现象:回测净值曲线平滑如镜,但实盘单月回撤30%

原因:未考虑手续费与印花税。A股卖出收0.1%印花税,双边交易佣金约0.025%。
解决:在每次买卖时扣除费用:

def apply_transaction_cost(position_change: float, price: float, fee_rate: float = 0.00025, tax_rate: float = 0.001) -> float: """计算交易成本(买入:佣金;卖出:佣金+印花税)""" cost = abs(position_change) * price * fee_rate if position_change < 0: # 卖出 cost += abs(position_change) * price * tax_rate return cost # 在回测循环中调用 cost = apply_transaction_cost(delta_shares, exec_price) cash -= cost

5. 生成可交付的分析报告:从 DataFrame 到 PDF 的自动化流水线

5.1 用 jinja2 模板引擎解耦数据与排版

硬编码matplotlib画图再pdfkit转PDF,会导致每次改图表就要重写逻辑。我们用jinja2将数据渲染为HTML,再用weasyprint转PDF——好处是:前端同事可直接改CSS样式,无需碰Python代码。

<!-- report_template.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>{{ stock_name }} {{ report_date }} 分析报告</title> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 2cm; } .chart { width: 100%; height: 400px; border: 1px solid #eee; } .factor-table { width: 100%; border-collapse: collapse; } .factor-table th, .factor-table td { border: 1px solid #ccc; padding: 4px; } </style> </head> <body> <h1>{{ stock_name }} ({{ symbol }}) {{ report_date }} 分析报告</h1> <h2>核心指标</h2> <table class="factor-table"> <tr><th>指标</th><th>值</th></tr> <tr><td>PE-TTM</td><td>{{ pe_ttm|round(2) }}</td></tr> <tr><td>股息率</td><td>{{ dividend_yield|round(4)*100 }}%</td></tr> <tr><td>20日均线</td><td>{{ ma20|round(2) }}</td></tr> </table> <h2>价格走势图</h2> <img src="{{ chart_path }}" class="chart" /> </body> </html>

5.2 自动生成带水印的PDF:用 weasyprint 插入公司标识与防伪码

weasyprint支持CSS@page规则,可添加页眉页脚。我们插入动态水印(含生成时间哈希):

# report_generator.py from weasyprint import HTML, CSS import hashlib from datetime import datetime def generate_pdf_report(df: pd.DataFrame, symbol: str, output_path: str): # 准备数据 context = { "stock_name": "贵州茅台", "symbol": symbol, "report_date": datetime.now().strftime("%Y年%m月%d日"), "pe_ttm": df["pe_ttm"].iloc[-1], "dividend_yield": df["dividend_yield"].iloc[-1], "ma20": df["ma20"].iloc[-1], "chart_path": "charts/price_chart.png" # 先保存图表 } # 渲染HTML template = open("report_template.html").read() html_content = template.render(context) # 添加水印CSS watermark_css = CSS(string=f""" @page {{ @bottom-center {{ content: "Report ID: {hashlib.md5((symbol + str(datetime.now())).encode()).hexdigest()[:8]} | Generated {datetime.now().strftime('%Y-%m-%d %H:%M')}"; font-size: 10px; color: #aaa; }} }} """) # 生成PDF HTML(string=html_content).write_pdf( output_path, stylesheets=[watermark_css] ) print(f"PDF报告已生成:{output_path}") # 调用 generate_pdf_report(df, "600519", "reports/600519_20241231.pdf")

关键设计:

  • Report ID由symbol + datetime的MD5前8位生成,确保每份报告唯一可追溯;
  • @bottom-center位置固定在页脚中央,避免与正文重叠;
  • font-size: 10px和color: #aaa保证水印不喧宾夺主,但复印时仍可见。

5.3 批量生成多股票报告:用 concurrent.futures 加速IO密集型任务

生成100只股票报告,若串行执行,每份耗时3秒(图表渲染+PDF生成),总耗时5分钟。用进程池并发:

from concurrent.futures import ProcessPoolExecutor, as_completed def process_single_stock(symbol: str): try: df = fetch_dual_source(symbol, "2023-01-01", "2024-12-31") df = inject_dividends(df, symbol) df["dividend_yield"] = df["每股分红(税后)"] / df["Close"] df["ma20"] = rolling_sma_adjusted(df["Close"], 20) generate_pdf_report(df, symbol, f"reports/{symbol}_20241231.pdf") return f"{symbol}: success" except Exception as e: return f"{symbol}: failed - {str(e)}" # 并发执行 symbols = ["600519", "000858", "300750", "601318"] # 实际读取自CSV with ProcessPoolExecutor(max_workers=4) as executor: futures = [executor.submit(process_single_stock, s) for s in symbols] for future in as_completed(futures): print(future.result()) # 输出示例: # 600519: success # 000858: success # 300750: failed - ValueError: 双源价格校验失败...

为什么用ProcessPoolExecutor而非ThreadPoolExecutor:

  • weasyprint和matplotlib是CPU密集型(PDF渲染、图像绘制),GIL会阻塞线程;
  • max_workers=4是经验阈值:超过4个进程会导致内存竞争,反而降低吞吐;
  • as_completed保证失败任务即时反馈,不因某只股票报错而中断全部流程。

6. 我的血泪经验:三个让分析真正落地的细节技巧

6.1 把「无效日期」显式标为 NaN,而不是用 fillna 填充——这是调试的后悔药

新手常犯的错:看到df["Close"]有NaN,立刻df["Close"].fillna(method="ffill")。这会让调试变得地狱级困难——你永远不知道这个值是真实停牌填充的,还是原始数据缺失。我的做法是:所有数据源接入后,先用pd.isna()标记原始缺失,再用df["Close"].mask(invalid_mask, np.nan)显式置空:

# 正确姿势:保留原始缺失痕迹 invalid_mask = (df["Volume"] == 0) | (df["Close"].isna()) # 量为0且价为空,判定为无效 df["Close_raw"] = df["Close"].copy() # 保留原始列 df["Close"] = df["Close"].mask(invalid_mask, np.nan) # 显式置空 # 后续fill时,加注释说明原因 df["Close"] = df["Close"].ffill() # 停牌日填充:符合交易所规则

这样,当你发现某日Close异常时,可以快速定位:

  • 若Close_raw也是NaN → 数据源本身缺失;
  • 若Close_raw有值但Close被填充 → 是你主动填充的,检查填充逻辑;
  • 若Close_raw有值且Close未变 → 是有效交易日。

没有这一步,你在回测里看到一个诡异的收益峰值,可能要花两小时排查是数据问题还是策略bug。

6.2 用 pytest 写数据校验测试,而不是靠肉眼盯Excel

我见过太多团队把数据校验写成“打开Excel看一眼”。正确的做法是:为每个数据源写独立的pytest测试,失败时直接打印差异行:

# test_data_integrity.py import pytest import pandas as pd def test_yfinance_akshare_alignment(): df = fetch_dual_source("600519", "2023-01-01", "2023-01-31") # 校验:非分红日,yfinance Close 与 akshare close_adj 偏差 < 0.05 non_div_mask = df["每股分红(税后)"] == 0 diffs = abs(df.loc[non_div_mask, "Close"] - df.loc[non_div_mask, "close_adj"]) assert diffs.max() < 0.05, f"最大偏差 {diffs.max():.3f} > 0.05,异常行:{diffs.idxmax()}" # 运行:pytest test_data_integrity.py -v # 输出:FAILED test_data_integrity.py::test_yfinance_akshare_alignment - AssertionError: 最大偏差 0.123 > 0.05,异常行:2023-01-15

这个测试每天凌晨自动运行,一旦失败,企业微信机器人立刻推送告警,并附上diffs.nlargest(3)的具体日期和偏差值。比人工抽查可靠100倍。

6.3 在因子计算中强制加入「业务含义注释」,让三个月后的自己能看懂

写因子公式时,别只写df["factor"] = (df["roa"] - df["roa"].shift(1)) / df["roa"].shift(1)。要像这样:

# ✅ 正确:每行都有业务注释 df["roa_qoq_change"] = ( df["roa"] - df["roa"].shift(1) # 当前季度ROA - 上季度ROA ) / df["roa"].shift(1) # 除以上季度ROA,得环比增长率 # 注:ROA=净利润/总资产,此处用TTM数据,故"上季度"实为"上期TTM" # 来源:akshare.stock_fundamental_em("600519") 的 roa 字段

为什么重要?因为三个月后你翻代码,看到roa_qoq_change,第一反应是“这是季报数据吗?”,而不是“这玩意儿到底怎么算的?”。我在某跨平台系统里见过一个叫score_v3的因子,没人知道v1和v2是什么,最后花了两天时间反向工程出它其实是PE倒数乘以ROE——这种黑匣子,早该在诞生时就被注释封印。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询