Backtrader Web化:从回测脚本到量化交易平台的全栈架构
在量化圈里Backtrader大概是出镜率最高的开源回测框架了。我身边很多朋友写策略第一反应就是把Backtrader装上在本机跑个cerebro看几根K线、几个回撤指标然后就没有然后了。策略文件散落在各个文件夹参数调了几十版也说不清楚哪个最优想给团队看回测结果只能截图更别提一键切到模拟盘去跑真实行情。Backtrader Web就是我为了解决这一连串问题做的一套开源全栈方案底层还是Backtrader外层包上Web服务、任务队列、结果存储和交易对接让回测、策略管理、模拟/实盘交易在同一个平台里闭环。不管你是刚学Python量化交易策略的小白还是已经被本地脚本折磨到崩溃的成熟玩家这套平台的架构思路和实操细节应该都能给你一点启发。1. 为什么回测脚本撑不起一个量化工作流1.1 本地脚本的三大痛点参数漂移、结果丢失、协作靠截图先说参数漂移。大多数人的策略代码长这样一个python文件顶部十几行全局变量MA_PERIOD20、RSI_THRESHOLD70然后往下就是策略逻辑。调参的时候直接改常量跑完看一眼最终收益记在心里或者记在微信备注里。一个月以后回看你根本不知道这份结果的参数是MA20还是MA30更不知道行情数据切片是哪一天到哪一天。这就是我常说的参数漂移——策略逻辑没变参数版本失控了。第二个痛点是结果丢失。Backtrader跑完回测会打印一组收益、回撤、夏普指标但这些指标默认只存在于控制台。你把窗口关掉结果就没了。有人会截图保存但截图只能看到最终数字看不到逐笔成交、权益曲线、持仓变化。真正要分析策略为什么赚钱反而要靠那些被丢掉的明细数据。第三个痛点是协作问题。当你从一个人写策略变成一个小团队互相对策略问题立刻爆炸A跑了一版多标的组合B在另一个人电脑上跑出完全不同的结果谁都不敢说自己的数字是对的。最后大家只能坐在一起用同一条命令、同一个环境重新跑一遍才能对齐。这种协作方式效率极低。1.2 回测与实盘之间差的是整个工程层很多人的想法是回测脚本能跑通那直接把策略信号用Websocket发到券商API不就行了真这么干的人大多在第一周就被教育了。Backtrader帮我们解决的是策略逻辑本身什么时候给什么信号的决策回路。但它完全不管账户资金怎么管理、订单是否被交易所接受、成交回报什么时候回来、网络断了怎么处理、风控规则怎么拦截异常订单。这些问题不是一行代码能补上的而是一整套围绕交易生命周期的工程系统。Web化的意义就是把这套工程系统显式地做出来策略上传后有版本记录回测任务提交后有排队和状态跑完的结果落到数据库里可以随时翻旧账参数网格可以批量对比模拟盘产生的每一条订单都能回溯源到策略实例。做到这一步用户只需要在浏览器里点几下就能完成过去要在三个IDE窗口里来回折腾的事情。我通常建议两类人认真考虑Backtrader Web化一类是策略数量超过十个的个人开发者另一类是正在组建小型量化团队的人。前者需要管理混乱的策略资产后者需要一套统一的环境来保证结果可复现。这两类人的共同特点是回测已经不再是他们的瓶颈工程化才是。2. 平台骨架怎么搭技术选型与模块边界2.1 技术栈先想清楚数据怎么流再决定用什么框架做技术选型不要先看框架热度先画一遍数据流策略从前端提交进来经过后端写入任务队列worker节点消费任务并运行Backtrader回测结果回写数据库前端通过接口和WebSocket拿到结果。在这个数据流基础上我的最终选型如下模块选型理由Web框架FastAPI异步支持好天然适合WebSocket实时推送自带OpenAPI文档任务队列Redis RQ回测任务是CPU密集型不需要Kafka级别的吞吐RQ足够轻量主数据库PostgreSQL订单、任务、策略元数据都需要强事务和索引缓存Redis行情快照、任务锁、WebSocket广播都用它前端Vue3 ECharts回测曲线、持仓饼图、收益曲线都需要成熟的可视化组件部署Docker Compose全套服务依赖简单Compose足够覆盖中小规模为什么不用Django不是不能用而是Backtrader策略本身就是Python代码后端选了Python再用一个偏同步的Django模型和异步任务、WebSocket的配合会别扭很多。FastAPI的异步端点配合RQ worker可以让Web服务保持轻量回测任务全部在后台进程执行。2.2 模块边界回测引擎、任务队列、交易代理必须解耦我在做这个平台时给自己定了一个很死的规矩回测引擎、任务队列、交易代理三个模块之间绝对不能互相直接调用。回测引擎只负责接收一个完整定义的策略任务并返回结果任务队列只管把任务从一个进程搬运到另一个进程交易代理独立成插件在模拟盘和实盘之间切换时只需要换一个broker实现类。整个系统我拆成了五个核心服务API网关处理用户请求和权限、worker消费回测任务并运行Cerebro、trader独立进程负责模拟盘/实盘订单路由、数据服务拉取和缓存行情、前端静态服务。五个服务共享PostgreSQL和Redis但业务代码完全不交叉。这样拆的好处是故障隔离。最典型的场景就是trader进程连接真实行情源时偶发断线它只会让交易代理的重连逻辑触发绝不会拖垮正在跑的几十个回测任务。如果你把回测和交易写成一个大进程某个策略在实盘里的网络超时可能会阻塞所有回测排队。2.3 数据库建模策略、任务、订单、持仓分开落表数据库是这套系统的地基。我设计了一张相对精简的模型表重点不是字段多而是每一张表都独立解决一个问题表名关键字段用途strategiesid, code, name, owner_id, version策略模板文件一个策略可生成多个任务backtest_jobsid, strategy_id, params_json, status, result_summary每次回测任务的状态与参数快照backtest_tradesjob_id, datetime, symbol, side, price, size, pnl回测成交明细用于分析live_instancesid, strategy_id, broker_type, status模拟盘/实盘运行实例ordersinstance_id, order_id, status, filled_price, commission实盘订单主表positionsinstance_id, symbol, qty, avg_price实时持仓快照这里有一个经常被忽略的细节参数快照。backtest_jobs里的params_json字段保存的是用户提交时的完整参数JSON不能从策略文件里反推。因为策略文件可能被更新而历史任务必须能精确复现当时的参数。3. 四个绕不开的改造策略加载、异步回测、多股并行、结果持久化3.1 策略动态加载与子进程沙箱Web平台不可能每次加新策略都改代码、重启服务所以策略必须是动态加载的。我的做法是把策略文件上传到服务器某个目录提交任务时指定策略模块名和类名worker进程运行代码时用importlib动态导入该策略类。import importlib.util def load_strategy_class(strategy_path: str, class_name: str): spec importlib.util.spec_from_file_location(user_strategy, strategy_path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) cls getattr(module, class_name) return cls动态加载要小心用户上传的策略代码包含恶意逻辑。我的处理是把回测任务扔进子进程并用resource模块限制CPU时间和内存同时在exec前对import做白名单校验只允许backtrader、pandas、numpy、datetime这些安全库。策略代码里如果出现open、exec、eval、import os这类高风险操作就直接拒绝加载。这个沙箱不可能做到绝对安全但对于中小团队内部平台已经够用。如果你做的是面向公众的开放平台建议用容器级别的隔离比如每个回测任务跑在一个独立Docker容器里而不是只靠Python层隔离。3.2 把Cerebro放进任务队列异步化的正确姿势Backtrader的cerebro.run()是同步阻塞的一个任务跑几十秒到几分钟都很正常。如果直接在FastAPI请求里调用会造成HTTP连接长时间挂起并发一高Web服务直接卡死。正确姿势是任务异步化。我的worker代码大致是这样的def run_backtest(job_id: str, strategy_path: str, class_name: str, params: dict, data_paths: list[str], start: str, end: str): cerebro bt.Cerebro() for path in data_paths: df load_market_data(path, start, end) data bt.feeds.PandasData(datanamedf) cerebro.adddata(data) strategy_cls load_strategy_class(strategy_path, class_name) cerebro.addstrategy(strategy_cls, **params) cerebro.broker.setcash(params.get(cash, 100000)) cerebro.broker.setcommission(commissionparams.get(commission, 0.0003)) result cerebro.run() strategy_obj result[0] # 持久化成交、指标、净值曲线 save_result_to_db(job_id, strategy_obj)worker节点用RQ消费Redis队列每条消息包含job_id和任务参数。跑完任务后work调用save_result_to_db把Backtrader的orders、positions、净值序列全部写入PostgreSQL。这样Web前端只需要轮询任务状态或者通过WebSocket收到完成通知再读取数据库展示结果即可。3.3 多股回测与参数网格的参数传递Backtrader本身支持多股回测只需要给同一个Cerebro添加多个数据源。Web端我设计成用户选出几个标的后端按股票代码去行情库取数据生成多个PandasData加入同一个Cerebro。股票列表是动态的不能写死在策略里所以我在任务参数中增加symbols字段worker里循环添加数据源。for symbol in params[symbols]: df load_market_data(symbol, start, end) data bt.feeds.PandasData(datanamedf, namesymbol) cerebro.adddata(data)这里要注意的是多股回测时Backtrader的策略next方法会在每一根K线触发但多个股票的数据可能不在同一时间对齐。建议在平台数据服务层先按交易日对齐缺失数据用ffill补齐否则策略会在某些标的没有新K线时误以为行情没变影响信号计算。参数网格的传递也很关键。我让前端把参数做成字典数组比如[{ma_period: 10, rsi_threshold: 70}, {ma_period: 20, rsi_threshold: 70}]。worker拿到数组后每个参数组合生成一个子任务跑完后把结果汇总成一个对比表格和一组净值曲线存放在同一个job_id下面。这样用户一次操作就能完成多参数扫描不需要自己写循环脚本。3.4 结果序列化留存交易明细而不是只看净值图很多回测平台的展示止步于一张收益曲线图但真正对策略迭代有帮助的是逐笔成交明细。我强烈建议每次回测保存三份东西净值曲线序列、成交记录、成交时的持仓快照。def save_result_to_db(job_id, strategy_obj): # 1. 净值曲线 equity strategy_obj.broker.getvalue() dates strategy_obj.data.datetime.array np.savez_compressed(f/data/{job_id}_equity.npz, datesdates, equityequity) # 2. 成交明细 trades [] for trade in strategy_obj.stats.tradeanalyser: trades.append({ ref: trade.ref, symbol: trade.data._name, open_date: str(trade.dtopen), close_date: str(trade.dtclose), pnl: trade.pnl, }) db.insert_backtest_trades(job_id, trades) # 3. 保存图片报告 save_equity_chart(job_id, dates, equity)净值曲线我用.npz压缩存储不会占用数据库空间图片报告直接生成PNG存到静态目录前端通过Nginx直接访问。如果每天跑几百个任务要注意给任务结果加上保留期限超过30天自动归档到冷存储防止数据库积压。4. 从回测走向模拟盘和实盘Broker抽象、状态同步与风控4.1 BrokerBaseBacktrader扩展实盘的官方抓手Backtrader的撮合逻辑藏在Cerebro内部默认是BackBroker用来模拟市价成交。要接模拟盘或实盘正确办法是替换Broker而不是在策略里硬写API调用。我实现了一个PaperBroker继承了backtrader.brokers.BrokerBase。class PaperBroker(bt.brokers.BrokerBase): def __init__(self, order_senderNone): self.order_sender order_sender super(PaperBroker, self).__init__() def buy(self, owner, data, size, priceNone, exectypeNone, **kwargs): order self._create_order(owner, data, size, price) # 在这里把订单发往外部模拟盘 if self.order_sender: self.order_sender.submit_paper_order(order) return order def sell(self, owner, data, size, priceNone, exectypeNone, **kwargs): return self.buy(owner, data, -size, price, exectype, **kwargs) def getposition(self, data): return self._position_map.get(data._name, Position())Cerebro初始化时用cerebro.broker PaperBroker(order_sendertrader_client)这样策略里的buy/sell调用全部进入了自定义broker。至于这个订单是真的发给模拟盘还是真实券商则由order_sender实现决定。平台切换实盘只需要把PaperBroker换成LiveBroker策略完全不用改。4.2 订单状态机回测订单和真实订单不是一个物种这是我最想强调的一点。Backtrader回测里的订单是同步的调用buy/sell后Cerebro会立刻按当前bar或者下一根bar撮合订单状态直接从Created跳到Completed。真实交易完全不同网络请求发出后订单可能处于PendingSubmit、PendingNew、PartiallyFilled、Filled、Rejected、Canceled各种状态而且这些状态是异步回来的。平台侧需要一张完整的订单状态机表。我建议这样设计应用收到策略的buy/sell请求后先写入orders表状态为PENDING然后发给broker网关网关返回平台订单号后更新为SUBMITTED收到成交回报如果数量小于请求数量则更新为PARTIAL全部成交才更新为FILLED。任何超时状态都要能回滚比如订单卡在SUBMITTED超过30秒就主动撤单并把状态标记为TIMEOUT。这个状态机还必须有幂等设计。网络抖动会导致同一笔订单重复上报我在平台上用一个唯一的client_order_id字段做去重网关每次上报的成交回报必须携带这个ID数据库里对这个字段建唯一索引第二次插入直接跳过。4.3 平台级风控回测里可以没有实盘必须有回测的时候策略亏光就亏光了重来一遍就行。实盘如果策略突然出现异常比如行情数据跳变导致信号循环触发可能几秒钟就把账户打穿。因此我在平台层加了一道独立于策略之外的风控闸门任何订单在发往broker之前必须通过风控检查。我的风控规则目前有四条单笔下单金额不超过总资金的5%单标的最大持仓不超过总资金的20%每日最大亏损达到总资金3%则当日停止开新仓策略连续产生同向加仓信号时只允许在距上次加仓超过10个bar后执行。规则写在独立的独立模块不依赖任何策略代码。def risk_check(account, order): # 最大亏损熔断 if account.day_pnl / account.total_equity -0.03: return False, daily loss limit hit # 仓位校验 notional order.price * abs(order.size) if notional / account.total_equity 0.05: return False, single order too large # 标的黑名单 if order.data._name in blacklist_symbols: return False, symbol in blacklist return True, ok风控模块是平台项目里最没有花活但最重要的部分。我见过同事在策略里写了整数倍下单逻辑结果整除运算出错导致下单数量为0被券商接口拒绝后策略进入死循环。风控闸门直接拦截了这种零数量订单算是救了我一次。4.4 模拟盘到实盘我建议这样分段切换我自己的切换路径是这样的策略先在本平台里做至少三十次历史回测覆盖不同行情周期然后创建一个模拟盘实例用真实行情推触发信号跑满两周以上确认模拟盘结果与回测偏差在二成以内后再切到小资金实盘仓位限定在回测默认仓位的三成以内。平台里提供一个环境配置类似这样class TradingEnv: MODE os.getenv(TRADE_MODE, paper) # paper / live def create_broker(self): if self.MODE paper: return PaperBroker(order_senderpaper_gateway) elif self.MODE live: return LiveBroker(order_senderlive_gateway)切到实盘后还可以设置一个总熔断开关如果累计亏损超过初始资金的8%trader进程自动停止下发所有订单并推送一条告警。这个开关放在平台代码里不放在策略代码里防止策略自己关闭风控。5. 前端交互、用户隔离与OpenAPI设计5.1 回测工作台提交任务、看进度、对比报告前端界面我尽量做得克制核心就三个页面策略列表、回测工作台、结果详情。策略列表展示每一个策略模板包含最近一次的更新时间、回测次数和平均收益。点击一个策略进入工作台用户选择数据起止时间、设置参数、勾选标的点击提交后任务进入队列。任务状态实时刷新我用WebSocket把worker的进度推送过来后端在任务开始、完成、失败时发出事件前端收到后自动更新按钮状态并加载结果。结果详情页的核心是趋势对比。点开一个任务左侧显示回测收益指标卡总收益、最大回撤、夏普、胜率右侧显示净值曲线和回撤曲线。如果有参数网格下面会多一个对比表格每一行显示一组参数和对应指标点行就能切换显示这组参数对应的净值曲线。交互逻辑简单直接页面加载速度基本由数据库索引决定。5.2 多用户隔离与API权限控制如果平台给团队用多用户隔离是刚需。我采用JWT做认证令牌所有接口都要求BearerToken。策略表和任务表都带owner_id普通用户只能访问自己的数据管理员能看到全部资源但操作也受审计。async def get_current_user(credentialsDepends(oauth2_scheme)): payload jwt.decode(credentials, settings.SECRET_KEY, algorithms[HS256]) user await get_user_by_id(payload[sub]) if user is None: raise HTTPException(status_code401, detailinvalid token) return user async def get_owned_strategy(strategy_id: int, userDepends(get_current_user)): strategy await db_get_strategy(strategy_id, user.id) if strategy is None: raise HTTPException(status_code404, detailstrategy not found) return strategy开放OpenAPI是一个加分项团队里有人不想用前端可以直接通过HTTP API提交回测任务、拉取结果。我开放了三个核心接口创建回测任务、查询任务状态、查询回测结果。这些接口都加了限流默认每用户每分钟最多20次请求防止有人用平台当免费行情接口。API密钥存在独立的表里和JWT分开管理方便单独吊销。6. 一键部署、性能优化和运维踩坑实录6.1 Docker Compose一键起全套服务整套平台我收拢在一个docker-compose.yml里包含postgres、redis、api、worker、trader、nginx六个服务。生产环境就三台机器也够用一台跑api和nginx一台跑worker一台跑trader和数据库。本地开发环境用Compose起全部服务一个命令搞定。services: postgres: image: postgres:15 environment: POSTGRES_DB: btweb POSTGRES_PASSWORD: ${PG_PASSWORD} volumes: - pg_data:/var/lib/postgresql/data redis: image: redis:7 api: build: ./server command: uvicorn main:app --host 0.0.0.0 --port 8000 depends_on: [postgres, redis] worker: build: ./server command: python worker.py depends_on: [postgres, redis] trader: build: ./server command: python trader_service.py depends_on: [postgres, redis] nginx: image: nginx:1.25 volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - 80:80 depends_on: [api]部署时最容易踩的坑是数据库初始化。我建议在worker启动前加一个数据库迁移脚本用alembic管理。否则新机器上第一次启动docker-compose 直接跑会因数据库表不存在而报错。6.2 性能优化行情缓存、连接池、结果归档回测平台的性能瓶颈通常不在CPU而在行情数据和结果存储。行情接口如果在每次回测时都去拉全量历史延迟很高。我的做法是把常用标的的日线数据预缓存到Redis里key设计成market:{symbol}:{start}:{end}首次回测拉取后缓存半小时同一时间段反复回测可秒加载。数据库方面PostgreSQL连接池必须配好。FastAPI和worker进程各自维护一个连接池不能每次请求都新建连接。线程池大小与worker并发数要匹配否则在任务高峰时大量连接排队数据库出现慢查询。结果归档不能省。回测成交表只要连续跑一个月数据量轻松破百万。我在表上加了job_id和datetime联合索引并按月归档过期的明细到独立分区主表只保留最近三个月的活跃数据。如果你不做归档总有一天结果详情页的分页查询会慢到让人怀疑人生。6.3 三个让我熬夜的线上坑第一个坑是时区问题。行情数据里带的是交易所本地时间我一开始没有统一转成UTC存储导致多股回测时不同标的的K线错位策略信号出现大量假阴假阳。后来我把所有行情入库时强制转成UTC展示时再转回用户所在时区这个问题才根治。第二个坑是worker进程崩溃导致任务永远卡在pending。后来我在任务表里加了heartbeat_time字段worker每30秒更新一次调度器每隔五分钟扫一次表发现超过两分钟没心跳的任务直接标记失败并触发重试队列。没有这个机制线上跑两周就会积压一堆僵尸任务。第三个坑是交易代理的重复下单。trader进程从数据库读到一条待处理订单发送给券商网关网关成交后回调但回调报文因为网络超时没有及时回来trader重试机制又把同一笔订单又发了一遍。最后用client_order_id唯一索引从源头拦截了这种重复同时把trader的重试逻辑改成必须从数据库读取最新状态后再决定是否重发。做这个平台前后花了大半年时间最大的感受是把Backtrader从脚本变成Web平台真正的难点从来不是回测引擎本身而是怎么把策略、数据、订单、风控、用户这些环节串成一个闭环。这一圈做下来回测效率的提升只是最表层的好处更重要的是把从策略到模拟盘再到实盘的链路真正跑通了每一步都有记录、有依据、可追溯。如果你也正在做类似的事我建议不要一上来就追求全功能先把单个策略从回测到模拟盘这一条路打通再把多股、参数网格、多用户一层层往上加。路是一步步走出来的平台也是。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →