ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

backtrader源码阅读:从metabase元类看参数与lines机制

backtrader源码阅读:从metabase元类看参数与lines机制

读backtrader源码,我的建议是从metabase.py开刀,而不是从cerebro.py或strategy.py。原因很直接:这个框架里几乎所有公有类都挂着元类。你写策略时那句params = (('period', 20),),为什么后面就能用self.p.period取到值?你声明lines = ('myline',),为什么self.lines.myline后面就能挂数据?这些都不是Python自带的能力,而是元类在类创建那一刻偷偷把声明翻成了可执行结构。不先把元类这层窗户纸捅破,后面读指标叠加、参数优化、多数据源绑定,基本是看一段懵一段。这篇文章我会把backtrader的元类家族逐个拆开,配上可以自己跑的验证代码,目标是让你看完之后再回头翻源码,不再是“字都认识但连不起来”的状态。

1. 元类在backtrader中的分量,比你想的重

1.1 一行策略代码背后藏着多少元类动作

先看一段最常见的策略骨架:

import backtrader as bt class MyStrategy(bt.Strategy): params = ( ('period', 20), ('stake', 100), ) lines = ('signal',) def __init__(self): self.sma = bt.indicators.SMA(self.data, period=self.p.period)

看起来平平无奇,但这段代码在“类定义被执行”到“实例被创建”之间的几百毫秒内,发生了至少这些事:params元组被扫描成有序键值对,合并父类Strategy已有的几十个参数,生成一个专门的参数类挂到MyStrategy._params;lines元组被解析,生成Lines容器对象,内部为每条线创建LineBuffer;p、params、l、lines这些快捷访问属性被绑定到实例;__init__被外部调用时,传入的参数会先和params合并校验,再进入你写的初始化逻辑。这一套动作如果没有元类,就只能在每个策略的__init__里手写,框架层面的统一完全无从谈起。

我第一次读源码时就是因为跳过了这一层,直接追cerebro.run(),结果在self.strats那里卡了很久——为什么用户传的类能被自动实例化、参数还能批量展开?答案其实早在类定义阶段就准备好了。

1.2 不用元类行不行:一次反事实推演

假设backtrader的作者放弃元类,改用最朴素的做法:每个内置类都写一个庞大的__init__,用kwargs接所有参数。会发生什么?首先,参数继承会变成灾难。Strategy本身继承了LineIterator、LineSeries、ParamsBase这一长串父类,每层都有各自的参数,子类__init__里要手动super().__init__(**kwargs)并过滤掉不属于自己的键。其次,参数优化功能会失效,cerebro.optstrategy需要遍历“每个策略支持哪些参数名”,如果参数是散落在各个__init__里的局部变量,框架根本拿不到一份可靠的参数清单。再次,用户自定义指标时得记住所有父类参数名,冲突了也不知道谁覆盖谁。

元类方案的核心价值,是把“参数声明”和“线声明”变成类级别的元数据,在类创建时固化下来。用户侧只写数据,框架侧负责收集、合并、校验、分发。这是典型的“用声明式换复杂度”的取舍,代价是源码阅读门槛提高了,收益是整个框架的可扩展性——你自己写指标、写策略、写自定义broker,只要遵守同一套声明语法,就能被框架无缝接住。

2. 补课:Python元类的三个核心事实

2.1 type不是函数,是类本身

很多人对type()的印象停留在“查类型的内置函数”,实际上type本身就是一个类,而且是所有类的元类。你随手写的class Foo: pass,type(Foo)返回<class 'type'>,意思是Foo是type的实例。更绕的是type(type)也返回<class 'type'>,type是它自己的实例。这个自指结构是理解元类的地基:元类就是“用来创建类的类”,它必须能创建自己,否则连启动都做不到。

在Python 2的年代,元类通过类体里的__metaclass__属性声明;Python 3改成了类定义时的关键字参数metaclass=。backtrader要同时兼容两个版本,所以内部有个with_metaclass的辅助函数,把两种写法包起来。你读源码时如果看到类似ParamsBase(with_metaclass(MetaParams, object))这种写法,不要被吓到,它等价于class ParamsBase(object, metaclass=MetaParams),只是多绕了一层为了兼容老版本。

2.2 类创建过程的三个时间点

理解元类要抓住类创建的三个时间点,它们对应三个可以被拦截的方法:

  • 第一个时间点是__prepare__,负责准备类体的命名空间,默认返回一个空字典。backtrader没怎么用它,但了解这个概念有助于理解“为什么类体里的赋值是顺序执行的”。
  • 第二个时间点是__new__,负责真正创建类对象。方法签名是(mcls, name, bases, namespace),mcls是元类自己,name是类名字符串,bases是父类元组,namespace是类体执行完后的命名空间字典。backtrader的大部分元类工作都在这里完成。
  • 第三个时间点是__init__,负责初始化已经创建好的类对象。它和__new__接收同样的参数,但此时类对象已经存在。backtrader用得少。

除此之外还有一个容易被忽略的__call__。当元类定义了__call__,那么“调用类来创建实例”这个动作就会被元类接管。这一条是backtrader元类体系的精髓,第3节会重点讲。

2.3 最小可用元类:10行代码改造类属性

理论说多了容易虚,直接上一段能跑的代码。下面这个元类会把类体里所有非下划线开头的属性名转成大写:

class UpperMeta(type): def __new__(mcls, name, bases, ns): new_ns = {} for k, v in ns.items(): if k.startswith('__'): new_ns[k] = v else: new_ns[k.upper()] = v return super().__new__(mcls, name, bases, new_ns) class Foo(metaclass=UpperMeta): bar = 1 baz = 2 print(Foo.BAR, Foo.BAZ) # 1 2 print(Foo.bar) # AttributeError

这段代码里super().__new__必须传四个参数,因为元类的__new__签名固定。ns是普通字典,遍历顺序在Python 3.7+是有序的。这段代码本身没什么用,但把它改造成“收集params元组”只需要换几行——你马上会看到,backtrader的MetaParams骨架和它几乎一模一样,只是收集的东西更复杂。

3. 从MetaBase看backtrader的元类模板

3.1 把__init__拆成五个钩子

打开metabase.py,你会看到一个不长的MetaBase类,它的__call__方法大概是这个结构(简化后):

class MetaBase(type): def doprenew(cls, *args, **kwargs): return cls, args, kwargs def donew(cls, *args, **kwargs): obj = cls.__new__(cls, *args, **kwargs) return obj, args, kwargs def dopreinit(cls, obj, *args, **kwargs): return obj, args, kwargs def doinit(cls, obj, *args, **kwargs): obj.__init__(*args, **kwargs) return obj, args, kwargs def dopostinit(cls, obj, *args, **kwargs): return obj, args, kwargs def __call__(cls, *args, **kwargs): cls, args, kwargs = cls.doprenew(*args, **kwargs) obj, args, kwargs = cls.donew(*args, **kwargs) obj, args, kwargs = cls.dopreinit(obj, *args, **kwargs) obj, args, kwargs = cls.doinit(obj, *args, **kwargs) obj, args, kwargs = cls.dopostinit(obj, *args, **kwargs) return obj

这个设计的聪明之处在于:实例化过程被切成五段,每段返回(对象, 参数, 关键字参数)三元组,下一个钩子接收上一个的输出。子元类想在哪一步插手,就重写哪一个钩子,其他步骤不受影响。这种“模板方法模式”在框架开发里是常见套路,backtrader把它做到了元类层面。

3.2 为什么用钩子而不是直接重写__call__

反问一句:直接重写__call__不行吗?行,但会出问题。MetaParams需要在实例创建后设置参数,MetaLineSeries需要在类创建时处理lines(注意是类创建,不是实例创建),MetaStrategy需要在实例创建后绑定数据源和broker。如果每个元类都完整重写一遍__call__,重复代码会爆掉,而且子元类想复用父元类逻辑会产生复杂的super()调用链。

钩子方案把“横切关注点”分离了:MetaParams只重写donew来注入参数,MetaLineSeries只重写__new__来处理线结构。这是软件工程里“早期拆分、晚期组合”思路的典型应用。你读源码时看到某个元类只实现了dopreinit,就知道它的职责一定很窄,只跟实例初始化前置逻辑有关。

3.3 MetaBase的继承者清单

从MetaBase直接或间接派生的元类,构成了整个backtrader的类型系统骨架。粗略列一下核心成员:

  • MetaParams:参数体系的地基,几乎所有需要用户传参的类背后都是它。
  • MetaLineSeries:在MetaParams基础上增加了lines的处理,是所有“具备数据线”的类的祖先。
  • MetaLineRoot:更底层,处理LineRoot相关逻辑,位于lineroot.py。
  • MetaLineIterator:在lineiterator.py中,是所有能迭代推进的线对象(策略、指标、观察器)的元类。
  • MetaLineBuffer、MetaLineActions:分别位于linebuffer.py、lineactions.py,处理数据缓冲与运算。
  • MetaStrategy、MetaIndicator、MetaObserver、MetaSizer、MetaBroker:面向具体业务的元类。

读到这里你应该意识到,backtrader不是“用了元类”,而是“以元类为骨架搭了整个框架”。这跟某些项目里“为了让代码显得高级而顺手用一下元类”有本质区别。也正因为如此,你不掌握元类,源码阅读会处处碰壁。

4. MetaParams:参数体系的发动机

4.1 params为什么是元组而不是字典

backtrader规定用户写params = (('period', 20), ('stake', 100)),而不是params = {'period': 20, 'stake': 100}。很多人第一反应是“字典不是更自然吗”。原因有三层。

第一层原因是历史包袱。backtrader项目起步早,要兼容Python 3.6之前的版本,那时字典不保证插入顺序,而参数顺序对于参数展示、参数优化、序列化都有影响。

第二层原因是元组容器更紧凑,遍历和复制成本低,在类创建阶段被反复扫描也不会成为性能瓶颈。

第三层原因是元组允许同名参数出现(虽然会在合并时被覆盖),处理逻辑比字典顺手。字典在扫描时会丢失重复键的信息,而元组可以保留全部原始输入,方便做诊断和错误提示。

这也解释了你为什么不能写params = dict(...):元类按顺序遍历时,会把dict当成一个不可迭代的类型,或者在迭代时拿到错误结构。写法错误是新手最容易踩的坑之一,后面第8节会专门列排查清单。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进