1#!/usr/bin/env python
2# -*- coding: utf-8; py-indent-offset:4 -*-
3###############################################################################
4#
5# Copyright (C) 2015, 2016, 2017 Daniel Rodriguez
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program.  If not, see <http://www.gnu.org/licenses/>.
19#
20###############################################################################
21from __future__ import (absolute_import, division, print_function,
22                        unicode_literals)
23
24
25from datetime import datetime
26
27import backtrader as bt
28
29
30class MetaRollOver(bt.DataBase.__class__):
31    def __init__(cls, name, bases, dct):
32        '''Class has already been created ... register'''
33        # Initialize the class
34        super(MetaRollOver, cls).__init__(name, bases, dct)
35
36    def donew(cls, *args, **kwargs):
37        '''Intercept const. to copy timeframe/compression from 1st data'''
38        # Create the object and set the params in place
39        _obj, args, kwargs = super(MetaRollOver, cls).donew(*args, **kwargs)
40
41        if args:
42            _obj.p.timeframe = args[0]._timeframe
43            _obj.p.compression = args[0]._compression
44
45        return _obj, args, kwargs
46
47
48class RollOver(bt.with_metaclass(MetaRollOver, bt.DataBase)):
49    '''Class that rolls over to the next future when a condition is met
50
51    Params:
52
53        - ``checkdate`` (default: ``None``)
54
55          This must be a *callable* with the following signature::
56
57            checkdate(dt, d):
58
59          Where:
60
61            - ``dt`` is a ``datetime.datetime`` object
62            - ``d`` is the current data feed for the active future
63
64          Expected Return Values:
65
66            - ``True``: as long as the callable returns this, a switchover can
67              happen to the next future
68
69        If a commodity expires on the 3rd Friday of March, ``checkdate`` could
70        return ``True`` for the entire week in which the expiration takes
71        place.
72
73            - ``False``: the expiration cannot take place
74
75        - ``checkcondition`` (default: ``None``)
76
77          **Note**: This will only be called if ``checkdate`` has returned
78          ``True``
79
80          If ``None`` this will evaluate to ``True`` (execute roll over)
81          internally
82
83          Else this must be a *callable* with this signature::
84
85            checkcondition(d0, d1)
86
87          Where:
88
89            - ``d0`` is the current data feed for the active future
90            - ``d1`` is the data feed for the next expiration
91
92          Expected Return Values:
93
94            - ``True``: roll-over to the next future
95
96        Following with the example from ``checkdate``, this could say that the
97        roll-over can only happend if the *volume* from ``d0`` is already less
98        than the volume from ``d1``
99
100            - ``False``: the expiration cannot take place
101    '''
102
103    params = (
104        # ('rolls', []),  # array of futures to roll over
105        ('checkdate', None),  # callable
106        ('checkcondition', None),  # callable
107    )
108
109    def islive(self):
110        '''Returns ``True`` to notify ``Cerebro`` that preloading and runonce
111        should be deactivated'''
112        return True
113
114    def __init__(self, *args):
115        self._rolls = args
116
117    def start(self):
118        super(RollOver, self).start()
119        for d in self._rolls:
120            d.setenvironment(self._env)
121            d._start()
122
123        # put the references in a separate list to have pops
124        self._ds = list(self._rolls)
125        self._d = self._ds.pop(0) if self._ds else None
126        self._dexp = None
127        self._dts = [datetime.min for xx in self._ds]
128
129    def stop(self):
130        super(RollOver, self).stop()
131        for d in self._rolls:
132            d.stop()
133
134    def _gettz(self):
135        '''To be overriden by subclasses which may auto-calculate the
136        timezone'''
137        if self._rolls:
138            return self._rolls[0]._gettz()
139        return bt.utils.date.Localizer(self.p.tz)
140
141    def _checkdate(self, dt, d):
142        if self.p.checkdate is not None:
143            return self.p.checkdate(dt, d)
144
145        return False
146
147    def _checkcondition(self, d0, d1):
148        if self.p.checkcondition is not None:
149            return self.p.checkcondition(d0, d1)
150
151        return True
152
153    def _load(self):
154        while self._d is not None:
155            _next = self._d.next()
156            if _next is None:  # no values yet, more will come
157                continue
158            if _next is False:  # no values from current data src
159                if self._ds:
160                    self._d = self._ds.pop(0)
161                    self._dts.pop(0)
162                else:
163                    self._d = None
164                continue
165
166            dt0 = self._d.datetime.datetime()  # current dt for active data
167
168            # Synchronize other datas using dt0
169            for i, d_dt in enumerate(zip(self._ds, self._dts)):
170                d, dt = d_dt
171                while dt < dt0:
172                    if d.next() is None:
173                        continue
174                    self._dts[i] = dt = d.datetime.datetime()
175
176            # Move expired future as much as needed
177            while self._dexp is not None:
178                if not self._dexp.next():
179                    self._dexp = None
180                    break
181
182                if self._dexp.datetime.datetime() < dt0:
183                    continue
184
185            if self._dexp is None and self._checkdate(dt0, self._d):
186                # rule has been met ... check other factors only if 2 datas
187                # still there
188                if self._ds and self._checkcondition(self._d, self._ds[0]):
189                    # Time to switch to next data
190                    self._dexp = self._d
191                    self._d = self._ds.pop(0)
192                    self._dts.pop(0)
193
194            # Fill the line and tell we die
195            self.lines.datetime[0] = self._d.lines.datetime[0]
196            self.lines.open[0] = self._d.lines.open[0]
197            self.lines.high[0] = self._d.lines.high[0]
198            self.lines.low[0] = self._d.lines.low[0]
199            self.lines.close[0] = self._d.lines.close[0]
200            self.lines.volume[0] = self._d.lines.volume[0]
201            self.lines.openinterest[0] = self._d.lines.openinterest[0]
202            return True
203
204        # Out of the loop -> self._d is None, no data feed to return from
205        return False
206