动量策略
原文: https://www.backtrader.com/blog/2019-05-20-momentum-strategy/momentum-strategy/
在另一篇伟大的帖子中,泰迪·科克再次展示了算法交易策略的发展路径:
- 研究首次应用pandas
- 回溯测试,然后使用backtrader
荣誉
该职位可在以下网址找到:
Teddy Koker给我发了条短信,问我是否可以对backtrader的用法发表评论。下面是我的意见。这只是我个人的拙见,因为作为backtrader的作者,我对如何最好地使用该平台存在偏见。
而我个人对于如何制定特定结构的品味,不必与其他人喜欢使用平台的方式相匹配。
笔记
事实上,让平台开放来插入几乎任何东西,用不同的方式来做同样的事情,是一个有意识的决定,让人们以他们认为合适的方式使用它(在平台目标、语言可能性和我所做的失败设计决定的限制范围内)
在这里,我们将只关注本可以以不同方式完成的事情。“不同”是否更好始终是一个意见问题。而backtrader的作者并不一定总是对“更好”与“backtrader”(因为实际开发必须适合开发者,而不是“backtrader”的作者)
参数:dictvstuple of tuples
许多随backtrader提供的样本以及文档和/或博客中提供的样本都使用tuple of tuples模式作为参数。例如,代码:
class Momentum(bt.Indicator):
    lines = ('trend',)
    params = (('period', 90),) 
与这个范例一起,人们总是有机会使用dict。
class Momentum(bt.Indicator):
    lines = ('trend',)
    params = dict(period=90)  # or params = {'period': 90} 
随着时间的推移,这已成为更轻的使用,并成为首选模式的作者。
笔记
作者更喜欢dict(period=90),因为它更容易打字,不需要引用。但是花括号符号{'period': 90}是许多其他符号的首选。
dict和tuple方法之间的潜在差异:
- 
使用 tuple of tuples参数时,保留声明的顺序,这在枚举它们时可能很重要。提示 Python 3.7中的默认有序字典的声明顺序应该没有问题(如果使用CPython即使是实现细节,也应该使用3.6)
在下面作者修改的示例中,将使用dict符号。
Momentum指示器
在本文中,这就是指标的定义方式
class Momentum(bt.Indicator):
    lines = ('trend',)
    params = (('period', 90),)
    def __init__(self):
        self.addminperiod(self.params.period)
    def next(self):
        returns = np.log(self.data.get(size=self.p.period))
        x = np.arange(len(returns))
        slope, _, rvalue, _, _ = linregress(x, returns)
        annualized = (1 + slope) ** 252
        self.lines.trend[0] = annualized * (rvalue ** 2) 
使用力,即:使用已经存在的东西,如PeriodN指示器,该指示器:
- 已经定义了一个period参数,并且知道如何将其传递给系统
因此,这可能更好
class Momentum(bt.ind.PeriodN):
    lines = ('trend',)
    params = dict(period=50)
    def next(self):
        ... 
我们已经跳过了为使用addminperiod而定义__init__的必要性,因为addminperiod只应在例外情况下使用。
为了继续,backtrader定义了一个OperationN指标,该指标必须定义了一个func属性,它将获得作为参数传递的period条,并将返回值放入定义的行中。
考虑到这一点,我们可以将以下代码想象为潜在代码
def momentum_func(the_array):
    r = np.log(the_array)
    slope, _, rvalue, _, _ = linregress(np.arange(len(r)), r)
    annualized = (1 + slope) ** 252
    return annualized * (rvalue ** 2)
class Momentum(bt.ind.OperationN):
    lines = ('trend',)
    params = dict(period=50)
    func = momentum_func 
这意味着我们将指标的复杂性置于指标之外。我们甚至可以从外部库中导入momentum_func,如果底层函数发生变化,则该指示器无需更改即可反映新的行为。作为奖励,我们有纯声明性指标。否__init__、否addminperiod和否next
战略
让我们看看__init__部分。
class Strategy(bt.Strategy):
    def __init__(self):
        self.i = 0
        self.inds = {}
        self.spy = self.datas[0]
        self.stocks = self.datas[1:]
        self.spy_sma200 = bt.indicators.SimpleMovingAverage(self.spy.close,
                                                            period=200)
        for d in self.stocks:
            self.inds[d] = {}
            self.inds[d]["momentum"] = Momentum(d.close,
                                                period=90)
            self.inds[d]["sma100"] = bt.indicators.SimpleMovingAverage(d.close,
                                                                       period=100)
            self.inds[d]["atr20"] = bt.indicators.ATR(d,
                                                      period=20) 
关于风格的一些事情:
- 
尽可能使用参数,而不是固定值 
- 
使用较短和较短的名称(例如,对于导入),在大多数情况下会增加可读性 
- 
充分使用 Python 
- 
不要使用 close作为数据馈送。一般地传递数据提要,它将使用 close。这看起来可能不相关,但在试图使代码处处通用时(如在指示器中)确实有所帮助
人们会/应该考虑的第一件事:尽可能将所有内容作为参数。因此
class Strategy(bt.Strategy):
    params = dict(
        momentum=Momentum,  # parametrize the momentum and its period
        momentum_period=90,
        movav=bt.ind.SMA,  # parametrize the moving average and its periods
        idx_period=200,
        stock_period=100,
        volatr=bt.ind.ATR,  # parametrize the volatility and its period
        vol_period=20,
    )
    def __init__(self):
        # self.i = 0  # See below as to why the counter is commented out
        self.inds = collections.defaultdict(dict)  # avoid per data dct in for
        # Use "self.data0" (or self.data) in the script to make the naming not
        # fixed on this being a "spy" strategy. Keep things generic
        # self.spy = self.datas[0]
        self.stocks = self.datas[1:]
        # Again ... remove the name "spy"
        self.idx_mav = self.p.movav(self.data0, period=self.p.idx_period)
        for d in self.stocks:
            self.inds[d]['mom'] = self.p.momentum(d, period=self.momentum_period)
            self.inds[d]['mav'] = self.p.movav(d, period=self.p.stock_period)
            self.inds[d]['vol'] = self.p.volatr(d, period=self.p.vol_period) 
通过使用params并更改两个命名约定,我们已经使__init__(及其策略)完全可定制和通用(没有任何spy引用)
next及其len
backtrader尽可能使用 Python 范例。它确实有时会失败,但它会尝试。
让我们看看next中发生了什么
 def next(self):
        if self.i % 5 == 0:
            self.rebalance_portfolio()
        if self.i % 10 == 0:
            self.rebalance_positions()
        self.i += 1 
这里是 Pythonlen范例的帮助之处。让我们使用它
 def next(self):
        l = len(self)
        if l % 5 == 0:
            self.rebalance_portfolio()
        if l % 10 == 0:
            self.rebalance_positions() 
如你所见,没有必要保留self.i计数器。策略和大多数对象的长度一直由系统提供、计算和更新。
next和prenext
代码包含此转发
 def prenext(self):
        # call next() even when data is not available for all tickers
        self.next() 
进入next时无防护
 def next(self):
        if self.i % 5 == 0:
            self.rebalance_portfolio()
        ... 
好的,我们知道正在使用一个无生存偏差的数据集,但是通常不保护prenext => next转发不是一个好主意。
- 
反向交易者在所有缓冲区(指示器、数据馈送)至少可以传递数据点时调用 next。100-bar移动平均线显然只有在数据馈送中有 100 个数据点时才会传递。这意味着当输入 next时,数据馈送将有100 data points需要检查,移动平均线仅为1 data point
- 
backtrader提供 prenext作为钩子,让开发者在上述保证能够满足之前访问东西。例如,当有多个数据源正在使用且它们的开始日期不同时,这非常有用。开发人员可能希望在满足所有数据源(和相关指标)的所有保证以及第一次调用next之前进行一些检查或采取一些措施。
在一般情况下,prenext => next转发应具有如下防护装置:
 def prenext(self):
        # call next() even when data is not available for all tickers
        self.next()
    def next(self):
        d_with_len = [d for d in self.datas if len(d)]
        ... 
这意味着只有来自self.datas的子集d_with_len可以使用担保。
笔记
指示器必须使用类似的防护装置。
因为在策略的整个生命周期内进行这种计算似乎毫无意义,所以可以进行这样的优化
 def __init__(self):
        ...
        self.d_with_len = []
    def prenext(self):
        # Populate d_with_len
        self.d_with_len = [d for d in self.datas if len(d)]
        # call next() even when data is not available for all tickers
        self.next()
    def nextstart(self):
        # This is called exactly ONCE, when next is 1st called and defaults to
        # call `next`
        self.d_with_len = self.datas  # all data sets fulfill the guarantees now
        self.next()  # delegate the work to next
    def next(self):
        # we can now always work with self.d_with_len with no calculation
        ... 
防护计算移动到prenext,在满足保证时停止调用。然后将调用nextstart,通过覆盖它,我们可以将保存要使用的数据集的list重置为完整的数据集,即:self.datas
有了这个,所有的守卫都被从next移除。
next带计时器
虽然作者在这里的意图是每 5/10 天重新平衡(投资组合/头寸),但这可能意味着每周/每两周重新平衡。
如果出现以下情况,len(self) % period进近将失败:
- 
数据集不是在周一开始的 
- 
在交易假期期间,这将使再平衡偏离方向 
为了克服这个问题,可以使用backtrader中的内置功能
- 使用文件-计时器
使用它们将确保再平衡在它应该发生的时候发生。让我们想象一下,我们的意图是在周五重新平衡
让我们为我们的策略中的params和__init__添加一点魔力
class Strategy(bt.Strategy):
    params = dict(
       ...
       rebal_weekday=5,  # rebalance 5 is Friday
    )
    def __init__(self):
        ...
        self.add_timer(
            when=bt.Timer.SESSION_START,
            weekdays=[self.p.rebal_weekday],
            weekcarry=True,  # if a day isn't there, execute on the next
        )
        ... 
现在我们已经准备好知道什么时候是星期五了。即使周五恰好是交易假日,添加weekcarry=True也可以确保我们在周一收到通知(如果周一也是假日,则为周二,或……)
计时器的通知在notify_timer中获取
def notify_timer(self, timer, when, *args, **kwargs):
    self.rebalance_portfolio() 
因为在原始代码中还有一个rebalance_positions每10条发生一次,所以可以:
- 
增加一个 2nd计时器,同样适用于周五 
- 
使用计数器仅作用于每个 2和调用,甚至可以在计时器本身中使用 allow=callable参数
笔记
计时器甚至可以更好地用于实现以下模式:
- 
rebalance_portfolio每月 2日和 4日周五
- 
rebalance_positions仅限每月 4日周五
一些额外的
其他一些事情可能纯粹是个人品味的问题。
个人品味 1
在next期间,始终使用预先构建的比较,而不是比较事物。例如,来自代码(多次使用)
 if self.spy < self.spy_sma200:
            return 
我们可以这样做。第一次在__init__期间
 def __init__(self):
        ...
        self.spy_filter = self.spe < self.spy_sma200 
后来
 if self.spy_filter:
            return 
考虑到这一点,如果我们想要改变spy_filter条件,我们只需在__init__中进行一次,而不需要在代码中的多个位置进行。
这同样适用于此处的另一个比较d < self.inds[d]["sma100"]:
 # sell stocks based on criteria
        for i, d in enumerate(self.rankings):
            if self.getposition(self.data).size:
                if i > num_stocks * 0.2 or d < self.inds[d]["sma100"]:
                    self.close(d) 
也可以在__init__期间预建,因此更改为类似的内容
 # sell stocks based on criteria
        for i, d in enumerate(self.rankings):
            if self.getposition(self.data).size:
                if i > num_stocks * 0.2 or self.inds[d]['sma_signal']:
                    self.close(d) 
个人品味 2
让一切都成为参数。例如,在上面的几行中,我们看到一个0.2,它用于代码的几个部分:将其作为参数。与其他值相同,如0.001和100(实际上已经建议将其作为创建移动平均线的参数)
将所有内容作为参数,只需更改策略的实例化,而不是策略本身,就可以打包代码并尝试不同的事情。

