ARTICLE DETAIL

资讯详情

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

ETH 15分钟K线量化回测:Backtrader实现100次滚动策略验证

ETH 15分钟K线量化回测:Backtrader实现100次滚动策略验证 这次我们来看一个很具体、也很实用的量化小项目在以太坊ETH15分钟图表上做短线策略回测并且连续记录100次回测结果。为什么要单独盯15分钟级别因为短线策略最怕两件事一是周期太长导致信号太少二是周期太短导致手续费和滑点吃掉利润。15分钟K线在两者之间相对平衡既能产生足够的交易样本又比1分钟级别稳定是很多个人量化玩家做加密币短线验证的常用周期。这个回测项目最值得关注的几个点基于Backtrader框架做事件驱动回测、ETH 15分钟K线数据输入、策略采用“双均线趋势 RSI过滤器 固定止损”的组合逻辑、通过脚本循环执行100次回测并自动汇总指标。整个项目跑完你能得到的不只是一条净值曲线而是一份包含胜率、盈亏比、最大回撤、连续亏损次数等维度的回测统计表。这篇文章不会去论证“短线策略能不能稳定赚钱”那种结论在没有实盘验证之前都不成立。本文要做的是把整套可复用的回测流程拆开数据怎么取、环境怎么搭、策略怎么写、100次回测怎么批量执行、结果怎么汇总以及最容易踩的坑在哪里。如果你已经在写量化策略但每次验证新想法都要手动开图表、记参数、反复点回测按钮那这篇文章可以直接收藏。读完你可以照着一套脚本把历史回测流水线化以后每个新策略都能在半小时内跑完一轮批量验证。注意一句话本文内容仅作技术研究和教学用途不构成任何投资建议。加密货币市场波动极大任何策略在实盘前都需要经过充分验证并遵守所在地区法律法规。1. 核心能力速览能力项说明项目类型加密货币短线策略历史回测交易标的ETH以太坊以 ETH/USDT 交易对为例K线周期15分钟回测框架BacktraderPython 事件驱动回测框架策略逻辑双均线趋势判断 RSI 过滤 固定止损 固定仓位回测次数100次滚动数据窗口或参数组合批量执行输出指标总交易次数、胜率、盈亏比、最大回撤、累计收益率、夏普比率数据要求历史K线 CSV 或交易所公开接口数据运行环境Python 3.9Backtrader、pandas、numpy、matplotlib硬件门槛普通 CPU 即可100次回测仅需几十秒到几分钟是否支持API数据层可接交易所公开K线接口回测结果可导出 CSV是否支持批量任务支持通过循环脚本批量执行多次回测适合场景策略想法快速验证、参数敏感性分析、历史信号复盘从能力表可以看出这个项目的硬件门槛很低。回测是纯计算任务没有模型推理不依赖 GPU也没有显存压力。100次回测的耗时主要取决于数据量、策略复杂度和是否启用 matplotlib 绘图。通常会比你跑一次深度学习推理快得多。2. 适用场景与使用边界2.1 适合谁用这个项目适合三类人。第一类是量化交易初学者。Backtrader 的学习曲线在量化框架里属于中等偏友好15分钟K线数据量又不算大很适合用来理解“事件驱动回测”的基本流程。第二类是已经有策略想法、但不想每次手动点回测按钮的人。把“取数 - 跑策略 - 记录指标 - 画图”写成脚本后后续验证新参数只需要改一行配置。第三类是做策略复盘的人。ETH 15分钟级别每天会产生96根K线一个月大约2880根足够做日线级别趋势跟踪的初步测试。如果你关心某个时间段内策略表现是否稳定100次滚动回测比单次回测更有参考价值。2.2 能解决什么问题把历史数据上的策略信号可视化确认交易指令是否按预期触发。通过多次回测统计胜率和盈亏比减少“单一区间回测表现好”造成的误判。量化手续费和滑点对短线策略的影响。快速比较不同参数组合下的回撤和收益率。2.3 不适合什么场景15分钟K线回测不适合以下几类情况高频交易级别的验证。秒级或毫秒级撮合模型需要逐笔成交数据15分钟K线粒度太粗。实盘交易信号推送。回测框架本身不负责实盘下单接实盘需要另行开发交易执行模块。没有任何历史数据的币种。新上线的小币种K线样本不够回测结果没有统计意义。忽略流动性和深度的情况。回测假设按收盘价成交实际短线挂单会被盘口深度影响。2.4 合规与风险边界回测结果代表的是历史数据下的策略表现不代表未来收益。加密货币交易在不同地区有不同监管要求请确认你的研究和交易行为符合所在地区法律法规。不要使用回测结果向他人承诺收益也不要基于未经实盘验证的策略投入大额资金。如果后续把策略连接实盘交易API还需要特别注意API密钥保管、下单权限限制和风控机制。建议先用模拟盘验证一段时间再考虑小资金实盘。3. 环境准备与前置条件3.1 系统与Python版本建议在 Windows 10/11、Ubuntu 20.04 或 macOS 上运行。Python 版本建议 3.9 到 3.11Backtrader 对 Python 3.12 的兼容性需要实测不推荐在新环境中直接冒险。检查Python版本python --version如果还没有 Python建议安装 Anaconda 或 Miniconda方便创建独立环境conda create -n eth_bt python3.10 -y conda activate eth_bt3.2 安装依赖库需要安装的核心库有四个backtrader、pandas、numpy、matplotlib。pip install backtrader pandas numpy matplotlib如果下载速度不理想可以临时使用国内镜像源pip install backtrader pandas numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后验证python -c import backtrader; import pandas; print(deps ok)能正常输出 d eps ok 就说明环境就绪。3.3 数据准备检查清单是否存在至少连续3个月的ETH 15分钟K线数据。CSV列名是否包含 open_time、open、high、low、close、volume。open_time 是否为时间戳或可解析的日期时间字符串。数据中是否存在跳空、重复时间戳、NaN值。手续费和滑点参数是否按实际交易环境预留。3.4 磁盘与内存ETH 15分钟K线数据量不大。以一年数据为例约 365 * 96 35040 行CSV文件通常只有几MB。100次回测产生的中间数据可能会多一些但整体磁盘占用可以控制在几百MB以内。内存方面普通8GB内存的机器跑这个项目没有压力。4. 数据准备与回测框架搭建4.1 数据来源与格式ETH 15分钟K线数据可以来自交易所公开历史数据接口、第三方数据服务或本地CSV文件。以常见交易所公开K线接口返回的结构为例字段通常包含开盘时间、开盘价、最高价、最低价、收盘价、成交量等。一个统一的数据清洗流程如下import pandas as pd def load_eth_15m_data(file_path): 加载ETH 15分钟K线CSV数据统一列名和类型 实际文件需要按你的数据源调整列名 df pd.read_csv(file_path) # 统一列名 df.rename( columns{ timestamp: open_time, open: open, high: high, low: low, close: close, volume: volume, }, inplaceTrue, ) # open_time 转成 datetime 并设为索引 df[open_time] pd.to_datetime(df[open_time]) df.set_index(open_time, inplaceTrue) # 只保留必要列 df df[[open, high, low, close, volume]].astype(float) # 去重、排序、去空值 df df[~df.index.duplicated(keepfirst)].sort_index() df.dropna(subset[open, high, low, close, volume], inplaceTrue) return df如果你还没有本地CSV也可以通过接口拉取。下面是一个通用的接口拉取模板实际接口地址、参数名和限频要求需要按你选用的数据源调整import requests import pandas as pd def fetch_eth_15m_from_api(symbolETHUSDT, limit3000): 从公开K线接口拉取ETH 15分钟数据。 这里使用的是通用示例实际地址请替换为你的数据源地址。 url https://your-data-source.example.com/api/v3/klines params { symbol: symbol, interval: 15m, limit: limit, } resp requests.get(url, paramsparams, timeout30) resp.raise_for_status() data resp.json() df pd.DataFrame(data, columns[ open_time, open, high, low, close, volume, close_time, quote_volume, count, taker_buy_volume, taker_buy_quote_volume, ignore, ]) df[open_time] pd.to_datetime(df[open_time], unitms) df.set_index(open_time, inplaceTrue) df df[[open, high, low, close, volume]].astype(float) return df这里要特别提醒不同数据源的返回字段顺序可能不同解析前先打印前几行确认字段结构不要盲目套用列名。4.2 Backtrader数据馈送把清洗后的 pandas DataFrame 传入 Backtrader需要注意列名映射。Backtrader 默认认识 open、high、low、close、volume 这些列名但时间索引要通过timeframe和compression参数声明。import backtrader as bt def create_bt_data(df): data bt.feeds.PandasData( datanamedf, datetimeNone, # 使用 DataFrame 索引 openopen, highhigh, lowlow, closeclose, volumevolume, openinterestNone, timeframebt.TimeFrame.Minutes, compression15, ) return data这里设置timeframebt.TimeFrame.Minutes, compression15后Backtrader 能够按照15分钟周期正确驱动策略事件。如果没有正确设置策略的 next() 调用频率会按每天而不是每根K线触发导致信号严重失真。5. 短线策略逻辑设计这个项目里的策略不需要太复杂。回测的核心目的不是构建一个逻辑复杂的黑箱模型而是建立一套可重复的验证流程。策略越简单后期分析结果时越容易定位问题。采用一个经典的组合逻辑快线均线5周期SMA。慢线均线20周期SMA。买入条件快线上穿慢线并且 RSI(14) 低于 50。卖出条件快线下穿慢线或者 RSI(14) 高于 75。止损规则入场后价格向不利方向波动超过 2%强制平仓。仓位规则每次固定投入总资金的 20%不做加仓。选择这个逻辑的原因是它足够简单每个信号都有明确含义适合作为第一批回测测试用例。如果你之后想测试其他策略只需要替换 Backtrader 的 Strategy 类。6. 回测代码实现6.1 策略类实现import backtrader as bt class EmaRsiShortLineStrategy(bt.Strategy): ETH 15分钟短线策略 - 快线下穿慢线且 RSI 超买时卖出 - 入场后固定比例止损 params ( (fast_period, 5), (slow_period, 20), (rsi_period, 14), (rsi_buy_threshold, 50), (rsi_sell_threshold, 75), (stop_loss_pct, 0.02), (risk_per_trade, 0.2), ) def __init__(self): self.fast_ma bt.indicators.SMA(self.data.close, periodself.p.fast_period) self.slow_ma bt.indicators.SMA(self.data.close, periodself.p.slow_period) self.rsi bt.indicators.RSI(self.data.close, periodself.p.rsi_period) self.cross_up bt.indicators.CrossOver(self.fast_ma, self.slow_ma) self.order None def next(self): if self.order: return if not self.position: # 入场金叉 RSI低于阈值 if self.cross_up 0 and self.rsi[0] self.p.rsi_buy_threshold: size self.broker.getvalue() * self.p.risk_per_trade / self.data.close[0] self.order self.buy(sizesize) else: # 出场死叉 或 RSI超买 或 触发止损 if self.cross_up 0 or self.rsi[0] self.p.rsi_sell_threshold: self.order self.close() else: current_price self.data.close[0] entry_price self.position.price drawdown_pct (current_price - entry_price) / entry_price if drawdown_pct -self.p.stop_loss_pct: self.order self.close() def notify_order(self, order): if order.status in [order.Completed, order.Canceled, order.Margin]: self.order None这段代码里值得注意的地方notify_order必须把self.order重置为 None否则策略会一直认为上一笔订单还在执行中后续K线无法开仓。止损比例 2% 是短线策略里比较常见的参数但这不是推荐值具体数值必须根据ETH的历史波动率做调整。15分钟级别的价格波动如果经常超过2%这个止损很容易被噪音扫掉。6.2 单次回测封装把单次回测逻辑封装成函数方便后续在循环中重复调用def run_backtest(df, strategy_params, cash10000.0, commission0.001): 执行一次Backtrader回测 df: ETH 15分钟K线 DataFrame strategy_params: 策略参数 dict cash: 初始资金 commission: 手续费比例 返回指标 dict cerebro bt.Cerebro() cerebro.addstrategy(EmaRsiShortLineStrategy, **strategy_params) data create_bt_data(df) cerebro.adddata(data) cerebro.broker.setcash(cash) cerebro.broker.setcommission(commissioncommission) # 分析器收益率、回撤、交易记录 cerebro.addanalyzer(bt.analyzers.Returns, _namereturns) cerebro.addanalyzer(bt.analyzers.DrawDown, _namedrawdown) cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _nametrades) results cerebro.run() strat results[0] # 提取指标 ret strat.analyzers.returns.get_analysis() dd strat.analyzers.drawdown.get_analysis() trade_analysis strat.analyzers.trades.get_analysis() total_trades trade_analysis.total.closed if trade_analysis.total else 0 won trade_analysis.won.total if trade_analysis.won else 0 lost trade_analysis.lost.total if trade_analysis.lost else 0 win_rate won / total_trades if total_trades 0 else 0.0 return { final_value: cerebro.broker.getvalue(), total_return: ret[rtot], max_drawdown: dd[max][drawdown], total_trades: total_trades, win_rate: win_rate, won: won, lost: lost, }这里手续费按千分之一设置这是很多现货交易场景常见费率但具体以你的实际交易渠道为准。如果不设置佣金回测结果会偏乐观尤其是15分钟级别这种频繁交易的策略手续费对最终收益影响很大。7. 100次回测的执行与结果统计7.1 为什么做100次单次回测只能说明策略在某一段特定行情下的表现。ETH 不同时间段的市场状态差异很大有趋势行情、震荡行情、急涨急跌行情。如果策略只在某一小段上涨行情中表现好其他区间表现很差单次回测很容易掩盖这个问题。做100次回测的目的是拿到一个指标分布。重点看三件事胜率和盈亏比的中位数是否稳定。最大回撤的分布是否在所有区间内都可控。是否存在某些极端行情区间导致策略完全失效。7.2 滚动窗口方案推荐使用滚动窗口方式每次回测取连续3个月数据每次向后滚动1周这样100次回测覆盖约100周的数据范围。import pandas as pd import numpy as np def generate_rolling_windows(df, window_days90, step_days7): windows [] start df.index[0] end df.index[-1] current start while current pd.Timedelta(dayswindow_days) end: win_start current win_end current pd.Timedelta(dayswindow_days) windows.append((win_start, win_end)) current pd.Timedelta(daysstep_days) return windows7.3 批量执行100次def batch_backtest(df, window_days90, step_days7, max_rounds100): windows generate_rolling_windows(df, window_days, step_days) records [] for i, (start, end) in enumerate(windows[:max_rounds], start1): sub_df df.loc[start:end] if len(sub_df) 500: continue params { fast_period: 5, slow_period: 20, rsi_period: 14, rsi_buy_threshold: 50, rsi_sell_threshold: 75, stop_loss_pct: 0.02, risk_per_trade: 0.2, } try: metrics run_backtest(sub_df, params) metrics[round] i metrics[start] start.strftime(%Y-%m-%d) metrics[end] end.strftime(%Y-%m-%d) records.append(metrics) print(f第 {i:3d} 次回测完成交易 {metrics[total_trades]} 笔胜率 {metrics[win_rate]:.2%}) except Exception as e: print(f第 {i:3d} 次回测异常{e}) result_df pd.DataFrame(records) result_df.to_csv(eth_15m_backtest_100_records.csv, indexFalse, encodingutf-8-sig) return result_df执行df load_eth_15m_data(eth_15m.csv) result_df batch_backtest(df, window_days90, step_days7, max_rounds100)跑完后eth_15m_backtest_100_records.csv就是100次回测的原始记录。每一行对应一次回测的关键指标。7.4 结果统计与可视化拿到100条记录后做二次统计分析。重点看统计量均值、中位数、标准差、25%分位数、75%分位数。import matplotlib.pyplot as plt # 打印核心统计量 summary_cols [total_return, max_drawdown, total_trades, win_rate] summary result_df[summary_cols].describe() print(summary) # 胜率分布直方图 plt.figure(figsize(10, 5)) plt.hist(result_df[win_rate], bins20, alpha0.7, edgecolorblack) plt.xlabel(Win Rate) plt.ylabel(Count) plt.title(ETH 15m Strategy Win Rate Distribution (100 Runs)) plt.grid(True, alpha0.3) plt.tight_layout() plt.savefig(win_rate_distribution.png, dpi150)再画一个看更多维度的箱线图import matplotlib.pyplot as plt fig, axes plt.subplots(2, 2, figsize(12, 8)) axes[0, 0].boxplot(result_df[total_return]) axes[0, 0].set_title(Total Return) axes[0, 1].boxplot(result_df[max_drawdown]) axes[0, 1].set_title(Max Drawdown) axes[1, 0].boxplot(result_df[total_trades]) axes[1, 0].set_title(Total Trades) axes[1, 1].boxplot(result_df[win_rate]) axes[1, 1].set_title(Win Rate) plt.tight_layout() plt.savefig(backtest_100_boxplot.png, dpi150)统计结果出来后按以下顺序读数据先看胜率中位数如果100次回测的胜率中位数在50%以下策略本身可能不具备优势。再看盈亏比和总收益率胜率低不代表亏钱盈亏比高时低胜率也能盈利。然后看最大回撤的75%分位数如果75%分位数的回撤已经超出心理承受范围就要考虑降低仓位或加过滤条件。最后看交易次数如果某次回测只有不到20笔交易样本量不足那次指标没有参考意义。7.5 从100次到结论100次回测的另一个价值是可以做参数敏感性分析。比如你可以把 fast_period 从 5 改为 10slow_period 从 20 改为 40重新跑一遍100次对比两组指标分布。最终形成一份完整的回测记录表round,start,end,total_return,max_drawdown,total_trades,win_rate,final_value 1,2024-01-01,2024-04-01,-0.035,0.081,42,0.381,9650 2,2024-01-08,2024-04-08,0.012,0.065,39,0.436,10120 ...上表只是字段演示实际数值以你本机回测输出为准。不要因为某一次回测结果好就急着实盘也不要因为某一次结果差就立刻否定策略。8. 资源占用与性能观察8.1 计算与内存ETH 15分钟K线回测的负载很低。Cerebro 在每次回测中只加载一个数据源策略也只做均线和RSI计算没有复杂矩阵运算。100次回测中超过90%的时间可能都花在了数据切片、CSV写入和matplotlib绘图上。如果发现批量回测跑得很慢优先检查两点一是plt.savefig是否在每轮回测内部调用二是是否在循环内重复加载全量CSV数据。建议把绘图放到100次全部完成后统一执行。8.2 减少不必要开销的写法在循环外读取全量数据循环内只做df.loc[start:end]切片。关闭 Cerebro 的stdstats绘图用cerebro.run(exactbarsTrue)减少内存占用。在批量执行时不启动 matplotlib 交互模式只在最后保存图片。cerebro bt.Cerebro(exactbarsTrue)加了exactbarsTrue后Backtrader 不会在内存中保留完整的价格线用于绘图内存占用会显著下降代价是回测结束后无法直接从 Cerebro 内部绘图需要自己保存指标。8.3 日志输出节奏100次循环不要每次 print 完整指标只输出每次回测的交易数和胜率即可。如果某次回测异常再打印异常信息。全量详细日志建议写入文件方便事后排查。9. 常见问题与排查方法下表整理了最容易遇到的10个问题按照出现频率从高到低排列问题现象可能原因排查方式解决方案导入CSV后K线数量为零时间索引解析失败或列名不匹配打印 df.head() 和 df.info()检查列名确认 open_time 能被 pd.to_datetime 解析回测完成后没有生成任何交易数据时间跨度不够或均线周期太长查看均线是否在数据末尾仍有足够长度缩短均线周期或增加数据量下单提示 Margin 或 Order Rejected现金不足或仓位计算为负数检查 risk_per_trade 与初始资金调小仓位比例或减少每笔交易金额止损没有生效15分钟K线跳空止损价没有被后续K线触及打印交易记录确认出场原因改用收盘价止损或加入限价单回测结果比实盘好太多没有设置手续费和滑点检查 CommissionInfo设置 commission 和 slip_percRSI 指标为NaN数据集前K线数量不足打印策略在早期K线的指标状态增加 warm-up 检查跳过指标未就绪的K线100次回测越跑越慢每次循环都加载全量CSV或频繁创建新 Cerebro检查循环内部是否有重复IO全量DF只加载一次循环内只切片matplotlib 画出的图不显示交互模式未开启或没有调用 show检查 plt.show() 是否在脚本底层保存为文件但不调用 show回测结果反复不一致未设置随机种子查看是否用到随机抽样的参数固定 random_seed 或确定参数组合CSV中时间戳为毫秒显示拒识未指定时间解析格式打印原始时间列样例使用 pd.to_datetime(col, unitms)9.1 关于手续费和滑点的一点点细节Backtrader 的setcommission可以设置百分比手续费但滑动点差通常需要自定义 CommissionInfo 类import backtrader as bt class CustomCommission(bt.CommInfoBase): params ( (commission, 0.001), (slip_perc, 0.0005), ) def _getcommission(self, size, price, pseudoexec): commission abs(size) * price * self.p.commission slippage abs(size) * price * self.p.slip_perc return commission slippage cerebro.broker.addcommissioninfo(CustomCommission())这个示例演示了如何把滑点拆出来计算。实盘前务必根据你的实际交易渠道和流动性重新设置参数。10. 最佳实践与使用建议10.1 先小参数验证第一次跑通流程先用最短的时间窗口和最小参数集跑通代码。比如先用30天数据、10次回测验证流程确认CSV能够正确输出后再扩展到100次。这样能避免在批量模式下花大量时间调试基础问题。10.2 保留一套最小可运行配置把数据清洗、策略、回测三个模块拆成独立函数然后保留一份run_demo.py里面写死一组默认参数。以后新想法只需要复制一份策略类不用动其他模块。项目目录建议这样组织eth_15m_backtest/ ├── data/ │ └── eth_15m.csv ├── strategies/ │ └── ema_rsi_strategy.py ├── backtest/ │ └── engine.py ├── results/ │ ├── eth_15m_backtest_100_records.csv │ ├── win_rate_distribution.png │ └── backtest_100_boxplot.png ├── run_demo.py └── requirements.txt10.3 批量任务加日志与重试100次回测中如果某次因为数据窗口不足而失败不要让整个脚本崩溃。建议在循环里用 try-except 捕获异常跳过异常窗口并记录原因。跑完后单独检查失败批次是否影响了统计分布。10.4 接口服务与访问限制如果你把回测封装成 Web 服务比如通过 Flask 或 FastAPI 提供“指定日期区间回测”的接口需要限制访问范围。回测接口不需要对外开放公网建议只绑定127.0.0.1或者在服务前面加一层鉴权否则容易被滥用。10.5 合规与授权如果数据来源是某个数据服务商请确认你的使用方式符合其服务条款尤其不要批量下载后二次分发。后续如果把策略接人实盘交易务必备份好API密钥限制IP白名单并设置下单金额上限。10.6 从回测到实盘的检查清单手续费、滑点是否已计入回测。是否在多个不同行情区间下测试过策略。100次回测的胜率和最大回撤分布是否在可接受范围。是否做过滚动窗口的前视偏差检查即策略只用当时已发生的数据。是否在模拟盘中至少运行一段时间验证执行链路。11. 小结ETH 15分钟图表上的短线策略回测核心价值不在于某一个策略有多神而在于把回测这件事变成一条可重复、可批量、可统计的流水线。用Backtrader做事件驱动回测用滚动窗口做100次批量验证最后用表格和图表给出指标分布——这套流程可以直接复用到其他币种、其他周期、其他策略。第一次跑通后最先应该验证的是策略能否在数据窗口内产生足够的交易信号。如果100次回测中有不少窗口交易次数少于20笔说明策略在实际行情中触发频率偏低需要考虑缩短均线周期或放宽入场条件。最容易踩的坑是手续费和滑点设置不真实导致回测收益虚高。后续可以继续扩展的方向把单策略回测扩展到参数网格搜索自动寻找参数最优区间。把ETH单一品种扩展到ETH、BTC、SOL等多个交易对Backtrader支持通过adddata组合多数据源实现多股或多元资产组合回测这也是它的一大优势。多元资产回测能看到策略在不同币种上的表现差异避免单一币种拟合。引入更多过滤器比如基于ATR的动态止损、基于成交量的突破过滤。把回测结果接入报告生成系统每天定时跑一次数据更新并生成最新回测图表。建议收藏备用。等你有新策略想法的时候直接把这套流程搬出来跑一遍节省下来的时间足够多看几十根K线了。
返回列表