多股組合操作
多股組合操作,是一種高級的操作模式。多股組合操作通常有兩種模式,一種是定期調倉,即定期再平衡,比如每週1調倉,或每月1號調倉等。另一種是非定期調倉,比如每日判斷,進行調倉,或者每隔n天進行調倉。
定期調倉(再平衡)通常通過定時器timer來進入調倉邏輯,而非定期調倉通常通過傳統的策略next方法進入調倉邏輯。當然,這並非絕對。
這兩種多股調倉操作,都不能用backtrader內建的自動確定最小期的方法來做(比如為了求20日均線,自動跳過前20個bar),因為有些股票有交易的日期很靠後,它的最小期很大,其他股票也會採用這個最小期,這會導致其他股票浪費最小期前的資料,因此,必須自己控制最小期,也就是prenext方法裡必須寫上self.next()直接跳轉到next。然後如果用到了比如5日均線這樣的指標,你要自己判斷資料對象線長度是否夠長。
儘管網上有一個用backtrader執行多股組合回測的案例,但並未很好地處理好多股回測中的一些問題。本文將給出完善的處理方案。
(基於next的非定期再平衡的策略實現請參考我們編寫的教學和視訊課程)
本文介紹基於定時器timer的多股定期再平衡策略的實現.
本案例的目的是介紹使用backtrader進行組合管理時,要注意的一些技術要點,策略本身僅供參考。策略的大致邏輯如下:每年5月1日,9月1日,11月1日進行組合再平衡操作(若該日休市,則順延到開市日進行再平衡操作)。
首先載入一組股票(股票池),在再平衡日,從股票池挑出至少上市3年,且淨資產收益率roe>0.1,市盈率 pe在0到100間的股票,這組選出的股票再按成交量從大到小排序,選出前100隻股票(如果選出的股票少於100隻,則按實際來),將全部帳戶價值按等比例分配買入這些股票。
該策略反應瞭如下幾個技術要點,把這些要點整明白,基本上就可用於實戰了,程式碼更詳細的解讀特別是定時器timer的用法參考我們編寫的教學和視訊課程:
1 擴展PandasData類
2 第一個資料應該對應指數,作為時間基準
3 資料預處理:刪除原始資料中無交易的及缺指標的記錄
4 先平倉再執行後續買賣
5 下單量的計算方法
6 如何保證先賣後買以空出資金
7 怎樣按明日開盤價計算下單數量
8 為行情資料對象提供名字
9 買賣數量如何設為100的整數倍
10 設定符合中國股市的佣金模式,考慮印花稅
11 漲跌停板的處理
# 考慮中國佣金,下單量100的整數倍,漲跌停板,滑點
# 考慮一個技術指標,展示怎樣處理最小期問題
from datetime import datetime, time
from datetime import timedelta
import pandas as pd
import numpy as np
import backtrader as bt
import os.path # 管理路徑
import sys # 發現指令碼名字(in argv[0])
import glob
from backtrader.feeds import PandasData # 用於擴展DataFeed
# 建立新的data feed類
class PandasDataExtend(PandasData):
# 增加線
lines = ("pe", "roe", "marketdays")
params = (
("pe", 15),
("roe", 16),
("marketdays", 17),
) # 上市天數
class stampDutyCommissionScheme(bt.CommInfoBase):
"""
本佣金模式下,買入股票僅支付佣金,賣出股票支付佣金和印花稅.
"""
params = (
("stamp_duty", 0.005), # 印花稅率
("commission", 0.001), # 佣金率
("stocklike", True),
("commtype", bt.CommInfoBase.COMM_PERC),
)
def _getcommission(self, size, price, pseudoexec):
"""
If size is greater than 0, this indicates a long / buying of shares.
If size is less than 0, it idicates a short / selling of shares.
"""
if size > 0: # 買入,不考慮印花稅
return size * price * self.p.commission
elif size < 0: # 賣出,考慮印花稅
return -size * price * (self.p.stamp_duty + self.p.commission)
else:
return 0 # just in case for some reason the size is 0.
class Strategy(bt.Strategy):
params = dict(
rebal_monthday=[1], num_volume=100, period=5, # 每月1日執行再平衡 # 成交量取前100名
)
# 日誌函數
def log(self, txt, dt=None):
# 以第一個資料data0,即指數作為時間基準
dt = dt or self.data0.datetime.date(0)
print("%s, %s" % (dt.isoformat(), txt))
def __init__(self):
self.lastRanks = [] # 上次交易股票的列表
# 0號是指數,不進入選股池,從1號往後進入股票池
self.stocks = self.datas[1:]
# 記錄以往訂單,在再平衡日要全部取消未成交的訂單
self.order_list = []
# 移動平均線指標
self.sma = {d: bt.ind.SMA(d, period=self.p.period) for d in self.stocks}
# 定時器
self.add_timer(
when=bt.Timer.SESSION_START,
monthdays=self.p.rebal_monthday, # 每月1號觸發再平衡
monthcarry=True, # 若再平衡日不是交易日,則順延觸發notify_timer
)
def notify_timer(self, timer, when, *args, **kwargs):
# 只在5,9,11月的1號執行再平衡
if self.data0.datetime.date(0).month in [5, 9, 11]:
self.rebalance_portfolio() # 執行再平衡
# def next(self):
# print('next 帳戶總值', self.data0.datetime.datetime(0), self.broker.getvalue())
# for d in self.stocks:
# if(self.getposition(d).size!=0):
# print(d._name, '持倉' ,self.getposition(d).size)
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
# 訂單狀態 submitted/accepted,無動作
return
# 訂單完成
if order.status in [order.Completed]:
if order.isbuy():
self.log(
"買單執行,%s, %.2f, %i"
% (order.data._name, order.executed.price, order.executed.size)
)
elif order.issell():
self.log(
"賣單執行, %s, %.2f, %i"
% (order.data._name, order.executed.price, order.executed.size)
)
else:
self.log(
"訂單作廢 %s, %s, isbuy=%i, size %i, open price %.2f"
% (
order.data._name,
order.getstatusname(),
order.isbuy(),
order.created.size,
order.data.open[0],
)
)
# 記錄交易收益情況
def notify_trade(self, trade):
if trade.isclosed:
print(
"毛收益 %0.2f, 扣傭後收益 % 0.2f, 佣金 %.2f, 市值 %.2f, 現金 %.2f"
% (
trade.pnl,
trade.pnlcomm,
trade.commission,
self.broker.getvalue(),
self.broker.getcash(),
)
)
def rebalance_portfolio(self):
# 從指數取得當前日期
self.currDate = self.data0.datetime.date(0)
print("rebalance_portfolio currDate", self.currDate, len(self.stocks))
# 如果是指數的最後一本bar,則退出,防止取下一日開盤價越界錯
if len(self.datas[0]) == self.data0.buflen():
return
# 取消以往所下訂單(已成交的不會起作用)
for o in self.order_list:
self.cancel(o)
self.order_list = [] # 重設訂單列表
# for d in self.stocks:
# print('sma', d._name, self.sma[d][0],self.sma[d][1], d.marketdays[0])
# 最終標的選取過程
# 1 先做排除篩選過程
self.ranks = [
d
for d in self.stocks
if len(d) > 0 and d.marketdays > 3 * 365 # 重要,到今日至少要有一根實際bar # 到今天至少上市
# 今日未停牌 (若去掉此句,則今日停牌的也可能進入,並下訂單,次日若復牌,則次日可能成交)(假設原始資料中已刪除無交易的記錄)
and d.datetime.date(0) == self.currDate
and d.roe >= 0.1
and d.pe < 100
and d.pe > 0
and len(d) >= self.p.period # 最小期,至少需要5根bar
and d.close[0] > self.sma[d][1]
]
# 2 再做排序挑選過程
self.ranks.sort(key=lambda d: d.volume, reverse=True) # 按成交量從大到小排序
self.ranks = self.ranks[0 : self.p.num_volume] # 取前num_volume名
if len(self.ranks) == 0: # 無股票選中,則返回
return
# 3 以往買入的標的,本次不在標的中,則先平倉
data_toclose = set(self.lastRanks) - set(self.ranks)
for d in data_toclose:
print("sell 平倉", d._name, self.getposition(d).size)
o = self.close(data=d)
self.order_list.append(o) # 記錄訂單
# 4 本次標的下單
# 每隻股票買入資金百分比,預留2%的資金以應付佣金和計算誤差
buypercentage = (1 - 0.02) / len(self.ranks)
# 得到目標市值
targetvalue = buypercentage * self.broker.getvalue()
# 為保證先賣後買,股票要按持倉市值從大到小排序
self.ranks.sort(key=lambda d: self.broker.getvalue([d]), reverse=True)
self.log(
"下單, 標的個數 %i, targetvalue %.2f, 當前總市值 %.2f"
% (len(self.ranks), targetvalue, self.broker.getvalue())
)
for d in self.ranks:
# 按次日開盤價計算下單量,下單量是100的整數倍
size = int(
abs((self.broker.getvalue([d]) - targetvalue) / d.open[1] // 100 * 100)
)
validday = d.datetime.datetime(1) # 該股下一實際交易日
if self.broker.getvalue([d]) > targetvalue: # 持倉過多,要賣
# 次日跌停價近似值
lowerprice = d.close[0] * 0.9 + 0.02
o = self.sell(
data=d,
size=size,
exectype=bt.Order.Limit,
price=lowerprice,
valid=validday,
)
else: # 持倉過少,要買
# 次日漲停價近似值
upperprice = d.close[0] * 1.1 - 0.02
o = self.buy(
data=d,
size=size,
exectype=bt.Order.Limit,
price=upperprice,
valid=validday,
)
self.order_list.append(o) # 記錄訂單
self.lastRanks = self.ranks # 跟蹤上次買入的標的
##########################
# 主程序開始
#########################
cerebro = bt.Cerebro(stdstats=False)
cerebro.addobserver(bt.observers.Broker)
cerebro.addobserver(bt.observers.Trades)
# cerebro.broker.set_coc(True) # 以訂單建立日的收盤價成交
# cerebro.broker.set_coo(True) # 以次日開盤價成交
datadir = "./dataswind" # 資料檔案位於本指令碼所在目錄的data子目錄中
datafilelist = glob.glob(os.path.join(datadir, "*")) # 資料檔案路徑列表
maxstocknum = 20 # 股票池最大股票數目
# 注意,排序第一個檔案必須是指數資料,作為時間基準
datafilelist = datafilelist[0:maxstocknum] # 擷取指定數量的股票池
print(datafilelist)
# 將目錄datadir中的資料檔案載入進系統
for fname in datafilelist:
df = pd.read_csv(fname, skiprows=0, header=0,) # 不忽略行 # 列頭在0行
# df = df[~df['交易狀態'].isin(['停牌一天'])] # 去掉停牌日記錄
df["date"] = pd.to_datetime(df["date"]) # 轉成日期類型
df = df.dropna()
# print(df.info())
# print(df.head())
data = PandasDataExtend(
dataname=df,
datetime=0, # 日期列
open=2, # 開盤價所在列
high=3, # 最高價所在列
low=4, # 最低價所在列
close=5, # 收盤價價所在列
volume=6, # 成交量所在列
pe=7,
roe=8,
marketdays=9,
openinterest=-1, # 無未平倉量列
fromdate=datetime(2002, 4, 1), # 起始日2002, 4, 1
todate=datetime(2015, 12, 31), # 結束日 2015, 12, 31
plot=False,
)
ticker = fname[-13:-4] # 從檔案路徑名取得股票程式碼
cerebro.adddata(data, name=ticker)
cerebro.addstrategy(Strategy)
startcash = 10000000
cerebro.broker.setcash(startcash)
# 防止下單時現金不夠被拒絕。只在執行時檢查現金夠不夠。
cerebro.broker.set_checksubmit(False)
comminfo = stampDutyCommissionScheme(stamp_duty=0.001, commission=0.001)
cerebro.broker.addcommissioninfo(comminfo)
results = cerebro.run()
print("最終市值: %.2f" % cerebro.broker.getvalue())
# cerebro.plot()
以上這個策略,其實也可以通過next方法進入策略邏輯,具體程式碼和詳情請參考我們的教學: