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.
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
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()