Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

多股組合操作

多股組合操作,是一種高級的操作模式。多股組合操作通常有兩種模式,一種是定期調倉,即定期再平衡,比如每週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方法進入策略邏輯,具體程式碼和詳情請參考我們的教學: