Skip to content

Trading Cryptocurrency Fractional Sizes in backtrader

To start with, let's summarize in two lines how the approach to backtrader works:

  • It is like a construction kit with a basic building block (Cerebro) into which many different pieces can be plugged in

  • The basic distribution contains many pieces like Indicators, Analyzers, Observers, Sizers, Filters, Data Feeds, Brokers, Commission/Asset Info Schemes, ...

  • New building blocks can be easily constructed from scratch or based on existing building block

  • The basic building block (Cerebro) does already some automatic "plugging in" to make it easier to work with the framework without having to worry about all the details.

As such the framework is pre-configured to offer a behavior with defaults such as:

  • Work with a single/main data feed
  • 1-day timeframe/compression combination
  • 10,000 units of monetary currency
  • Stock trading

This may of may not fit everyone, but the important thing is: it can be customized to the individual needs of each trader/programmer

Trading Stocks: Integers

As stated above, the default configuration is for stock trading and when one is trading stocks one buys/sells complete shares, (i.e.: 1, 2 ... 50 ... 1000, and not amounts like 1.5 or 1001.7589 shares.

This means that when a user does the following in the default configuration:

    def next(self):
        # Apply 50% of the portfolio to buy the main asset
        self.order_target_percent(target=0.5)

The following happens:

  • The system calculates how many shares of the asset are needed, so that the value in the portfolio of the given asset is as close as possible to 50%

  • But because the default configuration is to work with shares the resulting number of shares will be an whole number, i.e.: an integer

Note

Notice that the default configuration is to work with a single/main data feed, and that's why the actual data is not specified in the call to order_percent_target. When operating with multiple data feeds, one has to specify which data to acquire/sell (unless the main data is meant)

Trading Cryptocurrencies: Fractions

It is obvious that when trading cryptocurrencies, with even 20 decimals, one can buy "half of a bitcoin".

The good thing is that one can actually change the information pertaining to the asset. This is achieved through the CommissionInfo family of pluggable pieces.

Some documentation: Docs - Commission Schemes - https://www.backtrader.com/docu/commission-schemes/commission-schemes/

Note

It has to be admitted that the name is unfortunate, because the schemes do not only contain information about commission, but also about other things.

In the fractional scenario, the interest is this method of the scheme: getsize(price, cash), which has the following docstring

Returns the needed size to meet a cash operation at a given price

The schemes are intimately related to the broker and through the broker api, the schemes can be added in the system.

The broker docs are at: Docs - Broker - https://www.backtrader.com/docu/broker/

And the relevant method is: addcommissioninfo(comminfo, name=None). Where in addition to adding a scheme which applies to all assets (when name is None), one can set schemes which apply only to assets with specific names.

Implementing the fractional scheme

This can be easily achieved by extending the existing basis scheme, named CommissionInfo.

class CommInfoFractional(bt.CommissionInfo):
    def getsize(self, price, cash):
        '''Returns fractional size for cash operation @price'''
        return self.p.leverage * (cash / price)

Ditto and done. Subclassing CommissionInfo and writing a one line method, the objective is achieved. Because the original scheme definition supports leverage, this is taken into account into the calculation, just in case cryptocurrencies can be bought with leverage (for which the default value is 1.0, i.e.: no leverage)

Later in the code, the scheme will be added (controlled via a command line parameter) like this

    if args.fractional:  # use the fractional scheme if requested
        cerebro.broker.addcommissioninfo(CommInfoFractional())

I.e.: an instance (notice the () to instantiate) of the subclassed scheme is added. As explained above, the name parameter is not set and this means it will apply to all assets in the system.

Testing the Beast

A full script implementing a trivial moving average crossover for long/short positions is provided below which can be directly used in the shell. The default data feed for the test is one of the data feeds from the backtrader repository.

Integer Run: No Fractions - No Fun

$ ./fractional-sizes.py --plot
2005-02-14,3079.93,3083.38,3065.27,3075.76,0.00
2005-02-15,3075.20,3091.64,3071.08,3086.95,0.00
...
2005-03-21,3052.39,3059.18,3037.80,3038.14,0.00
2005-03-21,Enter Short
2005-03-22,Sell Order Completed - Size: -16 @Price: 3040.55 Value: -48648.80 Comm: 0.00
2005-03-22,Trade Opened  - Size -16 @Price 3040.55
2005-03-22,3040.55,3053.18,3021.66,3050.44,0.00
...

A short trade with a size of 16 units has been opened. The entire log, not shown for obvious reasons, contains many other operations all with trades with whole sizes.

No Fractions

Fractional Run

After the hard subclassing and one-lining work for the fractions ...

$ ./fractional-sizes.py --fractional --plot
2005-02-14,3079.93,3083.38,3065.27,3075.76,0.00
2005-02-15,3075.20,3091.64,3071.08,3086.95,0.00
...
2005-03-21,3052.39,3059.18,3037.80,3038.14,0.00
2005-03-21,Enter Short
2005-03-22,Sell Order Completed - Size: -16.457437774427774 @Price: 3040.55 Value: -50039.66 Comm: 0.00
2005-03-22,Trade Opened  - Size -16.457437774427774 @Price 3040.55
2005-03-22,3040.55,3053.18,3021.66,3050.44,0.00
...

V for Victory. The short trade has been opened with the same crossover, but this time with a fractional size of -16.457437774427774

Fractions

Notice that the final portfolio value in the charts is different and that is because the actual trades sizes are different.

Conclusion

Yes, backtrader can. With the pluggable/extensible construction kit approach, it is easy to customize the behavior to the particular needs of the trader programmer.

The script

#!/usr/bin/env python
# -*- coding: utf-8; py-indent-offset:4 -*-
###############################################################################
# Copyright (C) 2019 Daniel Rodriguez - MIT License
#  - https://opensource.org/licenses/MIT
#  - https://en.wikipedia.org/wiki/MIT_License
###############################################################################
import argparse
import logging
import sys

import backtrader as bt

# This defines not only the commission info, but some other aspects
# of a given data asset like the "getsize" information from below
# params = dict(stocklike=True)  # No margin, no multiplier


class CommInfoFractional(bt.CommissionInfo):
    def getsize(self, price, cash):
        '''Returns fractional size for cash operation @price'''
        return self.p.leverage * (cash / price)


class St(bt.Strategy):
    params = dict(
        p1=10, p2=30,  # periods for crossover
        ma=bt.ind.SMA,  # moving average to use
        target=0.5,  # percentage of value to use
    )

    def __init__(self):
        ma1, ma2 = [self.p.ma(period=p) for p in (self.p.p1, self.p.p2)]
        self.cross = bt.ind.CrossOver(ma1, ma2)

    def next(self):
        self.logdata()
        if self.cross > 0:
            self.loginfo('Enter Long')
            self.order_target_percent(target=self.p.target)
        elif self.cross < 0:
            self.loginfo('Enter Short')
            self.order_target_percent(target=-self.p.target)

    def notify_trade(self, trade):
        if trade.justopened:
            self.loginfo('Trade Opened  - Size {} @Price {}',
                         trade.size, trade.price)
        elif trade.isclosed:
            self.loginfo('Trade Closed  - Profit {}', trade.pnlcomm)

        else:  # trade updated
            self.loginfo('Trade Updated - Size {} @Price {}',
                         trade.size, trade.price)

    def notify_order(self, order):
        if order.alive():
            return

        otypetxt = 'Buy ' if order.isbuy() else 'Sell'
        if order.status == order.Completed:
            self.loginfo(
                ('{} Order Completed - '
                 'Size: {} @Price: {} '
                 'Value: {:.2f} Comm: {:.2f}'),
                otypetxt, order.executed.size, order.executed.price,
                order.executed.value, order.executed.comm
            )
        else:
            self.loginfo('{} Order rejected', otypetxt)

    def loginfo(self, txt, *args):
        out = [self.datetime.date().isoformat(), txt.format(*args)]
        logging.info(','.join(out))

    def logerror(self, txt, *args):
        out = [self.datetime.date().isoformat(), txt.format(*args)]
        logging.error(','.join(out))

    def logdebug(self, txt, *args):
        out = [self.datetime.date().isoformat(), txt.format(*args)]
        logging.debug(','.join(out))

    def logdata(self):
        txt = []
        txt += ['{:.2f}'.format(self.data.open[0])]
        txt += ['{:.2f}'.format(self.data.high[0])]
        txt += ['{:.2f}'.format(self.data.low[0])]
        txt += ['{:.2f}'.format(self.data.close[0])]
        txt += ['{:.2f}'.format(self.data.volume[0])]
        self.loginfo(','.join(txt))


def run(args=None):
    args = parse_args(args)

    cerebro = bt.Cerebro()

    data = bt.feeds.BacktraderCSVData(dataname=args.data)
    cerebro.adddata(data)  # create and add data feed

    cerebro.addstrategy(St)  # add the strategy

    cerebro.broker.set_cash(args.cash)  # set broker cash

    if args.fractional:  # use the fractional scheme if requested
        cerebro.broker.addcommissioninfo(CommInfoFractional())

    cerebro.run()  # execute

    if args.plot:  # Plot if requested to
        cerebro.plot(**eval('dict(' + args.plot + ')'))


def logconfig(pargs):
    if pargs.quiet:
        verbose_level = logging.ERROR
    else:
        verbose_level = logging.INFO - pargs.verbose * 10  # -> DEBUG

    logger = logging.getLogger()
    for h in logger.handlers:  # Remove all loggers from root
        logger.removeHandler(h)

    stream = sys.stdout if not pargs.stderr else sys.stderr  # choose stream

    logging.basicConfig(
        stream=stream,
        format="%(message)s",  # format="%(levelname)s: %(message)s",
        level=verbose_level,
    )


def parse_args(pargs=None):
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description='Fractional Sizes with CommInfo',
    )

    pgroup = parser.add_argument_group('Data Options')
    parser.add_argument('--data', default='../../datas/2005-2006-day-001.txt',
                        help='Data to read in')

    pgroup = parser.add_argument_group(title='Broker Arguments')
    pgroup.add_argument('--cash', default=100000.0, type=float,
                        help='Starting cash to use')

    pgroup.add_argument('--fractional', action='store_true',
                        help='Use fractional commission info')

    pgroup = parser.add_argument_group(title='Plotting Arguments')
    pgroup.add_argument('--plot', default='', nargs='?', const='{}',
                        metavar='kwargs', help='kwargs: "k1=v1,k2=v2,..."')

    pgroup = parser.add_argument_group('Verbosity Options')
    pgroup.add_argument('--stderr', action='store_true',
                        help='Log to stderr, else to stdout')
    pgroup = pgroup.add_mutually_exclusive_group()
    pgroup.add_argument('--quiet', '-q', action='store_true',
                        help='Silent (errors will be reported)')
    pgroup.add_argument('--verbose', '-v', action='store_true',
                        help='Increase verbosity level')

    # Parse and process some args
    pargs = parser.parse_args(pargs)
    logconfig(pargs)  # config logging
    return pargs


if __name__ == '__main__':
    run()