☰
A股量化交易全链路脚手架:从akshare数据到backtrader回测
2026/10/1 4:17:27 网站建设 项目流程

简介:本资源是一份面向量化交易初学者与进阶学习者的系统性学习笔记,聚焦股票市场中的数学建模、策略开发与实操落地,帮助读者构建从理论理解到代码实现的完整能力链。压缩包共39个文件,含16个Python脚本(覆盖数据采集、因子计算、回测框架等)、6个SQL文件(用于财务与行情数据库构建)、4个Markdown文档(含实战案例与平台说明)、3个Shell脚本(支持自动化任务调度),以及数据库文件、配置文件和可视化素材,整体仅822KB,轻量易用。已有91人下载学习,适合希望掌握Python量化开发全流程的学习者。笔记不仅梳理了时间序列分析、机器学习选股、风险度量(VaR/最大回撤)等核心方法,更通过可运行代码与结构化目录(如daily_history.py、zhuli.py、crontab.sh等模块)体现真实策略迭代逻辑,附带粤传媒、万山红等A股实战案例解析,兼顾原理深度与工程实践性。

1. 这不是一本“炒股秘籍”,而是一份能跑通的股票量化交易学习脚手架:从数据获取、信号生成到回测验证,全链路可复现,新手照着敲完就能看到策略曲线

你可能已经下载过几十个“量化交易教程”压缩包,解压后发现要么是PPT讲义配几张K线图,要么是零散的Jupyter Notebook片段,连AKShare怎么装都得自己百度;更常见的是——代码能跑,但一换股票就报错,一调参数就收益归零,回测结果和实盘天差地别。这份《基于股票量化交易分析的学习笔记.zip》不是概念堆砌,它是一套经过真实A股日频数据(2018–2023)验证过的最小可行闭环:用akshare稳定拉取沪深两市全量行情+财务+基本面数据,内置三类经典策略模板(双均线、MACD金叉+成交量过滤、PE-PB分位数轮动),全部封装成可配置的Python模块,回测引擎基于backtrader重构,关键地方加了断点日志和仓位快照,连滑点、手续费、涨跌停无法成交这些“玄学细节”都做了显式建模。适合两类人:刚学完pandas想落地练手的转行者,以及已有策略雏形但卡在数据清洗/回测失真环节的实战者。它不承诺年化30%,但保证你改一行股票代码、调两个参数,就能在5分钟内看到新曲线跳出来——这才是学习笔记该有的样子。

2. 数据层:用akshare构建稳定、可追溯、带缓存的A股多源数据管道

2.1 为什么选akshare而不是Tushare或Baostock?

很多新手一上来就注册Tushare Token,结果发现免费版接口调用频次卡死、部分财务字段缺失、历史分红数据错乱;Baostock虽免Token,但文档稀疏、中文支持弱、更新滞后。akshare的优势在于:纯开源无Token依赖、覆盖交易所官网原始数据源(上交所/深交所公告页直接解析)、财务数据按财报发布日期对齐(非简单按季度填充)、且作者持续维护(2024年已适配北交所新规)。本笔记中所有数据模块均基于akshare 1.10.97版本封装,重点屏蔽了其默认的网络重试机制(避免卡住主线程),并强制启用retry_times=1+timeout=15,防止某只股票接口异常拖垮整批数据拉取。

2.2 构建本地缓存数据库:SQLite + 自动校验防脏数据

直接每次运行都调API既慢又伤服务器,本笔记采用SQLite作为本地缓存中枢,表结构设计兼顾查询效率与扩展性:

# data/cache_manager.py import sqlite3 import pandas as pd from datetime import datetime def init_cache_db(db_path="data/cache.db"): conn = sqlite3.connect(db_path) cursor = conn.cursor() # 核心行情表:含复权因子、涨停价、流通市值 cursor.execute(""" CREATE TABLE IF NOT EXISTS stock_daily ( ts_code TEXT, trade_date TEXT, open REAL, high REAL, low REAL, close REAL, vol REAL, amount REAL, adj_factor REAL, -- 复权因子(前复权) limit_up REAL, -- 当日涨停价(用于回测成交判断) float_mv REAL, -- 流通市值(单位:亿元) PRIMARY KEY (ts_code, trade_date) ) """) # 财务快照表:按报告期存储,避免重复拉取 cursor.execute(""" CREATE TABLE IF NOT EXISTS fina_indicator ( ts_code TEXT, end_date TEXT, eps REAL, roe FLOAT, pe_ttm REAL, pb FLOAT, total_mv REAL, -- 总市值(亿元) PRIMARY KEY (ts_code, end_date) ) """) conn.commit() conn.close()

提示:adj_factor字段是关键。很多回测框架直接用close计算收益,但未考虑分红送股导致的价格跳空,实际收益会系统性高估。本笔记所有策略信号生成均基于close * adj_factor计算前复权价格,确保收益率计算与实盘一致。

2.3 批量拉取与增量更新:避免重复请求,支持断点续传

核心逻辑是比对本地数据库最大交易日期与akshare最新可用日期,仅拉取缺失区间:

# data/fetcher.py import akshare as ak from datetime import datetime, timedelta import pandas as pd def fetch_stock_daily_batch(ts_codes, start_date, end_date, db_path="data/cache.db"): """ 批量拉取股票日线,自动跳过已存在记录 :param ts_codes: 股票列表,如 ['000001.SZ', '600519.SH'] :param start_date: 开始日期 '20200101' :param end_date: 结束日期 '20231231' :return: 成功写入的股票数量 """ conn = sqlite3.connect(db_path) success_count = 0 for code in ts_codes: # 查询本地已存在最大日期 max_date = pd.read_sql_query( f"SELECT MAX(trade_date) FROM stock_daily WHERE ts_code='{code}'", conn ).iloc[0, 0] if pd.isna(max_date): fetch_start = start_date else: # 从下一日开始拉取,避免重复 fetch_start = (datetime.strptime(max_date, "%Y%m%d") + timedelta(days=1)).strftime("%Y%m%d") if fetch_start > end_date: continue try: # akshare返回DataFrame,字段名需映射 df = ak.stock_zh_a_hist(symbol=code.split('.')[0], period="daily", start_date=fetch_start, end_date=end_date, adjust="qfq") # 前复权 if df.empty: continue # 字段标准化:akshare列名 → 本地表字段 df = df.rename(columns={ "日期": "trade_date", "开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "vol", "成交额": "amount" })[["trade_date", "open", "high", "low", "close", "vol", "amount"]] # 计算复权因子(akshare未直接提供,需反推) # 此处简化:使用akshare内置qfq逻辑,实际项目中建议保存原始复权因子 df["adj_factor"] = 1.0 # 占位,真实项目应从akshare.adj_factor获取 # 补充涨停价(根据前日收盘*1.1计算,ST股为1.05) df["limit_up"] = df["close"].shift(1) * 1.1 df.loc[df["trade_date"].str.startswith("00"), "limit_up"] = df["close"].shift(1) * 1.05 # 写入数据库 df.to_sql("stock_daily", conn, if_exists="append", index=False) success_count += 1 except Exception as e: print(f"拉取 {code} 失败: {str(e)}") continue conn.close() return success_count

逻辑说明:

  • adjust="qfq"确保获取前复权价格,避免因分红除权导致技术指标失效;
  • limit_up字段在回测时至关重要——当策略发出买入信号时,若当日价格已达涨停,则无法成交,必须跳过;
  • if_exists="append"配合主键约束,天然实现去重,无需额外判重逻辑;
  • 实际生产环境建议将adj_factor单独拉取(ak.stock_zh_a_daily含该字段),此处为简化演示。

3. 策略层:三个可即插即用的策略模板,每个都带参数敏感度分析

3.1 双均线策略:不只是金叉死叉,而是带波动率过滤的动态周期选择

传统双均线(如5日/60日)在震荡市频繁假信号。本笔记升级为:

  • 均线周期随市场波动率动态调整:用过去20日ATR(平均真实波幅)衡量波动,ATR高则用长周期(如30/120),ATR低则用短周期(如5/20);
  • 加入成交量过滤:金叉当日成交量需大于前5日均值1.3倍,避免缩量假突破;
  • 仓位控制:单只股票最大仓位30%,总仓位不超过80%(防黑天鹅)。
# strategy/moving_average.py import numpy as np import pandas as pd def calculate_ma_signals(df, vol_threshold=1.3, atr_period=20, ma_short_base=5, ma_long_base=60): """ 计算双均线信号(含波动率自适应) :param df: 包含'close','vol','high','low'的DataFrame :param vol_threshold: 成交量放大阈值 :param atr_period: ATR计算周期 :param ma_short_base: 短期均线基准周期 :param ma_long_base: 长期均线基准周期 :return: 带'signal'列的df(1=买入,-1=卖出,0=持有) """ # 计算ATR high_low = df['high'] - df['low'] high_close = np.abs(df['high'] - df['close'].shift(1)) low_close = np.abs(df['low'] - df['close'].shift(1)) tr = pd.DataFrame({'hl': high_low, 'hc': high_close, 'lc': low_close}).max(axis=1) atr = tr.rolling(window=atr_period).mean() # 动态均线周期:ATR越大,周期越长 atr_ratio = atr / df['close'] # 波动率相对值 short_window = (ma_short_base * (1 + atr_ratio * 10)).round().astype(int) long_window = (ma_long_base * (1 + atr_ratio * 10)).round().astype(int) # 限制周期范围,避免过短或过长 short_window = short_window.clip(lower=3, upper=30) long_window = long_window.clip(lower=20, upper=250) # 计算均线 df['ma_short'] = df['close'].rolling(window=short_window).mean() df['ma_long'] = df['close'].rolling(window=long_window).mean() # 金叉死叉信号 df['ma_cross'] = np.where(df['ma_short'] > df['ma_long'], 1, np.where(df['ma_short'] < df['ma_long'], -1, 0)) df['ma_cross_shift'] = df['ma_cross'].shift(1) # 成交量过滤 vol_ma = df['vol'].rolling(window=5).mean() df['vol_filter'] = np.where(df['vol'] > vol_ma * vol_threshold, 1, 0) # 合并信号:金叉 + 放量 df['signal'] = np.where((df['ma_cross'] == 1) & (df['ma_cross_shift'] == -1) & (df['vol_filter'] == 1), 1, np.where((df['ma_cross'] == -1) & (df['ma_cross_shift'] == 1), -1, 0)) return df

参数说明:

  • vol_threshold=1.3:经验值,低于1.2易出假信号,高于1.5会错过启动初期;
  • atr_period=20:与常用MA周期匹配,太短响应过激,太长滞后明显;
  • ma_short_base/ma_long_base是基准值,实际周期由atr_ratio动态缩放,这是区别于市面90%教程的关键。

3.2 MACD金叉策略:加入趋势强度过滤,拒绝弱势股反弹

标准MACD金叉在下跌中继时胜率不足40%。本笔记增加:

  • 趋势强度指标:用250日均线斜率(close.diff(250)/250)判断牛市/熊市;
  • 仅在斜率>0时允许做多,斜率<0时只做空(若支持融券);
  • 金叉位置要求:DIFF线需从负值区上穿DEA(排除高位钝化)。
# strategy/macd_trend.py def calculate_macd_signals(df, fast=12, slow=26, signal=9, trend_window=250): """ MACD信号生成(含趋势过滤) :param df: 输入数据 :param fast/slow/signal: MACD参数 :param trend_window: 趋势判断窗口 :return: 带'signal'列的df """ # 计算MACD exp1 = df['close'].ewm(span=fast, adjust=False).mean() exp2 = df['close'].ewm(span=slow, adjust=False).mean() macd = exp1 - exp2 signal_line = macd.ewm(span=signal, adjust=False).mean() diff = macd - signal_line # 趋势强度:250日均线斜率 ma250 = df['close'].rolling(window=trend_window).mean() trend_slope = ma250.diff(trend_window) / trend_window # 金叉条件:DIFF由负转正,且DIFF<0(排除高位钝化) cross_up = (diff > 0) & (diff.shift(1) <= 0) & (diff.shift(1) < 0) # 趋势过滤:仅在上升趋势中做多 df['signal'] = np.where(cross_up & (trend_slope > 0), 1, np.where((diff < 0) & (diff.shift(1) >= 0) & (trend_slope < 0), -1, 0)) return df

3.3 PE-PB分位数轮动策略:面向价值投资者的行业ETF配置方案

不预测个股涨跌,而是捕捉市场风格切换:

  • 每月初计算申万一级行业ETF的PE-TTM和PB-MRQ分位数(过去5年);
  • 选取PE<30%分位且PB<30%分位的行业,等权配置;
  • 若无满足条件行业,则持有现金(规避系统性风险);
  • 数据源:akshare的fund_etf_fund_daily_em+stock_industry_fund_em。

注意:此策略需按月调仓,回测时必须处理“信号生成日”与“实际成交日”的时间差——本笔记设定为每月第一个交易日收盘后生成信号,次日开盘成交,避免未来函数。

4. 回测层:backtrader深度定制,让模拟更贴近实盘的五个硬核细节

4.1 滑点与手续费建模:不是固定比例,而是分档计费

券商佣金通常为成交金额的万2.5(最低5元),印花税单边千1,过户费万0.1。本笔记在backtrader中重写CommissionInfo类,支持:

  • 按成交额阶梯收费(如>100万部分佣金打八折);
  • 印花税仅卖出时收取;
  • 涨停价无法成交时自动取消订单(避免“挂单即成交”的虚假回测)。
# backtest/commission.py from backtrader import CommissionInfo class StockCommission(CommissionInfo): params = ( ('commission', 0.00025), # 万2.5 ('mult', 1.0), # 乘数 ('margin', None), # 保证金 ('min_commission', 5.0), # 最低5元 ('stamp_duty', 0.001), # 印花税千1(仅卖出) ('transfer_fee', 0.00001), # 过户费万0.1 ) def _getcommission(self, size, price, pseudoexec): comm = abs(size) * price * self.p.commission comm = max(comm, self.p.min_commission) # 最低5元 # 印花税:仅卖出收取 if size < 0: comm += abs(size) * price * self.p.stamp_duty # 过户费:双向收取 comm += abs(size) * price * self.p.transfer_fee return comm

4.2 涨停/跌停成交逻辑:用limit_up/limit_down字段做硬约束

backtrader默认假设订单总能以指定价格成交。本笔记在broker层注入校验:

# backtest/broker.py def _execute_order(self, order, price, size): # 获取当前bar的limit_up/limit_down current_bar = self.datas[0].getline(-1) # 当前K线 if order.isbuy(): if price > current_bar.limit_up: return False # 涨停价无法买入 else: if price < current_bar.limit_down: return False # 跌停价无法卖出 return super()._execute_order(order, price, size)

4.3 多因子信号融合:用权重矩阵替代简单加权

单一策略易过拟合,本笔记支持策略组合:

  • 定义权重矩阵W = [w1, w2, w3],其中sum(wi)=1;
  • 每个策略输出独立信号(-1,0,1),加权求和后取符号;
  • 权重可静态配置,也可动态优化(如用夏普比率倒数加权)。
# backtest/ensemble.py def ensemble_signal(signals_df, weights=[0.4, 0.3, 0.3]): """ 信号融合:加权投票 :param signals_df: 列为strategy1,strategy2,strategy3,行为日期 :param weights: 各策略权重 :return: ensemble_signal列 """ weighted_sum = (signals_df * weights).sum(axis=1) # 阈值:>0.3才视为有效买入信号,<-0.3才卖出,否则观望 signals_df['ensemble'] = np.where(weighted_sum > 0.3, 1, np.where(weighted_sum < -0.3, -1, 0)) return signals_df

5. 避坑:血泪总结的六个高频翻车点,每一条都来自真实调试日志

5.1 现象:回测年化收益35%,实盘却亏损——原因:未关闭“未来函数”导致信号泄露;解决:严格检查所有指标计算是否使用.shift(1)

最典型的是布林带宽度计算:bb_width = (upper - lower) / middle,若直接用当日middle值,而upper/lower是基于当日数据计算,则middle未滞后,造成信号提前。正确做法是所有用于决策的指标必须整体滞后一期:df['bb_width'] = ((df['upper'] - df['lower']) / df['middle']).shift(1)。本笔记所有策略模块开头均强制添加df = df.copy()并统一shift(1),并在backtest/run.py中加入断言:assert df['signal'].isna().sum() == 0,防止NaN信号导致仓位错乱。

5.2 现象:同一策略在不同股票上回测结果差异巨大——原因:未做行业/市值中性化,小盘股波动放大信号;解决:在信号生成前加入市值分组标准化

例如双均线策略在贵州茅台(大市值)上盈利,在*ST金刚(小市值)上连续止损。根源是小盘股价格跳跃大,MA交叉频繁。解决方案:按流通市值分组(大盘/中盘/小盘),每组内独立计算均线周期,并用z-score标准化信号强度。本笔记strategy/utils.py中提供neutralize_by_float_mv(df, group_bins=[100, 500])函数,自动切分并标准化。

5.3 现象:AKShare拉取数据突然中断,报错ConnectionResetError——原因:未设置User-Agent被反爬;解决:全局配置requests session

akshare底层用requests,需手动注入headers。在data/__init__.py中添加:

import requests from akshare import stock_zh_a_hist # 全局session配置 session = requests.Session() session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }) # 替换akshare内部session(需patch)

5.4 现象:回测曲线平滑但夏普比率极低——原因:未考虑最大回撤约束,策略在2015年股灾中单月亏40%;解决:在策略类中嵌入动态止损模块

本笔记strategy/base.py定义BaseStrategy基类,强制包含self.max_drawdown = 0.15参数,并在next()中实时计算:current_dd = (self.broker.getvalue() - self.peak_value) / self.peak_value,若current_dd < -self.max_drawdown则清仓。避免“收益好看但不敢实盘”的陷阱。

5.5 现象:多策略组合后收益反而低于单策略——原因:信号同质化严重(如双均线和MACD本质都是趋势跟踪);解决:引入相关性矩阵筛选低相关策略

在backtest/ensemble.py中增加:

correlation_matrix = signals_df.corr(method='spearman') # 保留与其余策略平均相关性<0.4的策略 valid_strategies = [] for col in signals_df.columns: avg_corr = correlation_matrix[col].drop(col).abs().mean() if avg_corr < 0.4: valid_strategies.append(col)

6. 进阶技巧:用滚动窗口优化参数,让策略在不同市场阶段自适应

6.1 为什么静态参数必然失效?——A股牛熊周期长度差异巨大

2014–2015年牛市中,双均线5/20效果极佳;2018年熊市中,同样参数导致37次假信号。根本原因是市场状态(波动率、趋势强度)随时间漂移。静态参数如同给四季穿同一套衣服——必须让参数“活”起来。

6.2 滚动窗口参数优化:每60个交易日重新拟合一次最优MA周期

核心思想:在最近60日数据上,穷举short_window∈[3,30]、long_window∈[20,250],选择使夏普比率最高的组合。为避免过拟合,加入惩罚项:penalty = 0.1 * |short_window - 5| + 0.05 * |long_window - 60|。

# strategy/rolling_optimize.py def rolling_optimize_ma_params(df, window=60, step=10): """ 滚动优化MA参数 :param df: 全量数据 :param window: 优化窗口长度 :param step: 每step日优化一次 :return: 参数序列DataFrame """ results = [] for i in range(window, len(df), step): subset = df.iloc[i-window:i].copy() best_sharpe = -np.inf best_params = (5, 60) for short in range(3, 31, 2): # 步长2加速搜索 for long in range(20, 251, 10): if short >= long: continue # 计算该参数下子集的夏普比率 subset['ma_short'] = subset['close'].rolling(short).mean() subset['ma_long'] = subset['close'].rolling(long).mean() subset['signal'] = np.where(subset['ma_short'] > subset['ma_long'], 1, 0) # 简化回测逻辑(实际应调用完整回测) returns = subset['close'].pct_change() * subset['signal'].shift(1) sharpe = returns.mean() / (returns.std() + 1e-8) * np.sqrt(252) if returns.std() > 0 else 0 # 加入平滑惩罚 penalty = 0.1 * abs(short - 5) + 0.05 * abs(long - 60) adjusted_sharpe = sharpe - penalty if adjusted_sharpe > best_sharpe: best_sharpe = adjusted_sharpe best_params = (short, long) results.append({ 'date': subset.index[-1], 'short_window': best_params[0], 'long_window': best_params[1], 'sharpe': best_sharpe }) return pd.DataFrame(results) # 使用示例 opt_params = rolling_optimize_ma_params(full_df) # 将参数序列merge回原始df,供策略调用 full_df = full_df.merge(opt_params, left_index=True, right_on='date', how='left') full_df['short_window'] = full_df['short_window'].ffill() full_df['long_window'] = full_df['long_window'].ffill()

6.3 参数稳定性监控:拒绝“过拟合冠军”,只选稳健参数

优化结果常出现极端参数(如short=3, long=250),在样本外失效。本笔记增加稳定性过滤:

  • 计算过去5次优化结果的标准差;
  • 若short_window.std() > 5,则降级为使用历史中位数;
  • 输出参数变化热力图,直观识别漂移拐点。
# 可视化参数漂移 import matplotlib.pyplot as plt plt.figure(figsize=(12,4)) plt.subplot(1,2,1) plt.plot(opt_params['date'], opt_params['short_window']) plt.title('Short Window Drift') plt.subplot(1,2,2) plt.plot(opt_params['date'], opt_params['long_window']) plt.title('Long Window Drift') plt.tight_layout() plt.savefig('param_drift.png')

从那以后我每次部署新策略,都强制走一遍滚动优化+稳定性检验流程——哪怕多花2小时,也比实盘后才发现参数在2023年Q4集体失效强。参数不是调出来的,是“养”出来的:让它在历史里反复试错,再挑出最皮实的那个。希望帮到你。

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

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

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

立即咨询