用保守公式重新平衡
原文: https://www.backtrader.com/blog/2019-07-19-rebalancing-conservative/rebalancing-conservative/
本文提出了保守公式方法:Python 中的保守公式:量化投资变得容易
*这是许多可能的再平衡方法之一,但很容易掌握。方法概述:
-
x
股票选自Y
(1000 只中的 100 只) -
甄选准则如下:
- 低波动性
- 高净支付收益率
- 高动量
- 每月重新平衡
考虑到这一点,让我们在backtrader中展示一个可能的实现
数据
即使有一个成功的策略,如果没有可用的数据,实际上也不会赢得任何东西。这意味着必须考虑数据的外观和加载方式。
假设一组CSV(“逗号分隔值”)文件可用,包含以下特征
-
ohlcv
月度数据 -
在包含净支付收益率(
npy
的v
之后有一个额外的字段,以拥有ohlcvn
数据集。
因此,CSV数据的格式如下所示
date, open, high, low, close, volume, npy
2001-12-31, 1.0, 1.0, 1.0, 1.0, 0.5, 3.0
2002-01-31, 2.0, 2.5, 1.1, 1.2, 3.0, 5.0
...
即:每月一行。数据加载器引擎现在可以准备好,为它创建与backtrader一起交付的通用内置 CSV 加载器的简单扩展。
class NetPayOutData(bt.feeds.GenericCSVData):
lines = ('npy',) # add a line containing the net payout yield
params = dict(
npy=6, # npy field is in the 6th column (0 based index)
dtformat='%Y-%m-%d', # fix date format a yyyy-mm-dd
timeframe=bt.TimeFrame.Months, # fixed the timeframe
openinterest=-1, # -1 indicates there is no openinterest field
)
就是这样。请注意,向ohlcv
数据流添加一点基础数据是多么容易。
-
通过使用表达式
lines=('npy',)
。其他常用字段(open
、high
…)已经是GenericCSVData
的一部分 -
通过
params = dict(npy=6)
指示加载位置。其他字段具有预定义的位置。
参数中的时间范围也已更新,以反映数据的月度性质。
注
实际字段和加载位置见文档-数据馈送参考-通用 CSVDATA(均可定制)
数据加载器必须用一个文件名正确地实例化,但这是以后的事情,当下面给出一个标准样板文件以获得一个完整的脚本时。
战略
让我们把这个逻辑放到一个标准的反向交易者策略中。为了使其尽可能通用和可定制,将使用与之前数据相同的params
方法。
在深入研究策略之前,让我们从快速总结中考虑其中的一点。
x
股票从Y
宇宙中选择
策略本身并不负责向宇宙中添加股票,而是负责选择。如果代码中的x
和Y
是固定的,则可能只添加了 50 只股票,仍然尝试选择 100 只。为应对此类情况,将采取以下措施:
-
有一个值为
0.10
(即:10%
的selperc
参数,表示要从宇宙中选择的库存量。这意味着,如果存在 1000 只股票,则只会选择 100 只;如果宇宙由 50 只股票组成,则只会选择 5 只股票。
至于股票的排名公式,如下所示:
-
(momentum * net payout) / volatility
这意味着那些动量更大、回报更高、波动性更低的人得分更高。
对于momentum
将使用RateOfChange
指标(又名ROC
,该指标衡量期间价格变化的比率。
net payout
已经是数据馈送的一部分。
为了计算volatility
,将使用股票n-periods
返回的StandardDeviation
(n-periods
,因为东西将作为参数保留)。
有了这些信息,策略已经可以初始化,使用正确的参数和指标设置和计算,这些将在以后的每个月迭代中使用。
首先是声明和参数
class St(bt.Strategy):
params = dict(
selcperc=0.10, # percentage of stocks to select from the universe
rperiod=1, # period for the returns calculation, default 1 period
vperiod=36, # lookback period for volatility - default 36 periods
mperiod=12, # lookback period for momentum - default 12 periods
reserve=0.05 # 5% reserve capital
)
请注意,上面没有提到的内容已经添加,这是一个参数reserve=0.05
(即5%,用于计算每只股票的分配百分比,将储备资本保留在银行中。虽然对于模拟来说,人们可能希望使用 100%的资本,但这样做可能会遇到常见的问题,例如价格差距、浮点精度,最终会错过一些市场条目。
在做其他事情之前,先创建一个小的日志记录方法,它允许记录投资组合是如何重新平衡的。
def log(self, arg):
print('{} {}'.format(self.datetime.date(), arg))
在__init__
方法开始时,计算要排名的股票数量,并应用储备资本参数确定银行的每股百分比。
def __init__(self):
# calculate 1st the amount of stocks that will be selected
self.selnum = int(len(self.datas) * self.p.selcperc)
# allocation perc per stock
# reserve kept to make sure orders are not rejected due to
# margin. Prices are calculated when known (close), but orders can only
# be executed next day (opening price). Price can gap upwards
self.perctarget = (1.0 - self.p.reserve) % self.selnum
最后初始化结束,计算波动率和动量的每股指标,然后将其应用于每股排名公式计算。
# returns, volatilities and momentums
rs = [bt.ind.PctChange(d, period=self.p.rperiod) for d in self.datas]
vs = [bt.ind.StdDev(ret, period=self.p.vperiod) for ret in rs]
ms = [bt.ind.ROC(d, period=self.p.mperiod) for d in self.datas]
# simple rank formula: (momentum * net payout) / volatility
# the highest ranked: low vol, large momentum, large payout
self.ranks = {d: d.npy * m / v for d, v, m in zip(self.datas, vs, ms)}
现在是每个月迭代的时候了。排名可在self.ranks
字典中找到。每次迭代都必须对键/值对进行排序,以确定哪些项目必须离开,哪些项目必须成为投资组合的一部分(保留或添加)
def next(self):
# sort data and current rank
ranks = sorted(
self.ranks.items(), # get the (d, rank), pair
key=lambda x: x[1][0], # use rank (elem 1) and current time "0"
reverse=True, # highest ranked 1st ... please
)
iterable 按相反顺序排序,因为排名公式为排名最高的股票提供了更高的分数。
重新平衡现在已经到期。
再平衡 1:排名靠前,有未平仓的股票
# put top ranked in dict with data as key to test for presence
rtop = dict(ranks[:self.selnum])
# For logging purposes of stocks leaving the portfolio
rbot = dict(ranks[self.selnum:])
这里发生了一些 Python 的诡计,因为使用了dict
。原因是,如果将排名靠前的股票放入一个list
中,Python会在内部使用操作符==
来检查操作符in
是否存在。虽然不太可能,但两支股票在同一天的价值是相同的。当使用dict
时,在检查是否存在作为键一部分的项时,使用哈希值。
注:出于记录目的,还创建了rbot
(排名垫底的)以及rtop
中不存在的股票。
为了以后区分必须离开投资组合的股票、必须重新平衡的股票和排名靠前的股票,我们准备了一份投资组合中当前的股票列表。
# prepare quick lookup list of stocks currently holding a position
posdata = [d for d, pos in self.getpositions().items() if pos]
重新平衡 2:出售不再排名靠前的产品
就像在现实世界中一样,在backtrader生态系统中,为了确保有足够的现金,必须先卖出再买入。
# remove those no longer top ranked
# do this first to issue sell orders and free cash
for d in (d for d in posdata if d not in rtop):
self.log('Exit {} - Rank {:.2f}'.format(d._name, rbot[d][0]))
self.order_target_percent(d, target=0.0)
当前未平仓且不再排名靠前的股票被出售(即target=0.0
。
注
一个简单的self.close(data)
在这里就足够了,而不是明确说明目标百分比。
再平衡 3:为所有排名靠前的股票发布目标订单
投资组合的总价值随着时间的推移而变化,投资组合中已经存在的股票可能必须略微增加/减少当前头寸,以匹配预期的百分比。order_target_percent
是进入市场的理想方法,因为它会自动计算是否需要buy
或sell
订单。
# rebalance those already top ranked and still there
for d in (d for d in posdata if d in rtop):
self.log('Rebal {} - Rank {:.2f}'.format(d._name, rtop[d][0]))
self.order_target_percent(d, target=self.perctarget)
del rtop[d] # remove it, to simplify next iteration
在将新股票添加到投资组合之前,需要对已有头寸的股票进行再平衡,因为新股票只会发出buy
订单并消耗现金。在重新平衡后,将现有股票从rtop[data].pop()
中移除,rtop
中的剩余股票是将新添加到投资组合中的股票。
# issue a target order for the newly top ranked stocks
# do this last, as this will generate buy orders consuming cash
for d in rtop:
self.log('Enter {} - Rank {:.2f}'.format(d._name, rtop[d][0]))
self.order_target_percent(d, target=self.perctarget)
运行它并评估它!
只有数据加载器类和策略是不够的。就像其他框架一样,需要一些样板文件。下面的代码使之成为可能。
def run(args=None):
args = parse_args(args)
cerebro = bt.Cerebro()
# Data feed kwargs
dkwargs = dict(**eval('dict(' + args.dargs + ')'))
# Parse from/to-date
dtfmt, tmfmt = '%Y-%m-%d', 'T%H:%M:%S'
if args.fromdate:
fmt = dtfmt + tmfmt * ('T' in args.fromdate)
dkwargs['fromdate'] = datetime.datetime.strptime(args.fromdate, fmt)
if args.todate:
fmt = dtfmt + tmfmt * ('T' in args.todate)
dkwargs['todate'] = datetime.datetime.strptime(args.todate, fmt)
# add all the data files available in the directory datadir
for fname in glob.glob(os.path.join(args.datadir, '*')):
data = NetPayOutData(dataname=fname, **dkwargs)
cerebro.adddata(data)
# add strategy
cerebro.addstrategy(St, **eval('dict(' + args.strat + ')'))
# set the cash
cerebro.broker.setcash(args.cash)
cerebro.run() # execute it all
# Basic performance evaluation ... final value ... minus starting cash
pnl = cerebro.broker.get_value() - args.cash
print('Profit ... or Loss: {:.2f}'.format(pnl))
在执行以下操作的情况下:
-
解析参数并使其可用(这显然是可选的,因为一切都可以硬编码,但好的实践就是好的实践)
-
创建一个
cerebro
引擎实例。是的,这是西班牙语中【大脑】的意思,是负责在黑暗中协调管弦乐动作的框架的一部分。尽管它可以接受多个选项,但是缺省值应该足以满足大多数用例。 -
加载数据文件,通过简单的目录扫描
args.datadir
完成,所有文件都加载NetPayOutData
并添加到cerebro
实例中 -
添加策略
-
设置现金,默认为
1,000,000
。考虑到这个用例是针对500
宇宙中的100
股票的,所以有一些备用现金似乎是公平的。这也是一个可以改变的论点。 -
并致电
cerebro.run()
-
最后对性能进行了评价
为了能够直接从命令行运行具有不同参数的程序,下面提供了一个启用了argparse
的样板文件,以及完整的代码
绩效评估
以最终结果值的形式添加的简单绩效评估,即:最终资产净值减去起始现金。
backtrader生态系统提供了一套内置的性能分析器,也可以使用,如:SharpeRatio
、Variability-Weighted Return
、SQN
等。参见文件-分析仪参考
全集
最后,将大部分工作作为一个整体呈现。享受
```py import argparse import datetime import glob import os.path
import backtrader as bt
class NetPayOutData(bt.feeds.GenericCSVData): lines = ('npy',) # add a line containing the net payout yield params = dict( npy=6, # npy field is in the 6th column (0 based index) dtformat='%Y-%m-%d', # fix date format a yyyy-mm-dd timeframe=bt.TimeFrame.Months, # fixed the timeframe openinterest=-1, # -1 indicates there is no openinterest field )
class St(bt.Strategy): params = dict( selcperc=0.10, # percentage of stocks to select from the universe rperiod=1, # period for the returns calculation, default 1 period vperiod=36, # lookback period for volatility - default 36 periods mperiod=12, # lookback period for momentum - default 12 periods reserve=0.05 # 5% reserve capital )
def log(self, arg):
print('{} {}'.format(self.datetime.date(), arg))
def __init__(self):
# calculate 1st the amount of stocks that will be selected
self.selnum = int(len(self.datas) * self.p.selcperc)
# allocation perc per stock
# reserve kept to make sure orders are not rejected due to
# margin. Prices are calculated when known (close), but orders can only
# be executed next day (opening price). Price can gap upwards
self.perctarget = (1.0 - self.p.reserve) / self.selnum
# returns, volatilities and momentums
rs = [bt.ind.PctChange(d, period=self.p.rperiod) for d in self.datas]
vs = [bt.ind.StdDev(ret, period=self.p.vperiod) for ret in rs]
ms = [bt.ind.ROC(d, period=self.p.mperiod) for d in self.datas]
# simple rank formula: (momentum * net payout) / volatility
# the highest ranked: low vol, large momentum, large payout
self.ranks = {d: d.npy * m / v for d, v, m in zip(self.datas, vs, ms)}
def next(self):
# sort data and current rank
ranks = sorted(
self.ranks.items(), # get the (d, rank), pair
key=lambda x: x[1][0], # use rank (elem 1) and current time "0"
reverse=True, # highest ranked 1st ... please
)
# put top ranked in dict with data as key to test for presence
rtop = dict(ranks[:self.selnum])
# For logging purposes of stocks leaving the portfolio
rbot = dict(ranks[self.selnum:])
# prepare quick lookup list of stocks currently holding a position
posdata = [d for d, pos in self.getpositions().items() if pos]
# remove those no longer top ranked
# do this first to issue sell orders and free cash
for d in (d for d in posdata if d not in rtop):
self.log('Leave {} - Rank {:.2f}'.format(d._name, rbot[d][0]))
self.order_target_percent(d, target=0.0)
# rebalance those already top ranked and still there
for d in (d for d in posdata if d in rtop):
self.log('Rebal {} - Rank {:.2f}'.format(d._name, rtop[d][0]))
self.order_target_percent(d, target=self.perctarget)
del rtop[d] # remove it, to simplify next iteration
# issue a target order for the newly top ranked stocks
# do this last, as this will generate buy orders consuming cash
for d in rtop:
self.log('Enter {} - Rank {:.2f}'.format(d._name, rtop[d][0]))
self.order_target_percent(d, target=self.perctarget)
def run(args=None): args = parse_args(args)
cerebro = bt.Cerebro()
# Data feed kwargs
dkwargs = dict(**eval('dict(' + args.dargs + ')'))
# Parse from/to-date
dtfmt, tmfmt = '%Y-%m-%d', 'T%H:%M:%S'
if args.fromdate:
fmt = dtfmt + tmfmt * ('T' in args.fromdate)
dkwargs['fromdate'] = datetime.datetime.strptime(args.fromdate, fmt)
if args.todate:
fmt = dtfmt + tmfmt * ('T' in args.todate)
dkwargs['todate'] = datetime.datetime.strptime(args.todate, fmt)
# add all the data files available in the directory datadir
for fname in glob.glob(os.path.join(args.datadir, '*')):
data = NetPayOutData(dataname=fname, **dkwargs)
cerebro.adddata(data)
# add strategy
cerebro.addstrategy(St, **eval('dict(' + args.strat + ')'))
# set the cash
cerebro.broker.setcash(args.cash)
cerebro.run() # execute it all
# Basic performance evaluation ... final value ... minus starting cash
pnl = cerebro.broker.get_value() - args.cash
print('Profit ... or Loss: {:.2f}'.format(pnl))
def parse_args(pargs=None): parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, description=('Rebalancing with the Conservative Formula'), )
parser.add_argument('--datadir', required=True,
help='Directory with data files')
parser.add_argument('--dargs', default='',
metavar='kwargs', help='kwargs in k1=v1,k2=v2 format')
# Defaults for dates
parser.add_argument('--fromdate', required=False, default='',
help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')
parser.add_argument('--todate', required=False, default='',
help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')
parser.add_argument('--cerebro', required=False, default='',
metavar='kwargs', help='kwargs in k1=v1,k2=v2 format')
parser.add_argument('--cash', default=1000000.0, type=float,
metavar='kwargs', help='kwargs in k1=v1,k2=v2 format')
parser.add_argument('--strat', required=False, default='',
metavar='kwargs', help='kwargs in k1=v1,k2=v2 format')
return parser.parse_args(pargs)
if name == 'main': run() ```*