十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python量化回测平台实战:从数据清洗到策略评估的完整指南

Python量化回测平台实战:从数据清洗到策略评估的完整指南 简介这是基于Python的量化回测平台系统源码包专注于日线策略编写与历史数据回测适合金融量化初学者、个人投资者及Python开发者使用。压缩包共含17个文件以7个Python脚本为核心覆盖主程序、交易策略、投资组合及输出模块同时提供ipynb交互式演示文档、Excel行情数据、Markdown使用说明与RAR分卷数据整体仅10.43MB结构清晰便于按需调用。内置三种可直接运行的策略ETF轮动策略运行极快量价双金叉策略和随机森林机器学习策略均约十分钟完成回测用户可参照教程自行修改参数或编写新策略从数据导入、回测设置到策略执行均有完整指引。资源内附两张回测结果图便于直观对照策略表现。目前已有437人学习使用适合希望快速搭建量化回测框架、验证交易思路的学习者。1. 一个 Python 量化回测平台的 zip 包先拆源码、数据、教程三块标题里“基于 python 的量化回测平台系统源码数据教程.zip”这个长名字信息量其实已经不少源码对应回测引擎与策略层数据对应行情文件与清洗流程教程对应运行环境和参数约定。把这三件事拆开看一个 zip 包就能从“跑一次看个结果”变成可反复复现的内部研究工具。量化回测最隐蔽的坑往往不在策略本身而在这三层之间的口径不一致例如复权方式不同、手续费没计入、信号日期错位一天都会让结果产生数量级差异。pandas、numpy、matplotlib 三件套构成了 Python 回测平台的主流技术底座写一套最小可运行回测系统并不需要复杂框架。这套方案适合已经会用 pandas 处理结构化数据、想进一步验证交易思路的工程师也适合手上有策略逻辑但不清楚回测流程规范的研究员。接下来按数据、引擎、策略、评估四个层面展开每一层都会落到可以运行的代码上。2. 数据层OHLCV 的加载、清洗与复权口径2.1 一份干净的 OHLCV 表该有哪些字段几乎所有回测平台的数据入口都能收敛为一张 OHLCV 行情表。表里最基本的列是日期、开盘价、最高价、最低价、收盘价和成交量在此基础上可以扩展出成交金额、换手率、涨跌幅等辅助字段。平台源码里通常会给每个数据文件设定统一的列名和类型避免不同数据源混用时出现命名冲突。字段类型含义必须满足的约束datedatetime64交易日期全局唯一升序排列openfloat64开盘价大于 0highfloat64最高价同时大于等于 open 和 closelowfloat64最低价同时小于等于 open 和 closeclosefloat64收盘价大于 0volumefloat64成交量停牌日应为 0 或缺失amountfloat64成交金额可选用于流动性过滤turnoverfloat64换手率可选用于活跃度筛选字段口径不统一是回测结果对不上的第一大原因。例如成交量字段有人存手数有人存股数甚至有人直接把金额存了进来一旦策略代码里出现 volume 的均值、分位数或量能倍数单位不一致就会让所有过滤条件失效。我一般在数据导入阶段就统一约定volume 使用股数amount 使用元并在入口处做一次单位校验数值超过阈值就拒绝入库。2.2 用 pandas 把 CSV 清洗成标准回测输入原始 CSV 数据最常出现的问题是日期格式混杂、表头带空行、除权日价格跳空、停牌日 volume 为零。下面这段清洗代码覆盖了从 CSV 到标准 DataFrame 的完整过程。import pandas as pd def load_ohlcv(filepath: str) - pd.DataFrame: df pd.read_csv( filepath, parse_dates[date], dtype{ open: float64, high: float64, low: float64, close: float64, volume: float64, }, ) # 去掉价格或成交量缺失的行避免指标计算时隐式跳过 df df.dropna(subset[open, high, low, close, volume]) # 同一交易日可能重复写入保留最后一次记录 df df.drop_duplicates(subset[date], keeplast) # 按日期升序排列并重建索引shift 与 rolling 都依赖时间顺序 df df.sort_values(date).reset_index(dropTrue) # 停牌日 volume 为 0不参与策略信号与收益计算 df df.loc[df[volume] 0].reset_index(dropTrue) # 校验最高价约束拦截明显脏数据 invalid (df[high] df[open]) | (df[high] df[close]) if invalid.any(): df df.loc[~invalid] return df参数说明parse_dates 让 date 列在读取阶段转成 datetime64后续多标的合并时可以直接按时间索引对齐dropna 必须放在去重之前执行因为同一交易可能有一条完整、一条残缺的数据先删空值再删重复才不会有残留。停牌日过滤条件在分钟线数据里不能直接用 volume 0部分交易所的集合竞价成交量可能恰好为零此时应改用 amount 0 或 amount.isna() 来判断。清洗阶段发现的异常行数量最好被打印出来平台源码里可以加一行日志输出方便之后的数据质量核对。2.3 复权怎么选回测用后复权看图用前复权如果直接拿不复权价格做回测遇到除权除息日价格会出现向下跳空导致计算出的收益率虚高信号里的均线也会在除权日出现假死叉。业界通行做法是回测时使用后复权价格保证价格序列连续可算看盘和实盘对照时使用前复权价格因为它能真实反映当前价位附近的持仓成本。前复权和后复权的差别在于基准日不同。前复权以最新交易日为基准对历史价格做整体缩放历史最低价可能会变成负数或低于上市首日真实价格后复权以上市首日为基准价格幅度保持原样。两者在收益率序列上完全一致区别只是绝对数值的呈现方式。你只须保证回测全程用同一种复权口径策略排名就不受影响最怕的是下载数据时用了前复权历史数据后来又更新导致前复权价格整体变化回测结果无法跨时间复现。2.4 从 finshare、tushare 这类数据源拉行情后的统一入库使用 tushare、akshare、finshare 等 Python 库获取股票数据时接口返回的列名和单位往往与平台内部 schema 不一致。比如某个库的日线接口返回 trade_date、vol、amount另一个库可能直接返回 date、volume如果不清洗就塞进引擎策略代码里到处都要针对数据源做兼容判断。常见做法是拉取之后第一步就统一字段名。def normalize_to_ohlcv(raw: pd.DataFrame) - pd.DataFrame: rename_map { trade_date: date, open: open, high: high, low: low, close: close, vol: volume, amount: amount, } df raw.rename(columnsrename_map) df[date] pd.to_datetime(df[date]) df df[[date, open, high, low, close, volume, amount]] return df.sort_values(date).reset_index(dropTrue)这套标准化处理的价值在于策略层和引擎层永远只面对一套标准 DataFrame数据源换掉之后回测代码一行都不用改。额外保留 amount 字段是为后续流动性过滤预留空间例如过滤掉日均成交金额低于某个阈值的标的这类过滤在打板或小市值策略里尤其必要。3. 回测引擎向量化撮合与成本模型的参数边界3.1 向量化回测与事件驱动回测适用边界在哪里回测引擎可以粗略分成两类。向量化回测用整列数据一次性算出信号、持仓和收益代码简洁运行速度快适合参数寻优和策略初筛事件驱动回测则逐笔模拟订单簿、成交和资金变动适合模拟真实交易细节但开发量和运行耗时都高一个量级。平台的源码里通常默认走向量化路径这一点和实践中的选择一致。如果你的策略只看日线收盘数据不需要模拟盘中撤单、部分成交、连续竞价细节向量化已经足够。需要切换到事件驱动的情形主要是策略信号本身依赖当前持仓或账户资金比如按固定比例加减仓、风控触发清仓或者需要逐笔模拟限价单是否被成交。判断标准很简单——向量化里持仓和收益计算能否用整列平移加整列点乘表达能就向量化不能就事件驱动。3.2 最小向量化撮合代码信号、持仓与资金曲线实现一个最小回测引擎只需要处理三件事信号延迟一期、交易成本、收益率累乘。def run_backtest(df, signal, fee_rate0.0003, slippage_rate0.0002, initial_cash100000.0): import pandas as pd data df.copy() # 信号在第 T 日收盘后产生第 T1 日才可用避免未来函数 data[position] signal.astype(int).shift(1).fillna(0).clip(0, 1) # 每日收益用收盘价计算 data[market_ret] data[close].pct_change().fillna(0) # 发生仓位变化时才产生交易成本 turnover data[position].diff().abs().fillna(0) cost turnover * (fee_rate slippage_rate) # 策略收益 方向收益 - 交易成本成本按成交金额比例计 data[strategy_ret] data[position] * data[market_ret] - cost data[equity] initial_cash * (1 data[strategy_ret]).cumprod() return data[[date, position, market_ret, strategy_ret, equity]]逻辑拆解shift(1) 是防未来函数的关键signal 在当日收盘后确定到下一根 K 线才执行否则回测收益会引入实际交易无法获得的当日信号当日成交利润position 用 clip(0, 1) 禁止做空与加杠杆如果要允许做空改成 clip(-1, 1) 并在 strategy_ret 里保持符号正确成本只在 diff 不为零的那一天扣这意味着换手越频繁成本累计越高最终体现在资金曲线的斜率变化上。这里有一个容易忽略的点cost 直接减去默认按成交金额乘费率折算在日收益里方向符号没有单独处理。在允许做空的情况下卖出开仓与买入平仓的成本方向会更复杂需要把 cost 拆成 buy_cost 和 sell_cost 两个分量分别代入。对多空双向回测这套最小引擎需要多一点扩展否则资金曲线在空头段会虚高。3.3 手续费、滑点与涨跌停约束的建模成本模型是回测真实度的分水岭。这一节把回测引擎相关参数和常见取值范围汇总成一张表参数保存在配置文件或命令行参数里不要散落在策略代码中。参数常见范围作用备注佣金费率0.0001 ~ 0.0003双边收取的券商佣金按账号实际费率填印花税0.0005 ~ 0.001卖出时单边征收A股卖出必须加过户费0.00001 ~ 0.00002股票过户登记费用按成交金额双边滑点率0.0002 ~ 0.001模拟限价单差值流动性差的股票取大值最小交易单位100 股A股一手整数仓位不足时不可成交涨跌停约束10% / 20%涨停买不进、跌停卖不出回测中直接过滤信号涨跌停约束特别容易在向量化回测里丢。常见做法是在生成信号之后加一层过滤涨停日不产生买入信号跌停日不产生卖出信号。判断涨停价可以使用当日收盘价相对前收盘价的涨幅阈值沪深主板接近 10%创业板和科创板接近 20%。在策略层做过滤比在引擎层做兼容更干净因为策略判断“今天能不能买或卖”本身需要结合信号语义引擎层只负责成交与记账。4. 策略层接口设计、双均线实例与参数搜索4.1 定义策略接口策略与引擎解耦的第一步回测平台发展到一定阶段策略代码会越来越多如果每个策略都自己读行情、自己循环算收益整个项目会迅速变成难以维护的脚本堆。常见做法是抽象一个 Strategy 基类只暴露 generate_signal 这一个方法输入标准 OHLCV输出 0/1 信号序列。from abc import ABC, abstractmethod import pandas as pd class Strategy(ABC): def __init__(self, params: dict): self.params params abstractmethod def generate_signal(self, df: pd.DataFrame) - pd.Series: 输入标准 OHLCV DataFrame返回对齐索引的 0/1 信号序列 raise NotImplementedError这个接口的价值在后期会显现出来参数扫描时可以用同一个类名构造不同参数对象单元测试时只需构造一段固定行情的 DataFrame检查信号的长度、类型和取值边界接入新的策略思路时不需要动引擎的任何代码。4.2 双均线加量能过滤一套策略模板以经典双均线策略为基础加入量能过滤条件限制无量突破的假信号。这里的量能过滤本质是放量确认价格上涨且成交量超过近期均值的一定倍数时才认为突破有效。class DualMAVolumeStrategy(Strategy): def generate_signal(self, df: pd.DataFrame) - pd.Series: p self.params fast p.get(fast, 5) slow p.get(slow, 20) vol_window p.get(vol_window, 20) vol_mult p.get(vol_mult, 1.2) ma_fast df[close].rolling(fast).mean() ma_slow df[close].rolling(slow).mean() vol_ma df[volume].rolling(vol_window).mean() signal (ma_fast ma_slow) (df[volume] vol_ma * vol_mult) return signal.astype(int).fillna(0)参数说明fast 和 slow 分别是快慢均线窗口默认 5 和 20 对应周线与月线级别vol_window 是均量的回看窗口vol_mult 是放量倍数大于 1 表示当前成交量必须超过均量才允许开仓设置为 0 则退化成纯双均线策略。rolling 窗口前面的 NaN 全部由 fillna(0) 处理也就是说数据起始段不会有空仓以外的信号这是避免策略在回测初期因为数据不足而误开仓的关键。参数默认值推荐范围策略含义fast53 ~ 20快线窗口控制敏感度slow2030 ~ 200慢线窗口控制持仓周期vol_window2010 ~ 60成交量均线回看窗口vol_mult1.20.8 ~ 2.0放量倍数的确认阈值量能型指标在 A 股策略里属于最常见的一类增强因子。类似“量能饱和度”这类自定义指标本质上也是对成交量做归一化或平滑处理比如把当日成交量除以一段时间内的最大成交量输出一个 0 到 1 之间的饱和度值。使用时只需要在 generate_signal 里多算一列饱和度序列再套一个阈值条件完全不需要改动引擎。4.3 把现成指标公式接入策略市面上流传的“三步点金”这类指标公式源码大多是基于 K 线形态和量价关系的通达信公式。这类公式需要翻译成 pandas 代码才能进入回测平台。翻译过程中最需要注意窗口对齐通达信里的 REF(X, 1) 对应 pandas 的 shift(1)HHV(X, N) 对应 rolling(N).max()CROSS(A, B) 需要写成 (A B) 且前一期 (A B) 的前后比较逻辑。将指标公式的计算结果作为布尔向量传入信号序列接下来就能直接用 Strategy 类包装。指标计算可以独立成函数放进 indicators.py避免策略代码里堆满窗口计算。这样做的附带好处是未来同一指标可以被多个策略复用调试时也只需要对着指标函数单独验证。4.4 网格搜索参数与过拟合防范参数搜索的第一步是把数据拆成训练段、验证段、样本外段三段。很多人直接在全部历史数据上搜索最优参数等价于用未来数据选参数实盘必然失真。from itertools import product def grid_search(strategy_class, df, param_grid, fee_rate0.0003): train df[(df[date] 2020-01-01) (df[date] 2022-12-31)].reset_index(dropTrue) val df[(df[date] 2022-12-31) (df[date] 2024-06-30)].reset_index(dropTrue) results [] for values in product(*param_grid.values()): params dict(zip(param_grid.keys(), values)) # 训练段生成信号并回测 bt run_backtest(train, strategy_class(params).generate_signal(train), fee_ratefee_rate) # 验证段独立评估 val_bt run_backtest(val, strategy_class(params).generate_signal(val), fee_ratefee_rate) results.append({ **params, train_sharpe: calc_sharpe(bt[strategy_ret]), val_sharpe: calc_sharpe(val_bt[strategy_ret]), train_mdd: calc_mdd(bt[equity]), val_mdd: calc_mdd(val_bt[equity]), }) return pd.DataFrame(results).sort_values(val_sharpe, ascendingFalse)这段代码的关键设计是在验证段上按 val_sharpe 排序而不是按训练段指标排序。参数组合在训练段内表现再好只要验证段收益显著走坏就应该被标记为过拟合组合。另一个需要注意的参数是 param_grid 的粒度fast 在 3 到 30 之间按 1 步进slow 在 30 到 200 之间按 10 步进组合数量会到上千组配合向量化引擎几十秒能跑完但换成事件驱动引擎就会慢到不可接受。5. 评估指标与净值可视化回测结论可以被审计5.1 四类核心指标的计算口径策略评估最先要看的是年化收益、最大回撤、夏普比率、卡玛比率四个数字而不是总收益率和胜率。总收益率会掩盖净值局部的剧烈波动胜率则容易在低赔率的策略里制造假象。指标口径方面A 股的年度交易日统计算 244 天比较常见。def calc_metrics(equity, ret, periods244, risk_free0.02): total_return equity.iloc[-1] / equity.iloc[0] - 1 years len(equity) / periods annual_return (1 total_return) ** (1 / years) - 1 annual_vol ret.std() * (periods ** 0.5) sharpe (annual_return - risk_free) / annual_vol if annual_vol 0 else 0.0 drawdown equity / equity.cummax() - 1 max_drawdown drawdown.min() calmar annual_return / abs(max_drawdown) if max_drawdown ! 0 else 0.0 return { 年化收益: annual_return, 年化波动率: annual_vol, 夏普比率: sharpe, 最大回撤: max_drawdown, 卡玛比率: calmar, }夏普比率在 A 股日频策略里如果超过 2 就要警觉大概率是口径或数据出了问题比如用了未来函数或者把收益率序列里的 NaN 不当处理卡玛比率衡量的是每一份回撤换来的年化收益对趋势跟踪类策略更直观卡玛大于 1 已经算不错。5.2 净值曲线与回撤区间的可视化净值曲线是发现引擎 bug 最快的工具。资金曲线出现陡峭拐点时直接定位那个日期的 position 变化如果回撤阴影连续多日停留在 -20% 以下同时策略本身又没有任何止损机制基本可以判断风险已经超出账户能承受的范围。import matplotlib.pyplot as plt def plot_backtest(df): fig, axes plt.subplots(2, 1, figsize(12, 8), sharexTrue) ax0, ax1 axes ax0.plot(df[date], df[equity], colorsteelblue, labelStrategy Equity) ax0.set_title(Equity Curve) ax0.legend() drawdown df[equity] / df[equity].cummax() - 1 ax1.fill_between(df[date], drawdown * 100, 0, colortomato, alpha0.5) ax1.set_title(Drawdown (%)) plt.tight_layout() plt.show()这段代码在 5 年以上日线数据上运行绘图耗时通常在秒级。图输出后需要注意两点第一把策略参数和回测成本参数直接写进图表下方的注释或文件名否则三天后就查不清这张图到底用的什么参数第二净值曲线和回撤图之间保持同一时间轴常见做法是 sharexTrue 加上 figsize 中的高度比例 2:1让回撤区间的走势与净值曲线一一对应。本文还有配套的精品资源点击获取
返回列表