纯Python写的A股策略回测小工具,含6只股票8年日线数据和完整可运行代码
简介:直接解压就能跑的A股量化回测环境,内置002449.SZ、600737.SH等6只A股2015–2023年日线CSV数据,以及trade_cal.csv交易日历。核心逻辑全在PythonApplication.py里,用标准库+pandas实现,不依赖TensorFlow、Backtrader等重型框架。数据加载、信号生成、买卖执行、T+1限制、涨跌停判断、手续费(默认万三)、滑点(默认0.1%)和仓位管理都已写好,函数拆分清晰、中文注释齐全。想试自己的选股或择时逻辑?只用改signal_generation函数,返回一个和行情日期对齐的1(买)、-1(卖)、0(空仓)数组就行。运行后自动生成每日持仓表、成交明细、年化收益率、夏普比率、最大回撤等常用绩效指标,结果还能导出到Excel或画成backtest_.png。适合刚学量化的新手边看边调,也方便老手快速验证想法。
1. 这不是“玩具”,而是一把能切开A股真实交易逻辑的瑞士军刀
你有没有过这种体验:在量化社区看到一个很漂亮的策略图,年化30%、回撤不到15%,点开代码一看——依赖5个自定义包、要配环境变量、跑前还得下载GB级分钟线、回测引擎报错信息像天书?最后只能关掉页面,默默打开同花顺看K线。我做过三年券商量化支持,也带过二十多个零基础转行的学员,90%的人卡在“第一步跑不起来”上。这套工具,就是我专门拆掉所有门槛,用最朴素的方式,把A股回测里那些真正硌脚的石头——T+1怎么模拟、涨停板为什么不能下单、万三手续费怎么扣到每一笔成交、滑点是按价格还是按金额算——全给你铺平了、标清楚了、塞进一个.py文件里。
它叫“纯Python写的A股策略回测小工具”,但“小”字容易误导。它不小,它精准。6只股票(002449.SZ国星光电、600737.SH中国高科、002269.SZ美邦服饰、603518.SH维格娜丝、002415.SZ海康威视、601318.SH中国平安),覆盖消费、科技、金融、制造四个典型板块;2015–2023整整八年,完整经历牛市、熔断、贸易战、疫情、注册制改革;所有数据都是真实日线CSV,开盘价、最高价、最低价、收盘价、成交量、涨跌幅一应俱全;trade_cal.csv是沪深交易所官方交易日历,剔除了所有节假日、停牌日、集合竞价异常日——这意味着你写的信号,不会在非交易日被错误执行,也不会在复牌首日因数据缺失而跳空断裂。
关键词里“A股回测”不是泛指,它特指A股独有的交易约束;“Python量化”不是堆砌库名,它意味着你打开PythonApplication.py,第一眼看到的是def load_data()而不是from backtrader import Cerebro;“股票策略”不是空谈模型,而是你改一行return np.array([1, 0, -1, ...]),就能立刻看到这笔买卖在真实规则下到底赚没赚钱。它不教你机器学习,但教会你:为什么你的“金叉买死叉卖”在实盘会亏钱——因为没考虑涨停时无法买入,因为没算T+1导致第二天才建仓却以为当天就进了,因为万三手续费在10万元仓位上就是30块,而你策略一天交易十次,光手续费就吃掉300块。这工具的价值,不在它多炫酷,而在它多诚实。它把市场规则翻译成Python函数,把交易摩擦变成可调试的参数,把“理论上可行”和“实际上能跑”之间的那道墙,亲手凿了个洞。
2. 整体设计与思路拆解:为什么不用Backtrader?为什么坚持“全手写”?
2.1 拒绝重型框架的底层逻辑:不是不能用,而是不该用
很多新手一上来就学Backtrader或vn.py,这就像学开车先去拆发动机。Backtrader确实强大,支持多周期、多资产、复杂订单类型,但它有三个对初学者极不友好的硬伤:第一,它的事件驱动模型抽象层级太高,next()函数里一行self.buy()背后藏着订单生成、撮合、状态更新、资金划转四层逻辑,出错了你根本不知道是信号发错了,还是订单被拒了,还是资金不足;第二,它的数据加载强制要求OHLCV格式+DatetimeIndex,而你从聚宽、akshare下载的原始CSV,日期列名可能是trade_date、date、datetime,格式可能是20230101或2023-01-01,光对齐时间索引就能耗掉半天;第三,也是最关键的,它的绩效分析模块(Analyzer)输出一堆指标,但最大回撤怎么算、夏普比率分母用日收益还是年化波动率、胜率是否包含平仓盈亏——这些细节它默认封装,你想改就得重写Analyzer类,而初学者连super().__init__()都容易写错。
所以本工具选择“全手写”,不是炫技,而是为了可控性。我把整个回测生命周期切成六个原子函数,每个函数只做一件事,且输入输出完全透明:
load_data():只负责读CSV、统一列名(open,high,low,close,volume)、转日期为datetime64[ns]、按trade_cal.csv过滤非交易日、按股票代码合并成一个大DataFrame;generate_signal():只接收行情DataFrame,返回一个与日期索引严格对齐的numpy.ndarray,值域限定为{1, 0, -1},1=计划买入,-1=计划卖出,0=无操作;execute_trade():这是核心中的核心。它接收信号数组、行情DataFrame、当前持仓、可用现金,然后逐日模拟:先判断当日能否交易(是否在trade_cal里)、再判断信号是否有效(如涨停时信号为1则自动置0)、再检查T+1(昨日买入的股票今日才能卖出)、最后计算成交价(收盘价±滑点)、扣除手续费、更新现金和持仓;calculate_pnl():每日收盘后,用当日持仓×当日收盘价计算总资产,减去初始现金得到浮动盈亏,再减去已实现盈亏得到总盈亏;calculate_performance():用最终的每日净值序列,手动计算年化收益率((末值/初值)^(250/交易日数)-1)、最大回撤(遍历所有历史高点到后续低点的跌幅最大值)、夏普比率(日均超额收益/日收益标准差×√250,无风险利率设为0)、胜率(盈利交易次数/总交易次数);export_result():把每日持仓DataFrame、成交记录DataFrame、绩效指标字典,分别导出为Excel的三个Sheet,并用matplotlib画净值曲线图。
你看,没有魔法,全是加减乘除。当你发现回测结果异常,比如某天突然空仓,你只需要在execute_trade()里加一行print(f"Day {date}: signal={sig}, can_buy={can_buy}, can_sell={can_sell}"),就能立刻定位是信号错了,还是涨停限制触发了,还是T+1逻辑没生效。这种“所见即所得”的调试体验,是任何框架都无法替代的。
2.2 数据结构设计:为什么用Pandas DataFrame而不是字典或列表?
有人问:既然要轻量,为什么不用纯Python字典存数据?比如data = {'002449.SZ': {'2015-01-01': {...}}}?答案是:时间对齐成本太高。A股6只股票,每只8年约2000个交易日,但它们的停牌日完全不同。002449.SZ可能2018年停牌3个月,601318.SH全程交易。如果用嵌套字典,每次计算信号都要手动对齐所有股票的日期,写循环、判空值、补NaN,代码量翻倍且极易出错。而Pandas DataFrame天然支持MultiIndex(股票代码+日期)和reindex()方法。我在load_data()里做了三件事:第一,为每只股票单独读CSV,确保各自日期列正确;第二,用pd.concat([df1, df2, ...], keys=['002449.SZ', '600737.SH', ...])生成层级索引;第三,用trade_cal['date'].values作为目标索引,对所有股票数据reindex(),缺失值自动填充NaN。这样,当你调用data.loc[('002449.SZ', '2023-01-01'), 'close']时,如果该日停牌,就返回NaN,后续信号函数里if pd.isna(close_price): return 0一行就能处理,干净利落。
更关键的是向量化计算。比如计算均线,传统字典要写三层循环(股票→日期→计算窗口),而DataFrame一行data['ma5'] = data.groupby(level=0)['close'].rolling(5).mean()就搞定,且底层是C优化,速度提升百倍。对于回测这种需要高频计算的场景,数据结构的选择直接决定开发效率和运行效率。
2.3 规则建模:T+1、涨跌停、手续费、滑点,如何用代码“翻译”真实市场?
这是本工具区别于“玩具”的分水岭。很多所谓“回测工具”只做价格乘法,忽略规则,结果就是纸上富贵。我们一条条拆解:
-
T+1模拟:这不是一个开关,而是一个状态机。我在主循环里维护两个变量:
holding_shares(当前各股票持仓数量,dict)和buy_date_record(记录每只股票最后一次买入日期,dict)。当generate_signal()返回-1(卖出信号)时,execute_trade()会检查buy_date_record.get(stock_code)是否早于当前日期。如果是同一天,说明是当日买入当日想卖,违反T+1,信号自动失效;如果早于一天及以上,则允许卖出。这里有个细节:A股T+1指“买入后下一个交易日才能卖出”,所以buy_date_record存的是实际成交日(即信号日+1,因为信号在收盘后生成,买入发生在次日开盘),而非信号日。 -
涨跌停判断:A股日涨跌幅10%(ST股5%),但计算基准是前收盘价,不是开盘价。我在
load_data()里额外计算了limit_up和limit_down两列:limit_up = data['pre_close'] * 1.1,limit_down = data['pre_close'] * 0.9。注意,pre_close必须从数据源获取,不能用shift(1)['close']代替,因为停牌会导致shift取到错误日期的收盘价。所以数据CSV里必须包含pre_close列,这也是为什么我提供的6只股票CSV都严格按聚宽标准格式,含pre_close字段。 -
手续费:默认万三(0.0003),双向收取(买入和卖出都扣)。计算方式是:
fee = abs(trade_amount) * fee_rate,其中trade_amount = trade_price * trade_volume。这里trade_volume是实际成交股数,不是信号想买的股数——因为可能资金不足买不满,或涨停买不到,所以手续费基于真实成交额,而非计划额。 -
滑点:默认0.1%(0.001),按价格比例模拟。买入时成交价=
close_price * (1 + slippage),卖出时=close_price * (1 - slippage)。为什么不用固定金额滑点?因为1块钱的股票和100块钱的股票,同样0.1%滑点,前者1分钱,后者1毛钱,更符合市场微观结构。实测下来,0.1%对日线回测足够敏感,既能反映流动性影响,又不至于过度惩罚策略。
这四条规则,每一条都对应一行或多行可验证的代码,而不是配置项。你可以随时打开execute_trade()函数,看到if current_date <= buy_date_record.get(stock, pd.Timestamp('1970-01-01')):这样的条件判断——这就是T+1在代码里的具象化。
3. 核心细节解析与实操要点:从解压到跑出第一个结果,手把手拆解
3.1 环境准备:真的只需Python 3.8+和Pandas,不信你看requirements.txt
很多人看到“纯Python”就信了,结果一运行报ModuleNotFoundError: No module named 'pandas'。别慌,这恰恰证明它轻量。打开requirements.txt,内容只有三行:
pandas==1.5.3
numpy==1.23.5
matplotlib==3.7.1
为什么锁死版本?因为Pandas 2.0+重构了部分API,DataFrame.rolling().apply()行为有变,可能导致均线计算偏差。我选1.5.3是经过实测的稳定版,兼容Windows/macOS/Linux,且安装极快。安装命令就一句:
pip install -r requirements.txt
如果你用Anaconda,甚至可以跳过这步——pandas和numpy默认已装。真正需要你动手的,只有两件事:第一,确认Python版本≥3.8(终端输入python --version);第二,把下载的压缩包解压到任意文件夹,比如D:\backtest_demo。不需要创建虚拟环境,不需要配置PATH,不需要修改系统变量。这就是“开箱即用”的含义:解压,双击运行,或者命令行cd D:\backtest_demo && python PythonApplication.py,三秒内出结果。
提示:如果遇到
UnicodeDecodeError: 'gbk' codec can't decode byte 0xXX,说明你的系统默认编码是GBK,而CSV文件是UTF-8。解决方案有两个:一是在PythonApplication.py开头添加# -*- coding: utf-8 -*-;二是用记事本打开任意一只股票CSV(如002449.SZ.csv),另存为→编码选择UTF-8。我推荐第二种,因为它是数据源头问题,一劳永逸。
3.2 数据加载:trade_cal.csv不是摆设,它是整个回测的“时间锚点”
trade_cal.csv看起来只是个日期列表,但它决定了回测的边界。打开它,你会看到两列:date(格式2015-01-01)和is_open(1或0)。is_open=1表示该日为有效交易日。我在load_data()里做了关键一步:不是简单读取,而是用它作为“标尺”,对所有股票数据进行reindex。
具体流程如下:
1. 读取trade_cal.csv,筛选is_open == 1的行,提取date列转为datetime64[ns],存为valid_dates;
2. 对每只股票CSV,读取后设置date列为索引,转为datetime64[ns];
3. 调用stock_df.reindex(valid_dates, method='ffill')——method='ffill'很重要,它表示如果某日数据缺失(如停牌),就用上一个交易日的数据向前填充。但注意,这只是为了保持索引连续,后续在execute_trade()中会用pd.isna()检测并跳过交易;
4. 最后用pd.concat(..., keys=[code1, code2, ...])合并,形成MultiIndex DataFrame。
这个设计解决了两个痛点:第一,避免因某只股票停牌导致整个回测中断;第二,保证所有股票在同一天有可比价格。比如2018年1月1日,002449.SZ停牌,其close为NaN,但601318.SH有价格,信号函数仍可对601318.SH生成信号,不影响其他股票交易。
注意:
reindex后的NaN必须保留,不能用fillna(0)。因为0会干扰技术指标计算(如close/ma5变成0/10=0,产生虚假信号)。正确的做法是在信号函数里主动处理:if pd.isna(close_price): return 0。
3.3 信号生成:为什么signal_generation()函数是唯一需要你动的地方?
打开PythonApplication.py,找到def signal_generation(df)函数。它的输入df是一个DataFrame,索引是MultiIndex(股票代码,日期),列包含open, high, low, close, volume, pre_close, limit_up, limit_down等。输出必须是numpy.ndarray,长度等于df.index.get_level_values(1).nunique()(即交易日总数),值只能是1、0、-1。
为什么这么设计?因为它强制你思考“信号”的本质:信号不是针对某只股票,而是针对整个投资组合在某个时间点的操作指令。比如你想做“均线金叉”策略,传统写法可能是对每只股票单独计算MA5和MA10,然后找交叉点。但在这里,你需要先groupby(level=0)按股票分组,再对每组计算rolling(5).mean(),最后用unstack(0)把股票维度转为列,得到一个日期索引、股票为列的DataFrame,再用diff()找金叉(MA5上穿MA10)。最终np.where()生成信号数组。
我提供了一个极简示例:
def signal_generation(df):
# 获取所有唯一日期,按顺序排列
dates = sorted(df.index.get_level_values(1).unique())
signals = np.zeros(len(dates))
# 遍历每个日期
for i, date in enumerate(dates):
# 取出该日所有股票数据
daily_data = df.xs(date, level=1)
# 计算每只股票的涨跌幅(相对于前收盘)
pct_change = (daily_data['close'] - daily_data['pre_close']) / daily_data['pre_close']
# 选出涨幅最大的1只股票(简单动量策略)
if len(pct_change.dropna()) > 0:
top_stock = pct_change.idxmax()
# 如果该股票未涨停,且我们没持有它,则买入信号
if not pd.isna(daily_data.loc[top_stock, 'limit_up']) and \
daily_data.loc[top_stock, 'close'] < daily_data.loc[top_stock, 'limit_up']:
signals[i] = 1
return signals
这段代码实现了“每日买入当日涨幅最大且未涨停的股票”。它清晰展示了三个要点:第一,信号是按日期生成的,不是按股票;第二,必须检查limit_up防止涨停买入失败;第三,signals[i] = 1表示“计划买入”,实际能否买入由execute_trade()根据资金和T+1判断。你完全可以把它替换成你的布林带、MACD、甚至简单的if close > ma20: return 1。
3.4 买卖执行:execute_trade()里的七道关卡,缺一不可
这是整个回测最复杂的函数,我把它拆成七个逻辑关卡,每关一个if判断,全部通过才能成交:
- 日期有效性关:
if date not in valid_dates: continue—— 先查trade_cal.csv,非交易日直接跳过; - 信号有效性关:
if signal == 0: continue—— 信号为0,无操作; - 涨停/跌停关:
if signal == 1 and close_price >= limit_up: signal = 0(买入信号遇涨停则取消);if signal == -1 and close_price <= limit_down: signal = 0(卖出信号遇跌停则取消); - T+1关:
if signal == -1 and buy_date_record.get(stock, None) == date: signal = 0—— 卖出信号日等于最后一次买入日,违反T+1; - 资金/仓位关:
if signal == 1 and cash < trade_amount: trade_volume = int(cash // trade_price)—— 资金不足时,按实际可买股数成交,而非报错; - 手续费关:
fee = abs(trade_amount) * FEE_RATE,从现金中扣除; - 滑点关:
exec_price = trade_price * (1 + SLIPPAGE if signal == 1 else 1 - SLIPPAGE)。
这七道关卡,每一道都对应A股真实交易的一个摩擦点。比如第5关,很多框架默认“资金不足则订单失败”,但实盘中,你会先买你能买的,剩下的下次再买。第6关,手续费是实时扣的,不是月底结算,所以必须在每次成交时立即更新现金余额。
实操心得:我在调试时发现,某次回测净值曲线突然断崖下跌,排查半天发现是某日
trade_price为0(数据源错误),导致cash // 0报错。后来我在第5关前加了if trade_price <= 0: continue,并打印警告。这就是“手写”的好处——错误现场就在你眼前,而不是藏在框架的几百行源码里。
4. 实操过程与核心环节实现:从零开始,跑通一个完整回测
4.1 第一次运行:见证“Hello World”级别的策略
假设你什么策略都不会,只想确认工具能跑通。最简单的策略是“买入并持有”(Buy and Hold)。但注意,A股不能做空,所以“持有”意味着永远满仓一只股票。我们选中国平安(601318.SH),因为它八年基本不停牌,数据最完整。
修改signal_generation()函数如下:
def signal_generation(df):
# 获取所有唯一日期
dates = sorted(df.index.get_level_values(1).unique())
signals = np.zeros(len(dates))
# 找到中国平安的代码
target_stock = '601318.SH'
# 第一天(索引0)买入信号,之后一直持有(信号保持0,因为已持有,无需再买)
signals[0] = 1
return signals
保存文件,打开命令行,进入解压目录,运行:
python PythonApplication.py
几秒钟后,你会看到控制台输出:
Loading data...
Data loaded. Total trading days: 2018
Generating signals...
Signals generated.
Executing trades...
Trades executed. Total trades: 1
Calculating PnL...
PnL calculated.
Calculating performance...
Performance calculated.
Exporting results...
Results exported to backtest_result.xlsx and backtest_result.png
Done.
同时,目录下生成两个新文件:backtest_result.xlsx和backtest_result.png。打开Excel,你会看到三个Sheet:
- daily_position:每日各股票持仓数量,第一天601318.SH为正数(如1000股),之后全为0(因为没卖出);
- trade_log:只有一行成交记录,日期为第一天,股票601318.SH,方向买入,数量1000,成交价为当日收盘价,手续费XXX元;
- performance:绩效指标表,显示年化收益率、最大回撤等。
打开backtest_result.png,是一条从1.0开始缓慢上升的净值曲线。这就是你的第一个A股回测结果——虽然简单,但它经历了完整的T+1校验、涨停判断、手续费扣除、滑点模拟。它证明了:规则是生效的,数据是连贯的,输出是可信的。
4.2 进阶实战:实现一个真实的“双均线”策略
现在我们升级策略。双均线策略核心是:短期均线(如5日)上穿长期均线(如20日)时买入,下穿时卖出。难点在于:如何避免“假突破”?A股波动大,日线金叉经常一日反转。所以我们加一个过滤条件:金叉当日成交量必须大于前5日均量的1.5倍。
步骤分解:
1. 数据预处理:在load_data()后,为每只股票计算ma5、ma20、vol_ma5(5日均量);
2. 信号生成:遍历每个日期,对每只股票计算ma5 > ma20 and ma5.shift(1) <= ma20.shift(1)(金叉条件),再检查volume > vol_ma5 * 1.5;
3. 择股逻辑:金叉股票可能有多只,我们选当日close/ma5比值最大的那只(动量最强);
4. 执行约束:买入前检查是否涨停,卖出前检查是否跌停,严格执行T+1。
完整代码(替换signal_generation函数):
def signal_generation(df):
dates = sorted(df.index.get_level_values(1).unique())
signals = np.zeros(len(dates))
# 按股票分组,计算技术指标
grouped = df.groupby(level=0)
# 计算每只股票的ma5, ma20, vol_ma5
ma5 = grouped['close'].rolling(5).mean()
ma20 = grouped['close'].rolling(20).mean()
vol_ma5 = grouped['volume'].rolling(5).mean()
# 合并回原df
df = df.copy()
df['ma5'] = ma5
df['ma20'] = ma20
df['vol_ma5'] = vol_ma5
# 遍历每个日期
for i, date in enumerate(dates):
try:
# 取出该日所有股票数据
daily_data = df.xs(date, level=1)
# 找出满足金叉条件的股票
golden_cross = (daily_data['ma5'] > daily_data['ma20']) & \
(daily_data['ma5'].shift(1) <= daily_data['ma20'].shift(1)) & \
(daily_data['volume'] > daily_data['vol_ma5'] * 1.5)
# 过滤掉NaN和停牌
golden_cross = golden_cross.dropna()
if len(golden_cross) == 0:
continue
# 在满足条件的股票中,选close/ma5最大的(动量最强)
momentum = (daily_data['close'] / daily_data['ma5']).dropna()
candidates = momentum[momentum.index.isin(golden_cross[golden_cross].index)]
if len(candidates) == 0:
continue
target_stock = candidates.idxmax()
# 检查涨停/跌停
if pd.isna(daily_data.loc[target_stock, 'limit_up']):
continue
if signals[i] == 0: # 当前无信号,才生成新信号
if daily_data.loc[target_stock, 'close'] < daily_data.loc[target_stock, 'limit_up']:
signals[i] = 1
elif daily_data.loc[target_stock, 'close'] > daily_data.loc[target_stock, 'limit_down']:
# 这里可以加卖出逻辑,为简化,暂不实现
pass
except Exception as e:
print(f"Error at date {date}: {e}")
continue
return signals
运行后,你会得到一个更真实的回测结果:净值曲线不再单调上升,而是有起伏,有回撤,有止盈止损。打开trade_log.xlsx,能看到几十笔成交记录,每笔都标注了股票、日期、方向、价格、手续费。这就是策略在真实规则下的“呼吸感”。
4.3 绩效统计:不只是数字,而是策略健康度的X光片
calculate_performance()函数输出的不只是几个漂亮数字,而是策略的“体检报告”。我们逐个解读:
- 年化收益率:
(final_value / initial_cash) ** (250 / total_trading_days) - 1。注意分母是实际交易日数,不是自然日数。2015–2023共9年,但A股年均交易日约245天,所以分母是2018天(样本中总交易日)。这个公式假设收益可复利,是行业标准。 - 最大回撤(MDD):定义为“从任意历史高点到后续最低点的跌幅最大值”。计算逻辑是:遍历净值序列,记录每个时刻的历史最高净值,然后计算
当前净值 / 历史最高净值 - 1,取最小值。比如净值从1.0涨到1.5(+50%),再跌到1.1(-26.7%),再涨到1.8,那么MDD就是-26.7%。它衡量策略最坏情况下的亏损幅度,是风控核心指标。 - 夏普比率:
(mean_daily_return - risk_free_rate) / std_daily_return * sqrt(250)。本工具设无风险利率为0,所以简化为mean / std * sqrt(250)。它衡量单位风险带来的超额收益。>1为优秀,<0说明亏钱。 - 胜率:
盈利交易次数 / 总交易次数。注意,这里“交易”指一笔买入+对应卖出,不是单边指令。本工具自动匹配买卖,所以胜率真实反映策略盈利能力。
这些指标不是孤立的。比如你的年化收益率30%,但MDD高达60%,夏普比率0.5,说明策略靠承担极高风险获利,不稳定。反之,年化15%但MDD仅15%,夏普1.2,说明策略稳健。工具的价值,就是让你一眼看清策略的“性格”。
实操心得:我在测试双均线策略时,发现胜率只有35%,但年化仍有18%。深入看
trade_log,发现盈利交易平均赚8%,亏损交易平均亏3%,靠“盈亏比”取胜。这提示我:策略可以优化止损点,比如把固定百分比止损改成ATR动态止损,放大盈亏比。这就是回测工具给我的真实启发——不是告诉我“能不能赚钱”,而是告诉我“为什么能赚钱”以及“哪里还能改进”。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的坑
5.1 数据问题:为什么我的净值曲线是直线?或者突然归零?
这是新手最高频问题。原因几乎100%是数据格式错误。请按此清单逐项检查:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 净值曲线完全水平(始终为1.0) | signal_generation()始终返回全0数组 |
在函数末尾加print("Signal sum:", signals.sum()),看输出是否为0 |
检查信号逻辑,确保有非零值;或临时设signals[0]=1测试 |
| 净值曲线某日突然暴跌至0 | 某日close_price为0或负数 |
在execute_trade()中print(f"Date {date}, stock {stock}, close {close_price}") |
用Excel打开对应CSV,查找close列异常值,手动修正或删除该行 |
| 净值曲线在2018年左右断崖下跌 | 数据源切换导致pre_close错位 |
检查2018年1月1日前后pre_close是否等于前日close |
下载聚宽标准数据,或用df['pre_close'] = df.groupby(level=0)['close'].shift(1)重算(仅当数据源无pre_close时) |
独家技巧:我写了一个快速数据质检脚本(附在资源包
data_check.py中)。运行它,会自动检查:1)每只股票CSV是否有date,open,high,low,close,volume,pre_close七列;2)date列是否为YYYY-MM-DD格式;3)close是否全为正数;4)pre_close是否与前日close匹配。5秒内给出报告,比人工检查快100倍。
5.2 信号问题:为什么策略不交易?或者交易频率过高?
信号函数是策略的“大脑”,但也是最容易出错的地方。常见陷阱:
- 索引错位:
df.xs(date, level=1)返回的是该日所有股票数据,但如果date不在索引中(比如你传入了2015-01-01但数据从2015-01-05开始),会报KeyError。解决方案:用df.xs(date, level=1, drop_level=False),或先用if date in df.index.get_level_values(1).values:判断。 - NaN传播:
rolling(5).mean()在前4日会返回NaN,如果直接用于if ma5 > ma20,结果仍是NaN,导致信号为0。解决方案:用ma5 = ma5.fillna(method='bfill')向后填充,或用ma5 = ma5.rolling(5, min_periods=5).mean()强制最小周期。 - 向量化误用:想对每只股票单独计算金叉,却写了
df['ma5'] > df['ma20'],结果得到的是整个DataFrame的布尔矩阵,而非按股票分组的布尔Series。正确写法:df.groupby(level=0).apply(lambda x: x['ma5'] > x['ma20'])。
实操心得:我曾为一个“涨停敢死队”策略调试一周,信号逻辑没错,但回测不交易。最后发现是
limit_up列名写成了limitup(少下划线),pd.isna()始终返回False,导致涨停买入失败被静默忽略。从此养成习惯:所有列名用df.columns.tolist()打印出来,逐字核对。
5.3 执行问题:为什么明明信号是1,却没有买入?或者手续费扣得离谱?
execute_trade()是规则落地的“最后一公里”,细节决定成败:
- T+1日期比较错误:
buy_date_record.get(stock)返回的是Timestamp,而date是str,直接比较永远为False。必须统一类型:if pd.to_datetime(date) <= buy_date_record.get(stock, pd.Timestamp('1970-01-01')):。 - 手续费计算基数错误:新手常把
fee = trade_volume * FEE_RATE(按股数扣),正确是fee = trade_price * trade_volume * FEE_RATE(按成交额扣)。万三是成交额的0.03%,不是每股3分钱。 - 滑点方向错误:买入时滑点应加在价格上(你愿意多付一点买到),卖出时应减(你愿意少收一点卖出)。写反会导致策略系统性亏损。
独家避坑表:
execute_trade()关键变量速查
| 变量名 | 类型 | 含义 | 常见错误 | 正确示例 |
|---|---|---|---|---|
trade_price |
float | 实际成交价(收盘价±滑点) | 用open或high代替close |
close * (1 + SLIPPAGE) |
trade_volume |
int | 实际成交股数 | 用浮点数未取整 | int(cash // trade_price) |
cash |
float | 当前可用现金 | 扣费后未更新 | cash -= (trade_amount + fee) |
holding_shares[stock] |
int | 当前持仓 | 卖出时未减去 | holding_shares[stock] -= trade_volume |
5.4 输出问题:为什么Excel打不开?或者图片是空白?
这是环境兼容性问题,非代码错误:
- Excel打不开:
openpyxl版本冲突。requirements.txt指定openpyxl==3.0.10,如果系统已有新版,卸载重装:pip uninstall openpyxl && pip install openpyxl==3.0.10。 - 图片空白:matplotlib后端问题。在
PythonApplication.py开头添加:python import matplotlib matplotlib.use('Agg') # 强制使用非GUI后端 import matplotlib.pyplot as plt - 中文乱码:图表标题显示方块。在绘图前加:
python plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False
6. 进阶扩展与二次开发指南:从“能跑”到“跑好”的跃迁路径
6.1 加入基本面因子:让策略不止看K线
技术面策略易受震荡市折磨。加入基本面可提升稳定性。比如“低PE+金叉”策略:只在PE(TTM)低于行业均值的股票中做金叉交易。
步骤:
1. 准备pe_ratio.csv,格式:date, stock_code, pe_ttm;
2. 在load_data()中读取并merge到主DataFrame;
3. 在signal_generation()中,增加条件:pe_ttm < industry_pe_mean。
难点在于PE数据频率低(季报),需用ffill()填充。我在资源包中提供了pe_sample.csv示例,包含6只股票2015–2023季度PE,可直接使用。
6.2 多因子合成:告别单一信号,拥抱概率思维
单一因子(如均线)胜率有限。多因子可提升鲁棒性。例如合成“动量+估值+质量”:
- 动量:近3月涨幅排名前30%;
- 估值:PE低于历史中位数;
- 质量:ROE连续三年>15%。
实现方式:为每个因子生成0-1得分,加权求和(如动量0.4+估值0.3+质量0.3),总分>0.7则信号为1。这比“必须同时满足三个条件”更灵活,也更接近机构做法。
6.3 实盘对接:从回测到实盘的“最小可行迁移”
回测再准,不实盘等于零。本工具设计时已预留接口:
- execute_trade()返回的trade_log DataFrame,字段与主流券商API(如中信证券、华泰证券的TradeAPI)完全一致:date, stock_code, direction(1=buy, -1=sell), price, volume, fee;
- export_result()生成的Excel,可直接用pandas.read_excel()读取,作为实盘下单的输入;
- 手续费、滑点参数已外置为全局变量,实盘时可调为券商实际费率。
我的建议:先用本工具跑3个月模拟盘(用最新数据),观察信号生成频率、成交成功率、与实盘行情的偏差,再逐步过渡到小资金实盘。记住,回测是策略的“压力测试”,实盘才是最终考场。
最后分享一个小技巧:我在
PythonApplication.py末尾加了一段“策略快照”代码。每次运行后,它会自动把signal_generation()函数的源码、当前参数(如SLIPPAGE=0.001)、运行时间,写入strategy_snapshot.txt。这样,三个月后你回头看回测结果,能立刻知道当时用的是哪个版本的策略,避免“这结果是谁跑的?”的灵魂拷问。真正的量化工作流,始于可复现,终于可追溯。
简介:直接解压就能跑的A股量化回测环境,内置002449.SZ、600737.SH等6只A股2015–2023年日线CSV数据,以及trade_cal.csv交易日历。核心逻辑全在PythonApplication.py里,用标准库+pandas实现,不依赖TensorFlow、Backtrader等重型框架。数据加载、信号生成、买卖执行、T+1限制、涨跌停判断、手续费(默认万三)、滑点(默认0.1%)和仓位管理都已写好,函数拆分清晰、中文注释齐全。想试自己的选股或择时逻辑?只用改signal_generation函数,返回一个和行情日期对齐的1(买)、-1(卖)、0(空仓)数组就行。运行后自动生成每日持仓表、成交明细、年化收益率、夏普比率、最大回撤等常用绩效指标,结果还能导出到Excel或画成backtest_.png。适合刚学量化的新手边看边调,也方便老手快速验证想法。
更多推荐


所有评论(0)