diff --git a/backtrader/analyzers/returns.py b/backtrader/analyzers/returns.py index 7f426a376..074cb4d61 100644 --- a/backtrader/analyzers/returns.py +++ b/backtrader/analyzers/returns.py @@ -124,8 +124,17 @@ def stop(self): self._value_end = self.strategy.broker.fundvalue # Compound return - self.rets['rtot'] = rtot = ( - math.log(self._value_end / self._value_start)) + try: + nlrtot = self._value_end / self._value_start + except ZeroDivisionError: + rtot = float('-inf') + else: + if nlrtot < 0.0: + rtot = float('-inf') + else: + rtot = math.log(nlrtot) + + self.rets['rtot'] = rtot # Average return self.rets['ravg'] = ravg = rtot / self._tcount @@ -135,7 +144,11 @@ def stop(self): if tann is None: tann = self._TANN.get(self.data._timeframe, 1.0) # assign default - self.rets['rnorm'] = rnorm = math.expm1(ravg * tann) + if ravg > float('-inf'): + self.rets['rnorm'] = rnorm = math.expm1(ravg * tann) + else: + self.rets['rnorm'] = rnorm = ravg + self.rets['rnorm100'] = rnorm * 100.0 # human readable % def _on_dt_over(self): diff --git a/backtrader/analyzers/tradeanalyzer.py b/backtrader/analyzers/tradeanalyzer.py index 8d768318b..118a072b5 100644 --- a/backtrader/analyzers/tradeanalyzer.py +++ b/backtrader/analyzers/tradeanalyzer.py @@ -86,7 +86,7 @@ def notify_trade(self, trade): # Trade just closed won = res.won = int(trade.pnlcomm >= 0.0) - lost = res.lost = int(won) + lost = res.lost = int(not won) tlong = res.tlong = trade.long tshort = res.tshort = not trade.long diff --git a/backtrader/brokers/__init__.py b/backtrader/brokers/__init__.py index df6e0dff9..b638c0d29 100644 --- a/backtrader/brokers/__init__.py +++ b/backtrader/brokers/__init__.py @@ -40,3 +40,8 @@ from .oandabroker import OandaBroker except ImportError as e: pass # The user may not have something installed + +try: + from .ccxtbroker import CCXTBroker +except ImportError as e: + pass # The user may not have something installed diff --git a/backtrader/brokers/ccxtbroker.py b/backtrader/brokers/ccxtbroker.py new file mode 100644 index 000000000..9d125fc3e --- /dev/null +++ b/backtrader/brokers/ccxtbroker.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# -*- coding: utf-8; py-indent-offset:4 -*- +############################################################################### +# +# Copyright (C) 2015, 2016, 2017 Daniel Rodriguez +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +from backtrader import BrokerBase, Order +from backtrader.utils.py3 import queue +from backtrader.stores.ccxtstore import CCXTStore, CCXTOrder + +class CCXTBroker(BrokerBase): + '''Broker implementation for CCXT cryptocurrency trading library. + + This class maps the orders/positions from CCXT to the + internal API of ``backtrader``. + ''' + + order_types = {Order.Market: 'market', + Order.Limit: 'limit', + Order.Stop: 'stop', + Order.StopLimit: 'stop limit'} + + def __init__(self, exchange, currency, config, retries=5): + super(CCXTBroker, self).__init__() + + self.store = CCXTStore.get_store(exchange, config, retries) + + self.currency = currency + + self.notifs = queue.Queue() # holds orders which are notified + + def getcash(self): + return self.store.getcash(self.currency) + + def getvalue(self, datas=None): + return self.store.getvalue(self.currency) + + def get_notification(self): + try: + return self.notifs.get(False) + except queue.Empty: + return None + + def notify(self, order): + self.notifs.put(order) + + def getposition(self, data): + currency = data.symbol.split('/')[0] + return self.store.getposition(currency) + + def get_value(self, datas=None, mkt=False, lever=False): + return self.store.getvalue(self.currency) + + def get_cash(self): + return self.store.getcash(self.currency) + + def _submit(self, owner, data, exectype, side, amount, price, params): + order_type = self.order_types.get(exectype) + _order = self.store.create_order(symbol=data.symbol, order_type=order_type, side=side, + amount=amount, price=price, params=params) + order = CCXTOrder(owner, data, amount, _order) + self.notify(order) + return order + + def buy(self, owner, data, size, price=None, plimit=None, + exectype=None, valid=None, tradeid=0, oco=None, + trailamount=None, trailpercent=None, + **kwargs): + return self._submit(owner, data, exectype, 'buy', size, price, kwargs) + + def sell(self, owner, data, size, price=None, plimit=None, + exectype=None, valid=None, tradeid=0, oco=None, + trailamount=None, trailpercent=None, + **kwargs): + return self._submit(owner, data, exectype, 'sell', size, price, kwargs) + + def cancel(self, order): + return self.store.cancel_order(order) + + def get_orders_open(self, safe=False): + return self.store.fetch_open_orders() diff --git a/backtrader/dataseries.py b/backtrader/dataseries.py index 2770ffb27..a87df819c 100644 --- a/backtrader/dataseries.py +++ b/backtrader/dataseries.py @@ -40,7 +40,7 @@ class TimeFrame(object): names = Names # support old naming convention @classmethod - def getname(cls, tframe, compression=None): + def getname(cls, tframe, compression=1): tname = cls.Names[tframe] if compression > 1 or tname == cls.Names[-1]: return tname # for plural or 'NoTimeFrame' return plain entry diff --git a/backtrader/feeds/__init__.py b/backtrader/feeds/__init__.py index e39af5633..5c0da9550 100644 --- a/backtrader/feeds/__init__.py +++ b/backtrader/feeds/__init__.py @@ -52,3 +52,8 @@ from .rollover import RollOver from .chainer import Chainer + +try: + from .ccxt import CCXT +except ImportError: + pass # The user may not have something installed diff --git a/backtrader/feeds/ccxt.py b/backtrader/feeds/ccxt.py new file mode 100644 index 000000000..516ccc00a --- /dev/null +++ b/backtrader/feeds/ccxt.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +# -*- coding: utf-8; py-indent-offset:4 -*- +############################################################################### +# +# Copyright (C) 2015, 2016, 2017 Daniel Rodriguez +# Copyright (C) 2017 Ed Bartosh +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +from collections import deque +from datetime import datetime + +import backtrader as bt +from backtrader.feed import DataBase +from backtrader.stores.ccxtstore import CCXTStore + +class CCXT(DataBase): + """ + CryptoCurrency eXchange Trading Library Data Feed. + + Params: + + - ``historical`` (default: ``False``) + + If set to ``True`` the data feed will stop after doing the first + download of data. + + The standard data feed parameters ``fromdate`` and ``todate`` will be + used as reference. + + - ``backfill_start`` (default: ``True``) + + Perform backfilling at the start. The maximum possible historical data + will be fetched in a single request. + """ + + params = ( + ('historical', False), # only historical download + ('backfill_start', False), # do backfilling at the start + ) + + # States for the Finite State Machine in _load + _ST_LIVE, _ST_HISTORBACK, _ST_OVER = range(3) + + def __init__(self, exchange, symbol, ohlcv_limit=None, config={}, retries=5): + self.symbol = symbol + self.ohlcv_limit = ohlcv_limit + + self.store = CCXTStore.get_store(exchange, config, retries) + + self._data = deque() # data queue for price data + self._last_id = '' # last processed trade id for ohlcv + self._last_ts = 0 # last processed timestamp for ohlcv + + def start(self, ): + DataBase.start(self) + + if self.p.fromdate: + self._state = self._ST_HISTORBACK + self.put_notification(self.DELAYED) + + self._fetch_ohlcv(self.p.fromdate) + else: + self._state = self._ST_LIVE + self.put_notification(self.LIVE) + + def _load(self): + if self._state == self._ST_OVER: + return False + + while True: + if self._state == self._ST_LIVE: + if self._timeframe == bt.TimeFrame.Ticks: + return self._load_ticks() + else: + self._fetch_ohlcv() + return self._load_ohlcv() + elif self._state == self._ST_HISTORBACK: + ret = self._load_ohlcv() + if ret: + return ret + else: + # End of historical data + if self.p.historical: # only historical + self.put_notification(self.DISCONNECTED) + self._state = self._ST_OVER + return False # end of historical + else: + self._state = self._ST_LIVE + self.put_notification(self.LIVE) + continue + + def _fetch_ohlcv(self, fromdate=None): + """Fetch OHLCV data into self._data queue""" + granularity = self.store.get_granularity(self._timeframe, self._compression) + + if fromdate: + since = int((fromdate - datetime(1970, 1, 1)).total_seconds() * 1000) + else: + if self._last_ts > 0: + since = self._last_ts + else: + since = None + + limit = self.ohlcv_limit + + while True: + dlen = len(self._data) + for ohlcv in sorted(self.store.fetch_ohlcv(self.symbol, timeframe=granularity, + since=since, limit=limit)): + if None in ohlcv: + continue + + tstamp = ohlcv[0] + if tstamp > self._last_ts: + self._data.append(ohlcv) + self._last_ts = tstamp + since = tstamp + 1 + + if dlen == len(self._data): + break + + def _load_ticks(self): + if self._last_id: + trades = self.store.fetch_trades(self.symbol) + else: + # first time get the latest trade only + trades = [self.store.fetch_trades(self.symbol)[-1]] + + for trade in trades: + trade_id = trade['id'] + + if trade_id > self._last_id: + trade_time = datetime.strptime(trade['datetime'], '%Y-%m-%dT%H:%M:%S.%fZ') + self._data.append((trade_time, float(trade['price']), float(trade['amount']))) + self._last_id = trade_id + + try: + trade = self._data.popleft() + except IndexError: + return None # no data in the queue + + trade_time, price, size = trade + + self.lines.datetime[0] = bt.date2num(trade_time) + self.lines.open[0] = price + self.lines.high[0] = price + self.lines.low[0] = price + self.lines.close[0] = price + self.lines.volume[0] = size + + return True + + def _load_ohlcv(self): + try: + ohlcv = self._data.popleft() + except IndexError: + return None # no data in the queue + + tstamp, open_, high, low, close, volume = ohlcv + + dtime = datetime.utcfromtimestamp(tstamp // 1000) + + self.lines.datetime[0] = bt.date2num(dtime) + self.lines.open[0] = open_ + self.lines.high[0] = high + self.lines.low[0] = low + self.lines.close[0] = close + self.lines.volume[0] = volume + + return True + + def haslivedata(self): + return self._state == self._ST_LIVE and self._data + + def islive(self): + return not self.p.historical diff --git a/backtrader/feeds/rollover.py b/backtrader/feeds/rollover.py index bd00e108e..2d64b0822 100644 --- a/backtrader/feeds/rollover.py +++ b/backtrader/feeds/rollover.py @@ -152,7 +152,7 @@ def _checkcondition(self, d0, d1): def _load(self): while self._d is not None: - if not self._d.next(): # no values from current data source + if self._d.next() is not False: # no values from current data src if self._ds: self._d = self._ds.pop(0) self._dts.pop(0) diff --git a/backtrader/plot/locator.py b/backtrader/plot/locator.py index e9cf745b8..ccf070562 100644 --- a/backtrader/plot/locator.py +++ b/backtrader/plot/locator.py @@ -27,6 +27,7 @@ ''' import datetime +import warnings from matplotlib.dates import AutoDateLocator as ADLocator from matplotlib.dates import RRuleLocator as RRLocator @@ -36,7 +37,7 @@ MONTHS_PER_YEAR, DAYS_PER_WEEK, SEC_PER_HOUR, SEC_PER_DAY, num2date, rrulewrapper, YearLocator, - MicrosecondLocator, warnings) + MicrosecondLocator) from dateutil.relativedelta import relativedelta import numpy as np diff --git a/backtrader/resamplerfilter.py b/backtrader/resamplerfilter.py index e94a5aa54..c4cec0d21 100644 --- a/backtrader/resamplerfilter.py +++ b/backtrader/resamplerfilter.py @@ -111,7 +111,7 @@ class _BaseResampler(with_metaclass(metabase.MetaParams, object)): def __init__(self, data): self.subdays = TimeFrame.Ticks < self.p.timeframe < TimeFrame.Days self.subweeks = self.p.timeframe < TimeFrame.Weeks - self.componly = (self.subdays and + self.componly = (not self.subdays and data._timeframe == self.p.timeframe and not (self.p.compression % data._compression) ) @@ -524,7 +524,6 @@ def __call__(self, data, fromcheck=False, forcedata=None): if consumed: self.bar.bupdate(data) # update new or existing bar - data.backwards() # remove used bar # if self.bar.isopen and (onedge or (docheckover and checkbarover)) cond = self.bar.isopen() @@ -533,6 +532,10 @@ def __call__(self, data, fromcheck=False, forcedata=None): if docheckover: cond = self._checkbarover(data, fromcheck=fromcheck, forcedata=forcedata) + + if consumed: + data.backwards() # remove used bar + if cond: dodeliver = False if forcedata is not None: diff --git a/backtrader/stores/ccxtstore.py b/backtrader/stores/ccxtstore.py new file mode 100644 index 000000000..466305e7d --- /dev/null +++ b/backtrader/stores/ccxtstore.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +# -*- coding: utf-8; py-indent-offset:4 -*- +############################################################################### +# +# Copyright (C) 2017 Ed Bartosh +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import time +from functools import wraps + +import ccxt +from ccxt.base.errors import NetworkError, ExchangeError + +import backtrader as bt +from backtrader import OrderBase + +class CCXTOrder(OrderBase): + def __init__(self, owner, data, size, ccxt_order): + self.owner = owner + self.data = data + self.ccxt_order = ccxt_order + self.ordtype = self.Buy if ccxt_order['side'] == 'buy' else self.Sell + amount = ccxt_order.get('amount') + if amount: + self.size = float(amount) + else: + self.size = size + + super(CCXTOrder, self).__init__() + +class CCXTStore(object): + '''API provider for CCXT feed and broker classes.''' + + # Supported granularities + _GRANULARITIES = { + (bt.TimeFrame.Minutes, 1): '1m', + (bt.TimeFrame.Minutes, 3): '3m', + (bt.TimeFrame.Minutes, 5): '5m', + (bt.TimeFrame.Minutes, 15): '15m', + (bt.TimeFrame.Minutes, 30): '30m', + (bt.TimeFrame.Minutes, 60): '1h', + (bt.TimeFrame.Minutes, 90): '90m', + (bt.TimeFrame.Minutes, 120): '2h', + (bt.TimeFrame.Minutes, 240): '4h', + (bt.TimeFrame.Minutes, 360): '6h', + (bt.TimeFrame.Minutes, 480): '8h', + (bt.TimeFrame.Minutes, 720): '12h', + (bt.TimeFrame.Days, 1): '1d', + (bt.TimeFrame.Days, 3): '3d', + (bt.TimeFrame.Weeks, 1): '1w', + (bt.TimeFrame.Weeks, 2): '2w', + (bt.TimeFrame.Months, 1): '1M', + (bt.TimeFrame.Months, 3): '3M', + (bt.TimeFrame.Months, 6): '6M', + (bt.TimeFrame.Years, 1): '1y', + } + + # exchange -> store dictionary to track already initialized exchanges + stores = {} + # exchange -> config dictionry + configs = {} + + @classmethod + def get_store(cls, exchange, config, retries): + store = cls.stores.get(exchange) + if store: + store_conf = cls.configs[exchange] + if store_conf: + if not set(config.items()).issubset(set(store_conf.items())): + raise ValueError("%s exchange is already configured: %s" % \ + (exchange, store_conf)) + return store + + cls.configs[exchange] = config + cls.stores[exchange] = cls(exchange, config, retries) + + return cls.stores[exchange] + + def __init__(self, exchange, config, retries): + self.exchange = getattr(ccxt, exchange)(config) + self.retries = retries + + def get_granularity(self, timeframe, compression): + if not self.exchange.has['fetchOHLCV']: + raise NotImplementedError("'%s' exchange doesn't support fetching OHLCV data" % \ + self.exchange.name) + + granularity = self._GRANULARITIES.get((timeframe, compression)) + if granularity is None: + raise ValueError("backtrader CCXT module doesn't support fetching OHLCV " + "data for time frame %s, comression %s" % \ + (bt.TimeFrame.getname(timeframe), compression)) + + if self.exchange.timeframes and granularity not in self.exchange.timeframes: + raise ValueError("'%s' exchange doesn't support fetching OHLCV data for " + "%s time frame" % (self.exchange.name, granularity)) + + return granularity + + def retry(method): + @wraps(method) + def retry_method(self, *args, **kwargs): + for i in range(self.retries): + time.sleep(self.exchange.rateLimit / 1000) + try: + return method(self, *args, **kwargs) + except (NetworkError, ExchangeError): + if i == self.retries - 1: + raise + + return retry_method + + @retry + def getcash(self, currency): + return self.exchange.fetch_balance()['free'].get(currency, 0.0) + + @retry + def getvalue(self, currency): + return self.exchange.fetch_balance()['total'].get(currency, 0.0) + + @retry + def getposition(self, currency): + return self.getvalue(currency) + + @retry + def create_order(self, symbol, order_type, side, amount, price, params): + order = self.exchange.create_order(symbol=symbol, type=order_type, side=side, + amount=amount, price=price, params=params) + return self.exchange.parse_order(order['info']) + + @retry + def cancel_order(self, order): + return self.exchange.cancel_order(order.ccxt_order['id']) + + @retry + def fetch_trades(self, symbol): + return self.exchange.fetch_trades(symbol) + + @retry + def fetch_ohlcv(self, symbol, timeframe, since, limit): + return self.exchange.fetch_ohlcv(symbol, timeframe=timeframe, since=since, limit=limit) + + @retry + def fetch_open_orders(self): + return self.exchange.fetchOpenOrders() diff --git a/backtrader/utils/dateintern.py b/backtrader/utils/dateintern.py index cfcd30be5..bcced1f53 100644 --- a/backtrader/utils/dateintern.py +++ b/backtrader/utils/dateintern.py @@ -206,7 +206,7 @@ def date2num(dt, tz=None): is a :func:`float`. """ if tz is not None: - dt = tz.localize(dwhen) + dt = tz.localize(tz) if hasattr(dt, 'tzinfo') and dt.tzinfo is not None: delta = dt.tzinfo.utcoffset(dt) diff --git a/backtrader/version.py b/backtrader/version.py index 982541b94..a0a22dd3e 100644 --- a/backtrader/version.py +++ b/backtrader/version.py @@ -22,6 +22,6 @@ unicode_literals) -__version__ = '1.9.65.122' +__version__ = '1.9.66.122' __btversion__ = tuple(int(x) for x in __version__.split('.')) diff --git a/changelog.txt b/changelog.txt index 57692b4b2..fe5b591df 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,12 @@ +1.9.66.122 + - Fix regression introduced with 8f537a1c2c271eb5cfc592b373697732597d26d6 + which voids the count of lost trades + - Allow rollover to distinguish between no values temporarily (with None) + and no values permanently (with False) + - Avoid math domain error for negative returns in logarithmic calculations + - Fix local variable declaration for compound returns + - Fix typo in date2num tz conversion which shows up in direct usage + 1.9.65.122 - Fix commission info assigment and orderref seeking in OandaStore (PR#367) - Add strategy type to OptReturn (PR#364)