Developing Sizers in Backtrader – Part 2

This is the second post covering the development of sizers in Backtrader. The first post provided an introduction to the basics, looked at the anatomy of a sizer and provided some basic sizing algorithms. This post by contrast, will dive a bit deeper into the subject by taking a look at comminfo and how it can be used to fine-tune your sizing algorithms. Note: Since this post is primarily focused on how to apply comminfo to sizing algorithms, readers may wish to read the related commission’s scheme post: Backtrader: Commission Schemes

Comminfo

Each time you make a buy or sell call within a strategy, the sizer class is passed the strategy’s comminfo instance. This allows you to access commission attributes (variables) such as commission, mult and marginas well as comminfo’s own methods (functions). Having access to commission parameters and methods can allow the sizer to account for the exact commission that would be charged to open and close a position. Some further reading on commissions can be found here:
  1. Official Docs: https://www.backtrader.com/docu/commission-schemes/commission-schemes.html
  2. Backtest-Rookies: Backtrader: Commission Scheme
As you look through the documentation, you might notice that most comminfo methods require a size parameter when called. Given that we are trying to calculate the size, one might argue that these methods are not immediately useful. However, don’t discard them just yet.  They can be used to make verifications or adjustments to your sizing once you have an initial idea of the size you want to return.

Accounting for Commissions

If we want to properly control our total risk, taking account of commissions is essential. As such, our sizing algorithm should factor in this cost so we don’t induce death by a thousand paper cuts. Additionally, if we don’t factor in these costs, we can easily end up returning a size that we don’t enough cash to open. This results in missed trades and sometimes a bit of head scratching! An example of this is below. If you run the code, with the basic max risk sizer developed in part 1 and crank up the commissions, you will the issue.
'''
Author: www.backtest-rookies.com

MIT License

Copyright (c) 2017 backtest-rookies.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''

import backtrader as bt
from datetime import datetime
import math
import argparse


def parse_args():
    parser = argparse.ArgumentParser(description='Sizers Testing')


    parser.add_argument('--commperc',
                        default=0.01,
                        type=float,
                        help='The percentage commission to apply to a trade')


    return parser.parse_args()

class maxRiskSizer(bt.Sizer):
    '''
    Returns the number of shares rounded down that can be purchased for the
    max risk tolerance
    '''
    params = (('risk', 0.1),)

    def _getsizing(self, comminfo, cash, data, isbuy):
        if isbuy == True:
            size = math.floor((cash * self.p.risk) / data[0])
        else:
            size = math.floor((cash * self.p.risk) / data[0]) * -1
        return size


class firstStrategy(bt.Strategy):

    def __init__(self):
        self.rsi = bt.indicators.RSI_SMA(self.data.close, period=21)

    def next(self):

        if not self.position:
            if self.rsi < 30:
                self.buy()
        elif self.rsi > 70:
            self.close()
        else:
            pass


args = parse_args()

#Variable for our starting cash
startcash = 10000

#Create an instance of cerebro
cerebro = bt.Cerebro()

#Add our strategy
cerebro.addstrategy(firstStrategy)


data = bt.feeds.Quandl(
    dataname='F',
    fromdate = datetime(2016,1,1),
    todate = datetime(2017,1,1),
    buffered= True
    )

#Add the data to Cerebro
cerebro.adddata(data)

# Set our desired cash start
cerebro.broker.setcash(startcash)


cerebro.addsizer(maxRiskSizer)

cerebro.broker.setcommission(commission=args.commperc)

# Run over everything
cerebro.run()

#Get final portfolio Value
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash

#Print out the final result
print('Final Portfolio Value: ${}'.format(portvalue))
print('P/L: ${}'.format(pnl))

#Finally plot the end results
cerebro.plot(style='candlestick')
Important Note: The data in this tutorial uses the Quandl API. With this API you are limited to the number calls you can make per day. To have unlimited access to their free data, sign up for an API key here and add the apikeykeyword to the quandl data call like so:
data = bt.feeds.Quandl(
    dataname='F',
    fromdate = datetime(2016,1,1),
    todate = datetime(2017,1,1),
    buffered= True,
    apikey="INSERT YOUR API KEY"
    )
Reference: https://www.quandl.com/?modal=register Now run the code with the following commands. Take care to replace the file name with whatever you save the code as. Example 1: python .\sizers-part2-perc-commission-too-high.py Example 2: python .\sizers-part2-perc-commission-too-high.py --commperc 10 You will see that in the second example, no trades are taken. That is because we don’t have enough cash in the account to pay for the number of shares returned by the sizer when a large commission is added. It is an extreme example, but this can also happen in less extreme circumstances. For example when trying to go “all in” or when you don’t have much cash left due to existing positions in the market.

This slideshow requires JavaScript.

Moving On

Getting back on track, we will now extend the maxRiskSizerto account for commissions. The maxRiskSizeris designed to calculate the maximum size position you can take without exceeding a certain percentage of the cash available in your account. It would, therefore, be even better if it could take the commission into account.

Example Code

'''
Author: www.backtest-rookies.com

MIT License

Copyright (c) 2018 backtest-rookies.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''

import backtrader as bt
from datetime import datetime
import math
import argparse


def parse_args():
    parser = argparse.ArgumentParser(description='Sizers Testing')

    parser.add_argument('--commsizer',
                            action ='store_true',help=('Use the Sizer '
                                'that takes commissions into account'))

    parser.add_argument('--commtype',
                        default="Percentage",
                        type=str,
                        choices=["Percentage", "Fixed"],
                        help='The type of commission to apply to a trade')

    parser.add_argument('--commission',
                        default=0.05,
                        type=float,
                        help='The amount of commission to apply to a trade')

    parser.add_argument('--risk',
                        default=0.01,
                        type=float,
                        help='The percentage of available cash to risk on a trade')

    parser.add_argument('--debug',
                            action ='store_true',
                            help=('Print Sizer Debugs'))

    return parser.parse_args()



class maxRiskSizer(bt.Sizer):
    '''
    Returns the number of shares rounded down that can be purchased for the
    max rish tolerance
    '''
    params = (('risk', 0.1),
            ('debug', True))

    def _getsizing(self, comminfo, cash, data, isbuy):

        max_risk =  math.floor(cash * self.p.risk)

        if isbuy == True:
            size = max_risk / data[0]
        else:
            size = max_risk / data[0] * -1

        #Finally round down to the nearest unit
        size = math.floor(size)

        if self.p.debug:
            if isbuy:
                buysell = 'Buying'
            else:
                buysell = 'Selling'
            print("------------- Sizer Debug --------------")
            print("Action: {}".format(buysell))
            print("Price: {}".format(data[0]))
            print("Cash: {}".format(cash))
            print("Max Risk %: {}".format(self.p.risk))
            print("Max Risk $: {}".format(max_risk))
            print("Current Price: {}".format(data[0]))
            print("Size: {}".format(size))
            print("----------------------------------------")
        return size


class maxRiskSizerComms(bt.Sizer):
    '''
    Returns the number of shares rounded down that can be purchased for the
    max risk tolerance
    '''
    params = (('risk', 0.1),
                ('debug', True))

    def _getsizing(self, comminfo, cash, data, isbuy):
        size = 0

        # Work out the maximum size assuming all cash can be used.
        max_risk = math.floor(cash * self.p.risk)

        comm = comminfo.p.commission

        if comminfo.stocklike: # We are using a percentage based commissions

            # Apply the commission to the price. We can then divide our risk
            # by this value
            com_adj_price = data[0] * (1 + (comm * 2)) # *2 for round trip
            comm_adj_max_risk = "N/A"

            if isbuy == True:
                comm_adj_size = max_risk / com_adj_price
                if comm_adj_size < 0: #Avoid accidentally going short
                    comm_adj_size = 0
            else:
                comm_adj_size = max_risk / com_adj_price * -1

        else: #Else is fixed size
            # Dedecut commission from available cash to invest
            comm_adj_max_risk = max_risk - (comm *2) # Round trip
            com_adj_price = "N/A"

            if comm_adj_max_risk < 0: # Not enough cash
                return 0

            if isbuy == True:
                comm_adj_size = comm_adj_max_risk / data[0]
            else:
                comm_adj_size = comm_adj_max_risk / data[0] * -1

        #Finally make sure we round down to the nearest unit.
        comm_adj_size = math.floor(comm_adj_size)

        if self.p.debug:
            if isbuy:
                buysell = 'Buying'
            else:
                buysell = 'Selling'
            print("------------- Sizer Debug --------------")
            print("Action: {}".format(buysell))
            print("Price: {}".format(data[0]))
            print("Cash: {}".format(cash))
            print("Max Risk %: {}".format(self.p.risk))
            print("Max Risk $: {}".format(max_risk))
            print("Commission Adjusted Max Risk: {}".format(comm_adj_max_risk))
            print("Current Price: {}".format(data[0]))
            print("Commission: {}".format(comm))
            print("Commission Adj Price (Round Trip): {}".format(com_adj_price))
            print("Size: {}".format(comm_adj_size))
            print("----------------------------------------")
        return comm_adj_size

class FixedCommisionScheme(bt.CommInfoBase):
    '''
    This is a simple fixed commission scheme
    '''
    params = (
        ('commission', 5),
        ('stocklike', False),
        ('commtype', bt.CommInfoBase.COMM_FIXED),
        )

    def _getcommission(self, size, price, pseudoexec):
        return self.p.commission

class firstStrategy(bt.Strategy):

    params = (('longshort', False),)

    def __init__(self):
        self.rsi = bt.indicators.RSI_SMA(self.data.close, period=21)

    def next(self):
        if not self.position:
            if self.rsi < 30:
                self.buy()
        elif self.position.size > 0 and self.rsi > 70:
            self.close()
        else:
            pass

args = parse_args()

#Variable for our starting cash
startcash = 10000

#Create an instance of cerebro
cerebro = bt.Cerebro()

#Add our strategy
cerebro.addstrategy(firstStrategy)

data = bt.feeds.Quandl(
    dataname='F',
    fromdate = datetime(2016,1,1),
    todate = datetime(2017,1,1),
    buffered= True
    )

#Add the data to Cerebro
cerebro.adddata(data)

# Set our desired cash start
cerebro.broker.setcash(startcash)

#add the sizer
if args.commsizer:
    cerebro.addsizer(maxRiskSizerComms, debug=args.debug, risk=args.risk)
else:
    cerebro.addsizer(maxRiskSizer, debug=args.debug, risk=args.risk)


if args.commtype.lower() == 'percentage':
    cerebro.broker.setcommission(args.commission)
else:
    #Add the new commissions scheme
    comminfo = FixedCommisionScheme(commission=args.commission)
    cerebro.broker.addcommissioninfo(comminfo)

# Run over everything
cerebro.run()

#Get final portfolio Value
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash

#Print out the final result
print('Final Portfolio Value: ${}'.format(round(portvalue,2)))
print('P/L: ${}'.format(round(pnl,2)))

#Finally plot the end results
cerebro.plot(style='candlestick')

Commentary

The code in the example allows you to run the same script in two modes. To do this, we use the argparsemodule to specify different options when running the script (for more on that see: Using argparse to change strategy parameters). In this case, we are using it so that we can easily compare the differences between the position sizes returned from the sizer. We also use it to adjust the commission size to further influence the returned sizes. Run the script with the “–help” option to find out more about what each option does. The following output shall be returned.
python3 .\sizers-part2-commission.py  --help
usage: sizers-part2-commission.py [-h] [--commsizer]
                                  [--commtype {Percentage,Fixed}]
                                  [--commission COMMISSION] [--risk RISK]
                                  [--debug]

Sizers Testing

optional arguments:
  -h, --help            show this help message and exit
  --commsizer           Use the Sizer that takes commissions into account
  --commtype {Percentage,Fixed}
                        The type of commission to apply to a trade
  --commission COMMISSION
                        The amount of commission to apply to a trade
  --risk RISK           The percentage of available cash to risk on a trade
  --debug               Print Sizer Debugs
Note: To see how the sizer is arriving at its final value running with the --debugflag is recommended. The code example contains two sample sizers, one fixed commission scheme and a strategy. The two sizers have the same objective. That is to provide the maximum size that can be purchased within a certain risk tolerance. Where they differ is that maxRiskSizerCommshas been extended to make an adjustment to the final price based on the amount of commission expected for a round trip (to buy and later sell the instrument). It also is able to change its algorithm depending on whether the commission scheme is stock-like or futures-like (percentage based or a fixed commission). (See: https://www.backtrader.com/docu/commission-schemes/commission-schemes.html#commissions-stocks-vs-futures) The maxRiskSizerCommsis the focus of this tutorial and where the magic happens. However, the basic sizer from part 1  has been included so that you can easily compare the differences. maxRiskSizersCommsarrives at its sizing calculation using the following steps
  1. It calculates the maximum cash we are willing to risk
  2. Then get the current commission value.
  3. Next, it will check whether the commission scheme is stocklike.
  4. If so, we deem commissionto be a percentage value. As such, we then apply a percentage based commission to the price. Note that the percentage is doubled to account for the ultimate sale commission of the same size.
  5. If not, we deem commissionto be a fixed value. As such, we then double the commission (again to account for the sale) and simply deduct it from the total we are willing to risk.
  6. Finally, we divide the amount we are willing to risk by the price (or adjusted price in step 4) of the Instrument.
Note: That when working with the comminfoobject, one thing to watch out for is to use the parameter shortcut p when accessing the commission scheme attributes.  This one stumped me for a few minutes. E.g to access the commission value, use comminfo.p.commissioninstead of comminfo.commission. If you do the latter, you will raise the following AttributeError.
AttributeError: 'CommInfoBase' object has no attribute 'commission'

Result

Assuming you shall save the example code assizers-part2-commission.py, the code can be run with the commands in the following examples. Note: If you are running on Windows and only have one version of Python installed, you will need to replace python3with just python in the examples below.

Example 1

The first example will run the script using the sizer that does not take commissions into account and uses default values for riskandcommission. Note that when the commtype is not specified, the default commission type is stock-like. python3 .\sizers-part2-commission.py --debug
------------- Sizer Debug --------------
Action: Buying
Price: 10.487417912902
Cash: 10000.0
Max Risk %: 0.01
Max Risk $: 100
Current Price: 10.487417912902
Size: 9
----------------------------------------
------------- Sizer Debug --------------
Action: Buying
Price: 11.587057110506
Cash: 10007.77084722578
Max Risk %: 0.01
Max Risk $: 100
Current Price: 11.587057110506
Size: 8
----------------------------------------
Final Portfolio Value: $10004.82
P/L: $4.82

Example 2

The second example will swap out the sizer with the maxRiskSizerCommssizer. As such, it will now account for the commission. python3 .\sizers-part2-commission.py --debug --commsizer
------------- Sizer Debug --------------
Action: Buying
Price: 10.487417912902
Cash: 10000.0
Max Risk %: 0.01
Max Risk $: 100
Commission Adjusted Max Risk: N/A
Current Price: 10.487417912902
Commission: 0.05
Commission Adj Price (Round Trip): 11.536159704192201
Size: 8
----------------------------------------
------------- Sizer Debug --------------
Action: Buying
Price: 11.587057110506
Cash: 10006.907419756248
Max Risk %: 0.01
Max Risk $: 100
Commission Adjusted Max Risk: N/A
Current Price: 11.587057110506
Commission: 0.05
Commission Adj Price (Round Trip): 12.745762821556601
Size: 7
----------------------------------------
Final Portfolio Value: $10004.33
P/L: $4.33
Notice that we now have some more debug output and the returned size is smaller?

Example 3

The final example changes the commission type to fixed and sets a $5 commission to each trade. python3 .\sizers-part2-commission.py --debug --commsizer --commtype Fixed --commission 5
------------- Sizer Debug --------------
Action: Buying
Price: 10.487417912902
Cash: 10000.0
Max Risk %: 0.01
Max Risk $: 100
Commission Adjusted Max Risk: 90.0
Current Price: 10.487417912902
Commission: 5.0
Commission Adj Price (Round Trip): N/A
Size: 8
----------------------------------------
------------- Sizer Debug --------------
Action: Buying
Price: 11.587057110506
Cash: 10006.033120578884
Max Risk %: 0.01
Max Risk $: 100
Commission Adjusted Max Risk: 90.0
Current Price: 11.587057110506
Commission: 5.0
Commission Adj Price (Round Trip): N/A
Size: 7
----------------------------------------
Final Portfolio Value: $10001.83
P/L: $1.83
From the output, we can see that the max risk was adjusted from 100 to 90 before the size calculation was made. Also, the commission adjusted price is now ‘N/A’ due to the fact we do not need to make a price adjustment for a fixed commission. Finally, one more thing that might be worth pointing out. Notice that when using strategy.close()the sizer is not called? This is to be expected as the framework does not need to consult the sizer to know what the current open position size is. It just takes the current open position size and uses that. This simplifies sizer development as you do not need to code a condition for a close()method call.

Find This Post Useful?

If this post saved you time and effort, please consider donating a coffee to support the site!  


Dontate with PayPal using any payment method you are comfortable with. 


3PUY12Tgp8xynrMCbBdLE56DShzCbxFG8i

0x9c32a2e1e4a06b0995777ac86745c0db1c13bdfc

LUph5xfqvn2bNfthhEcw9QiVLBYeztacFR