如果有人问我,量化交易入门最难的一步是什么,我的答案大概率不是策略设计,也不是回测框架,而是“搞到一份靠谱的历史数据”。早期我做回测,光在数据获取上就折腾了一周:有的平台要积分、有的接口限流、有的下载下来复权乱七八糟。直到换成akshare,才把这条最烦人的路走通。今天这一篇,就把akshare从安装、取数到布林通道策略回测的完整流程,一次性讲明白,适合刚接触Python量化、想自己动手验证一个技术指标策略的人。
1. akshare安装与数据获取:数据源是策略的地基
1.1 为什么我选了akshare而不是tushare或者baostock
数据获取这件事,国内Python量化圈最常用的三个免费方案就是tushare、baostock和akshare,我一开始全试过,说点实际感受:
- tushare:老牌库,但Pro版接口要积分,初始用户每天调用次数有限制,很多高频接口要攒积分才能开。如果你只是写个布林策略看看效果,那点调用权限其实够用,但每次换接口都要去查一下自己的权限等级,有点累。
- baostock:免费且稳定,接口返回的数据质量不错,但覆盖范围偏窄,主要就是股票日线、周线、月线和少量指数数据,想看实时行情、板块资金流、期货期权这类扩展数据就抓瞎了。
- akshare:定位是“把公开网页数据变成结构化DataFrame”,所以覆盖面极其夸张,股票、基金、期货、债券、宏观、数字货币都有对应接口。不需要token、不需要注册,直接pip安装就能用,特别适合快速验证想法。
真实使用中akshare的体验是“真香与无奈并存”。香在接口多、免费、随取随用,无奈在它本质是爬虫封装,数据源页面一改版,某些接口会短期“失灵”。好在维护频率很高,定期升级版本基本能跟上。做我们这种个人量化研究,它已经足够了。
1.2 安装过程里最容易踩的坑
先说安装,很多新手在网上搜“akshare下载”或“akshare安装”,莫名其妙下载一些打包好的源码包,其实完全没必要。akshare就在PyPI上,正常用pip安装就行:
pip install akshare如果你在国内网络环境下装得慢或者超时,直接换清华镜像源,速度快非常多:
pip install akshare -i https://pypi.tuna.tsinghua.edu.cn/simple安装过程中有几个坑我印象深刻,挨个说:
第一个坑是版本冲突。akshare依赖pandas、requests、beautifulsoup4、lxml这一堆库,如果你机器上已经装了一个比较老的pandas,akshare可能直接装不上,或者装上之后某些接口一调用就报错。建议在虚拟环境里单独建一个项目环境,比如用conda:
conda create -n quant python=3.10 conda activate quant pip install akshare第二个坑是版本更新太快。akshare的接口参数经常跟着数据源网页结构调整,比如某个接口上个月还叫stock_zh_a_daily,这个月可能就推荐你用stock_zh_a_hist。所以社区里老教程的代码直接复制运行时容易报错。我的习惯是安装后先固定版本号,写进requirements.txt:
akshare==1.12.x这样至少能保证这一段时间跑代码不崩。等代码写稳了再考虑升级。
第三个坑是接口返回的字段名有变动。同一个接口,不同版本返回的列可能不完全一样,这也是很多人复制代码报KeyError的原因。后面取数时会专门演示怎么处理。
提示:安装完成后建议先执行
python -c "import akshare; print(akshare.__version__)"确认没问题,再往下走。
1.3 用stock_zh_a_hist拉取日线数据
akshare里获取A股历史行情,目前最常用、也最稳定的是stock_zh_a_hist接口。它拿的是东方财富的日线数据,一次能拉很长一段区间,非常适合回测。
核心参数有这几个:
symbol:股票代码,比如贵州茅台是600519,不需要带交易所前缀period:频率,填daily就是日线start_date与end_date:起始和结束日期,格式是YYYYMMDDadjust:复权方式,qfq前复权、hfq后复权、空字符串是不复权
拿贵州茅台从2018年到2023年底的日线数据,代码这样写:
import akshare as ak import pandas as pd import numpy as np df = ak.stock_zh_a_hist( symbol="600519", period="daily", start_date="20180101", end_date="20231231", adjust="qfq" ) print(df.head())运行之后返回的DataFrame大概长这样:
日期 股票代码 开盘 收盘 最高 最低 ... 0 2018-01-02 600519 700.0 705.0 707.0 692.0 ... 1 2018-01-03 600519 705.5 710.0 715.0 698.0 ...不同akshare版本返回的列会有些许差异,有的版本多返回“股票代码”这一列,有的版本没有。稳妥的做法是拿到数据后,先打印df.columns看一眼,再统一重命名成自己好操作的英文列名:
# 根据实际返回列名做调整,我这边版本返回的列是这些 df.columns = ["date", "code", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_chg", "change", "turnover"] df = df[["date", "open", "close", "high", "low", "volume"]].copy() df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) print(df.head())这里提醒一句:日期一定要转成datetime类型,而且一定要按时间升序排序。akshare返回的数据通常已经是升序,但我不止一次遇到过接口返回顺序变化的情况,回测如果数据顺序错了,所有信号和收益全是乱的。
1.4 为什么回测必须用前复权数据
这个点很多人忽略,但它对布林通道策略的影响非常大。
如果直接用不复权数据回测,遇到除权除息日,股价会凭空跳空一大截。比如一只股票10转10,除权日当天股价可能直接从20元变成10元,日收益率会算出一个恐怖的负值。布林带是用最近N天的收盘价算的,这种跳空会瞬间击穿上轨或下轨,产生一堆虚假交易信号。
前复权(qfq)的逻辑是保持当前价格不变,把历史价格按分红送股比例往下调,这样价格序列连续,技术指标计算才有意义。日常做技术指标回测,默认都用前复权。
后复权(hfq)则是保持上市首日价格不变,把之后的价格往上调,适合看长期真实走势,但它在回测里算出的价格和真实成交价差很多,不方便模拟交易。所以权衡下来,回测布林策略我推荐用前复权。
2. 布林通道计算:公式简单,但这两个细节常被忽略
2.1 布林带的数学构成
布林通道(Bollinger Bands)是John Bollinger在1980年代提出的波动率指标,逻辑很好理解:用一条均线表示中枢,再用标准差表示股价围绕中枢的波动范围,形成上下两条通道。
公式分三步:
- 中轨(MID):最近N日收盘价的简单移动平均
- 标准差(STD):最近N日收盘价的标准差
- 上轨(UPPER)= MID + K × STD
- 下轨(LOWER)= MID - K × STD
最经典的参数是N=20、K=2。也就是说,用20日均线当基准,上下各扩展2倍标准差。为什么K选2?理论上,如果股价服从正态分布,大约95%的价格应该落在均值±2倍标准差范围内,所以一旦价格跑到通道外,就属于“小概率事件”,要么是机会、要么是异常。当然真实市场的收益分布有厚尾特征,这个后面调参再细说。
代码实现很直白:
N = 20 K = 2 df["mid"] = df["close"].rolling(N).mean() df["std"] = df["close"].rolling(N).std(ddof=0) df["upper"] = df["mid"] + K * df["std"] df["lower"] = df["mid"] - K * df["std"]2.2 标准差用总体还是样本,结果差异有多大
一个很容易被忽略的细节是:pandas的std()默认算的是样本标准差,分母是N-1;但布林带原始定义里用的标准差,取的是总体标准差,分母是N。
代码里如果直接写df["close"].rolling(N).std(),算出来的是样本标准差,K=2时通道会比理论值略宽一点。N=20时差异不算大,但在参数N比较小(比如N=5或N=10)时,通道宽度差5%以上,会直接改变触线信号的数量和位置。
我自己的习惯是显式写ddof=0,明确告诉读者这里用的是总体标准差,避免歧义。如果你去对比不同平台算出来的布林带,有时候数字对不上,十有八九就是标准差这个细节造成的。
再说一个和rolling有关的坑:数据前20行会因为窗口不足出现NaN。回测时不能让NaN留在后续计算里,否则信号和净值全是NaN。处理方式很简单:
df.dropna(inplace=True) df = df.reset_index(drop=True)2.3 均值回归vs突破:先想清楚你要的是哪种逻辑
布林通道策略有两套完全相反的逻辑,新手经常混在一起:
- 均值回归型:价格跌到下轨,认为超跌了,买入等反弹;价格涨到上轨,认为超涨了,卖出或清仓。这套逻辑默认市场是震荡市,价格会围绕均线来回摆动。
- 突破型:价格突破上轨,认为强势启动,买入追涨;价格跌破下轨,认为弱势向下,卖出或做空。这套逻辑默认市场在趋势中,通道外是行情的开始,不是行情的结束。
同一套布林带,两种策略的收益特征完全不一样。震荡市里均值回归表现好,趋势市里突破型能把肉吃满,但如果你不分青红皂白随便选一种,很容易得出“布林策略就是个垃圾”的结论。
本文后面的代码以均值回归型为主,因为它的信号逻辑最好理解,也最能说明回测里那些细节坑。但我会在参数调优部分对比突破型的效果,让大家直观看到差异。
3. 极简回测框架:不用回测库也能看出策略好坏
3.1 为什么不直接上backtrader
很多人一学回测就想着上backtrader、vn.py这类框架,我反而不推荐在验证布林策略时一开始就用。原因很简单:框架本身有学习成本,而且框架会把你的注意力从“策略逻辑”拉到“框架调用方式”上。布林通道策略的逻辑就几行,完全可以用pandas向量化验证,先跑通逻辑,搞清楚结果,再谈迁移到重型框架。
我自己写策略原型时,一直坚持“先用最朴素的代码实现,跑出结果再考虑工程化”。这样一旦结果不对,人肉debug也容易。
3.2 信号生成与次日执行
核心信号逻辑:当日收盘价低于或等于下轨,产生买入信号;当日收盘价高于或等于上轨,产生卖出信号。
df["signal"] = 0 df.loc[df["close"] <= df["lower"], "signal"] = 1 df.loc[df["close"] >= df["upper"], "signal"] = -1signal为1代表买入,-1代表卖出,0代表无操作。这里就涉及回测里最关键的问题:什么时候执行。
如果用当天收盘价计算布林带,又用当天收盘价判断是否触发信号,然后在当天收盘价上成交,这看起来没什么问题,但实际上已经引入了未来函数——因为你在用当天全部数据走完后的收盘价,去模拟“当天收盘那一刻”的交易,逻辑上很难自洽,而且实际交易中你也几乎不可能在收盘价那一瞬间精准买入。
更稳妥的处理方式:第T天收盘后计算信号,第T+1天开盘执行。代码里通过shift(1)把信号向后挪一天:
df["exec_signal"] = df["signal"].shift(1) # 持仓状态:买入后一直持有,直到卖出信号出现 position = 0 positions = [] for sig in df["exec_signal"].fillna(0): if sig == 1: position = 1 elif sig == -1: position = 0 positions.append(position) df["position"] = positions这个循环其实是在模拟一种最简单的状态机:碰到买入信号就全仓买入,碰到卖出信号就清仓,中间什么都不做。把它整理成向量化写法也行,但循环版逻辑更透明,几万行数据跑起来也很快。
3.3 净值曲线与核心绩效指标
持仓状态确定之后,计算每日收益和策略净值:
df["daily_ret"] = df["close"].pct_change().fillna(0) df["strategy_ret"] = df["position"] * df["daily_ret"] df["nav"] = (1 + df["strategy_ret"]).cumprod()这里有一个隐含假设:我们用“当日收盘价相对前一日收盘价的收益率”乘以“当日持仓状态”来计算策略收益。由于position是T-1日信号决定的,T日收益率是T-1到T的实际价格变化,所以等于T日开盘买入、T日收盘结算的效果,这个逻辑在日线回测里是合理的。
再算几个关键绩效指标:
def calc_performance(nav, strategy_ret, annual_factor=252): total_return = nav.iloc[-1] - 1 years = len(nav) / annual_factor annual_return = (1 + total_return) ** (1 / years) - 1 # 年化波动率 volatility = strategy_ret.std() * np.sqrt(annual_factor) # 夏普比率(假设无风险利率3%) sharpe = (strategy_ret.mean() * annual_factor - 0.03) / volatility # 最大回撤 max_drawdown = (nav / nav.cummax() - 1).min() return { "累计收益": total_return, "年化收益": annual_return, "年化波动": volatility, "夏普比率": sharpe, "最大回撤": max_drawdown, } perf = calc_performance(df["nav"], df["strategy_ret"]) print(perf)最大回撤这个指标尤其重要,它衡量的是从任意高点跌下来的最大幅度。很多人只看累计收益,结果实盘遇到30%回撤心态直接崩了,所以回测一定要把回撤打出来看看。
3.4 第一版回测结果长什么样
用贵州茅台2018-2023年的前复权日线,N=20、K=2、均值回归逻辑,跑出来的结果大致是:交易次数不算多,但每次买入基本都买在阶段低点附近,累计收益通常能跑赢“买入持有”,但要区分市场环境。牛市里这种“涨到上轨就卖”的策略很容易卖飞,反而跑不赢拿着不动;震荡市里则优势明显。
第一版回测结果出来后,先别急着兴奋,下一个章节才是真正决定这个策略可不可用的关键。
4. 给回测“挤水分”:交易成本、未来函数与停牌处理
4.1 交易成本模型
一个最容易被新手忽略的事实是:如果没有交易成本,任何频繁交易策略的回测都会漂亮得假。布林均值回归策略天然就是来回打脸的策略,信号频率越高,交易成本占比越大。
真实A股交易成本至少包括三部分:
- 佣金:默认万2.5到万3,且多数券商有最低5元收费
- 印花税:卖出时单边征收,2023年调降后是成交金额的万分之五
- 滑点:买入时实际成交价可能比信号价高一点,卖出时低一点,不是总成本,但对回测影响不小
把成本加进回测的方式很简单,在每次交易时扣掉双边成本:
TRADE_COST = 0.001 # 单边万五佣金+滑点+杂费,粗略合并 df["trade"] = df["position"].diff().abs().fillna(0) df["strategy_ret"] = df["position"] * df["daily_ret"] - df["trade"] * TRADE_COSTposition.diff()表示持仓变化,变化时说明发生了买入或卖出,减去对应交易成本。这里的0.001是单边成本假设,实际你可以调成0.0005或者0.002看结果变化。
加完成本再看收益,你会发现策略的夏普比率明显下降。如果某个策略在扣掉成本后收益直接从正变负,那它基本不具备实盘价值。
4.2 未来函数的三个隐蔽变种
前面提到用shift(1)避免信号与收益同日对齐,但未来函数还有其他形态,排查起来很隐蔽:
第一种:用当日最高最低价触发信号。比如有人把买入条件写成“最低价触及下轨就买”,这在实盘里根本做不到。你能看到最低价,已经是收盘后的事了,盘中你根本不知道最低点在哪里。所以信号只能基于收盘价、开盘价这类明确可用的价格。
第二种:用未来的MACD、均线等二次指标。比如先用全量数据计算出布林带,再拿这个布林带去筛选历史信号,看似没问题,但如果计算指标时用了未来数据填充缺失值,结果就会前视。最典型的错误是fillna(method="bfill"),用后面数据填前面缺口,这在回测里绝对禁止。
第三种:在整个数据集上做标准化。有人喜欢先把全历史收益率标准化,再做策略,这等于把未来数据信息泄露了。正确的做法是只用截止到T日的数据计算T日的指标。
写回测时,建议把策略逻辑单独封装成一个函数,强制传入“截至某个日期的数据”,从架构上避免未来函数。
4.3 停牌、缺失数据的处理
akshare返回的数据偶尔会缺几天,原因通常是停牌或数据源缺失。直接dropna()把指标NaN处理掉之后,还要检查交易日期是否连续。
一个简单办法是检查相邻交易日间隔:
df["date_diff"] = df["date"].diff().dt.days print(df["date_diff"].value_counts().sort_index())正常情况下A股间隔是1天或3天(周末)。如果出现5天、10天甚至更大的间隔,说明有停牌或者数据缺失。
停牌对策略的影响是:停牌期间你根本卖不掉股票,如果策略在停牌期间发出卖出信号,等到复牌可能已经跌了一段。严谨的做法的确是为停牌日填充价格并标记不可交易状态,但对布林策略这种低频策略来说,对结果影响不算太大。只要数据别缺得太离谱,日常研究可以接受。
5. 参数寻优与多标的验证:别把过拟合当圣杯
5.1 参数网格:N和K怎么配
布林通道的两个核心参数是N(均线周期)和K(标准差倍数)。不同市场、不同股票,最优参数可能完全不同。常见的粗糙调参办法是网格搜索:
import itertools results = [] for N, K in itertools.product([10, 20, 30, 50], [1.5, 2.0, 2.5, 3.0]): df["mid"] = df["close"].rolling(N).mean() df["std"] = df["close"].rolling(N).std(ddof=0) df["upper"] = df["mid"] + K * df["std"] df["lower"] = df["mid"] - K * df["std"] df["signal"] = 0 df.loc[df["close"] <= df["lower"], "signal"] = 1 df.loc[df["close"] >= df["upper"], "signal"] = -1 df["exec_signal"] = df["signal"].shift(1) position = 0 positions = [] for sig in df["exec_signal"].fillna(0): if sig == 1: position = 1 elif sig == -1: position = 0 positions.append(position) df["position"] = positions df["strategy_ret"] = df["position"] * df["daily_ret"] - df["position"].diff().abs().fillna(0) * 0.001 df["nav"] = (1 + df["strategy_ret"]).cumprod() final_nav = df["nav"].iloc[-1] results.append({"N": N, "K": K, "final_nav": final_nav, "sharpe": calc_sharpe(df["strategy_ret"])}) best = sorted(results, key=lambda x: x["sharpe"], reverse=True)[:5] print(best)但网格搜索有一个致命陷阱:在单一标的上最优的参数,往往只是过拟合。你把茅台调到最优的N=30、K=2.5,换到招商银行、宁德时代,效果可能立刻变差。
所以真正要看的不是“最优参数有多好”,而是“参数高原有多大”。把热力图打出来,如果最优参数周围一圈参数表现都不错,说明策略在该区域是稳健的,换参数不至于崩盘;如果只有孤零零一个点表现爆表,周围全是亏损,那基本就是数据挖掘偏差,实盘大概率失效。
5.2 多标的、多时间段验证
判断策略是否有效,更扎实的做法是换标的、换时间段做笔试试卷:
- 选5-10只不同行业的股票,比如消费、银行、新能源、医药、科技各来一只
- 时间上分牛市段、熊市段、震荡段,分别跑一遍
- 看几个关键指标:胜率、盈亏比、最大回撤、年化收益
如果某个策略只在特定行业、特定时间区间有效,它的应用价值就要打个问号。我自己的标准是:策略在多数标的、多数市场环境下能跑赢买入持有,或者在相同收益下回撤明显更小,才算“有点意思”。
布林均值回归策略在多标的验证后,通常会发现一个规律:它在高波动、震荡频繁的标的上表现好,在长期单边牛股的标的上很容易卖飞。这不是策略“错了”,而是它的适用边界本来就是这样。搞清楚自己策略适合什么市场,比找到一个万能参数重要得多。
5.3 稳健性判断:参数高原vs参数孤岛
再多说一句判断参数稳健性的方法。把N和K作为横纵坐标,把策略年化收益或夏普比率作为颜色,画一个热力图:
pivot_table = pd.DataFrame(results).pivot_table(index="N", columns="K", values="sharpe")然后观察颜色分布。如果高收益区域是一片连续的“高原”,说明参数选择不那么敏感;如果只是稀疏的几个亮点“孤岛”,大概率是噪声。面对孤岛型结果,正确做法不是去深挖那一个点,而是直接放弃这套参数组合,换数据源或者换策略逻辑。
注意:网格搜索只是快速验证工具,不是专业调参手段。真要精细化优化,可以用贝叶斯优化或者walk-forward分析,但那是后话,先把网格跑明白,对新手已经足够建立“参数敏感性”的概念了。
5.4 期末小结不是必要的,但最后说一个实操小习惯
每次跑完一组回测,我都会把关键结果追加到同一个CSV里,包括标的代码、起止时间、N、K、交易次数、年化收益、最大回撤、夏普比率。日积月累,这个表会变成自己最宝贵的历史档案。后面想验证新想法,直接拉出来对照,比现跑现记高效得多。
布林通道策略本身不复杂,同类技术指标策略的通用方法论都是这样:数据要干净、信号不能前视、成本必须扣、参数必须看稳健性而不是单点最优。把这四件事做到位,你的回测结果就具备了基本的参考价值。akshare负责提供数据弹药,布林带负责产生交易想法,两者结合,足够支撑你建立起第一套完整的量化验证流程。