├── .gitignore
├── LICENSE
├── README.md
├── atreyu_backtrader_api
├── __init__.py
├── custom_logger.py
├── ibbroker.py
├── ibdata.py
└── ibstore.py
├── images
├── image-01.png
├── image-02.png
├── image-03.png
├── image-04.png
├── image-05.png
├── image-06.png
├── image-07.png
└── image-08.png
├── requirements
├── requirements.txt
└── requirements_test.txt
├── setup.cfg
└── setup.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 2-Clause License
2 |
3 | Copyright (c) 2022, Atreyu Group LLC
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | * Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | * Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Introduction
2 | ============
3 |
4 | Currently `backtrader` has a built-in integration for Interactive Brokers (IB) [Trader Workstation API](), but it only works for older versions of the API.
5 |
6 | This project re-worked the orginal integration and created a pluggable version allowing `backtrader` to use the latest IB API.
7 |
8 | To start trading follow the steps below:
9 |
10 | 1. Open an account with IB
11 | 2. Download the [IB Python TWS API](https://github.com/InteractiveBrokers/tws-api-public)
12 | 3. Download the IB client (TWS or IB Gateway) [Trader Workstation Platform](https://www.interactivebrokers.com/en/home.php)
13 | 4. Test for connectivity – Check code sample below
14 |
15 | Installation
16 | ------------
17 |
18 | Create a local python virtual environment:
19 |
20 | `python3 -m venv ./env`
21 |
22 | Activate the environment, all the following packages will be install in the local ./env directory
23 |
24 | `source ./env/bin/activate`
25 |
26 | Install backtrader locally (see [Installing Backtrader](https://www.backtrader.com/docu/installation/) for more options)
27 |
28 | `pip install backtrader`
29 |
30 | Download Interactive Brokers TWS API (from [Trader Workstation API](https://github.com/InteractiveBrokers/tws-api-public))
31 |
32 | Select the latest copy, currently it is (twsapi_macunix.1016.01.zip) for Linux
33 |
34 | `unzip twsapi_macunix.1016.01.zip`
35 |
36 | The file will unzip to the directoty `IBJts`
37 |
38 | `cd IBJts/source/pythonclient`
39 |
40 | Run the setup.py to install the TWS API.
41 |
42 | `python setup.py install`
43 |
44 | Download a Atreyu Backtrader API, released version:
45 |
46 | `wget https://github.com/atreyuxtrading/atreyu-backtrader-api/archive/refs/tags/v1.0.zip`
47 |
48 | Unzip file, and install the Atreyu Backtrader API.
49 |
50 | `unzip v1.0.zip`
51 |
52 | `cd atreyu-backtrader-api-1.0 ; python setup.py install`
53 |
54 | Check Settings of Locally Running TWS
55 | -------------------------------------
56 |
57 | 
58 |
59 | Example: Download Realtime Bar Data from TWS
60 | -------------------------------------------
61 |
62 | ```python
63 | import backtrader as bt
64 | from atreyu_backtrader_api import IBData
65 |
66 | cerebro = bt.Cerebro()
67 |
68 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
69 | name="GOOG", # Data name
70 | dataname='GOOG', # Symbol name
71 | secType='STK', # SecurityType is STOCK
72 | exchange='SMART',# Trading exchange IB's SMART exchange
73 | currency='USD', # Currency of SecurityType
74 | rtbar=True, # Request Realtime bars
75 | _debug=True # Set to True to print out debug messagess from IB TWS API
76 | )
77 |
78 | cerebro.adddata(data)
79 | cerebro.run()
80 | ```
81 |
82 | Create A TestPrinter
83 | --------------------
84 | Note that this is created as a stratgey and will print all the bars that it receives
85 |
86 | ```python
87 |
88 | import backtrader as bt
89 |
90 | class TestPrinter(bt.Strategy):
91 |
92 | def log(self, txt, dt=None):
93 | ''' Logging function for this strategy'''
94 | dt = dt or self.datas[0].datetime.datetime(0)
95 | print(f'{dt}, {txt}')
96 |
97 | def __init__(self):
98 | self.open = self.datas[0].open
99 | self.high = self.datas[0].high
100 | self.low = self.datas[0].low
101 | self.close = self.datas[0].close
102 | self.volume = self.datas[0].volume
103 | self.openinterest = self.datas[0].openinterest
104 |
105 | def next(self):
106 | self.log(f'Open:{self.open[0]:.2f}, \
107 | High:{self.high[0]:.2f}, \
108 | Low:{self.low[0]:.2f}, \
109 | Close:{self.close[0]:.2f}, \
110 | Volume:{self.volume[0]:.2f}, \
111 | OpenInterest:{self.volume[0]:.2f}' )
112 |
113 | ```
114 |
115 | Simple BID_ASK Historical Data
116 | -------------------------------
117 |
118 | ```python
119 |
120 | import backtrader as bt
121 |
122 | from atreyu_backtrader_api import IBData
123 | from test_printer import TestPrinter
124 |
125 | import datetime as dt
126 |
127 | cerebro = bt.Cerebro()
128 |
129 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
130 | name="GOOG", # Data name
131 | dataname='GOOG', # Symbol name
132 | secType='STK', # SecurityType is STOCK
133 | exchange='SMART',# Trading exchange IB's SMART exchange
134 | currency='USD', # Currency of SecurityType
135 | historical=True,
136 | what='BID_ASK', # Update this parameter to select data type
137 | )
138 |
139 | cerebro.adddata(data)
140 |
141 | # Add the printer as a strategy
142 | cerebro.addstrategy(TestPrinter)
143 |
144 | cerebro.run()
145 |
146 | ```
147 | Output
148 | ------
149 | ```
150 | 2021-08-09 23:59:59.999986, Open:137.24, High:144.44, Low:136.25, Close:137.55, Volume:-1.00
151 | 2021-08-10 23:59:59.999986, Open:138.02, High:139.84, Low:125.00, Close:138.26, Volume:-1.00
152 | 2021-08-11 23:59:59.999986, Open:137.54, High:138.95, Low:130.66, Close:137.89, Volume:-1.00
153 | 2021-08-12 23:59:59.999986, Open:137.82, High:139.07, Low:130.00, Close:138.12, Volume:-1.00
154 | 2021-08-13 23:59:59.999986, Open:138.23, High:139.09, Low:137.78, Close:138.52, Volume:-1.00
155 | 2021-08-16 23:59:59.999986, Open:138.04, High:139.90, Low:125.00, Close:138.34, Volume:-1.00
156 | ....
157 | 2022-08-05 23:59:59.999986, Open:118.06, High:128.00, Low:111.06, Close:118.19, Volume:-1.00
158 | 2022-08-07 20:00:00, Open:118.93, High:120.88, Low:113.00, Close:119.02, Volume:-1.00
159 | ```
160 |
161 | Select Historical Data Types Using "what=" Parameter
162 | -----------------------------------------------------
163 | Historical data is returned in the form of candlesticks, and accessed using the “what=” parameter when requesting data. (see [Interactive Brokers Data Types](https://interactivebrokers.github.io/tws-api/historical_bars.html))
164 | 
165 |
166 | Fetch what=TRADES between 2016/01/01 - 2018/01/01
167 | -------------------------------------------------
168 |
169 | ```python
170 | import backtrader as bt
171 |
172 | from atreyu_backtrader_api import IBData
173 | from test_printer import TestPrinter
174 |
175 | import datetime as dt
176 |
177 | cerebro = bt.Cerebro()
178 |
179 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
180 | name="GOOG", # Data name
181 | dataname='GOOG', # Symbol name
182 | secType='STK', # SecurityType is STOCK
183 | exchange='SMART',# Trading exchange IB's SMART exchange
184 | currency='USD', # Currency of SecurityType
185 | fromdate = dt.datetime(2016, 1, 1),
186 | todate = dt.datetime(2018, 1, 1),
187 | historical=True,
188 | what='TRADES',
189 | )
190 |
191 | cerebro.adddata(data)
192 |
193 | # Add the printer as a strategy
194 | cerebro.addstrategy(TestPrinter)
195 |
196 | cerebro.run()
197 | ```
198 |
199 | Output
200 | ------
201 | ```
202 | 2016-01-05 00:00:00, Open:37.38, High:37.38, Low:36.56, Close:37.10, Volume:460493.60
203 | 2016-01-06 00:00:00, Open:37.00, High:37.60, Low:36.93, Close:37.15, Volume:272008.00
204 | 2016-01-07 00:00:00, Open:36.87, High:37.36, Low:36.25, Close:37.30, Volume:276044.20
205 | 2016-01-08 00:00:00, Open:36.17, High:36.92, Low:35.95, Close:36.50, Volume:425276.00
206 | ...
207 | 2017-12-27 00:00:00, Open:52.86, High:53.00, Low:52.51, Close:52.70, Volume:70263.00
208 | 2017-12-28 00:00:00, Open:52.90, High:52.92, Low:52.40, Close:52.46, Volume:151108.40
209 | 2017-12-29 00:00:00, Open:52.66, High:52.74, Low:52.24, Close:52.36, Volume:105796.60
210 | 2017-12-30 00:00:00, Open:52.42, High:52.55, Low:52.13, Close:52.24, Volume:75590.60
211 | ```
212 |
213 | How is the Data Presented in the Strategy?
214 | ------------------------------------------
215 | The data retrieved from IB is presented in the strategy as the variable self.datas[0].
216 |
217 | The latest close price is available at index 0, and progressively earlier prices are stored using a negative index. (See diagram below)
218 |
219 | 
220 |
221 | ```python
222 | import backtrader as bt
223 |
224 | # Create a Stratey
225 | class TestStrategy(bt.Strategy):
226 |
227 | def log(self, txt, ts=None):
228 | ''' Logging function for this strategy'''
229 | ts = ts or self.datas[0].datetime.datetime(0)
230 | print(f'{ts}, {txt}')
231 |
232 | def __init__(self):
233 | self.close = self.datas[0].close
234 |
235 | def next(self):
236 | # Current close
237 | self.log(f'Close:{self.close[0]:.2f}' )
238 | if self.close[0] < self.close[-1]:
239 | # current close less than previous close, think about buying
240 | if self.close[-1] < self.close[-2]:
241 | # previous close less than previous close, so buy
242 | self.log('BUY CREATE, %.2f' % self.close[0])
243 | self.buy()
244 | ```
245 |
246 | Using IB Historical Data to Drive a Strategy with "what=MIDPOINT"
247 | ---------------------------------------------------------------
248 |
249 | ```python
250 | import backtrader as bt
251 |
252 | from atreyu_backtrader_api import IBData
253 | from test_strategy import TestStrategy
254 |
255 | import datetime as dt
256 |
257 | cerebro = bt.Cerebro()
258 |
259 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
260 | name="GOOG", # Data name
261 | dataname='GOOG', # Symbol name
262 | secType='STK', # SecurityType is STOCK
263 | exchange='SMART',# Trading exchange IB's SMART exchange
264 | currency='USD', # Currency of SecurityType
265 | fromdate = dt.datetime(2016, 1, 1),
266 | todate = dt.datetime(2018, 1, 1),
267 | historical=True,
268 | what='MIDPOINT',
269 | )
270 |
271 | cerebro.adddata(data)
272 |
273 | # Add the test strategy
274 | cerebro.addstrategy(TestStrategy)
275 |
276 | # Set our desired cash start
277 | cerebro.broker.setcash(100000.0)
278 |
279 | cerebro.run()
280 |
281 | print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
282 | ```
283 |
284 | Naming Datasources and using them in a Strategy
285 | -----------------------------------------------
286 | Datasources can be given logical datanames (i.e. based on the "what" parameter), the logical name can then be accessed using the \_name variable.
287 | In the strategy below the logical name is stored in the self.name0 and self.name1 variables, and can be used to identify the buy for each symbol.
288 |
289 | ```python
290 | import backtrader as bt
291 |
292 | # Create a Stratey
293 | class TestStrategy(bt.Strategy):
294 |
295 | def log(self, txt, ts=None):
296 | ''' Logging function for this strategy'''
297 | ts = ts or self.datas[0].datetime.datetime(0)
298 | print(f'{ts}, {txt}')
299 |
300 | def __init__(self):
301 | self.close0 = self.datas[0].close
302 | self.name0 = self.datas[0]._name
303 |
304 | self.close1 = self.datas[1].close
305 | self.name1 = self.datas[1]._name
306 |
307 | def next(self):
308 | # Current close dataset0
309 | self.log(f'{self.name0} Close:{self.close0[0]:.2f}' )
310 | if self.close0[0] < self.close0[-1]:
311 | # current close less than previous close, think about buying
312 | if self.close0[-1] < self.close0[-2]:
313 | # previous close less than previous close, so buy
314 | self.log(f"BUY {self.name0} @ {self.close0[0]:.2f}")
315 | self.buy()
316 |
317 | # Current close dataset1
318 | self.log(f'{self.name1} Close:{self.close1[0]:.2f}' )
319 | if self.close1[0] < self.close1[-1]:
320 | # current close less than previous close, think about buying
321 | if self.close1[-1] < self.close1[-2]:
322 | # previous close less than previous close, so buy
323 | self.log(f"BUY {self.name1} @ {self.close1[0]:.2f}")
324 | self.buy()
325 | ```
326 | See the name parameter being used to tag each datasource in the example below:
327 |
328 | ```python
329 |
330 | import backtrader as bt
331 |
332 | from atreyu_backtrader_api import IBData
333 | from test_strategy import TestStrategy
334 |
335 | import datetime as dt
336 |
337 | cerebro = bt.Cerebro()
338 |
339 | goog_data = IBData(host='127.0.0.1', port=7497, clientId=35,
340 | name="GOOG_TRADES", # Data name
341 | dataname='GOOG', # Symbol name
342 | secType='STK', # SecurityType is STOCK
343 | exchange='SMART', # Trading exchange IB's SMART exchange
344 | currency='USD', # Currency of SecurityType
345 | fromdate = dt.datetime(2016, 1, 1),
346 | todate = dt.datetime(2018, 1, 1),
347 | historical=True,
348 | what='TRADES',
349 | )
350 |
351 | cerebro.adddata(goog_data)
352 |
353 | apple_data = IBData(host='127.0.0.1', port=7497, clientId=35,
354 | name="AAPL_MIDPOINT",# Data name
355 | dataname='AAPL', # Symbol name
356 | secType='STK', # SecurityType is STOCK
357 | exchange='SMART', # Trading exchange IB's SMART exchange
358 | currency='USD', # Currency of SecurityType
359 | fromdate = dt.datetime(2016, 1, 1),
360 | todate = dt.datetime(2018, 1, 1),
361 | historical=True,
362 | what='MIDPOINT',
363 | )
364 |
365 | cerebro.adddata(apple_data)
366 |
367 | # Add the test strategy
368 | cerebro.addstrategy(TestStrategy)
369 |
370 | # Set our desired cash start
371 | cerebro.broker.setcash(100000.0)
372 |
373 | cerebro.run()
374 |
375 | print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
376 |
377 | ```
378 |
379 | Output
380 | ------
381 |
382 | ```
383 | 2016-01-05 00:00:00, AAPL_MIDPOINT Close:26.43
384 | 2016-01-05 00:00:00, BUY AAPL_MIDPOINT @ 26.43
385 | 2016-01-05 00:00:00, GOOG_TRADES Close:37.10
386 | 2016-01-05 00:00:00, BUY GOOG_TRADES @ 37.10
387 | ...
388 | 2017-12-29 00:00:00, AAPL_MIDPOINT Close:42.71
389 | 2017-12-30 00:00:00, GOOG_TRADES Close:52.24
390 | 2017-12-30 00:00:00, BUY GOOG_TRADES @ 52.24
391 | 2017-12-30 00:00:00, AAPL_MIDPOINT Close:42.27
392 | Final Portfolio Value: 102168.92
393 | ```
394 |
395 | 5-Second Real-time Bars from IB
396 | -------------------------------
397 | Real-time bars represent a price performance for a specific period. These periods could be as long as a day or as short as a second, depending on the purpose for which the bar is to be used. Daily bars are usually the most popular for analysis whereas shorter duration bars can be used for trading.
398 |
399 | In the case of IB the TWS API can be used to fetch 5-second duration bar.
400 | The example below creates an active subscription that will return a single bar in real time every five seconds that has the OHLC values over that period. Additionally we are switching off the backfill of data from initial start to reconnect in case of connection disruption.
401 |
402 | ```python
403 |
404 | import backtrader as bt
405 |
406 | from atreyu_backtrader_api import IBData
407 | from test_printer import TestPrinter
408 |
409 | import datetime as dt
410 | from datetime import datetime, date, time
411 |
412 | cerebro = bt.Cerebro()
413 |
414 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
415 | name="AAPL", # Data name
416 | dataname='AAPL', # Symbol name
417 | secType='STK', # SecurityType is STOCK
418 | exchange='SMART', # Trading exchange IB's SMART exchange
419 | currency='USD', # Currency of SecurityType
420 | backfill_start=False,
421 | backfill=False,
422 | what='TRADES', # TRADES or MIDPOINT
423 | rtbar=True
424 | )
425 |
426 | cerebro.adddata(data)
427 |
428 | # Add the test strategy
429 | cerebro.addstrategy(TestPrinter)
430 |
431 | cerebro.run()
432 |
433 |
434 | ```
435 | Output
436 | ------
437 | ```
438 | 2022-08-11 15:22:20, Symbol: AAPL Open:169.29, High:169.29, Low:169.28, Close:169.29, Volume:101.13
439 | 2022-08-11 15:22:25, Symbol: AAPL Open:169.29, High:169.29, Low:169.25, Close:169.26, Volume:79.5
440 | 2022-08-11 15:22:30, Symbol: AAPL Open:169.27, High:169.30, Low:169.23, Close:169.23, Volume:57.5
441 | 2022-08-11 15:22:35, Symbol: AAPL Open:169.23, High:169.30, Low:169.22, Close:169.27, Volume:89.72
442 | ```
443 |
444 | Top Of Book Market Data (Level I)
445 | ---------------------------------
446 | Using the TWS API, real time market data can also be requested for trading and analysis. This data is not tick-by-tick but consists of aggregated snapshots taken at intra-second intervals which differ depending on the type of instrument:
447 |
448 | 
449 |
450 | We select non-bar data by setting rtbar=False, note that the data will still be presented in the OHLCV format for use in the strategy.
451 |
452 | ```python
453 | import backtrader as bt
454 |
455 | from atreyu_backtrader_api import IBData
456 | from test_printer import TestPrinter
457 |
458 | import datetime as dt
459 | from datetime import datetime, date, time
460 |
461 | cerebro = bt.Cerebro()
462 |
463 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
464 | name="AAPL", # Data name
465 | dataname='AAPL', # Symbol name
466 | secType='STK', # SecurityType is STOCK
467 | exchange='SMART', # Trading exchange IB's SMART exchange
468 | currency='USD', # Currency of SecurityType
469 | backfill_start=False,
470 | backfill=False,
471 | rtbar=False
472 | )
473 |
474 | cerebro.adddata(data)
475 |
476 | # Add the test strategy
477 | cerebro.addstrategy(TestPrinter)
478 |
479 | cerebro.run()
480 | ```
481 | Ouput
482 | -----
483 | ```
484 | 2022-08-11 16:36:11.410065, Symbol: AAPL Open:169.70, High:169.70, Low:169.70, Close:169.70, Volume:2200.0
485 | 2022-08-11 16:36:11.410105, Symbol: AAPL Open:169.70, High:169.70, Low:169.70, Close:169.70, Volume:100.0
486 | 2022-08-11 16:36:11.410156, Symbol: AAPL Open:169.70, High:169.70, Low:169.70, Close:169.70, Volume:100.0
487 | 2022-08-11 16:36:11.410196, Symbol: AAPL Open:169.70, High:169.70, Low:169.70, Close:169.70, Volume:253852.0
488 | 2022-08-11 16:36:11.411061, Symbol: AAPL Open:169.69, High:169.69, Low:169.69, Close:169.69, Volume:253852.0
489 | 2022-08-11 16:36:11.411081, Symbol: AAPL Open:169.69, High:169.69, Low:169.69, Close:169.69, Volume:1900.0
490 | 2022-08-11 16:36:11.411141, Symbol: AAPL Open:169.71, High:169.71, Low:169.71, Close:169.71, Volume:1900.0
491 | 2022-08-11 16:36:11.411161, Symbol: AAPL Open:169.71, High:169.71, Low:169.71, Close:169.71, Volume:2900.0
492 | ```
493 |
494 | Real-time Tick by Tick Data
495 | ---------------------------
496 | In addition to the aggregated tick snapshots, IB also has true tick-by-tick data, but it comes with some limitations. Tick-by-tick data corresponds to the data shown in the TWS Time & Sales. The maximum number of simultaneous tick-by-tick subscriptions allowed for a user is determined by the limitations below.
497 |
498 | Limitations - Additional symbol request can be purchased through a quote booster pack, each quote booster pack provides a 100 market data lines. There is a limit of 10 quote boosters packs per account and rest of the market data lines are allocated using equity and commissions.
499 |
500 | 
501 |
502 | The “what=” must be set to the following BID_ASK (BidAsk), TRADES (Last), TRADES_ALL (AllLast), MIDPOINT (MidPoint), the default is TRADES if “what” is not set. TRADES_ALL has additional trade types such as combos, derivatives, and average price trades that are not included in TRADES.
503 | Note in the example below “timeframe=bt.TimeFrame.Ticks” to select the tick-by-tick IB function.
504 |
505 | ```python
506 | import backtrader as bt
507 |
508 | from atreyu_backtrader_api import IBData
509 | from test_printer import TestPrinter
510 |
511 | import datetime as dt
512 | from datetime import datetime, date, time
513 |
514 | cerebro = bt.Cerebro()
515 |
516 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
517 | name="AAPL", # Data name
518 | dataname='AAPL', # Symbol name
519 | secType='STK', # SecurityType is STOCK
520 | exchange='SMART', # Trading exchange IB's SMART exchange
521 | currency='USD', # Currency of SecurityType
522 | timeframe=bt.TimeFrame.Ticks,
523 | what='BID_ASK', # TRADES, TRADES_ALL, MID_POINT
524 | rtbar=False
525 | )
526 |
527 | cerebro.adddata(data)
528 |
529 | # Add the test strategy
530 | cerebro.addstrategy(TestPrinter)
531 |
532 | cerebro.run()
533 | ```
534 |
535 | Output
536 | ------
537 | ```
538 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:260.0
539 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:100.0
540 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:100.0
541 | ...
542 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:100.0
543 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:100.0
544 | 2022-08-11 17:29:22, Symbol: AAPL Open:nan, High:nan, Low:nan, Close:169.41, Volume:100.0
545 | ```
546 |
547 | From Back Test to Paper Trading
548 | -------------------------------
549 | In this section we look at the steps in developing a simple strategy and back testing it with historical data from IB. Then modifying it to Paper Trader in the TWS frontend.
550 |
551 | An Example of a Simple Buy/Sell Strategy - Not that in this strategy we generate Buy and Sell market orders related to a simple moving average calculation. This strategy has been adapted from (https://www.backtrader.com/docu/quickstart/quickstart) with minor updates.
552 |
553 | ```python
554 | import backtrader as bt
555 |
556 | # Create a Stratey
557 | class TestStrategy(bt.Strategy):
558 | params = (
559 | ('ma_period', 15),
560 | )
561 |
562 | def log(self, txt, ts=None):
563 | ''' Logging function for this strategy'''
564 | ts = ts or self.datas[0].datetime.datetime(0)
565 | print(f'{ts}, {txt}')
566 |
567 | def __init__(self):
568 | # Keep a reference to the "close" line in the data[0] dataseries
569 | self.dataclose = self.datas[0].close
570 |
571 | # To keep track of pending orders and buy price/commission
572 | self.order = None
573 | self.buyprice = None
574 | self.buycomm = None
575 |
576 | # Add a MovingAverageSimple indicator
577 | self.sma = bt.indicators.SimpleMovingAverage(self.datas[0], period=self.params.ma_period)
578 |
579 | def notify_order(self, order):
580 | if order.status in [order.Submitted, order.Accepted]:
581 | # Buy/Sell order submitted/accepted to/by broker - Nothing to do
582 | return
583 |
584 | # Check if an order has been completed
585 | # Attention: broker could reject order if not enough cash
586 | if order.status in [order.Completed]:
587 | if order.isbuy():
588 | self.log(
589 | f'BUY EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
590 |
591 | self.buyprice = order.executed.price
592 | self.buycomm = order.executed.comm
593 | else: # Sell
594 | self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
595 |
596 | self.bar_executed = len(self)
597 |
598 | elif order.status in [order.Canceled, order.Margin, order.Rejected]:
599 | self.log('Order Canceled/Margin/Rejected')
600 |
601 | self.order = None
602 |
603 | def notify_trade(self, trade):
604 | if not trade.isclosed:
605 | return
606 |
607 | self.log(f'OPERATION PROFIT, GROSS: {trade.pnl:.2f}, NET: {trade.pnlcomm:.2f}')
608 |
609 | def next(self):
610 | # Simply log the closing price of the series from the reference
611 | self.log(f'Close, {self.dataclose[0]:.2f}')
612 |
613 | # Check if an order is pending ... if yes, we cannot send a 2nd one
614 | if self.order:
615 | return
616 |
617 | # Check if we are in the market
618 | if not self.position:
619 | # Not yet ... we MIGHT BUY if ...
620 | if self.dataclose[0] > self.sma[0]:
621 |
622 | # BUY, BUY, BUY!!! (with all possible default parameters)
623 | self.log(f'BUY CREATE @MKT: {self.dataclose[0]:.2f}')
624 |
625 | # Keep track of the created order to avoid a 2nd order
626 | self.order = self.buy()
627 | self.log(f'BUY CREATED Size: {self.order.size} @ MKT')
628 |
629 | else:
630 | if self.dataclose[0] < self.sma[0]:
631 | # SELL, SELL, SELL!!! (with all possible default parameters)
632 | self.log(f'SELL CREATE @ MKT: {self.dataclose[0]:.2f}')
633 |
634 | # Keep track of the created order to avoid a 2nd order
635 | self.order = self.sell()
636 | self.log(f'SELL CREATED Size: {self.order.size} @ MKT')
637 | ```
638 |
639 | Back Testing The Strategy Using IB Historical Data
640 | --------------------------------------------------
641 |
642 | ```python
643 | import backtrader as bt
644 |
645 | from atreyu_backtrader_api import IBData
646 | from test_strategy import TestStrategy
647 |
648 | import datetime as dt
649 | from datetime import datetime, date, time
650 |
651 | cerebro = bt.Cerebro()
652 |
653 | data = IBData(host='127.0.0.1', port=7497, clientId=35,
654 | name="AAPL", # Data name
655 | dataname='AAPL', # Symbol name
656 | secType='STK', # SecurityType is STOCK
657 | exchange='SMART', # Trading exchange IB's SMART exchange
658 | currency='USD', # Currency of SecurityType
659 | historical=True
660 | )
661 |
662 | cerebro.adddata(data)
663 |
664 | # Set our desired cash start
665 | cerebro.broker.setcash(100000.0)
666 |
667 | # Add the test strategy
668 | cerebro.addstrategy(TestStrategy)
669 |
670 | # Add a FixedSize sizer according to the stake
671 | cerebro.addsizer(bt.sizers.FixedSize, stake=10)
672 |
673 | cerebro.run()
674 |
675 | # Print out the final result
676 | print(f'Final Portfolio Value: {cerebro.broker.getvalue():.2f}')
677 | ```
678 | Output
679 | ------
680 | ```
681 | 2021-09-01 23:59:59.999989, Close, 153.16
682 | 2021-09-01 23:59:59.999989, BUY CREATE @MKT: 153.16
683 | 2021-09-01 23:59:59.999989, BUY CREATED Size: 10 @ MKT
684 | 2021-09-02 23:59:59.999989, BUY EXECUTED, Price: 152.86, Cost: 1528.60, Comm: 0.00
685 | 2021-09-02 23:59:59.999989, Close, 153.59
686 | 2021-09-03 23:59:59.999989, Close, 154.29
687 | 2021-09-07 23:59:59.999989, Close, 156.76
688 | 2021-09-08 23:59:59.999989, Close, 155.00
689 | 2021-09-09 23:59:59.999989, Close, 154.27
690 | 2021-09-10 23:59:59.999989, Close, 148.67
691 | 2021-09-10 23:59:59.999989, SELL CREATE @ MKT: 148.67
692 | 2021-09-10 23:59:59.999989, SELL CREATED Size: -10 @ MKT
693 | 2021-09-13 23:59:59.999989, SELL EXECUTED, Price: 149.70, Cost: 1528.60, Comm: 0.00
694 | 2021-09-13 23:59:59.999989, OPERATION PROFIT, GROSS: -31.60, NET: -31.60
695 | 2021-09-13 23:59:59.999989, Close, 149.53
696 | 2021-09-14 23:59:59.999989, Close, 148.19
697 | ...
698 | 2022-08-08 23:59:59.999989, Close, 165.27
699 | 2022-08-09 23:59:59.999989, Close, 165.01
700 | 2022-08-10 23:59:59.999989, Close, 169.10
701 | 2022-08-11 00:00:00, Close, 168.89
702 | Final Portfolio Value: 100409.00
703 | ```
704 |
705 | Paper Trading on IB
706 | -------------------
707 | Once a strategy has been back tested for basic operation it should be paper traded i.e. where simulated trades are generated and marked using live data to gain confidence in your overall trading strategy and goal.
708 |
709 | Be careful to login to TWS using the Paper Trading option (see arrow):
710 | 
711 |
712 | In addition to selecting “Paper Trading” the execution of the strategy also needs to be changed to use IB as the broker to place orders and update positions.
713 |
714 | ```python
715 | import backtrader as bt
716 |
717 | from atreyu_backtrader_api import IBData
718 | from atreyu_backtrader_api import IBStore
719 |
720 | from test_strategy import TestStrategy
721 |
722 | import datetime as dt
723 | from datetime import datetime, date, time
724 |
725 | cerebro = bt.Cerebro()
726 |
727 | ibstore = IBStore(host='127.0.0.1',
728 | port=7497,
729 | clientId=35)
730 |
731 | data = ibstore.getdata(name="AAPL", # Data name
732 | dataname='AAPL', # Symbol name
733 | secType='STK', # SecurityType is STOCK
734 | exchange='SMART', # Trading exchange IB's SMART exchange
735 | currency='USD' # Currency of SecurityType
736 | )
737 |
738 | cerebro.adddata(data)
739 |
740 | broker = ibstore.getbroker()
741 |
742 | # Set the broker
743 | cerebro.setbroker(broker)
744 |
745 | # Add the test strategy
746 | cerebro.addstrategy(TestStrategy)
747 |
748 | # Add a FixedSize sizer according to the stake
749 | cerebro.addsizer(bt.sizers.FixedSize, stake=10)
750 |
751 | cerebro.run()
752 | ```
753 |
754 | Output
755 | ------
756 | ```
757 | ...
758 | 2022-08-17 19:06:33.352691, Close, 174.84
759 | 2022-08-17 19:06:33.352741, Close, 174.84
760 | 2022-08-17 19:06:33.352781, Close, 174.84
761 | 2022-08-17 19:06:33.601432, SELL EXECUTED, Price: 174.83, Cost: 1749.40, Comm: 1.04
762 | 2022-08-17 19:06:33.601432, OPERATION PROFIT, GROSS: -0.10, NET: -2.14
763 | 2022-08-17 19:06:33.601432, Close, 174.83
764 | 2022-08-17 19:06:33.601462, Close, 174.83
765 | 2022-08-17 19:06:33.601522, Close, 174.85
766 | 2022-08-17 19:06:33.601522, BUY CREATE @MKT: 174.85
767 | 2022-08-17 19:06:33.601522, BUY CREATED Size: 10 @ MKT
768 | 2022-08-17 19:06:33.601533, Close, 174.85
769 | ...
770 | ```
771 |
772 | Once the strategy is initiated the orders start appearing in TWS
773 | ----------------------------------------------------------------
774 |
775 | 
776 |
777 | From Paper Trading To Real Trading – Monitoring, Debug and Go Live!
778 | -------------------------------------------------------------------
779 | Paper trading can help to validate the efficacy of a strategy, so it is prudent to continue paper trading until a stable performance and profitability profile has been achieved.
780 |
781 | Sometimes successful paper trading strategies may not translate to the same performance in real trading. This can be for a myriad of reasons from external factors (market impact, price slippage, market microstructure etc.) to simple programming issues.
782 |
783 | To help isolate and identify these issues it is important to have a robust monitoring and debugging facilities built into the trading process.
784 |
785 | The simplest and most effective function is having accurate logging that records not only the trades but identifies the assets, opening and closing prices and trigger conditions and parameters pertinent to the strategy. Additionally the Backtrader and TWS API also has debugging built and can be accessed by setting the appropriate configuration parameters.
786 |
787 | 
788 |
789 | An Example of a Simple Buy/Sell Strategy With Logging
790 | -----------------------------------------------------
791 | ```python
792 | import backtrader as bt
793 |
794 | import logging
795 | # Generate file level logger
796 | logger = logging.getLogger(__name__)
797 |
798 | # Create a Stratey
799 | class TestStrategy(bt.Strategy):
800 | params = (
801 | ('ma_period', 15),
802 | )
803 |
804 | def log(self, txt, ts=None):
805 | ''' Logging function for this strategy'''
806 | # Add log statement
807 | logger.info(f'{txt}')
808 | ...
809 |
810 | ```
811 |
812 | Setting Up A Log File
813 | ---------------------
814 | There is a example custom logger supplied in the atreyu_backtrader_api module, and can be referenced in the code as follows:
815 |
816 | ```python
817 | import logging
818 | from atreyu_backtrader_api.custom_logger import setup_custom_logger
819 | ```
820 | The logger has the following properties:
821 | - It can write a named log file into a named directory thereby keeping all the logs together.
822 | - It has log rotation, e.g. it does not overwrite logs on repeated restarts but backs each log keeping the specified logfile as the latest log.
823 | - Each log line can be echoed to the terminal as well as written to the logfile
824 |
825 | The logger can be setup as follows:
826 | ```python
827 | LOGGING_DEBUG = False
828 | logger = setup_custom_logger(global_name = 'root',
829 | debug_level = logging.DEBUG if LOGGING_DEBUG else logging.INFO,
830 | filename = "sma_strategy.log", # Set the log file name
831 | logdirname = './logs', # and the directory for the logs
832 | console = True) # Echo the log line to the terminal
833 |
834 | ```
835 | Configuring each sub-logger for the module and source file that is of interest, can control the logfile output.
836 | ```python
837 | LOGGING_DEBUG = False
838 | logger = setup_custom_logger(global_name = 'root',
839 | debug_level = logging.DEBUG if LOGGING_DEBUG else logging.INFO,
840 | filename = "sma_strategy.log", # Set the log file name
841 | logdirname = './logs', # and the directory for the logs
842 | console = True) # Echo the log line to the terminal
843 |
844 | # Log configuration for Atreyu Back Trader API module
845 | logging.getLogger('atreyu_backtrader_api').setLevel(logging.ERROR)
846 | # logging.getLogger('atreyu_backtrader_api.ibbroker').setLevel(logging.INFO)
847 | # logging.getLogger('atreyu_backtrader_api.ibdata').setLevel(logging.INFO)
848 | # logging.getLogger('atreyu_backtrader_api.ibstore').setLevel(logging.INFO)
849 |
850 | # Log configuration for IB API module
851 | logging.getLogger('ibapi').setLevel(logging.ERROR)
852 | # logging.getLogger('ibapi.decoder').setLevel(logging.ERROR)
853 | # logging.getLogger('ibapi.utils').setLevel(logging.ERROR)
854 | # logging.getLogger('ibapi.client').setLevel(logging.ERROR)
855 | #logging.getLogger('ibapi.decoder').setLevel(logging.DEBUG)
856 | ```
857 |
858 | Enable Logging in Backtrader
859 | To enable logging in the Backtrader framework the _debug = True variable can be passed when setting up the IBStore and IBData classes.
860 | ```python
861 | PAPER_TRADING_PORT = 7497
862 | LIVE_TRADING_PORT = 7496
863 |
864 | cerebro = bt.Cerebro()
865 | HOST = '127.0.0.1'
866 | PORT = PAPER_TRADING_PORT
867 | logger.info(f"Starting host: {HOST} port: {PORT}")
868 | ibstore = IBStore(host=HOST,
869 | port=PORT,
870 | clientId=35,
871 | _debug = True)
872 |
873 | data = ibstore.getdata(name="AAPL", # Data name
874 | dataname='AAPL', # Symbol name
875 | secType='STK', # SecurityType is STOCK
876 | exchange='SMART', # Trading exchange IB's SMART exchange
877 | currency='USD', # Currency of SecurityType
878 | _debug = True
879 | )
880 |
881 | cerebro.adddata(data)
882 |
883 | broker = ibstore.getbroker()
884 |
885 | # Set the broker
886 | cerebro.setbroker(broker)
887 | ```
888 | Output
889 | ------
890 | ```
891 | Calling updateAccountTime('20:10')
892 | Calling tickSize(16777217, 8, Decimal('361198'))
893 | Calling tickString(16777217, 45, '1661454621')
894 | Calling tickString(16777217, 84, 'Z')
895 | Calling tickPrice(16777217, 4, 169.19, 4361192208: CanAutoExecute: 0, PastLimit: 0, PreOpen: 0)
896 | Calling tickSize(16777217, 5, Decimal('100'))
897 | Calling tickSize(16777217, 5, Decimal('100'))
898 | Calling tickPrice(16777217, 1, 169.18, 4361192208: CanAutoExecute: 1, PastLimit: 0, PreOpen: 0)
899 | Calling tickSize(16777217, 0, Decimal('1600'))
900 | Calling get_acc_cash()
901 | Calling tickPrice(16777217, 2, 169.19, 4361192448: CanAutoExecute: 1, PastLimit: 0, PreOpen: 0)
902 | Calling get_acc_value()
903 | Calling tickSize(16777217, 3, Decimal('1500'))
904 | Calling get_acc_value()
905 | Calling tickSize(16777217, 0, Decimal('1600'))
906 | Calling tickSize(16777217, 3, Decimal('1500'))
907 | Calling tickString(16777217, 32, 'PQXZUH')
908 | Calling tickString(16777217, 33, 'KPQZNUH')
909 | ```
910 | Onward To Live Trading
911 | ----------------------
912 | Now that you have implemented logging, and have and good understanding of how to debug your strategy you are ready to go live.
913 |
914 | It is suggested that trading starts small, and that as confidence is gained trading sizes/frequency is increased within your prescribed risk appetite. At each stage it is recommended that you review the logs for anomalies that you may not notice from looking at the TWS screens.
915 |
916 | Switching the strategy to live trading is as simple as logging in with TWS for trading and changing the port designator in the code below:
917 |
918 | ```python
919 | PAPER_TRADING_PORT = 7497
920 | LIVE_TRADING_PORT = 7496
921 |
922 | cerebro = bt.Cerebro()
923 | HOST = '127.0.0.1'
924 | PORT = LIVE_TRADING_PORT
925 | logger.info(f"Starting host: {HOST} port: {PORT}")
926 | ibstore = IBStore(host=HOST,
927 | port=PORT,
928 | clientId=35,
929 | _debug = True)
930 |
931 | data = ibstore.getdata(name="AAPL", # Data name
932 | dataname='AAPL', # Symbol name
933 | secType='STK', # SecurityType is STOCK
934 | exchange='SMART', # Trading exchange IB's SMART exchange
935 | currency='USD', # Currency of SecurityType
936 | _debug = True
937 | )
938 |
939 | cerebro.adddata(data)
940 |
941 | broker = ibstore.getbroker()
942 |
943 | # Set the broker
944 | cerebro.setbroker(broker)
945 | ```
946 |
947 | Disclaimer
948 | ----------
949 | The software is provided on the conditions of the simplified BSD license.
950 |
951 | This project is not affiliated with Interactive Brokers Group, Inc.
952 |
953 |
--------------------------------------------------------------------------------
/atreyu_backtrader_api/__init__.py:
--------------------------------------------------------------------------------
1 | from .ibstore import IBStore
2 | from .ibbroker import IBBroker
3 | from .ibdata import IBData
4 | from .custom_logger import setup_custom_logger
5 |
6 | __all__ = [
7 | 'IBStore', 'IBBroker', 'IBData', 'setup_custom_logger',
8 | ]
9 | __version__ = '0.1.0'
10 |
--------------------------------------------------------------------------------
/atreyu_backtrader_api/custom_logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from datetime import datetime
4 | import os
5 |
6 | # Create the global logger
7 | def setup_custom_logger(global_name, filename, logdirname = './logs', debug_level = logging.ERROR, console_level = logging.ERROR, console = False):
8 |
9 | # Check if logdir exists
10 | if not os.path.exists(logdirname):
11 | # Create it if not there
12 | try:
13 | os.mkdir(logdirname)
14 | except OSError:
15 | print(f"Creation log_dir: {logdirname} failed")
16 | return None
17 | else:
18 | print (f"Created log_dir: {logdirname}")
19 |
20 | # Check if the file exists
21 | logfilename = os.path.join(logdirname, filename)
22 | if os.path.exists(logfilename):
23 | # Create a new logfile for every run
24 | now = datetime.now().strftime('%Y%m%dT%H%M%S')
25 | new_logfile = os.path.join(logdirname, now + "." + filename)
26 | os.rename(logfilename, new_logfile)
27 |
28 | # Set up a log line format
29 | log_fmt = '%(asctime)s.%(msecs)03d - %(levelname)-7s - %(threadName)s - %(filename)s:%(lineno)d [%(funcName)s] - %(message)s'
30 | date_fmt = '%Y-%m-%d %H:%M:%S'
31 | logging.basicConfig(filename=logfilename, level=debug_level, format=log_fmt, datefmt=date_fmt)
32 |
33 | logger = logging.getLogger(global_name)
34 | logger.setLevel(debug_level)
35 |
36 | if console:
37 | # Create console handler and set debug_level
38 | ch = logging.StreamHandler()
39 | ch.setLevel(console_level)
40 |
41 | # Create formatter
42 | formatter = logging.Formatter(fmt=log_fmt, datefmt=date_fmt)
43 |
44 | # Add formatter to ch
45 | ch.setFormatter(formatter)
46 |
47 | # Add ch to logger
48 | logger.addHandler(ch)
49 |
50 | return logger
51 |
--------------------------------------------------------------------------------
/atreyu_backtrader_api/ibbroker.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8; py-indent-offset:4 -*-
3 | ###############################################################################
4 | #
5 | # Copyright (C) 2015-2020 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 .
19 | #
20 | ###############################################################################
21 | from __future__ import (absolute_import, division, print_function,
22 | unicode_literals)
23 |
24 | import collections
25 | from copy import copy
26 | from datetime import date, datetime, timedelta
27 | import threading
28 | import uuid
29 |
30 | # import ib.ext.Order
31 | # import ib.opt as ibopt
32 |
33 | import ibapi.order
34 |
35 | from backtrader.feed import DataBase
36 | from backtrader import (TimeFrame, num2date, date2num, BrokerBase,
37 | Order, OrderBase, OrderData)
38 | from backtrader.utils.py3 import bytes, bstr, with_metaclass, queue, MAXFLOAT
39 | from backtrader.metabase import MetaParams
40 | from backtrader.comminfo import CommInfoBase
41 | from backtrader.position import Position
42 | from atreyu_backtrader_api import ibstore
43 | from backtrader.utils import AutoDict, AutoOrderedDict
44 | from backtrader.comminfo import CommInfoBase
45 |
46 | bytes = bstr # py2/3 need for ibpy
47 |
48 | import logging
49 | logger = logging.getLogger(__name__)
50 |
51 | class IBOrderState(object):
52 | # wraps OrderState object and can print it
53 | _fields = ['status', 'initMargin', 'maintMargin', 'equityWithLoan',
54 | 'commission', 'minCommission', 'maxCommission',
55 | 'commissionCurrency', 'warningText']
56 |
57 | def __init__(self, orderstate):
58 | for f in self._fields:
59 | # fname = 'm_' + f
60 | fname = f
61 | setattr(self, fname, getattr(orderstate, fname))
62 |
63 | def __str__(self):
64 | txt = list()
65 | txt.append('--- ORDERSTATE BEGIN')
66 | for f in self._fields:
67 | # fname = 'm_' + f
68 | fname = f
69 | txt.append('{}: {}'.format(f.capitalize(), getattr(self, fname)))
70 | txt.append('--- ORDERSTATE END')
71 | return '\n'.join(txt)
72 |
73 |
74 | class IBOrder(OrderBase, ibapi.order.Order):
75 | '''Subclasses the IBPy order to provide the minimum extra functionality
76 | needed to be compatible with the internally defined orders
77 |
78 | Once ``OrderBase`` has processed the parameters, the __init__ method takes
79 | over to use the parameter values and set the appropriate values in the
80 | ib.ext.Order.Order object
81 |
82 | Any extra parameters supplied with kwargs are applied directly to the
83 | ib.ext.Order.Order object, which could be used as follows::
84 |
85 | Example: if the 4 order execution types directly supported by
86 | ``backtrader`` are not enough, in the case of for example
87 | *Interactive Brokers* the following could be passed as *kwargs*::
88 |
89 | orderType='LIT', lmtPrice=10.0, auxPrice=9.8
90 |
91 | This would override the settings created by ``backtrader`` and
92 | generate a ``LIMIT IF TOUCHED`` order with a *touched* price of 9.8
93 | and a *limit* price of 10.0.
94 |
95 | This would be done almost always from the ``Buy`` and ``Sell`` methods of
96 | the ``Strategy`` subclass being used in ``Cerebro``
97 | '''
98 |
99 | def __str__(self):
100 | '''Get the printout from the base class and add some ib.Order specific
101 | fields'''
102 | basetxt = super(IBOrder, self).__str__()
103 | tojoin = [basetxt]
104 | tojoin.append('Ref: {}'.format(self.ref))
105 | tojoin.append('orderId: {}'.format(self.orderId))
106 | tojoin.append('Action: {}'.format(self.action))
107 | tojoin.append('Size (ib): {}'.format(self.totalQuantity))
108 | tojoin.append('Lmt Price: {}'.format(self.lmtPrice))
109 | tojoin.append('Aux Price: {}'.format(self.auxPrice))
110 | tojoin.append('OrderType: {}'.format(self.orderType))
111 | tojoin.append('Tif (Time in Force): {}'.format(self.tif))
112 | tojoin.append('GoodTillDate: {}'.format(self.goodTillDate))
113 | return '\n'.join(tojoin)
114 |
115 | # Map backtrader order types to the ib specifics
116 | _IBOrdTypes = {
117 | None: bytes('MKT'), # default
118 | Order.Market: bytes('MKT'),
119 | Order.Limit: bytes('LMT'),
120 | Order.Close: bytes('MOC'),
121 | Order.Stop: bytes('STP'),
122 | Order.StopLimit: bytes('STPLMT'),
123 | Order.StopTrail: bytes('TRAIL'),
124 | Order.StopTrailLimit: bytes('TRAIL LIMIT'),
125 | }
126 |
127 | def __init__(self, action, **kwargs):
128 |
129 | # Marker to indicate an openOrder has been seen with
130 | # PendinCancel/Cancelled which is indication of an upcoming
131 | # cancellation
132 | self._willexpire = False
133 |
134 | self.ordtype = self.Buy if action == 'BUY' else self.Sell
135 |
136 | super(IBOrder, self).__init__()
137 | ibapi.order.Order.__init__(self) # Invoke 2nd base class
138 |
139 | # Now fill in the specific IB parameters
140 | self.orderType = self._IBOrdTypes[self.exectype]
141 | self.permid = 0
142 |
143 | # 'B' or 'S' should be enough
144 | self.action = bytes(action)
145 |
146 | # Set the prices
147 | self.lmtPrice = 0.0
148 | self.auxPrice = 0.0
149 |
150 | if self.exectype == self.Market: # is it really needed for Market?
151 | pass
152 | elif self.exectype == self.Close: # is it ireally needed for Close?
153 | pass
154 | elif self.exectype == self.Limit:
155 | self.lmtPrice = self.price
156 | elif self.exectype == self.Stop:
157 | self.auxPrice = self.price # stop price / exec is market
158 | elif self.exectype == self.StopLimit:
159 | self.lmtPrice = self.pricelimit # req limit execution
160 | self.auxPrice = self.price # trigger price
161 | elif self.exectype == self.StopTrail:
162 | if self.trailamount is not None:
163 | self.auxPrice = self.trailamount
164 | elif self.trailpercent is not None:
165 | # value expected in % format ... multiply 100.0
166 | self.m_trailingPercent = self.trailpercent * 100.0
167 | elif self.exectype == self.StopTrailLimit:
168 | self.m_trailStopPrice = self.lmtPrice = self.price
169 | # The limit offset is set relative to the price difference in TWS
170 | self.lmtPrice = self.pricelimit
171 | if self.trailamount is not None:
172 | self.auxPrice = self.trailamount
173 | elif self.trailpercent is not None:
174 | # value expected in % format ... multiply 100.0
175 | self.m_trailingPercent = self.trailpercent * 100.0
176 |
177 | self.totalQuantity = abs(self.size) # ib takes only positives
178 |
179 | # self.m_transmit = self.transmit
180 | if self.parent is not None:
181 | self.m_parentId = self.parent.orderId
182 |
183 | # Time In Force: DAY, GTC, IOC, GTD
184 | if self.valid is None:
185 | tif = 'GTC' # Good til cancelled
186 | elif isinstance(self.valid, (datetime, date)):
187 | tif = 'GTD' # Good til date
188 | self.goodTillDate = bytes(self.valid.strftime('%Y%m%d %H:%M:%S'))
189 | elif isinstance(self.valid, (timedelta,)):
190 | if self.valid == self.DAY:
191 | tif = 'DAY'
192 | else:
193 | tif = 'GTD' # Good til date
194 | valid = datetime.now() + self.valid # .now, using localtime
195 | self.goodTillDate = bytes(valid.strftime('%Y%m%d %H:%M:%S'))
196 |
197 | elif self.valid == 0:
198 | tif = 'DAY'
199 | else:
200 | tif = 'GTD' # Good til date
201 | valid = num2date(self.valid)
202 | self.goodTillDate = bytes(valid.strftime('%Y%m%d %H:%M:%S'))
203 |
204 | self.tif = bytes(tif)
205 |
206 | # OCA
207 | self.ocaType = 1 # Cancel all remaining orders with block
208 |
209 | # pass any custom arguments to the order
210 | for key, value in kwargs.items():
211 | setattr(self, key, value)
212 |
213 | class IBCommInfo(CommInfoBase):
214 | '''
215 | Commissions are calculated by ib, but the trades calculations in the
216 | ```Strategy`` rely on the order carrying a CommInfo object attached for the
217 | calculation of the operation cost and value.
218 |
219 | These are non-critical informations, but removing them from the trade could
220 | break existing usage and it is better to provide a CommInfo objet which
221 | enables those calculations even if with approvimate values.
222 |
223 | The margin calculation is not a known in advance information with IB
224 | (margin impact can be gotten from OrderState objects) and therefore it is
225 | left as future exercise to get it'''
226 |
227 | def getvaluesize(self, size, price):
228 | # In real life the margin approaches the price
229 | return abs(size) * price
230 |
231 | def getoperationcost(self, size, price):
232 | '''Returns the needed amount of cash an operation would cost'''
233 | # Same reasoning as above
234 | return abs(float(size)) * float(price)
235 |
236 |
237 | class MetaIBBroker(BrokerBase.__class__):
238 | def __init__(cls, name, bases, dct):
239 | '''Class has already been created ... register'''
240 | # Initialize the class
241 | super(MetaIBBroker, cls).__init__(name, bases, dct)
242 | ibstore.IBStore.BrokerCls = cls
243 |
244 |
245 | class IBBroker(with_metaclass(MetaIBBroker, BrokerBase)):
246 | '''Broker implementation for Interactive Brokers.
247 |
248 | This class maps the orders/positions from Interactive Brokers to the
249 | internal API of ``backtrader``.
250 |
251 | Notes:
252 |
253 | - ``tradeid`` is not really supported, because the profit and loss are
254 | taken directly from IB. Because (as expected) calculates it in FIFO
255 | manner, the pnl is not accurate for the tradeid.
256 |
257 | - Position
258 |
259 | If there is an open position for an asset at the beginning of
260 | operaitons or orders given by other means change a position, the trades
261 | calculated in the ``Strategy`` in cerebro will not reflect the reality.
262 |
263 | To avoid this, this broker would have to do its own position
264 | management which would also allow tradeid with multiple ids (profit and
265 | loss would also be calculated locally), but could be considered to be
266 | defeating the purpose of working with a live broker
267 | '''
268 | params = ()
269 |
270 | def __init__(self, **kwargs):
271 | super(IBBroker, self).__init__()
272 |
273 | self.ib = ibstore.IBStore(**kwargs)
274 | self.startingcash = self.cash = 0.0
275 | self.startingvalue = self.value = 0.0
276 |
277 | self._lock_orders = threading.Lock() # control access
278 | self.orderbyid = dict() # orders by order id
279 | self.executions = dict() # notified executions
280 | self.ordstatus = collections.defaultdict(dict)
281 | self.notifs = queue.Queue() # holds orders which are notified
282 | self.tonotify = collections.deque() # hold oids to be notified
283 |
284 | def start(self):
285 | super(IBBroker, self).start()
286 | self.ib.start(broker=self)
287 | if self.ib.connected():
288 | self.ib.reqAccountUpdates()
289 | self.startingcash = self.cash = self.ib.get_acc_cash()
290 | self.startingvalue = self.value = self.ib.get_acc_value()
291 | else:
292 | self.startingcash = self.cash = 0.0
293 | self.startingvalue = self.value = 0.0
294 |
295 | def stop(self):
296 | super(IBBroker, self).stop()
297 | self.ib.stop()
298 |
299 | def getcash(self):
300 | # This call cannot block if no answer is available from ib
301 | self.cash = self.ib.get_acc_cash()
302 | logger.debug(f"get_acc_cash: {self.cash}")
303 | return self.cash
304 |
305 | def getvalue(self, datas=None):
306 | self.value = self.ib.get_acc_value()
307 | logger.debug(f"getvalue: {self.value}")
308 | return self.value
309 |
310 | def getposition(self, data, clone=True):
311 | position = self.ib.getposition(data.tradecontract, clone=clone)
312 | logger.info(f"getposition: {position}")
313 | return position
314 |
315 | def cancel(self, order):
316 | try:
317 | o = self.orderbyid[order.orderId]
318 | except (ValueError, KeyError):
319 | return # not found ... not cancellable
320 |
321 | if order.status == Order.Cancelled: # already cancelled
322 | return
323 |
324 | self.ib.cancelOrder(order.orderId)
325 |
326 | def orderstatus(self, order):
327 | try:
328 | o = self.orderbyid[order.orderId]
329 | except (ValueError, KeyError):
330 | o = order
331 |
332 | return o.status
333 |
334 | def submit(self, order):
335 | order.submit(self)
336 |
337 | # ocoize if needed
338 | if order.oco is None: # Generate a UniqueId
339 | order.ocaGroup = bytes(uuid.uuid4())
340 | else:
341 | order.ocaGroup = self.orderbyid[order.oco.orderId].ocaGroup
342 |
343 | self.orderbyid[order.orderId] = order
344 | self.ib.placeOrder(order.orderId, order.data.tradecontract, order)
345 | self.notify(order)
346 |
347 | return order
348 |
349 | def getcommissioninfo(self, data):
350 | logger.info("getcommissioninfo()")
351 | contract = data.tradecontract
352 | try:
353 | mult = float(contract.multiplier)
354 | except (ValueError, TypeError):
355 | mult = 1.0
356 |
357 | stocklike = contract.secType not in ('FUT', 'OPT', 'FOP',)
358 |
359 | return IBCommInfo(mult=mult, stocklike=stocklike)
360 |
361 | def _makeorder(self, action, owner, data,
362 | size, price=None, plimit=None,
363 | exectype=None, valid=None,
364 | tradeid=0, **kwargs):
365 |
366 | orderId=self.ib.nextOrderId()
367 | order = IBOrder(action, owner=owner, data=data,
368 | size=size, price=price, pricelimit=plimit,
369 | exectype=exectype, valid=valid,
370 | tradeid=tradeid,
371 | clientId=self.ib.clientId,
372 | orderId=orderId,
373 | **kwargs)
374 |
375 | order.addcomminfo(self.getcommissioninfo(data))
376 | return order
377 |
378 | def buy(self, owner, data,
379 | size, price=None, plimit=None,
380 | exectype=None, valid=None, tradeid=0,
381 | **kwargs):
382 |
383 | order = self._makeorder(
384 | 'BUY',
385 | owner, data, size, price, plimit, exectype, valid, tradeid,
386 | **kwargs)
387 |
388 | return self.submit(order)
389 |
390 | def sell(self, owner, data,
391 | size, price=None, plimit=None,
392 | exectype=None, valid=None, tradeid=0,
393 | **kwargs):
394 |
395 | order = self._makeorder(
396 | 'SELL',
397 | owner, data, size, price, plimit, exectype, valid, tradeid,
398 | **kwargs)
399 |
400 | return self.submit(order)
401 |
402 | def notify(self, order):
403 | self.notifs.put(order.clone())
404 |
405 | def get_notification(self):
406 | try:
407 | return self.notifs.get(False)
408 | except queue.Empty:
409 | pass
410 |
411 | return None
412 |
413 | def next(self):
414 | self.notifs.put(None) # mark notificatino boundary
415 |
416 | # Order statuses in msg
417 | (SUBMITTED, FILLED, CANCELLED, INACTIVE,
418 | PENDINGSUBMIT, PENDINGCANCEL, PRESUBMITTED) = (
419 | 'Submitted', 'Filled', 'Cancelled', 'Inactive',
420 | 'PendingSubmit', 'PendingCancel', 'PreSubmitted',)
421 |
422 | def push_orderstatus(self, msg):
423 | # Cancelled and Submitted with Filled = 0 can be pushed immediately
424 | try:
425 | order = self.orderbyid[msg.orderId]
426 | except KeyError:
427 | return # not found, it was not an order
428 |
429 | if msg.status == self.SUBMITTED and msg.filled == 0:
430 | if order.status == order.Accepted: # duplicate detection
431 | return
432 |
433 | order.accept(self)
434 | self.notify(order)
435 |
436 | elif msg.status == self.CANCELLED:
437 | # duplicate detection
438 | if order.status in [order.Cancelled, order.Expired]:
439 | return
440 |
441 | if order._willexpire:
442 | # An openOrder has been seen with PendingCancel/Cancelled
443 | # and this happens when an order expires
444 | order.expire()
445 | else:
446 | # Pure user cancellation happens without an openOrder
447 | order.cancel()
448 | self.notify(order)
449 |
450 | elif msg.status == self.PENDINGCANCEL:
451 | # In theory this message should not be seen according to the docs,
452 | # but other messages like PENDINGSUBMIT which are similarly
453 | # described in the docs have been received in the demo
454 | if order.status == order.Cancelled: # duplicate detection
455 | return
456 |
457 | # We do nothing because the situation is handled with the 202 error
458 | # code if no orderStatus with CANCELLED is seen
459 | # order.cancel()
460 | # self.notify(order)
461 |
462 | elif msg.status == self.INACTIVE:
463 | # This is a tricky one, because the instances seen have led to
464 | # order rejection in the demo, but according to the docs there may
465 | # be a number of reasons and it seems like it could be reactivated
466 | if order.status == order.Rejected: # duplicate detection
467 | return
468 |
469 | order.reject(self)
470 | self.notify(order)
471 |
472 | elif msg.status in [self.SUBMITTED, self.FILLED]:
473 | # These two are kept inside the order until execdetails and
474 | # commission are all in place - commission is the last to come
475 | self.ordstatus[msg.orderId][msg.filled] = msg
476 |
477 | elif msg.status in [self.PENDINGSUBMIT, self.PRESUBMITTED]:
478 | # According to the docs, these statuses can only be set by the
479 | # programmer but the demo account sent it back at random times with
480 | # "filled"
481 | if msg.filled:
482 | self.ordstatus[msg.orderId][msg.filled] = msg
483 | else: # Unknown status ...
484 | pass
485 |
486 | def push_execution(self, ex):
487 | self.executions[ex.execId] = ex
488 |
489 | def push_commissionreport(self, cr):
490 | with self._lock_orders:
491 | try:
492 | ex = self.executions.pop(cr.execId)
493 | oid = ex.orderId
494 | order = self.orderbyid[oid]
495 | ostatus = self.ordstatus[oid].pop(ex.cumQty)
496 |
497 | position = self.getposition(order.data, clone=False)
498 | pprice_orig = position.price
499 | size = ex.shares if ex.side[0] == 'B' else -ex.shares
500 | price = ex.price
501 | # use pseudoupdate and let the updateportfolio do the real update?
502 | psize, pprice, opened, closed = position.update(float(size), price)
503 |
504 | # split commission between closed and opened
505 | comm = cr.commission
506 | closedcomm = comm * float(closed) / float(size)
507 | openedcomm = comm - closedcomm
508 |
509 | comminfo = order.comminfo
510 | closedvalue = comminfo.getoperationcost(closed, pprice_orig)
511 | openedvalue = comminfo.getoperationcost(opened, price)
512 |
513 | # default in m_pnl is MAXFLOAT
514 | pnl = cr.realizedPNL if closed else 0.0
515 |
516 | # The internal broker calc should yield the same result
517 | # pnl = comminfo.profitandloss(-closed, pprice_orig, price)
518 |
519 | # Use the actual time provided by the execution object
520 | # The report from TWS is in actual local time, not the data's tz
521 | #dt = date2num(datetime.strptime(ex.time, '%Y%m%d %H:%M:%S'))
522 | dt_array = [] if ex.time == None else ex.time.split(" ")
523 | if dt_array and len(dt_array) > 1:
524 | dt_array.pop()
525 | ex_time = " ".join(dt_array)
526 | dt = date2num(datetime.strptime(ex_time, '%Y%m%d %H:%M:%S'))
527 | else:
528 | dt = date2num(datetime.strptime(ex.time, '%Y%m%d %H:%M:%S %A'))
529 |
530 | # Need to simulate a margin, but it plays no role, because it is
531 | # controlled by a real broker. Let's set the price of the item
532 | margin = order.data.close[0]
533 |
534 | order.execute(dt, float(size), price,
535 | float(closed), closedvalue, closedcomm,
536 | opened, openedvalue, openedcomm,
537 | margin, pnl,
538 | float(psize), pprice)
539 |
540 | if ostatus.status == self.FILLED:
541 | order.completed()
542 | self.ordstatus.pop(oid) # nothing left to be reported
543 | else:
544 | order.partial()
545 |
546 | if oid not in self.tonotify: # Lock needed
547 | self.tonotify.append(oid)
548 | except Exception as e:
549 | logger.exception(f"Exception: {e}")
550 |
551 | def push_portupdate(self):
552 | # If the IBStore receives a Portfolio update, then this method will be
553 | # indicated. If the execution of an order is split in serveral lots,
554 | # updatePortfolio messages will be intermixed, which is used as a
555 | # signal to indicate that the strategy can be notified
556 | with self._lock_orders:
557 | while self.tonotify:
558 | oid = self.tonotify.popleft()
559 | order = self.orderbyid[oid]
560 | self.notify(order)
561 |
562 | def push_ordererror(self, msg):
563 | with self._lock_orders:
564 | try:
565 | order = self.orderbyid[msg.id]
566 | except (KeyError, AttributeError):
567 | return # no order or no id in error
568 |
569 | if msg.errorCode == 202:
570 | if not order.alive():
571 | return
572 | order.cancel()
573 |
574 | elif msg.errorCode == 201: # rejected
575 | if order.status == order.Rejected:
576 | return
577 | order.reject()
578 |
579 | else:
580 | order.reject() # default for all other cases
581 |
582 | self.notify(order)
583 |
584 | def push_orderstate(self, msg):
585 |
586 | with self._lock_orders:
587 | try:
588 | order = self.orderbyid[msg.orderId]
589 | except (KeyError, AttributeError):
590 | return # no order or no id in error
591 |
592 | if msg.orderState.status in ['PendingCancel', 'Cancelled',
593 | 'Canceled']:
594 | # This is most likely due to an expiration]
595 | order._willexpire = True
596 |
--------------------------------------------------------------------------------
/atreyu_backtrader_api/ibdata.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8; py-indent-offset:4 -*-
3 | ###############################################################################
4 | #
5 | # Copyright (C) 2015-2020 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 .
19 | #
20 | ###############################################################################
21 | from __future__ import (absolute_import, division, print_function,
22 | unicode_literals)
23 |
24 | import datetime
25 | import time
26 |
27 | import backtrader as bt
28 | from backtrader.feed import DataBase
29 | from backtrader import TimeFrame, date2num, num2date
30 | from backtrader.utils.py3 import (integer_types, queue, string_types,
31 | with_metaclass)
32 | from backtrader.metabase import MetaParams
33 | from atreyu_backtrader_api import ibstore
34 |
35 | import logging
36 | logger = logging.getLogger(__name__)
37 |
38 | class MetaIBData(DataBase.__class__):
39 | def __init__(cls, name, bases, dct):
40 | '''Class has already been created ... register'''
41 | # Initialize the class
42 | super(MetaIBData, cls).__init__(name, bases, dct)
43 |
44 | # Register with the store
45 | ibstore.IBStore.DataCls = cls
46 |
47 |
48 | class IBData(with_metaclass(MetaIBData, DataBase)):
49 | '''Interactive Brokers Data Feed.
50 |
51 | Supports the following contract specifications in parameter ``dataname``:
52 |
53 | - TICKER # Stock type and SMART exchange
54 | - TICKER-STK # Stock and SMART exchange
55 | - TICKER-STK-EXCHANGE # Stock
56 | - TICKER-STK-EXCHANGE-CURRENCY # Stock
57 |
58 | - TICKER-CFD # CFD and SMART exchange
59 | - TICKER-CFD-EXCHANGE # CFD
60 | - TICKER-CDF-EXCHANGE-CURRENCY # Stock
61 |
62 | - TICKER-IND-EXCHANGE # Index
63 | - TICKER-IND-EXCHANGE-CURRENCY # Index
64 |
65 | - TICKER-YYYYMM-EXCHANGE # Future
66 | - TICKER-YYYYMM-EXCHANGE-CURRENCY # Future
67 | - TICKER-YYYYMM-EXCHANGE-CURRENCY-MULT # Future
68 | - TICKER-FUT-EXCHANGE-CURRENCY-YYYYMM-MULT # Future
69 |
70 | - TICKER-YYYYMM-EXCHANGE-CURRENCY-STRIKE-RIGHT # FOP
71 | - TICKER-YYYYMM-EXCHANGE-CURRENCY-STRIKE-RIGHT-MULT # FOP
72 | - TICKER-FOP-EXCHANGE-CURRENCY-YYYYMM-STRIKE-RIGHT # FOP
73 | - TICKER-FOP-EXCHANGE-CURRENCY-YYYYMM-STRIKE-RIGHT-MULT # FOP
74 |
75 | - CUR1.CUR2-CASH-IDEALPRO # Forex
76 |
77 | - TICKER-YYYYMMDD-EXCHANGE-CURRENCY-STRIKE-RIGHT # OPT
78 | - TICKER-YYYYMMDD-EXCHANGE-CURRENCY-STRIKE-RIGHT-MULT # OPT
79 | - TICKER-OPT-EXCHANGE-CURRENCY-YYYYMMDD-STRIKE-RIGHT # OPT
80 | - TICKER-OPT-EXCHANGE-CURRENCY-YYYYMMDD-STRIKE-RIGHT-MULT # OPT
81 |
82 | Params:
83 |
84 | - ``sectype`` (default: ``STK``)
85 |
86 | Default value to apply as *security type* if not provided in the
87 | ``dataname`` specification
88 |
89 | - ``exchange`` (default: ``SMART``)
90 |
91 | Default value to apply as *exchange* if not provided in the
92 | ``dataname`` specification
93 |
94 | - ``primaryExchange`` (default: ``None``)
95 |
96 | For certain smart-routed stock contracts that have the same symbol,
97 | currency and exchange, you would also need to specify the primary
98 | exchange attribute to uniquely define the contract. This should be
99 | defined as the native exchange of a contract
100 |
101 | - ``right`` (default: ``None``)
102 |
103 | Warrants, like options, require an expiration date, a right,
104 | a strike and an optional multiplier.
105 |
106 | - ``strike`` (default: ``None``)
107 |
108 | Warrants, like options, require an expiration date, a right,
109 | a strike and an optional multiplier.
110 |
111 | - ``expiry`` (default: ``None``)
112 |
113 | Warrants, like options, require an expiration date, a right,
114 | a strike and an optional multiplier.
115 | In this case expiry is 'lastTradeDateOrContractMonth'
116 |
117 | - ``currency`` (default: ``''``)
118 |
119 | Default value to apply as *currency* if not provided in the
120 | ``dataname`` specification
121 |
122 | - ``multiplier`` (default: ``None``)
123 |
124 | Occasionally, you can expect to have more than a single future
125 | contract for the same underlying with the same expiry. To rule
126 | out the ambiguity, the contract's multiplier can be given
127 |
128 | - ``tradingClass`` (default: ``None``)
129 |
130 | It is not unusual to find many option contracts with an almost identical
131 | description (i.e. underlying symbol, strike, last trading date,
132 | multiplier, etc.). Adding more details such as the trading class will help
133 |
134 | - ``localSymbol`` (default: ``None``)
135 |
136 | Warrants, like options, require an expiration date, a right, a strike and
137 | a multiplier. For some warrants it will be necessary to define a
138 | localSymbol or conId to uniquely identify the contract
139 |
140 | - ``historical`` (default: ``False``)
141 |
142 | If set to ``True`` the data feed will stop after doing the first
143 | download of data.
144 |
145 | The standard data feed parameters ``fromdate`` and ``todate`` will be
146 | used as reference.
147 |
148 | The data feed will make multiple requests if the requested duration is
149 | larger than the one allowed by IB given the timeframe/compression
150 | chosen for the data.
151 |
152 | - ``what`` (default: ``None``)
153 |
154 | If ``None`` the default for different assets types will be used for
155 | historical data requests:
156 |
157 | - 'BID' for CASH assets
158 | - 'TRADES' for any other
159 |
160 | Use 'ASK' for the Ask quote of cash assets
161 |
162 | Check the IB API docs if another value is wished
163 | (TRADES,MIDPOINT,BID,ASK,BID_ASK,ADJUSTED_LAST,HISTORICAL_VOLATILITY,
164 | OPTION_IMPLIED_VOLATILITY, REBATE_RATE, FEE_RATE,
165 | YIELD_BID, YIELD_ASK, YIELD_BID_ASK, YIELD_LAST)
166 |
167 | - ``rtbar`` (default: ``False``)
168 |
169 | If ``True`` the ``5 Seconds Realtime bars`` provided by Interactive
170 | Brokers will be used as the smalles tick. According to the
171 | documentation they correspond to real-time values (once collated and
172 | curated by IB)
173 |
174 | If ``False`` then the ``RTVolume`` prices will be used, which are based
175 | on receiving ticks. In the case of ``CASH`` assets (like for example
176 | EUR.JPY) ``RTVolume`` will always be used and from it the ``bid`` price
177 | (industry de-facto standard with IB according to the literature
178 | scattered over the Internet)
179 |
180 | Even if set to ``True``, if the data is resampled/kept to a
181 | timeframe/compression below Seconds/5, no real time bars will be used,
182 | because IB doesn't serve them below that level
183 |
184 | - ``qcheck`` (default: ``0.5``)
185 |
186 | Time in seconds to wake up if no data is received to give a chance to
187 | resample/replay packets properly and pass notifications up the chain
188 |
189 | - ``backfill_start`` (default: ``True``)
190 |
191 | Perform backfilling at the start. The maximum possible historical data
192 | will be fetched in a single request.
193 |
194 | - ``backfill`` (default: ``True``)
195 |
196 | Perform backfilling after a disconnection/reconnection cycle. The gap
197 | duration will be used to download the smallest possible amount of data
198 |
199 | - ``backfill_from`` (default: ``None``)
200 |
201 | An additional data source can be passed to do an initial layer of
202 | backfilling. Once the data source is depleted and if requested,
203 | backfilling from IB will take place. This is ideally meant to backfill
204 | from already stored sources like a file on disk, but not limited to.
205 |
206 | - ``latethrough`` (default: ``False``)
207 |
208 | If the data source is resampled/replayed, some ticks may come in too
209 | late for the already delivered resampled/replayed bar. If this is
210 | ``True`` those ticks will bet let through in any case.
211 |
212 | Check the Resampler documentation to see who to take those ticks into
213 | account.
214 |
215 | This can happen especially if ``timeoffset`` is set to ``False`` in
216 | the ``IBStore`` instance and the TWS server time is not in sync with
217 | that of the local computer
218 |
219 | - ``tradename`` (default: ``None``)
220 | Useful for some specific cases like ``CFD`` in which prices are offered
221 | by one asset and trading happens in a different onel
222 |
223 | - SPY-STK-SMART-USD -> SP500 ETF (will be specified as ``dataname``)
224 |
225 | - SPY-CFD-SMART-USD -> which is the corresponding CFD which offers not
226 | price tracking but in this case will be the trading asset (specified
227 | as ``tradename``)
228 |
229 | The default values in the params are the to allow things like ```TICKER``,
230 | to which the parameter ``sectype`` (default: ``STK``) and ``exchange``
231 | (default: ``SMART``) are applied.
232 |
233 | Some assets like ``AAPL`` need full specification including ``currency``
234 | (default: '') whereas others like ``TWTR`` can be simply passed as it is.
235 |
236 | - ``AAPL-STK-SMART-USD`` would be the full specification for dataname
237 |
238 | Or else: ``IBData`` as ``IBData(dataname='AAPL', currency='USD')``
239 | which uses the default values (``STK`` and ``SMART``) and overrides
240 | the currency to be ``USD``
241 | '''
242 | params = (
243 | ('secType', 'STK'), # usual industry value
244 | ('exchange', 'SMART'), # usual industry value
245 | ('primaryExchange', None), # native exchange of the contract
246 | ('right', None), # Option or Warrant Call('C') or Put('P')
247 | ('strike', None), # Future, Option or Warrant strike price
248 | ('multiplier', None), # Future, Option or Warrant multiplier
249 | ('expiry', None), # Future, Option or Warrant lastTradeDateOrContractMonth date
250 | ('currency', ''), # currency for the contract
251 | ('localSymbol', None), # Warrant localSymbol override
252 | ('rtbar', False), # use RealTime 5 seconds bars
253 | ('historical', False), # only historical download
254 | ('what', None), # historical - what to show
255 | ('useRTH', False), # historical - download only Regular Trading Hours
256 | ('qcheck', 0.5), # timeout in seconds (float) to check for events
257 | ('backfill_start', True), # do backfilling at the start
258 | ('backfill', True), # do backfilling when reconnecting
259 | ('backfill_from', None), # additional data source to do backfill from
260 | ('latethrough', False), # let late samples through
261 | ('tradename', None), # use a different asset as order target
262 | ('numberOfTicks', 1000), # Number of distinct data points. Max is 1000 per request.
263 | ('ignoreSize', False), # Omit updates that reflect only changes in size, and not price. Applicable to Bid_Ask data requests.
264 | )
265 |
266 | _store = ibstore.IBStore
267 |
268 | # Minimum size supported by real-time bars
269 | RTBAR_MINSIZE = (TimeFrame.Seconds, 5)
270 |
271 | # States for the Finite State Machine in _load
272 | _ST_FROM, _ST_START, _ST_LIVE, _ST_HISTORBACK, _ST_OVER = range(5)
273 |
274 | def _timeoffset(self):
275 | return self.ib.timeoffset()
276 |
277 | def _gettz(self):
278 | # If no object has been provided by the user and a timezone can be
279 | # found via contractdtails, then try to get it from pytz, which may or
280 | # may not be available.
281 |
282 | # The timezone specifications returned by TWS seem to be abbreviations
283 | # understood by pytz, but the full list which TWS may return is not
284 | # documented and one of the abbreviations may fail
285 | tzstr = isinstance(self.p.tz, string_types)
286 | if self.p.tz is not None and not tzstr:
287 | return bt.utils.date.Localizer(self.p.tz)
288 |
289 | if self.contractdetails is None:
290 | return None # nothing can be done
291 |
292 | try:
293 | import pytz # keep the import very local
294 | except ImportError:
295 | return None # nothing can be done
296 |
297 | tzs = self.p.tz if tzstr else self.contractdetails.timeZoneId
298 |
299 | if tzs == 'CST': # reported by TWS, not compatible with pytz. patch it
300 | tzs = 'CST6CDT'
301 |
302 | try:
303 | tz = pytz.timezone(tzs)
304 | except pytz.UnknownTimeZoneError:
305 | return None # nothing can be done
306 |
307 | # contractdetails there, import ok, timezone found, return it
308 | return tz
309 |
310 | def islive(self):
311 | '''Returns ``True`` to notify ``Cerebro`` that preloading and runonce
312 | should be deactivated'''
313 | return not self.p.historical
314 |
315 | def __init__(self, **kwargs):
316 | self.ib = self._store(**kwargs)
317 | self.precontract = self.parsecontract(self.p.dataname)
318 | self.pretradecontract = self.parsecontract(self.p.tradename)
319 |
320 | def setenvironment(self, env):
321 | '''Receives an environment (cerebro) and passes it over to the store it
322 | belongs to'''
323 | super(IBData, self).setenvironment(env)
324 | env.addstore(self.ib)
325 |
326 | def parsecontract(self, dataname):
327 | '''Parses dataname generates a default contract'''
328 | # Set defaults for optional tokens in the ticker string
329 | if dataname is None:
330 | return None
331 |
332 | exch = self.p.exchange
333 | primaryExch = self.p.primaryExchange
334 | curr = self.p.currency
335 | expiry = self.p.expiry
336 | strike = self.p.strike
337 | right = self.p.right
338 | mult = self.p.multiplier
339 | localSymbol = self.p.localSymbol
340 |
341 | # split the ticker string
342 | tokens = iter(dataname.split('-'))
343 |
344 | # Symbol and security type are compulsory
345 | symbol = next(tokens)
346 | try:
347 | sectype = next(tokens)
348 | except StopIteration:
349 | sectype = self.p.secType
350 |
351 | # security type can be an expiration date
352 | if sectype.isdigit():
353 | expiry = sectype # save the expiration ate
354 |
355 | if len(sectype) == 6: # YYYYMM
356 | sectype = 'FUT'
357 | else: # Assume OPTIONS - YYYYMMDD
358 | sectype = 'OPT'
359 |
360 | if sectype == 'CASH': # need to address currency for Forex
361 | symbol, curr = symbol.split('.')
362 |
363 | # See if the optional tokens were provided
364 | try:
365 | exch = next(tokens) # on exception it will be the default
366 | curr = next(tokens) # on exception it will be the default
367 |
368 | if sectype == 'FUT':
369 | if not expiry:
370 | expiry = next(tokens)
371 | mult = next(tokens)
372 |
373 | # Try to see if this is FOP - Futures on OPTIONS
374 | right = next(tokens)
375 | # if still here this is a FOP and not a FUT
376 | sectype = 'FOP'
377 | strike, mult = float(mult), '' # assign to strike and void
378 |
379 | mult = next(tokens) # try again to see if there is any
380 |
381 | elif sectype == 'OPT':
382 | if not expiry:
383 | expiry = next(tokens)
384 | strike = float(next(tokens)) # on exception - default
385 | right = next(tokens) # on exception it will be the default
386 |
387 | mult = next(tokens) # ?? no harm in any case
388 |
389 | except StopIteration:
390 | pass
391 |
392 | # Make the initial contract
393 | precon = self.ib.makecontract(
394 | symbol=symbol, sectype=sectype, exch=exch, curr=curr,
395 | expiry=expiry, strike=strike, right=right, mult=mult,
396 | primaryExch=primaryExch, localSymbol=localSymbol)
397 |
398 | return precon
399 |
400 | def start(self):
401 | '''Starts the IB connecction and gets the real contract and
402 | contractdetails if it exists'''
403 | super(IBData, self).start()
404 | # Kickstart store and get queue to wait on
405 | self.qlive = self.ib.start(data=self)
406 | self.qhist = None
407 |
408 | self._usertvol = not self.p.rtbar
409 | tfcomp = (self._timeframe, self._compression)
410 | if tfcomp < self.RTBAR_MINSIZE:
411 | # Requested timeframe/compression not supported by rtbars
412 | self._usertvol = True
413 |
414 | self.contract = None
415 | self.contractdetails = None
416 | self.tradecontract = None
417 | self.tradecontractdetails = None
418 |
419 | if self.p.backfill_from is not None:
420 | self._state = self._ST_FROM
421 | self.p.backfill_from.setenvironment(self._env)
422 | self.p.backfill_from._start()
423 | else:
424 | self._state = self._ST_START # initial state for _load
425 | self._statelivereconn = False # if reconnecting in live state
426 | self._subcription_valid = False # subscription state
427 | self._storedmsg = dict() # keep pending live message (under None)
428 |
429 | if not self.ib.connected():
430 | return
431 |
432 | self.put_notification(self.CONNECTED)
433 | # get real contract details with real conId (contractId)
434 | cds = self.ib.getContractDetails(self.precontract, maxcount=1)
435 | if cds is not None:
436 | cdetails = cds[0]
437 | self.contract = cdetails.contract
438 | self.contractdetails = cdetails
439 | else:
440 | # no contract can be found (or many)
441 | self.put_notification(self.DISCONNECTED)
442 | return
443 |
444 | if self.pretradecontract is None:
445 | # no different trading asset - default to standard asset
446 | self.tradecontract = self.contract
447 | self.tradecontractdetails = self.contractdetails
448 | else:
449 | # different target asset (typical of some CDS products)
450 | # use other set of details
451 | cds = self.ib.getContractDetails(self.pretradecontract, maxcount=1)
452 | if cds is not None:
453 | cdetails = cds[0]
454 | self.tradecontract = cdetails.contract
455 | self.tradecontractdetails = cdetails
456 | else:
457 | # no contract can be found (or many)
458 | self.put_notification(self.DISCONNECTED)
459 | return
460 |
461 | if self._state == self._ST_START:
462 | self._start_finish() # to finish initialization
463 | self._st_start()
464 |
465 | def stop(self):
466 | '''Stops and tells the store to stop'''
467 | super(IBData, self).stop()
468 | self.ib.stop()
469 |
470 | def reqdata(self):
471 | '''request real-time data. checks cash vs non-cash) and param useRT'''
472 | if self.contract is None or self._subcription_valid:
473 | return
474 |
475 | if self._usertvol and self._timeframe != bt.TimeFrame.Ticks:
476 | self.qlive = self.ib.reqMktData(self.contract, self.p.what)
477 | elif self._usertvol and self._timeframe == bt.TimeFrame.Ticks:
478 | self.qlive = self.ib.reqTickByTickData(self.contract, self.p.what)
479 | else:
480 | self.qlive = self.ib.reqRealTimeBars(self.contract, what = self.p.what)
481 |
482 | self._subcription_valid = True
483 | return self.qlive
484 |
485 | def canceldata(self):
486 | '''Cancels Market Data subscription, checking asset type and rtbar'''
487 | if self.contract is None:
488 | return
489 |
490 | if self._usertvol and self._timeframe != bt.TimeFrame.Ticks:
491 | self.ib.cancelMktData(self.qlive)
492 | elif self._usertvol and self._timeframe == bt.TimeFrame.Ticks:
493 | self.ib.cancelTickByTickData(self.qlive)
494 | else:
495 | self.ib.cancelRealTimeBars(self.qlive)
496 |
497 | def haslivedata(self):
498 | return bool(self._storedmsg or self.qlive)
499 |
500 | def _load(self):
501 | if self.contract is None or self._state == self._ST_OVER:
502 | return False # nothing can be done
503 |
504 | while True:
505 | if self._state == self._ST_LIVE:
506 | try:
507 | msg = (self._storedmsg.pop(None, None) or
508 | self.qlive.get(timeout=self._qcheck))
509 | except queue.Empty:
510 | if True:
511 | return None
512 |
513 | # Code invalidated until further checking is done
514 | # if not self._statelivereconn:
515 | # return None # indicate timeout situation
516 |
517 | # # Awaiting data and nothing came in - fake it up until now
518 | # dtend = self.num2date(date2num(datetime.datetime.utcnow()))
519 | # dtbegin = None
520 | # if len(self) > 1:
521 | # dtbegin = self.num2date(self.datetime[-1])
522 |
523 | # self.qhist = self.ib.reqHistoricalDataEx(
524 | # contract=self.contract,
525 | # enddate=dtend, begindate=dtbegin,
526 | # timeframe=self._timeframe,
527 | # compression=self._compression,
528 | # what=self.p.what, useRTH=self.p.useRTH, tz=self._tz,
529 | # sessionend=self.p.sessionend)
530 |
531 | # if self._laststatus != self.DELAYED:
532 | # self.put_notification(self.DELAYED)
533 |
534 | # self._state = self._ST_HISTORBACK
535 |
536 | # self._statelivereconn = False
537 | # continue # to reenter the loop and hit st_historback
538 |
539 | if msg is None: # Conn broken during historical/backfilling
540 | self._subcription_valid = False
541 | self.put_notification(self.CONNBROKEN)
542 | # Try to reconnect
543 | if not self.ib.reconnect(resub=True):
544 | self.put_notification(self.DISCONNECTED)
545 | return False # failed
546 |
547 | self._statelivereconn = self.p.backfill
548 | continue
549 |
550 | if msg == -504: # Conn broken during live
551 | self._subcription_valid = False
552 | self.put_notification(self.CONNBROKEN)
553 | # Try to reconnect
554 | if not self.ib.reconnect(resub=True):
555 | self.put_notification(self.DISCONNECTED)
556 | return False # failed
557 |
558 | # self._statelivereconn = self.p.backfill
559 | continue
560 |
561 | if msg == -354:
562 | self.put_notification(self.NOTSUBSCRIBED)
563 | return False
564 |
565 | elif msg == -1100: # conn broken
566 | # Tell to wait for a message to do a backfill
567 | # self._state = self._ST_DISCONN
568 | self._subcription_valid = False
569 | self._statelivereconn = self.p.backfill
570 | continue
571 |
572 | elif msg == -1102: # conn broken/restored tickerId maintained
573 | # The message may be duplicated
574 | if not self._statelivereconn:
575 | self._statelivereconn = self.p.backfill
576 | continue
577 |
578 | elif msg == -1101: # conn broken/restored tickerId gone
579 | # The message may be duplicated
580 | self._subcription_valid = False
581 | if not self._statelivereconn:
582 | self._statelivereconn = self.p.backfill
583 | self.reqdata() # resubscribe
584 | continue
585 |
586 | elif msg == -10225: # Bust event occurred, current subscription is deactivated.
587 | self._subcription_valid = False
588 | if not self._statelivereconn:
589 | self._statelivereconn = self.p.backfill
590 | self.reqdata() # resubscribe
591 | continue
592 |
593 | elif isinstance(msg, integer_types):
594 | # Unexpected notification for historical data skip it
595 | # May be a "not connected not yet processed"
596 | self.put_notification(self.UNKNOWN, msg)
597 | continue
598 |
599 | # Process the message according to expected return type
600 | if not self._statelivereconn:
601 | if self._laststatus != self.LIVE:
602 | if self.qlive.qsize() <= 1: # very short live queue
603 | self.put_notification(self.LIVE)
604 |
605 | if self._usertvol and self._timeframe != bt.TimeFrame.Ticks:
606 | ret = self._load_rtvolume(msg)
607 | elif self._usertvol and self._timeframe == bt.TimeFrame.Ticks:
608 | ret = self._load_rtticks(msg)
609 | else:
610 | ret = self._load_rtbar(msg)
611 | if ret:
612 | return True
613 |
614 | # could not load bar ... go and get new one
615 | continue
616 |
617 | # Fall through to processing reconnect - try to backfill
618 | self._storedmsg[None] = msg # keep the msg
619 |
620 | # else do a backfill
621 | if self._laststatus != self.DELAYED:
622 | self.put_notification(self.DELAYED)
623 |
624 | dtend = None
625 | if len(self) > 1:
626 | # len == 1 ... forwarded for the 1st time
627 | # get begin date in utc-like format like msg.datetime
628 | dtbegin = num2date(self.datetime[-1])
629 | elif self.fromdate > float('-inf'):
630 | dtbegin = num2date(self.fromdate)
631 | else: # 1st bar and no begin set
632 | # passing None to fetch max possible in 1 request
633 | dtbegin = None
634 |
635 | dtend = msg.datetime if self._usertvol else msg.time
636 |
637 | if self._timeframe != bt.TimeFrame.Ticks:
638 | self.qhist = self.ib.reqHistoricalDataEx(
639 | contract=self.contract, enddate=dtend, begindate=dtbegin,
640 | timeframe=self._timeframe, compression=self._compression,
641 | what=self.p.what, useRTH=self.p.useRTH, tz=self._tz,
642 | sessionend=self.p.sessionend)
643 | else:
644 | # dtend = num2date(dtend)
645 | self.qhist = self.ib.reqHistoricalTicksEx(
646 | contract=self.contract, enddate=dtend,
647 | what=self.p.what, useRTH=self.p.useRTH, tz=self._tz,
648 | )
649 |
650 | self._state = self._ST_HISTORBACK
651 | self._statelivereconn = False # no longer in live
652 | continue
653 |
654 | elif self._state == self._ST_HISTORBACK:
655 | try:
656 | msg = self.qhist.get(timeout=self.p.qcheck)
657 | except queue.Empty:
658 | if True:
659 | if self.p.historical: # only historical
660 | self.put_notification(self.DISCONNECTED)
661 | return False # end of historical
662 |
663 | # Live is also wished - go for it
664 | self._state = self._ST_LIVE
665 | continue
666 |
667 | if msg is None: # Conn broken during historical/backfilling
668 | # Situation not managed. Simply bail out
669 | self._subcription_valid = False
670 | self.put_notification(self.DISCONNECTED)
671 | return False # error management cancelled the queue
672 |
673 | elif msg == -354: # Data not subscribed
674 | self._subcription_valid = False
675 | self.put_notification(self.NOTSUBSCRIBED)
676 | return False
677 |
678 | elif msg == -420: # No permissions for the data
679 | self._subcription_valid = False
680 | self.put_notification(self.NOTSUBSCRIBED)
681 | return False
682 |
683 | elif isinstance(msg, integer_types):
684 | # Unexpected notification for historical data skip it
685 | # May be a "not connected not yet processed"
686 | self.put_notification(self.UNKNOWN, msg)
687 | continue
688 |
689 | if msg.date is not None:
690 | if self._timeframe == bt.TimeFrame.Ticks:
691 | if self._load_rtticks(msg, hist=True):
692 | return True
693 | else:
694 | if self._load_rtbar(msg, hist=True):
695 | return True # loading worked
696 |
697 | # the date is from overlapping historical request
698 | continue
699 |
700 | # End of histdata
701 | if self.p.historical: # only historical
702 | self.put_notification(self.DISCONNECTED)
703 | return False # end of historical
704 |
705 | # Live is also required - go for it
706 | self._state = self._ST_LIVE
707 | continue
708 |
709 | elif self._state == self._ST_FROM:
710 | if not self.p.backfill_from.next():
711 | # additional data source is consumed
712 | self._state = self._ST_START
713 | continue
714 |
715 | # copy lines of the same name
716 | for alias in self.lines.getlinealiases():
717 | lsrc = getattr(self.p.backfill_from.lines, alias)
718 | ldst = getattr(self.lines, alias)
719 |
720 | ldst[0] = lsrc[0]
721 |
722 | return True
723 |
724 | elif self._state == self._ST_START:
725 | if not self._st_start():
726 | return False
727 |
728 | def _st_start(self):
729 | if self.p.historical:
730 | self.put_notification(self.DELAYED)
731 | dtend = None
732 | if self.todate < float('inf'):
733 | dtend = num2date(self.todate)
734 |
735 | dtbegin = None
736 | if self.fromdate > float('-inf'):
737 | dtbegin = num2date(self.fromdate)
738 |
739 | if self._timeframe == bt.TimeFrame.Ticks:
740 | self.qhist = self.ib.reqHistoricalTicksEx(
741 | contract=self.contract, enddate=dtend, begindate=dtbegin,
742 | what=self.p.what, useRTH=self.p.useRTH, tz=self._tz)
743 | else:
744 | self.qhist = self.ib.reqHistoricalDataEx(
745 | contract=self.contract, enddate=dtend, begindate=dtbegin,
746 | timeframe=self._timeframe, compression=self._compression,
747 | what=self.p.what, useRTH=self.p.useRTH, tz=self._tz,
748 | sessionend=self.p.sessionend)
749 |
750 | self._state = self._ST_HISTORBACK
751 | return True # continue before
752 |
753 | # Live is requested
754 | if not self.ib.reconnect(resub=True):
755 | self.put_notification(self.DISCONNECTED)
756 | self._state = self._ST_OVER
757 | return False # failed - was so
758 |
759 | self._statelivereconn = self.p.backfill_start
760 | if self.p.backfill_start:
761 | self.put_notification(self.DELAYED)
762 |
763 | self._state = self._ST_LIVE
764 | return True # no return before - implicit continue
765 |
766 | def _load_rtbar(self, rtbar, hist=False):
767 | # A complete 5 second bar made of real-time ticks is delivered and
768 | # contains open/high/low/close/volume prices
769 | # The historical data has the same data but with 'date' instead of
770 | # 'time' for datetime
771 | dt = date2num(rtbar.time if not hist else rtbar.date)
772 | if dt < self.lines.datetime[-1] and not self.p.latethrough:
773 | return False # cannot deliver earlier than already delivered
774 |
775 | self.lines.datetime[0] = dt
776 | # Put the tick into the bar
777 | self.lines.open[0] = rtbar.open
778 | self.lines.high[0] = rtbar.high
779 | self.lines.low[0] = rtbar.low
780 | self.lines.close[0] = rtbar.close
781 | self.lines.volume[0] = rtbar.volume
782 | self.lines.openinterest[0] = 0
783 |
784 | return True
785 |
786 | def _load_rtvolume(self, rtvol):
787 | # A single tick is delivered and is therefore used for the entire set
788 | # of prices. Ideally the
789 | # contains open/high/low/close/volume prices
790 | # Datetime transformation
791 | dt = date2num(rtvol.datetime)
792 | if dt < self.lines.datetime[-1] and not self.p.latethrough:
793 | return False # cannot deliver earlier than already delivered
794 |
795 | self.lines.datetime[0] = dt
796 |
797 | # Put the tick into the bar
798 | tick = rtvol.price if rtvol.price else self.lines.close[-1]
799 | self.lines.open[0] = tick
800 | self.lines.high[0] = tick
801 | self.lines.low[0] = tick
802 | self.lines.close[0] = tick
803 | self.lines.volume[0] = rtvol.size if rtvol.size else self.lines.volume[-1]
804 | self.lines.openinterest[0] = 0
805 |
806 | return True
807 |
808 | def _load_rtticks(self, tick, hist=False):
809 |
810 | dt = date2num(tick.datetime if not hist else tick.date)
811 | if dt < self.lines.datetime[-1] and not self.p.latethrough:
812 | return False # cannot deliver earlier than already delivered
813 |
814 | self.lines.datetime[0] = dt
815 |
816 | if tick.dataType == 'RT_TICK_MIDPOINT':
817 | self.lines.close[0] = tick.midPoint
818 | elif tick.dataType == 'RT_TICK_BID_ASK':
819 | self.lines.open[0] = tick.bidPrice
820 | self.lines.close[0] = tick.askPrice
821 | self.lines.volume[0] = tick.bidSize
822 | self.lines.openinterest[0] = tick.askSize
823 | elif tick.dataType == 'RT_TICK_LAST':
824 | self.lines.close[0] = tick.price
825 | self.lines.volume[0] = tick.size
826 |
827 | return True
828 |
--------------------------------------------------------------------------------
/atreyu_backtrader_api/ibstore.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8; py-indent-offset:4 -*-
3 | ###############################################################################
4 | #
5 | # Copyright (C) 2015-2020 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 .
19 | #
20 | ###############################################################################
21 | from __future__ import (absolute_import, division, print_function,
22 | unicode_literals)
23 |
24 | # import functools
25 | import collections
26 | from copy import copy, deepcopy
27 | from datetime import date, datetime, timedelta
28 | import inspect
29 | import itertools
30 | import random
31 | import threading
32 | import time
33 |
34 | from backtrader import TimeFrame, Position
35 | from backtrader.metabase import MetaParams
36 | from backtrader.utils.py3 import bytes, bstr, queue, with_metaclass, long
37 | from backtrader.utils import AutoDict, UTC
38 |
39 | import bisect
40 |
41 | bytes = bstr # py2/3 need for ibpy
42 |
43 | from ibapi.client import EClient
44 | from ibapi.wrapper import EWrapper
45 | from ibapi.contract import Contract
46 | from ibapi.ticktype import TickTypeEnum
47 |
48 | import logging
49 | logger = logging.getLogger(__name__)
50 |
51 | ENABLE_DEBUG=True
52 |
53 | def _ts2dt(tstamp=None):
54 | # Transforms a RTVolume timestamp to a datetime object
55 | if not tstamp:
56 | return datetime.utcnow()
57 |
58 | sec, msec = divmod(long(tstamp), 1000)
59 | usec = msec * 1000
60 | return datetime.utcfromtimestamp(sec).replace(microsecond=usec)
61 |
62 | class ErrorMsg(object):
63 | def __init__(self, reqId, errorCode, errorString, advancedOrderRejectJson):
64 | self.vars = vars()
65 | del self.vars['self']
66 | self.reqId = reqId
67 | self.errorCode = errorCode
68 | self.errorString = errorString
69 | self.advancedOrderRejectJson = advancedOrderRejectJson
70 |
71 | def __str__(self):
72 | return f'{self.vars}'
73 |
74 | class OpenOrderMsg(object):
75 | def __init__(self, orderId, contract, order, orderState):
76 | self.vars = vars()
77 | del self.vars['self']
78 | self.orderId = orderId
79 | self.contract = contract
80 | self.order = order
81 | self.orderState = orderState
82 |
83 | def __str__(self):
84 | return f'{self.vars}'
85 |
86 | class OrderStatusMsg(object):
87 | def __init__(self, orderId , status, filled,
88 | remaining, avgFillPrice, permId,
89 | parentId, lastFillPrice, clientId,
90 | whyHeld, mktCapPrice):
91 | self.vars = vars()
92 | self.orderId = orderId
93 | self.status = status
94 | self.filled = filled
95 | self.remaining = remaining
96 | self.avgFillPrice = avgFillPrice
97 | self.permId = permId
98 | self.parentId = parentId
99 | self.lastFillPrice = lastFillPrice
100 | self.clientId = clientId
101 | self.whyHeld = whyHeld
102 | self.mktCapPrice = mktCapPrice
103 |
104 | def __str__(self):
105 | return f'{self.vars}'
106 |
107 | class RTVolume(object):
108 | '''Parses a tickString tickType 48 (RTVolume) event from the IB API into its
109 | constituent fields
110 |
111 | Supports using a "price" to simulate an RTVolume from a tickPrice event
112 | '''
113 | _fields = [
114 | ('price', float),
115 | ('size', float),
116 | ('datetime', _ts2dt),
117 | ('volume', float),
118 | ('vwap', float),
119 | ('single', bool)
120 | ]
121 |
122 | def __init__(self, rtvol='', price=None, tmoffset=None):
123 | self.vars = vars()
124 | # Use a provided string or simulate a list of empty tokens
125 | tokens = iter(rtvol.split(';'))
126 |
127 | # Put the tokens as attributes using the corresponding func
128 | for name, func in self._fields:
129 | setattr(self, name, func(next(tokens)) if rtvol else func())
130 |
131 | # If price was provided use it
132 | if price is not None:
133 | self.price = price
134 |
135 | if tmoffset is not None:
136 | self.datetime += tmoffset
137 |
138 | def __str__(self):
139 | return f'{self.vars}'
140 |
141 | class RTPrice(object):
142 | '''Set price from a tickPrice
143 | '''
144 | def __init__(self, price, tmoffset=None):
145 | self.vars = vars()
146 | # No size for tickPrice
147 | self.size = None
148 |
149 | # Set the price
150 | self.price = price
151 |
152 | # Set price to when we received it
153 | self.datetime = datetime.now()
154 |
155 | if tmoffset is not None:
156 | self.datetime += tmoffset
157 |
158 | def __str__(self):
159 | return f'{self.vars}'
160 |
161 | class RTSize(object):
162 | '''Set size from a tickSize
163 | '''
164 | def __init__(self, size, tmoffset=None):
165 | self.vars = vars()
166 | # No size for tickPrice
167 | self.price = None
168 |
169 | # Set the size
170 | self.size = size
171 |
172 | # Set price to when we received it
173 | self.datetime = datetime.now()
174 |
175 | if tmoffset is not None:
176 | self.datetime += tmoffset
177 |
178 | def __str__(self):
179 | return f'{self.vars}'
180 |
181 | class RTBar(object):
182 | '''Set realtimeBar object
183 | '''
184 | def __init__(self, reqId, time, open_, high, low, close, volume, wap, count):
185 | self.vars = vars()
186 | self.reqId = reqId
187 | self.time = time
188 | self.open = open_
189 | self.high = high
190 | self.low = low
191 | self.close = close
192 | self.volume = volume
193 | self.wap = wap
194 | self.count = count
195 |
196 | def __str__(self):
197 | return f'{self.vars}'
198 |
199 | class HistBar(object):
200 | '''Set historicalBar object
201 | '''
202 | def __init__(self, reqId, bar):
203 | self.vars = vars()
204 | self.reqId = reqId
205 | self.date = bar.date
206 | self.open = bar.open
207 | self.high = bar.high
208 | self.low = bar.low
209 | self.close = bar.close
210 | self.volume = bar.volume
211 | self.wap = bar.wap
212 | self.count = bar.barCount
213 |
214 | def __str__(self):
215 | return f'{self.vars}'
216 |
217 | class HistTick(object):
218 | '''Set historicalTick object: 'MIDPOINT', 'BID_ASK', 'TRADES'
219 | '''
220 | def __init__(self, tick, dataType):
221 | self.vars = vars()
222 | self.date = datetime.utcfromtimestamp(tick.time)
223 | self.tickType = tick.tickType if hasattr(tick, 'tickType') else int(0)
224 | self.dataType = dataType
225 | if dataType == 'RT_TICK_MIDPOINT':
226 | self.price = tick.price
227 | elif dataType == 'RT_TICK_LAST':
228 | self.price = tick.price
229 | self.size = float(tick.size)
230 | self.unreported = tick.tickAttribLast.unreported
231 | self.pastlimit = tick.tickAttribLast.pastLimit
232 | elif dataType == 'RT_TICK_BID_ASK':
233 | self.bidPrice = tick.priceBid
234 | self.askPrice = tick.priceAsk
235 | self.bidSize = float(tick.sizeBid)
236 | self.askSize = float(tick.sizeAsk)
237 |
238 | # self.exchange = tick.exchange
239 | # self.specialconditions = tick.tickAttribLast.specialConditions
240 |
241 | def __str__(self):
242 | return f'{self.vars}'
243 |
244 | class RTTickLast(object):
245 | '''Set realtimeTick object: 'TRADES'
246 | '''
247 | def __init__(self, tickType, time, price, size, tickAtrribLast, exchange, specialConditions):
248 | self.vars = vars()
249 | self.dataType = "RT_TICK_LAST"
250 | self.datetime = datetime.utcfromtimestamp(time)
251 | # self.tickType = TickTypeEnum.to_str(tickType)
252 | self.tickType = tickType
253 | self.price = price
254 | self.size = float(size)
255 | self.pastlimit = tickAtrribLast.pastLimit
256 | self.unreported = tickAtrribLast.unreported
257 | # self.exchange = exchange
258 | # self.specialConditions = specialConditions
259 |
260 | def __str__(self):
261 | return f'{self.vars}'
262 |
263 | class RTTickBidAsk(object):
264 | '''Set realtimeTick object: 'MIDPOINT', 'BID_ASK', 'TRADES'
265 | '''
266 | def __init__(self, time, bidPrice, askPrice, bidSize, askSize, tickAttribBidAsk):
267 | self.vars = vars()
268 | self.dataType = "RT_TICK_BID_ASK"
269 | self.datetime = datetime.utcfromtimestamp(time)
270 | self.bidPrice = bidPrice
271 | self.askPrice = askPrice
272 | self.bidSize = float(bidSize)
273 | self.askSize = float(askSize)
274 | self.bidPastLow = tickAttribBidAsk.bidPastLow
275 | self.askPastHigh = tickAttribBidAsk.askPastHigh
276 |
277 | def __str__(self):
278 | return f'{self.vars}'
279 |
280 | class RTTickMidPoint(object):
281 | '''Set realtimeTick object: 'MIDPOINT'
282 | '''
283 | def __init__(self, time, midPoint):
284 | self.vars = vars()
285 | self.dataType = "RT_TICK_MIDPOINT"
286 | self.datetime = datetime.utcfromtimestamp(time)
287 | self.midPoint = midPoint
288 |
289 | def __str__(self):
290 | return f'{self.vars}'
291 |
292 | class MetaSingleton(MetaParams):
293 | '''Metaclass to make a metaclassed class a singleton'''
294 | def __init__(cls, name, bases, dct):
295 | super(MetaSingleton, cls).__init__(name, bases, dct)
296 | cls._singleton = None
297 |
298 | def __call__(cls, *args, **kwargs):
299 | if cls._singleton is None:
300 | cls._singleton = (
301 | super(MetaSingleton, cls).__call__(*args, **kwargs))
302 |
303 | return cls._singleton
304 |
305 | def logibmsg(fn):
306 | def logmsg_decorator(self, *args, **kwargs):
307 | try:
308 | if self._debug:
309 | args_repr = [repr(a) for a in args]
310 | kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
311 | signature = ", ".join(args_repr + kwargs_repr)
312 | logger.debug(f"Calling {fn.__name__}({signature})")
313 | print(f"Calling {fn.__name__}({signature})")
314 | return fn(self, *args, **kwargs)
315 | except Exception as e:
316 | logger.exception(f"Exception raised in {fn.__name__}. exception: {str(e)}")
317 | raise e
318 |
319 | return logmsg_decorator
320 |
321 | class IBApi(EWrapper, EClient):
322 | def __init__(self, cb, _debug):
323 | EClient.__init__(self, self)
324 | EWrapper.__init__(self)
325 | self.cb = cb
326 | self._debug = _debug
327 |
328 | @logibmsg
329 | def currentTime(self, time):
330 | """ Server's current time. This method will receive IB server's system
331 | time resulting after the invokation of reqCurrentTime. """
332 | self.cb.currentTime(time)
333 |
334 | @logibmsg
335 | def updateAccountTime(self, timeStamp):
336 | logger.debug(f"timeStamp: {timeStamp}")
337 |
338 | @logibmsg
339 | def nextValidId(self, orderId):
340 | """ Receives next valid order id."""
341 | logger.debug(f"nextValidId: {orderId}")
342 | self.cb.nextValidId(orderId)
343 |
344 | @logibmsg
345 | def connectAck(self):
346 | """ callback signifying completion of successful connection """
347 | self.cb.connectAck()
348 |
349 | @logibmsg
350 | def connectionClosed(self):
351 | """This function is called when TWS closes the sockets
352 | connection with the ActiveX control, or when TWS is shut down."""
353 | logger.debug(f"connectionClosed")
354 | self.cb.connectionClosed()
355 |
356 | @logibmsg
357 | def managedAccounts(self, accountsList):
358 | """Receives a comma-separated string with the managed account ids."""
359 | self.cb.managedAccounts(accountsList)
360 |
361 | @logibmsg
362 | def accountDownloadEnd(self, accountName):
363 | """This is called after a batch updateAccountValue() and
364 | updatePortfolio() is sent."""
365 | self.cb.accountDownloadEnd(accountName)
366 |
367 | @logibmsg
368 | def updateAccountValue(self, key, val, currency, accountName):
369 | """ This function is called only when ReqAccountUpdates on
370 | EEClientSocket object has been called. """
371 | logger.debug(f"{key}, {val}, {currency}, {accountName}")
372 | self.cb.updateAccountValue(key, val, currency, accountName)
373 |
374 | @logibmsg
375 | def updatePortfolio(self, contract, position,
376 | marketPrice, marketValue,
377 | averageCost, unrealizedPNL,
378 | realizedPNL, accountName):
379 | """This function is called only when reqAccountUpdates on
380 | EEClientSocket object has been called."""
381 | self.cb.updatePortfolio(contract, position,
382 | marketPrice, marketValue,
383 | averageCost, unrealizedPNL,
384 | realizedPNL, accountName)
385 |
386 | @logibmsg
387 | def contractDetails(self, reqId, contractDetails):
388 | """Receives the full contract's definitions. This method will return all
389 | contracts matching the requested via EEClientSocket::reqContractDetails.
390 | For example, one can obtain the whole option chain with it."""
391 | self.cb.contractDetails(reqId, contractDetails)
392 |
393 | @logibmsg
394 | def contractDetailsEnd(self, reqId):
395 | """This function is called once all contract details for a given
396 | request are received. This helps to define the end of an option
397 | chain."""
398 | self.cb.contractDetailsEnd(reqId)
399 |
400 | @logibmsg
401 | def openOrder(self, orderId, contract, order, orderState):
402 | """This function is called to feed in open orders.
403 |
404 | orderID: OrderId - The order ID assigned by TWS. Use to cancel or
405 | update TWS order.
406 | contract: Contract - The Contract class attributes describe the contract.
407 | order: Order - The Order class gives the details of the open order.
408 | orderState: OrderState - The orderState class includes attributes Used
409 | for both pre and post trade margin and commission data."""
410 | self.cb.openOrder(OpenOrderMsg(orderId, contract, order, orderState))
411 |
412 | @logibmsg
413 | def openOrderEnd(self):
414 | """This is called at the end of a given request for open orders."""
415 | logger.debug(f"openOrderEnd")
416 | self.cb.openOrderEnd()
417 |
418 | @logibmsg
419 | def orderStatus(self, orderId , status, filled,
420 | remaining, avgFillPrice, permId,
421 | parentId, lastFillPrice, clientId,
422 | whyHeld, mktCapPrice):
423 | """This event is called whenever the status of an order changes. It is
424 | also fired after reconnecting to TWS if the client has any open orders.
425 |
426 | orderId: OrderId - The order ID that was specified previously in the
427 | call to placeOrder()
428 | status:str - The order status. Possible values include:
429 | PendingSubmit - indicates that you have transmitted the order, but have not yet received confirmation that it has been accepted by the order destination. NOTE: This order status is not sent by TWS and should be explicitly set by the API developer when an order is submitted.
430 | PendingCancel - indicates that you have sent a request to cancel the order but have not yet received cancel confirmation from the order destination. At this point, your order is not confirmed canceled. You may still receive an execution while your cancellation request is pending. NOTE: This order status is not sent by TWS and should be explicitly set by the API developer when an order is canceled.
431 | PreSubmitted - indicates that a simulated order type has been accepted by the IB system and that this order has yet to be elected. The order is held in the IB system until the election criteria are met. At that time the order is transmitted to the order destination as specified.
432 | Submitted - indicates that your order has been accepted at the order destination and is working.
433 | Cancelled - indicates that the balance of your order has been confirmed canceled by the IB system. This could occur unexpectedly when IB or the destination has rejected your order.
434 | Filled - indicates that the order has been completely filled.
435 | Inactive - indicates that the order has been accepted by the system (simulated orders) or an exchange (native orders) but that currently the order is inactive due to system, exchange or other issues.
436 | filled:int - Specifies the number of shares that have been executed.
437 | For more information about partial fills, see Order Status for Partial Fills.
438 | remaining:int - Specifies the number of shares still outstanding.
439 | avgFillPrice:float - The average price of the shares that have been executed. This parameter is valid only if the filled parameter value is greater than zero. Otherwise, the price parameter will be zero.
440 | permId:int - The TWS id used to identify orders. Remains the same over TWS sessions.
441 | parentId:int - The order ID of the parent order, used for bracket and auto trailing stop orders.
442 | lastFilledPrice:float - The last price of the shares that have been executed. This parameter is valid only if the filled parameter value is greater than zero. Otherwise, the price parameter will be zero.
443 | clientId:int - The ID of the client (or TWS) that placed the order. Note that TWS orders have a fixed clientId and orderId of 0 that distinguishes them from API orders.
444 | whyHeld:str - This field is used to identify an order held when TWS is trying to locate shares for a short sell. The value used to indicate this is 'locate'.
445 |
446 | """
447 | self.cb.orderStatus(OrderStatusMsg(orderId , status, filled,
448 | remaining, avgFillPrice, permId,
449 | parentId, lastFillPrice, clientId,
450 | whyHeld, mktCapPrice))
451 |
452 | @logibmsg
453 | def commissionReport(self, commissionReport):
454 | """The commissionReport() callback is triggered as follows:
455 | - immediately after a trade execution
456 | - by calling reqExecutions()."""
457 | self.cb.commissionReport(commissionReport)
458 |
459 | @logibmsg
460 | def error(self, reqId, errorCode, errorString, advancedOrderRejectJson = ""):
461 | self.cb.error(ErrorMsg(reqId, errorCode, errorString, advancedOrderRejectJson))
462 |
463 | @logibmsg
464 | def position(self, account, contract, pos, avgCost):
465 | """This event returns real-time positions for all accounts in
466 | response to the reqPositions() method."""
467 | self.cb.position(account, contract, pos, avgCost)
468 |
469 | @logibmsg
470 | def positionEnd(self):
471 | """This is called once all position data for a given request are
472 | received and functions as an end marker for the position() data. """
473 | self.cb.positionEnd()
474 |
475 | @logibmsg
476 | def tickPrice(self, reqId, tickType, price, attrib):
477 | """Market data tick price callback. Handles all price related ticks."""
478 | self.cb.tickPrice(reqId, tickType, price, attrib)
479 |
480 | @logibmsg
481 | def tickSize(self, reqId, tickType, size):
482 | """Market data tick size callback. Handles all size-related ticks."""
483 | self.cb.tickSize(reqId, tickType, size)
484 |
485 | @logibmsg
486 | def tickGeneric(self, reqId, tickType, value):
487 | self.cb.tickGeneric(reqId, tickType, value)
488 |
489 | @logibmsg
490 | def realtimeBar(self, reqId, time, open_, high, low, close, volume, wap, count):
491 | self.cb.realtimeBar(RTBar(reqId, time, open_, high, low, close, float(volume), wap, count))
492 |
493 | @logibmsg
494 | def historicalData(self, reqId, bar):
495 | self.cb.historicalData(HistBar(reqId, bar))
496 |
497 | @logibmsg
498 | def historicalDataUpdate(self, reqId, bar):
499 | '''Not implemented'''
500 | pass
501 |
502 | @logibmsg
503 | def historicalDataEnd(self, reqId, start, end):
504 | """ Marks the ending of the historical bars reception. """
505 | self.cb.historicalDataEnd(reqId, start, end)
506 |
507 | @logibmsg
508 | def execDetails(self, reqId, contract, execution):
509 | """This event is fired when the reqExecutions() functions is
510 | invoked, or when an order is filled. """
511 | self.cb.execDetails(reqId, contract, execution)
512 |
513 | @logibmsg
514 | def execDetailsEnd(self, reqId):
515 | """This function is called once all executions have been sent to
516 | a client in response to reqExecutions()."""
517 | pass
518 |
519 | @logibmsg
520 | def historicalTicks(self, reqId, ticks, done):
521 | """For whatToShow=MIDPOINT
522 | """
523 | for tick in ticks:
524 | self.cb.historicalTicks(reqId, HistTick(tick, 'RT_TICK_MIDPOINT'))
525 |
526 | @logibmsg
527 | def historicalTicksBidAsk(self, reqId, ticks, done):
528 | """returns historical tick data when whatToShow=BID_ASK"""
529 | for tick in ticks:
530 | self.cb.historicalTicks(reqId, HistTick(tick, 'RT_TICK_BID_ASK'))
531 |
532 | @logibmsg
533 | def historicalTicksLast(self, reqId, ticks, done):
534 | """returns tick-by-tick data for tickType = "Last" or "AllLast" """
535 | for tick in ticks:
536 | self.cb.historicalTicks(reqId, HistTick(tick, 'RT_TICK_LAST'))
537 |
538 | @logibmsg
539 | def tickByTickAllLast(self, reqId, tickType, time, price, size, tickAtrribLast, exchange, specialConditions):
540 | """returns tick-by-tick data for tickType = "Last" or "AllLast" """
541 | self.cb.tickByTickAllLast(reqId, tickType, time, price, size, tickAtrribLast, exchange, specialConditions)
542 |
543 | @logibmsg
544 | def tickByTickBidAsk(self, reqId, time, bidPrice, askPrice, bidSize, askSize, tickAttribBidAsk):
545 | """returns tick-by-tick data for tickType = "BidAsk" """
546 | self.cb.tickByTickBidAsk(reqId, time, bidPrice, askPrice, bidSize, askSize, tickAttribBidAsk)
547 |
548 | @logibmsg
549 | def tickByTickMidPoint(self, reqId, time, midPoint):
550 | """returns tick-by-tick data for tickType = "MidPoint" """
551 | self.cb.tickByTickBidAsk(reqId, time, midPoint)
552 |
553 | @logibmsg
554 | def tickString(self, reqId, tickType, value):
555 | self.cb.tickString(reqId, tickType, value)
556 |
557 |
558 | class IBStore(with_metaclass(MetaSingleton, object)):
559 | '''Singleton class wrapping an ibpy ibConnection instance.
560 |
561 | The parameters can also be specified in the classes which use this store,
562 | like ``IBData`` and ``IBBroker``
563 |
564 | Params:
565 |
566 | - ``host`` (default:``127.0.0.1``): where IB TWS or IB Gateway are
567 | actually running. And although this will usually be the localhost, it
568 | must not be
569 |
570 | - ``port`` (default: ``7496``): port to connect to. The demo system uses
571 | ``7497``
572 |
573 | - ``clientId`` (default: ``None``): which clientId to use to connect to
574 | TWS.
575 |
576 | ``None``: generates a random id between 1 and 65535
577 | An ``integer``: will be passed as the value to use.
578 |
579 | - ``notifyall`` (default: ``False``)
580 |
581 | If ``False`` only ``error`` messages will be sent to the
582 | ``notify_store`` methods of ``Cerebro`` and ``Strategy``.
583 |
584 | If ``True``, each and every message received from TWS will be notified
585 |
586 | - ``_debug`` (default: ``False``)
587 |
588 | Print all messages received from TWS as info output
589 |
590 | - ``reconnect`` (default: ``3``)
591 |
592 | Number of attempts to try to reconnect after the 1st connection attempt
593 | fails
594 |
595 | Set it to a ``-1`` value to keep on reconnecting forever
596 |
597 | - ``timeout`` (default: ``3.0``)
598 |
599 | Time in seconds between reconnection attemps
600 |
601 | - ``timeoffset`` (default: ``True``)
602 |
603 | If True, the time obtained from ``reqCurrentTime`` (IB Server time)
604 | will be used to calculate the offset to localtime and this offset will
605 | be used for the price notifications (tickPrice events, for example for
606 | CASH markets) to modify the locally calculated timestamp.
607 |
608 | The time offset will propagate to other parts of the ``backtrader``
609 | ecosystem like the **resampling** to align resampling timestamps using
610 | the calculated offset.
611 |
612 | - ``timerefresh`` (default: ``60.0``)
613 |
614 | Time in seconds: how often the time offset has to be refreshed
615 |
616 | - ``indcash`` (default: ``True``)
617 |
618 | Manage IND codes as if they were cash for price retrieval
619 | '''
620 |
621 | # Set a base for the data requests (historical/realtime) to distinguish the
622 | # id in the error notifications from orders, where the basis (usually
623 | # starting at 1) is set by TWS
624 | REQIDBASE = 0x01000000
625 |
626 | BrokerCls = None # broker class will autoregister
627 | DataCls = None # data class will auto register
628 |
629 | params = (
630 | ('host', '127.0.0.1'),
631 | ('port', 7496),
632 | ('clientId', None), # None generates a random clientid 1 -> 2^16
633 | ('broker_host', ''),
634 | ('broker_request_port', 12345),
635 | ('broker_subscribe_port', 12345),
636 | ('broker_user_name', ''),
637 | ('broker_password', ''),
638 | ('notifyall', False),
639 | ('_debug', False),
640 | ('reconnect', 3), # -1 forever, 0 No, > 0 number of retries
641 | ('timeout', 3.0), # timeout between reconnections
642 | ('timeoffset', True), # Use offset to server for timestamps if needed
643 | ('timerefresh', 60.0), # How often to refresh the timeoffset
644 | ('indcash', True), # Treat IND codes as CASH elements
645 | )
646 |
647 | @classmethod
648 | def getdata(cls, *args, **kwargs):
649 | '''Returns ``DataCls`` with args, kwargs'''
650 | return cls.DataCls(*args, **kwargs)
651 |
652 | @classmethod
653 | def getbroker(cls, *args, **kwargs):
654 | '''Returns broker with *args, **kwargs from registered ``BrokerCls``'''
655 | return cls.BrokerCls(*args, **kwargs)
656 |
657 | def __init__(self):
658 | super(IBStore, self).__init__()
659 |
660 | self._lock_q = threading.Lock() # sync access to _tickerId/Queues
661 | self._lock_accupd = threading.Lock() # sync account updates
662 | self._lock_pos = threading.Lock() # sync account updates
663 | self._lock_notif = threading.Lock() # sync access to notif queue
664 | self._updacclock = threading.Lock() # sync account updates
665 |
666 | # Account list received
667 | self._event_managed_accounts = threading.Event()
668 | self._event_accdownload = threading.Event()
669 |
670 | self.dontreconnect = False # for non-recoverable connect errors
671 |
672 | self._env = None # reference to cerebro for general notifications
673 | self.broker = None # broker instance
674 | self.datas = list() # datas that have registered over start
675 | self.ccount = 0 # requests to start (from cerebro or datas)
676 |
677 | self._lock_tmoffset = threading.Lock()
678 | self.tmoffset = timedelta() # to control time difference with server
679 |
680 | # Structures to hold datas requests
681 | self.qs = collections.OrderedDict() # key: tickerId -> queues
682 | self.ts = collections.OrderedDict() # key: queue -> tickerId
683 | self.iscash = dict() # tickerIds from cash products (for ex: EUR.JPY)
684 |
685 | self.histexreq = dict() # holds segmented historical requests
686 | self.histfmt = dict() # holds datetimeformat for request
687 | self.histsend = dict() # holds sessionend (data time) for request
688 | self.histtz = dict() # holds sessionend (data time) for request
689 |
690 | self.acc_cash = AutoDict() # current total cash per account
691 | self.acc_value = AutoDict() # current total value per account
692 | self.acc_upds = AutoDict() # current account valueinfos per account
693 |
694 | self.port_update = False # indicate whether to signal to broker
695 |
696 | self.positions = collections.defaultdict(Position) # actual positions
697 |
698 | self._tickerId = itertools.count(self.REQIDBASE) # unique tickerIds
699 | self.orderid = None # next possible orderid (will be itertools.count)
700 |
701 | self.cdetails = collections.defaultdict(list) # hold cdetails requests
702 |
703 | self.managed_accounts = list() # received via managedAccounts
704 |
705 | self.notifs = queue.Queue() # store notifications for cerebro
706 |
707 | # Use the provided clientId or a random one
708 | if self.p.clientId is None:
709 | self.clientId = random.randint(1, pow(2, 16) - 1)
710 | else:
711 | self.clientId = self.p.clientId
712 |
713 | self._debug = self.p._debug
714 | # ibpy connection object
715 | try:
716 | self.conn = IBApi(self, self._debug)
717 | self.conn.connect(self.p.host, self.p.port, self.clientId)
718 | self.apiThread = threading.Thread(target=self.conn.run, daemon=True)
719 | self.apiThread.start()
720 | except Exception as e:
721 | print(f"TWS Failed to connect: {e}")
722 |
723 | # This utility key function transforms a barsize into a:
724 | # (Timeframe, Compression) tuple which can be sorted
725 | def keyfn(x):
726 | n, t = x.split()
727 | tf, comp = self._sizes[t]
728 | return (tf, int(n) * comp)
729 |
730 | # This utility key function transforms a duration into a:
731 | # (Timeframe, Compression) tuple which can be sorted
732 | def key2fn(x):
733 | n, d = x.split()
734 | tf = self._dur2tf[d]
735 | return (tf, int(n))
736 |
737 | # Generate a table of reverse durations
738 | self.revdur = collections.defaultdict(list)
739 | # The table (dict) is a ONE to MANY relation of
740 | # duration -> barsizes
741 | # Here it is reversed to get a ONE to MANY relation of
742 | # barsize -> durations
743 | for duration, barsizes in self._durations.items():
744 | for barsize in barsizes:
745 | self.revdur[keyfn(barsize)].append(duration)
746 |
747 | # Once managed, sort the durations according to real duration and not
748 | # to the text form using the utility key above
749 | for barsize in self.revdur:
750 | self.revdur[barsize].sort(key=key2fn)
751 |
752 |
753 | def start(self, data=None, broker=None):
754 | logger.info(f"START data: {data} broker: {broker}")
755 | self.reconnect(fromstart=True) # reconnect should be an invariant
756 |
757 | # Datas require some processing to kickstart data reception
758 | if data is None and broker is None:
759 | self.cash = None
760 | return
761 |
762 | # Datas require some processing to kickstart data reception
763 | if data is not None:
764 | self._env = data._env
765 | # For datas simulate a queue with None to kickstart co
766 | self.datas.append(data)
767 |
768 | # if connection fails, get a fake registration that will force the
769 | # datas to try to reconnect or else bail out
770 | return self.getTickerQueue(start=True)
771 |
772 | elif broker is not None:
773 | self.broker = broker
774 |
775 |
776 | def stop(self):
777 | try:
778 | self.conn.disconnect() # disconnect should be an invariant
779 | except AttributeError:
780 | pass # conn may have never been connected and lack "disconnect"
781 |
782 | # Unblock any calls set on these events
783 | self._event_managed_accounts.set()
784 | self._event_accdownload.set()
785 |
786 | # @logibmsg
787 | def connected(self):
788 | # The isConnected method is available through __getattr__ indirections
789 | # and may not be present, which indicates that no connection has been
790 | # made because the subattribute sender has not yet been created, hence
791 | # the check for the AttributeError exception
792 | try:
793 | return self.conn.isConnected()
794 | except AttributeError:
795 | pass
796 |
797 | return False # non-connected (including non-initialized)
798 |
799 | # @logibmsg
800 | def reconnect(self, fromstart=False, resub=False):
801 | # This method must be an invariant in that it can be called several
802 | # times from the same source and must be consistent. An exampler would
803 | # be 5 datas which are being received simultaneously and all request a
804 | # reconnect
805 |
806 | # Policy:
807 | # - if dontreconnect has been set, no option to connect is possible
808 | # - check connection and use the absence of isConnected as signal of
809 | # first ever connection (add 1 to retries too)
810 | # - Calculate the retries (forever or not)
811 | # - Try to connct
812 | # - If achieved and fromstart is false, the datas will be
813 | # re-kickstarted to recreate the subscription
814 | firstconnect = False
815 | try:
816 | if self.conn.isConnected():
817 | if resub:
818 | self.startdatas()
819 | return True # nothing to do
820 | except AttributeError:
821 | # Not connected, several __getattr__ indirections to
822 | # self.conn.sender.client.isConnected
823 | firstconnect = True
824 |
825 | if self.dontreconnect:
826 | return False
827 |
828 | # This is only invoked from the main thread by datas and therefore no
829 | # lock is needed to control synchronicity to it
830 | retries = self.p.reconnect
831 | if retries >= 0:
832 | retries += firstconnect
833 |
834 | while retries < 0 or retries:
835 | logger.debug(f"Retries: {retries}")
836 | if not firstconnect:
837 | logger.debug(f"Reconnect in {self.p.timeout} secs")
838 | time.sleep(self.p.timeout)
839 |
840 | firstconnect = False
841 |
842 | try:
843 | logger.debug("Connect (host={self.p.host}, port={self.p.port}, clientId={self.clientId})")
844 | if self.conn.connect(self.p.host, self.p.port, self.clientId):
845 | if not fromstart or resub:
846 | self.startdatas()
847 | return True # connection successful
848 | except Exception as e:
849 | logger.exception(f"Failed to Connect {e}")
850 | return False
851 |
852 | if retries > 0:
853 | retries -= 1
854 |
855 | self.dontreconnect = True
856 | return False # connection/reconnection failed
857 |
858 |
859 | def startdatas(self):
860 | # kickstrat datas, not returning until all of them have been done
861 | ts = list()
862 | for data in self.datas:
863 | t = threading.Thread(target=data.reqdata)
864 | t.start()
865 | ts.append(t)
866 |
867 | for t in ts:
868 | t.join()
869 |
870 | @logibmsg
871 | def stopdatas(self):
872 | # stop subs and force datas out of the loop (in LIFO order)
873 | logger.debug(f"Stopping datas")
874 | qs = list(self.qs.values())
875 | ts = list()
876 | for data in self.datas:
877 | t = threading.Thread(target=data.canceldata)
878 | t.start()
879 | ts.append(t)
880 |
881 | for t in ts:
882 | t.join()
883 |
884 | for q in reversed(qs): # datamaster the last one to get a None
885 | q.put(None)
886 |
887 |
888 | def get_notifications(self):
889 | '''Return the pending "store" notifications'''
890 | # The background thread could keep on adding notifications. The None
891 | # mark allows to identify which is the last notification to deliver
892 | self.notifs.put(None) # put a mark
893 | notifs = list()
894 | while True:
895 | notif = self.notifs.get()
896 | if notif is None: # mark is reached
897 | break
898 | notifs.append(notif)
899 |
900 | return notifs
901 |
902 | def error(self, msg):
903 | # 100-199 Order/Data/Historical related
904 | # 200-203 tickerId and Order Related
905 | # 300-399 A mix of things: orders, connectivity, tickers, misc errors
906 | # 400-449 Seem order related again
907 | # 500-531 Connectivity/Communication Errors
908 | # 10000-100027 Mix of special orders/routing
909 | # 1100-1102 TWS connectivy to the outside
910 | # 1300- Socket dropped in client-TWS communication
911 | # 2100-2110 Informative about Data Farm status (id=-1)
912 |
913 | # All errors are logged to the environment (cerebro), because many
914 | # errors in Interactive Brokers are actually informational and many may
915 | # actually be of interest to the user
916 | if msg.reqId > 0:
917 | logger.error(f"{msg}")
918 | print(f"Error: {msg}")
919 | else:
920 | logger.debug(f"{msg}")
921 |
922 | if msg.reqId == -1 and msg.errorCode == 502:
923 | print(msg.errorString)
924 |
925 | if not self.p.notifyall:
926 | self.notifs.put((msg, tuple(vars(msg).values()), dict(vars(msg).items())))
927 |
928 | # Manage those events which have to do with connection
929 | if msg.errorCode is None:
930 | # Usually received as an error in connection of just before disconn
931 | pass
932 | elif msg.errorCode in [200, 203, 162, 320, 321, 322]:
933 | # cdetails 200 security not found, notify over right queue
934 | # cdetails 203 security not allowed for acct
935 | try:
936 | q = self.qs[msg.reqId]
937 | except KeyError:
938 | pass # should not happend but it can
939 | else:
940 | logger.warn(f"Cancel data queue for {msg.reqId}")
941 | self.cancelQueue(q, True)
942 |
943 | elif msg.errorCode in [354, 420]:
944 | # 354 no subscription, 420 no real-time bar for contract
945 | # the calling data to let the data know ... it cannot resub
946 | try:
947 | q = self.qs[msg.reqId]
948 | except KeyError:
949 | pass # should not happend but it can
950 | else:
951 | q.put(-msg.errorCode)
952 | logger.warn(f"Cancel data queue for {msg.reqId}")
953 | self.cancelQueue(q)
954 |
955 | elif msg.errorCode == 10225:
956 | # 10225-Bust event occurred, current subscription is deactivated.
957 | # Please resubscribe real-time bars immediately.
958 | try:
959 | q = self.qs[msg.reqId]
960 | except KeyError:
961 | pass # should not happend but it can
962 | else:
963 | q.put(-msg.errorCode)
964 |
965 | elif msg.errorCode == 326: # not recoverable, clientId in use
966 | self.dontreconnect = True
967 | self.conn.disconnect()
968 | self.stopdatas()
969 |
970 | elif msg.errorCode == 502:
971 | # Cannot connect to TWS: port, config not open, tws off (504 then)
972 | self.conn.disconnect()
973 | self.stopdatas()
974 |
975 | elif msg.errorCode == 504: # Not Connected for data op
976 | # Once for each data
977 | # pass # don't need to manage it
978 |
979 | # Connection lost - Notify ... datas will wait on the queue
980 | # with no messages arriving
981 | for q in self.ts: # key: queue -> ticker
982 | q.put(-msg.errorCode)
983 |
984 | elif msg.errorCode == 1300:
985 | # TWS has been closed. The port for a new connection is there
986 | # newport = int(msg.errorMsg.split('-')[-1]) # bla bla bla -7496
987 | self.conn.disconnect()
988 | self.stopdatas()
989 |
990 | elif msg.errorCode == 1100:
991 | # Connection lost - Notify ... datas will wait on the queue
992 | # with no messages arriving
993 | for q in self.ts: # key: queue -> ticker
994 | q.put(-msg.errorCode)
995 |
996 | elif msg.errorCode == 1101:
997 | # Connection restored and tickerIds are gone
998 | for q in self.ts: # key: queue -> ticker
999 | q.put(-msg.errorCode)
1000 |
1001 | elif msg.errorCode == 1102:
1002 | # Connection restored and tickerIds maintained
1003 | for q in self.ts: # key: queue -> ticker
1004 | q.put(-msg.errorCode)
1005 |
1006 | elif msg.errorCode < 500:
1007 | # Given the myriad of errorCodes, start by assuming is an order
1008 | # error and if not, the checks there will let it go
1009 | if msg.reqId < self.REQIDBASE:
1010 | if self.broker is not None:
1011 | self.broker.push_ordererror(msg)
1012 | else:
1013 | # Cancel the queue if a "data" reqId error is given: sanity
1014 | q = self.qs[msg.reqId]
1015 | logger.warn(f"Cancel data queue for {msg.reqId}")
1016 | self.cancelQueue(q, True)
1017 |
1018 |
1019 | def connectionClosed(self):
1020 | # Sometmes this comes without 1300/502 or any other and will not be
1021 | # seen in error hence the need to manage the situation independently
1022 | if self.connected():
1023 | self.conn.disconnect()
1024 | self.stopdatas()
1025 |
1026 | def updateAccountTime(self, timeStamp):
1027 | logger.debug(f"timeStamp: {timeStamp}")
1028 |
1029 | def connectAck(self):
1030 | logger.debug(f"connectAck")
1031 |
1032 | def managedAccounts(self, accountsList):
1033 | # 1st message in the stream
1034 | self.managed_accounts = accountsList.split(',')
1035 | self._event_managed_accounts.set()
1036 |
1037 | # Request time to avoid synchronization issues
1038 | self.reqCurrentTime()
1039 |
1040 | @logibmsg
1041 | def reqCurrentTime(self):
1042 | self.conn.reqCurrentTime()
1043 |
1044 | def currentTime(self, time):
1045 | if not self.p.timeoffset: # only if requested ... apply timeoffset
1046 | return
1047 | curtime = datetime.fromtimestamp(float(time))
1048 | with self._lock_tmoffset:
1049 | self.tmoffset = curtime - datetime.now()
1050 |
1051 | threading.Timer(self.p.timerefresh, self.reqCurrentTime).start()
1052 |
1053 | def timeoffset(self):
1054 | with self._lock_tmoffset:
1055 | return self.tmoffset
1056 |
1057 | def nextTickerId(self):
1058 | # Get the next ticker using next on the itertools.count
1059 | return next(self._tickerId)
1060 |
1061 | def nextValidId(self, orderId):
1062 | # Create a counter from the TWS notified value to apply to orders
1063 | self.orderid = itertools.count(orderId)
1064 |
1065 | def nextOrderId(self):
1066 | # Get the next ticker using next on the itertools.count made with the
1067 | # notified value from TWS
1068 | return next(self.orderid)
1069 |
1070 | def reuseQueue(self, tickerId):
1071 | '''Reuses queue for tickerId, returning the new tickerId and q'''
1072 | with self._lock_q:
1073 | # Invalidate tickerId in qs (where it is a key)
1074 | q = self.qs.pop(tickerId, None) # invalidate old
1075 | iscash = self.iscash.pop(tickerId, None)
1076 |
1077 | # Update ts: q -> ticker
1078 | tickerId = self.nextTickerId() # get new tickerId
1079 | self.ts[q] = tickerId # Update ts: q -> tickerId
1080 | self.qs[tickerId] = q # Update qs: tickerId -> q
1081 | self.iscash[tickerId] = iscash
1082 |
1083 | return tickerId, q
1084 |
1085 | def getTickerQueue(self, start=False):
1086 | '''Creates ticker/Queue for data delivery to a data feed'''
1087 | q = queue.Queue()
1088 | if start:
1089 | q.put(None)
1090 | return q
1091 |
1092 | with self._lock_q:
1093 | tickerId = self.nextTickerId()
1094 | self.qs[tickerId] = q # can be managed from other thread
1095 | self.ts[q] = tickerId
1096 | self.iscash[tickerId] = False
1097 |
1098 | return tickerId, q
1099 |
1100 | def cancelQueue(self, q, sendnone=False):
1101 | '''Cancels a Queue for data delivery'''
1102 | # pop ts (tickers) and with the result qs (queues)
1103 | tickerId = self.ts.pop(q, None)
1104 | self.qs.pop(tickerId, None)
1105 |
1106 | self.iscash.pop(tickerId, None)
1107 |
1108 | if sendnone:
1109 | q.put(None)
1110 |
1111 | def validQueue(self, q):
1112 | '''Returns (bool) if a queue is still valid'''
1113 | return q in self.ts # queue -> ticker
1114 |
1115 | def getContractDetails(self, contract, maxcount=None):
1116 | cds = list()
1117 | q = self.reqContractDetails(contract)
1118 | while True:
1119 | msg = q.get()
1120 | if msg is None:
1121 | break
1122 | cds.append(msg)
1123 |
1124 | if not cds or (maxcount and len(cds) > maxcount):
1125 | err = 'Ambiguous contract: none/multiple answers received'
1126 | self.notifs.put((err, cds, {}))
1127 | return None
1128 |
1129 | return cds
1130 |
1131 | def reqContractDetails(self, contract):
1132 | # get a ticker/queue for identification/data delivery
1133 | tickerId, q = self.getTickerQueue()
1134 | self.conn.reqContractDetails(tickerId, contract)
1135 | return q
1136 |
1137 | def contractDetailsEnd(self, reqId):
1138 | '''Signal end of contractdetails'''
1139 | logger.debug(f"Cancel data queue tickerId: {reqId} Q: {self.qs[reqId]}")
1140 | self.cancelQueue(self.qs[reqId], True)
1141 |
1142 | def contractDetails(self, reqId, contractDetails):
1143 | '''Receive answer and pass it to the queue'''
1144 | self.qs[reqId].put(contractDetails)
1145 |
1146 | @logibmsg
1147 | def reqHistoricalDataEx(self, contract, enddate, begindate,
1148 | timeframe, compression,
1149 | what=None, useRTH=False, tz='', sessionend=None,
1150 | tickerId=None):
1151 | '''
1152 | Extension of the raw reqHistoricalData proxy, which takes two dates
1153 | rather than a duration, barsize and date
1154 |
1155 | It uses the IB published valid duration/barsizes to make a mapping and
1156 | spread a historical request over several historical requests if needed
1157 | '''
1158 | # Keep a copy for error reporting purposes
1159 | kwargs = locals().copy()
1160 | kwargs.pop('self', None) # remove self, no need to report it
1161 |
1162 | if timeframe < TimeFrame.Seconds:
1163 | # Ticks are not supported
1164 | return self.getTickerQueue(start=True)
1165 |
1166 | if enddate is None:
1167 | enddate = datetime.now()
1168 |
1169 | if begindate is None:
1170 | duration = self.getmaxduration(timeframe, compression)
1171 | if duration is None:
1172 | err = ('No duration for historical data request for '
1173 | 'timeframe/compresison')
1174 | self.notifs.put((err, (), kwargs))
1175 | return self.getTickerQueue(start=True)
1176 | barsize = self.tfcomp_to_size(timeframe, compression)
1177 | if barsize is None:
1178 | err = ('No supported barsize for historical data request for '
1179 | 'timeframe/compresison')
1180 | self.notifs.put((err, (), kwargs))
1181 | return self.getTickerQueue(start=True)
1182 |
1183 | return self.reqHistoricalData(contract=contract, enddate=enddate,
1184 | duration=duration, barsize=barsize,
1185 | what=what, useRTH=useRTH, tz=tz,
1186 | sessionend=sessionend)
1187 |
1188 | # Check if the requested timeframe/compression is supported by IB
1189 | durations = self.getdurations(timeframe, compression)
1190 | if not durations: # return a queue and put a None in it
1191 | return self.getTickerQueue(start=True)
1192 |
1193 | # Get or reuse a queue
1194 | if tickerId is None:
1195 | tickerId, q = self.getTickerQueue()
1196 | logger.debug(f"Get tickerId: {tickerId} Q: {q}")
1197 | else:
1198 | tickerId, q = self.reuseQueue(tickerId) # reuse q for old tickerId
1199 | logger.debug(f"Reuse tickerId: {tickerId} Q: {q}")
1200 |
1201 | # Get the best possible duration to reduce number of requests
1202 | duration = None
1203 | for dur in durations:
1204 | intdate = self.dt_plus_duration(begindate, dur)
1205 | if intdate >= enddate:
1206 | intdate = enddate
1207 | duration = dur # begin -> end fits in single request
1208 | break
1209 |
1210 | if duration is None: # no duration large enough to fit the request
1211 | duration = durations[-1]
1212 |
1213 | # Store the calculated data
1214 | self.histexreq[tickerId] = dict(
1215 | contract=contract, enddate=enddate, begindate=intdate,
1216 | timeframe=timeframe, compression=compression,
1217 | what=what, useRTH=useRTH, tz=tz, sessionend=sessionend)
1218 |
1219 | barsize = self.tfcomp_to_size(timeframe, compression)
1220 | self.histfmt[tickerId] = timeframe >= TimeFrame.Days
1221 | self.histsend[tickerId] = sessionend
1222 | self.histtz[tickerId] = tz
1223 |
1224 | if contract.secType in ['CASH', 'CFD']:
1225 | self.iscash[tickerId] = 1 # msg.field code
1226 | if not what:
1227 | what = 'BID' # default for cash unless otherwise specified
1228 |
1229 | elif contract.secType in ['IND'] and self.p.indcash:
1230 | self.iscash[tickerId] = 4 # msg.field code
1231 |
1232 | what = what or 'TRADES'
1233 |
1234 | self.conn.reqHistoricalData(
1235 | tickerId,
1236 | contract,
1237 | #bytes(intdate.strftime('%Y%m%d %H:%M:%S') + ' GMT'),
1238 | bytes(intdate.strftime('%Y%m%d-%H:%M:%S')),
1239 | bytes(duration),
1240 | bytes(barsize),
1241 | bytes(what),
1242 | int(useRTH),
1243 | 2, # dateformat 1 for string, 2 for unix time in seconds
1244 | False,
1245 | [])
1246 |
1247 | return q
1248 |
1249 | def reqHistoricalData(self, contract, enddate, duration, barsize,
1250 | what=None, useRTH=False, tz='', sessionend=None):
1251 | '''Proxy to reqHistorical Data'''
1252 |
1253 | # get a ticker/queue for identification/data delivery
1254 | tickerId, q = self.getTickerQueue()
1255 |
1256 | if contract.secType in ['CASH', 'CFD']:
1257 | self.iscash[tickerId] = True
1258 | if not what:
1259 | what = 'BID' # TRADES doesn't work
1260 | elif what == 'ASK':
1261 | self.iscash[tickerId] = 2
1262 | else:
1263 | what = what or 'TRADES'
1264 |
1265 | # split barsize "x time", look in sizes for (tf, comp) get tf
1266 | tframe = self._sizes[barsize.split()[1]][0]
1267 | self.histfmt[tickerId] = tframe >= TimeFrame.Days
1268 | self.histsend[tickerId] = sessionend
1269 | self.histtz[tickerId] = tz
1270 |
1271 | self.conn.reqHistoricalData(
1272 | tickerId,
1273 | contract,
1274 | # bytes(enddate.strftime('%Y%m%d %H:%M:%S') + ' GMT'),
1275 | bytes(enddate.strftime('%Y%m%d-%H:%M:%S')),
1276 | bytes(duration),
1277 | bytes(barsize),
1278 | bytes(what),
1279 | int(useRTH),
1280 | 2,
1281 | False,
1282 | [])
1283 |
1284 | return q
1285 |
1286 | def reqHistoricalTicksEx(self, contract, enddate=None, begindate=None,
1287 | what=None, useRTH=False, tz='',
1288 | tickerId=None):
1289 | '''
1290 | Extension of the raw reqHistoricalData proxy, which takes two dates
1291 | rather than a duration, barsize and date
1292 |
1293 | It uses the IB published valid duration/barsizes to make a mapping and
1294 | spread a historical request over several historical requests if needed
1295 | '''
1296 | # Keep a copy for error reporting purposes
1297 | kwargs = locals().copy()
1298 | kwargs.pop('self', None) # remove self, no need to report it
1299 |
1300 | if enddate and begindate:
1301 | err = ('Only fromdate OR enddate can be specified not both')
1302 | self.notifs.put((err, (), kwargs))
1303 | return self.getTickerQueue(start=True)
1304 |
1305 | if enddate is None and begindate is None:
1306 | today = datetime.utcnow().date()
1307 | begindate = datetime(today.year, today.month, today.day)
1308 | # begindate = datetime.now()
1309 |
1310 | logger.debug(f"begin: {begindate} end: {enddate}")
1311 |
1312 | # Get or reuse a queue
1313 | if tickerId is None:
1314 | tickerId, q = self.getTickerQueue()
1315 | logger.debug(f"Get tickerId: {tickerId} Q: {q}")
1316 | else:
1317 | tickerId, q = self.reuseQueue(tickerId) # reuse q for old tickerId
1318 | logger.debug(f"Reuse tickerId: {tickerId} Q: {q}")
1319 |
1320 | if contract.secType in ['CASH', 'CFD']:
1321 | self.iscash[tickerId] = 1 # msg.field code
1322 | if not what:
1323 | what = 'BID' # default for cash unless otherwise specified
1324 |
1325 | elif contract.secType in ['IND'] and self.p.indcash:
1326 | self.iscash[tickerId] = 4 # msg.field code
1327 |
1328 | what = what or 'TRADES'
1329 |
1330 | self.conn.reqHistoricalTicks(
1331 | tickerId,
1332 | contract,
1333 | # bytes(begindate.strftime('%Y%m%d %H:%M:%S') + ' GMT') if begindate else '',
1334 | # bytes(enddate.strftime('%Y%m%d %H:%M:%S') + ' GMT') if enddate else '',
1335 | bytes(begindate.strftime('%Y%m%d-%H:%M:%S')) if begindate else '',
1336 | bytes(enddate.strftime('%Y%m%d-%H:%M:%S')) if enddate else '',
1337 | 100,
1338 | bytes(what),
1339 | int(useRTH),
1340 | True,
1341 | [])
1342 |
1343 | return q
1344 |
1345 | def reqHistoricalTicks(self, contract, enddate, begindate,
1346 | what=None, useRTH=False, tz=''):
1347 | '''Proxy to reqHistoricalTicks'''
1348 |
1349 | # get a ticker/queue for identification/data delivery
1350 | tickerId, q = self.getTickerQueue()
1351 |
1352 | if contract.secType in ['CASH', 'CFD']:
1353 | self.iscash[tickerId] = True
1354 | if not what:
1355 | what = 'BID' # TRADES doesn't work
1356 | elif what == 'ASK':
1357 | self.iscash[tickerId] = 2
1358 | else:
1359 | what = what or 'TRADES'
1360 |
1361 | if what == 'TRADES_ALL':
1362 | what = 'LastAll'
1363 | elif what == 'TRADES':
1364 | what = 'Last'
1365 | elif what == 'BID_ASK':
1366 | when = 'BidAsk'
1367 | elif what == 'MIDPOINT':
1368 | when = 'MidPoint'
1369 |
1370 | self.conn.reqHistoricalTicks(
1371 | tickerId,
1372 | contract,
1373 | # bytes(begindate.strftime('%Y%m%d %H:%M:%S') + ' GMT') if begindate else '',
1374 | bytes(begindate.strftime('%Y%m%d-%H:%M:%S')) if begindate else '',
1375 | # bytes(enddate.strftime('%Y%m%d %H:%M:%S') + ' GMT'),
1376 | # bytes(enddate.strftime('%Y%m%d %H:%M:%S') + ' GMT') if enddate else '',
1377 | bytes(enddate.strftime('%Y%m%d-%H:%M:%S')) if enddate else '',
1378 | 10,
1379 | bytes(what),
1380 | # int(useRTH),
1381 | 1,
1382 | True,
1383 | [])
1384 |
1385 | return q
1386 |
1387 | def cancelHistoricalData(self, q):
1388 | '''Cancels an existing HistoricalData request
1389 |
1390 | Params:
1391 | - q: the Queue returned by reqMktData
1392 | '''
1393 | with self._lock_q:
1394 | self.conn.cancelHistoricalData(self.ts[q])
1395 | logger.warn(f"Cancel data queue for {q}")
1396 | self.cancelQueue(q, True)
1397 |
1398 | @logibmsg
1399 | def reqRealTimeBars(self, contract, useRTH=False, duration=5, what = None):
1400 | '''Creates a request for (5 seconds) Real Time Bars
1401 |
1402 | Params:
1403 | - contract: a ib.ext.Contract.Contract intance
1404 | - useRTH: (default: False) passed to TWS
1405 | - duration: (default: 5) passed to TWS, no other value works in 2016)
1406 |
1407 | Returns:
1408 | - a Queue the client can wait on to receive a RTVolume instance
1409 | '''
1410 | # get a ticker/queue for identification/data delivery
1411 | tickerId, q = self.getTickerQueue()
1412 |
1413 | what = what or 'TRADES'
1414 |
1415 | # 20150929 - Only 5 secs supported for duration
1416 | self.conn.reqRealTimeBars(
1417 | tickerId,
1418 | contract,
1419 | duration,
1420 | # bytes('TRADES'),
1421 | bytes(what),
1422 | useRTH,
1423 | [])
1424 |
1425 | return q
1426 |
1427 | def cancelRealTimeBars(self, q):
1428 | '''Cancels an existing MarketData subscription
1429 |
1430 | Params:
1431 | - q: the Queue returned by reqMktData
1432 | '''
1433 | with self._lock_q:
1434 | tickerId = self.ts.get(q, None)
1435 | if tickerId is not None:
1436 | self.conn.cancelRealTimeBars(tickerId)
1437 |
1438 | logger.debug(f"Cancel data queue for {tickerId}")
1439 | self.cancelQueue(q, True)
1440 |
1441 | def reqMktData(self, contract, what=None):
1442 | '''Creates a MarketData subscription
1443 |
1444 | Params:
1445 | - contract: a ib.ext.Contract.Contract intance
1446 |
1447 | Returns:
1448 | - a Queue the client can wait on to receive a RTVolume instance
1449 | '''
1450 | # get a ticker/queue for identification/data delivery
1451 | tickerId, q = self.getTickerQueue()
1452 | ticks = '233' # request RTVOLUME tick delivered over tickString
1453 |
1454 | if contract.secType in ['CASH', 'CFD']:
1455 | self.iscash[tickerId] = True
1456 | ticks = '' # cash markets do not get RTVOLUME
1457 | if what == 'ASK':
1458 | self.iscash[tickerId] = 2
1459 |
1460 | # q.put(None) # to kickstart backfilling
1461 | # Can request 233 also for cash ... nothing will arrive
1462 | self.conn.reqMktData(tickerId, contract, bytes(ticks), False, False, [])
1463 | return q
1464 |
1465 | def reqTickByTickData(self, contract, what=None, ignoreSize=True):
1466 | '''
1467 | Tick-by-tick data corresponding to the data shown in the
1468 | TWS Time & Sales Window is available starting with TWS v969 and API v973.04.
1469 | '''
1470 |
1471 | if what == 'TRADES':
1472 | what = 'Last'
1473 | elif what == 'TRADES_ALL':
1474 | what = 'AllLast'
1475 | elif what == 'BID_ASK':
1476 | what = 'BidAsk'
1477 | elif what == 'MIDPOINT':
1478 | what = 'MidPoint'
1479 | else:
1480 | what = 'Last'
1481 |
1482 | tickerId, q = self.getTickerQueue()
1483 | self.conn.reqTickByTickData(tickerId, contract, what, 0, ignoreSize)
1484 | return q
1485 |
1486 | def cancelMktData(self, q):
1487 | '''Cancels an existing MarketData subscription
1488 |
1489 | Params:
1490 | - q: the Queue returned by reqMktData
1491 | '''
1492 | with self._lock_q:
1493 | tickerId = self.ts.get(q, None)
1494 | if tickerId is not None:
1495 | self.conn.cancelMktData(tickerId)
1496 |
1497 | logger.debug(f"Cancel data queue for {tickerId}")
1498 | self.cancelQueue(q, True)
1499 |
1500 | def cancelTickByTickData(self, q):
1501 | '''Cancels an existing MarketData subscription
1502 |
1503 | Params:
1504 | - q: the Queue returned by reqTickByTickData
1505 | '''
1506 | with self._lock_q:
1507 | tickerId = self.ts.get(q, None)
1508 | if tickerId is not None:
1509 | self.conn.cancelTickByTickData(tickerId)
1510 |
1511 | logger.debug(f"Cancel data queue for {tickerId}")
1512 | self.cancelQueue(q, True)
1513 |
1514 | def tickString(self, reqId, tickType, value):
1515 | # Receive and process a tickString message
1516 | tickerId = reqId
1517 | if tickType == 48: # RTVolume
1518 | try:
1519 | rtvol = RTVolume(value)
1520 | except ValueError: # price not in message ...
1521 | pass
1522 | else:
1523 | # Don't need to adjust the time, because it is in "timestamp"
1524 | # form in the message
1525 | self.qs[tickerId].put(rtvol)
1526 |
1527 | def tickPrice(self, reqId, tickType, price, attrib):
1528 | '''Cash Markets have no notion of "last_price"/"last_size" and the
1529 | tracking of the price is done (industry de-facto standard at least with
1530 | the IB API) following the BID price
1531 |
1532 | A RTVolume which will only contain a price is put into the client's
1533 | queue to have a consistent cross-market interface
1534 | '''
1535 |
1536 | # Used for "CASH" markets
1537 | # The price field has been seen to be missing in some instances even if
1538 | # "field" is 1
1539 | tickerId = reqId
1540 | fieldcode = self.iscash[tickerId]
1541 | if fieldcode:
1542 | if tickType == fieldcode: # Expected cash field code
1543 | try:
1544 | if price == -1.0:
1545 | # seems to indicate the stream is halted for example in
1546 | # between 23:00 - 23:15 CET for FOREX
1547 | return
1548 | except AttributeError:
1549 | pass
1550 |
1551 | try:
1552 | rtvol = RTVolume(price=price, tmoffset=self.tmoffset)
1553 | # print('rtvol with datetime:', rtvol.datetime)
1554 | except ValueError: # price not in message ...
1555 | pass
1556 | else:
1557 | self.qs[tickerId].put(rtvol)
1558 | else:
1559 | # Non-cash
1560 | try:
1561 | if price == -1.0:
1562 | # seems to indicate the stream is halted for example in
1563 | # between 23:00 - 23:15 CET for FOREX
1564 | return
1565 | except AttributeError:
1566 | pass
1567 | rtprice = RTPrice(price=price, tmoffset=self.tmoffset)
1568 | self.qs[tickerId].put(rtprice)
1569 |
1570 | def tickSize(self, reqId, tickType, size):
1571 | tickerId = reqId
1572 | rtsize = RTSize(size=size, tmoffset=self.tmoffset)
1573 | self.qs[tickerId].put(rtsize)
1574 |
1575 | def tickGeneric(self, reqId, tickType, value):
1576 | try:
1577 | if value == -1.0:
1578 | # seems to indicate the stream is halted for example in
1579 | # between 23:00 - 23:15 CET for FOREX
1580 | return
1581 | except AttributeError:
1582 | pass
1583 | tickerId = reqId
1584 | value = value # if msg.value != 0.0 else (1.0 + random.random())
1585 | rtprice = RTPrice(price=value, tmoffset=self.tmoffset)
1586 | self.qs[tickerId].put(rtprice)
1587 |
1588 | def realtimeBar(self, msg):
1589 | '''Receives x seconds Real Time Bars (at the time of writing only 5
1590 | seconds are supported)
1591 |
1592 | Not valid for cash markets
1593 | '''
1594 | # Get a naive localtime object
1595 | msg.time = datetime.utcfromtimestamp(float(msg.time))
1596 | self.qs[msg.reqId].put(msg)
1597 |
1598 | def historicalData(self, msg):
1599 | '''Receives the events of a historical data request'''
1600 | # For multi-tiered downloads we'd need to rebind the queue to a new
1601 | # tickerId (in case tickerIds are not reusable) and instead of putting
1602 | # None, issue a new reqHistData with the new data and move formward
1603 | tickerId = msg.reqId
1604 | q = self.qs[tickerId]
1605 |
1606 | dtstr = msg.date # Format when string req: YYYYMMDD[ HH:MM:SS]
1607 | if self.histfmt[tickerId]:
1608 | sessionend = self.histsend[tickerId]
1609 | dt = datetime.strptime(dtstr, '%Y%m%d')
1610 | dteos = datetime.combine(dt, sessionend)
1611 | tz = self.histtz[tickerId]
1612 | if tz:
1613 | dteostz = tz.localize(dteos)
1614 | dteosutc = dteostz.astimezone(UTC).replace(tzinfo=None)
1615 | # When requesting for example daily bars, the current day
1616 | # will be returned with the already happened data. If the
1617 | # session end were added, the new ticks wouldn't make it
1618 | # through, because they happen before the end of time
1619 | else:
1620 | dteosutc = dteos
1621 |
1622 | if dteosutc <= datetime.utcnow():
1623 | dt = dteosutc
1624 |
1625 | msg.date = dt
1626 | else:
1627 | msg.date = datetime.utcfromtimestamp(long(dtstr))
1628 |
1629 | q.put(msg)
1630 |
1631 | def historicalDataEnd(self, reqId, start, end):
1632 | tickerId = reqId
1633 | self.histfmt.pop(tickerId, None)
1634 | self.histsend.pop(tickerId, None)
1635 | self.histtz.pop(tickerId, None)
1636 | kargs = self.histexreq.pop(tickerId, None)
1637 | if kargs is not None:
1638 | self.reqHistoricalDataEx(tickerId=tickerId, **kargs)
1639 | return
1640 |
1641 | q = self.qs[tickerId]
1642 | self.cancelQueue(q)
1643 |
1644 | def historicalTicks(self, reqId, tick):
1645 | tickerId = reqId
1646 | self.qs[tickerId].put(tick)
1647 |
1648 | def historicalTicksEnd(self, reqId):
1649 | tickerId = reqId
1650 | q = self.qs[tickerId]
1651 | self.cancelTickByTickData(q)
1652 |
1653 | def tickByTickBidAsk(self, reqId, time, bidPrice, askPrice, bidSize, askSize, tickAttribBidAsk):
1654 | tickerId = reqId
1655 | tick = RTTickBidAsk(time, bidPrice, askPrice, bidSize, askSize, tickAttribBidAsk)
1656 | self.qs[tickerId].put(tick)
1657 |
1658 | def tickByTickAllLast(self, reqId, tickType, time, price, size, tickAtrribLast, exchange, specialConditions):
1659 | tickerId = reqId
1660 | tick = RTTickLast(tickType, time, price, size, tickAtrribLast, exchange, specialConditions)
1661 | self.qs[tickerId].put(tick)
1662 |
1663 | def tickByTickMidPoint(self, reqId, time, midPoint):
1664 | tickerId = reqId
1665 | tick = RTTickMidPoint(time, time, midPoint)
1666 | self.qs[tickerId].put(tick)
1667 |
1668 | # The _durations are meant to calculate the needed historical data to
1669 | # perform backfilling at the start of a connetion or a connection is lost.
1670 | # Using a timedelta as a key allows to quickly find out which bar size
1671 | # bar size (values in the tuples int the dict) can be used.
1672 |
1673 | _durations = dict([
1674 | # 60 seconds - 1 min
1675 | ('60 S',
1676 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1677 | '1 min')),
1678 |
1679 | # 120 seconds - 2 mins
1680 | ('120 S',
1681 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1682 | '1 min', '2 mins')),
1683 |
1684 | # 180 seconds - 3 mins
1685 | ('180 S',
1686 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1687 | '1 min', '2 mins', '3 mins')),
1688 |
1689 | # 300 seconds - 5 mins
1690 | ('300 S',
1691 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1692 | '1 min', '2 mins', '3 mins', '5 mins')),
1693 |
1694 | # 600 seconds - 10 mins
1695 | ('600 S',
1696 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1697 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins')),
1698 |
1699 | # 900 seconds - 15 mins
1700 | ('900 S',
1701 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1702 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins')),
1703 |
1704 | # 1200 seconds - 20 mins
1705 | ('1200 S',
1706 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1707 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1708 | '20 mins')),
1709 |
1710 | # 1800 seconds - 30 mins
1711 | ('1800 S',
1712 | ('1 secs', '5 secs', '10 secs', '15 secs', '30 secs',
1713 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1714 | '20 mins', '30 mins')),
1715 |
1716 | # 3600 seconds - 1 hour
1717 | ('3600 S',
1718 | ('5 secs', '10 secs', '15 secs', '30 secs',
1719 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1720 | '20 mins', '30 mins',
1721 | '1 hour')),
1722 |
1723 | # 7200 seconds - 2 hours
1724 | ('7200 S',
1725 | ('5 secs', '10 secs', '15 secs', '30 secs',
1726 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1727 | '20 mins', '30 mins',
1728 | '1 hour', '2 hours')),
1729 |
1730 | # 10800 seconds - 3 hours
1731 | ('10800 S',
1732 | ('10 secs', '15 secs', '30 secs',
1733 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1734 | '20 mins', '30 mins',
1735 | '1 hour', '2 hours', '3 hours')),
1736 |
1737 | # 14400 seconds - 4 hours
1738 | ('14400 S',
1739 | ('15 secs', '30 secs',
1740 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1741 | '20 mins', '30 mins',
1742 | '1 hour', '2 hours', '3 hours', '4 hours')),
1743 |
1744 | # 28800 seconds - 8 hours
1745 | ('28800 S',
1746 | ('30 secs',
1747 | '1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1748 | '20 mins', '30 mins',
1749 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours')),
1750 |
1751 | # 1 days
1752 | ('1 D',
1753 | ('1 min', '2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1754 | '20 mins', '30 mins',
1755 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours',
1756 | '1 day')),
1757 |
1758 | # 2 days
1759 | ('2 D',
1760 | ('2 mins', '3 mins', '5 mins', '10 mins', '15 mins',
1761 | '20 mins', '30 mins',
1762 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours',
1763 | '1 day')),
1764 |
1765 | # 1 weeks
1766 | ('1 W',
1767 | ('3 mins', '5 mins', '10 mins', '15 mins',
1768 | '20 mins', '30 mins',
1769 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours',
1770 | '1 day', '1 W')),
1771 |
1772 | # 2 weeks
1773 | ('2 W',
1774 | ('15 mins', '20 mins', '30 mins',
1775 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours',
1776 | '1 day', '1 W')),
1777 |
1778 | # 1 months
1779 | ('1 M',
1780 | ('30 mins',
1781 | '1 hour', '2 hours', '3 hours', '4 hours', '8 hours',
1782 | '1 day', '1 W', '1 M')),
1783 |
1784 | # 2+ months
1785 | ('2 M', ('1 day', '1 W', '1 M')),
1786 | ('3 M', ('1 day', '1 W', '1 M')),
1787 | ('4 M', ('1 day', '1 W', '1 M')),
1788 | ('5 M', ('1 day', '1 W', '1 M')),
1789 | ('6 M', ('1 day', '1 W', '1 M')),
1790 | ('7 M', ('1 day', '1 W', '1 M')),
1791 | ('8 M', ('1 day', '1 W', '1 M')),
1792 | ('9 M', ('1 day', '1 W', '1 M')),
1793 | ('10 M', ('1 day', '1 W', '1 M')),
1794 | ('11 M', ('1 day', '1 W', '1 M')),
1795 |
1796 | # 1+ years
1797 | ('1 Y', ('1 day', '1 W', '1 M')),
1798 | ])
1799 |
1800 | # Sizes allow for quick translation from bar sizes above to actual
1801 | # timeframes to make a comparison with the actual data
1802 | _sizes = {
1803 | 'secs': (TimeFrame.Seconds, 1),
1804 | 'min': (TimeFrame.Minutes, 1),
1805 | 'mins': (TimeFrame.Minutes, 1),
1806 | 'hour': (TimeFrame.Minutes, 60),
1807 | 'hours': (TimeFrame.Minutes, 60),
1808 | 'day': (TimeFrame.Days, 1),
1809 | 'W': (TimeFrame.Weeks, 1),
1810 | 'M': (TimeFrame.Months, 1),
1811 | }
1812 |
1813 | _dur2tf = {
1814 | 'S': TimeFrame.Seconds,
1815 | 'D': TimeFrame.Days,
1816 | 'W': TimeFrame.Weeks,
1817 | 'M': TimeFrame.Months,
1818 | 'Y': TimeFrame.Years,
1819 | }
1820 |
1821 | def getdurations(self, timeframe, compression):
1822 | key = (timeframe, compression)
1823 | if key not in self.revdur:
1824 | return []
1825 |
1826 | return self.revdur[key]
1827 |
1828 | def getmaxduration(self, timeframe, compression):
1829 | key = (timeframe, compression)
1830 | try:
1831 | return self.revdur[key][-1]
1832 | except (KeyError, IndexError):
1833 | pass
1834 |
1835 | return None
1836 |
1837 | def tfcomp_to_size(self, timeframe, compression):
1838 | if timeframe == TimeFrame.Months:
1839 | return '{} M'.format(compression)
1840 |
1841 | if timeframe == TimeFrame.Weeks:
1842 | return '{} W'.format(compression)
1843 |
1844 | if timeframe == TimeFrame.Days:
1845 | if not compression % 7:
1846 | return '{} W'.format(compression // 7)
1847 |
1848 | return '{} day'.format(compression)
1849 |
1850 | if timeframe == TimeFrame.Minutes:
1851 | if not compression % 60:
1852 | hours = compression // 60
1853 | return ('{} hour'.format(hours)) + ('s' * (hours > 1))
1854 |
1855 | return ('{} min'.format(compression)) + ('s' * (compression > 1))
1856 |
1857 | if timeframe == TimeFrame.Seconds:
1858 | return '{} secs'.format(compression)
1859 |
1860 | # Microseconds or ticks
1861 | return None
1862 |
1863 | def dt_plus_duration(self, dt, duration):
1864 | size, dim = duration.split()
1865 | size = int(size)
1866 | if dim == 'S':
1867 | return dt + timedelta(seconds=size)
1868 |
1869 | if dim == 'D':
1870 | return dt + timedelta(days=size)
1871 |
1872 | if dim == 'W':
1873 | return dt + timedelta(days=size * 7)
1874 |
1875 | if dim == 'M':
1876 | month = dt.month - 1 + size # -1 to make it 0 based, readd below
1877 | years, month = divmod(month, 12)
1878 | return dt.replace(year=dt.year + years, month=month + 1)
1879 |
1880 | if dim == 'Y':
1881 | return dt.replace(year=dt.year + size)
1882 |
1883 | return dt # could do nothing with it ... return it intact
1884 |
1885 | def calcdurations(self, dtbegin, dtend):
1886 | '''Calculate a duration in between 2 datetimes'''
1887 | duration = self.histduration(dtbegin, dtend)
1888 |
1889 | if duration[-1] == 'M':
1890 | m = int(duration.split()[0])
1891 | m1 = min(2, m) # (2, 1) -> 1, (2, 7) -> 2. Bottomline: 1 or 2
1892 | m2 = max(1, m1) # m1 can only be 1 or 2
1893 | checkdur = '{} M'.format(m2)
1894 | elif duration[-1] == 'Y':
1895 | checkdur = '1 Y'
1896 | else:
1897 | checkdur = duration
1898 |
1899 | sizes = self._durations[checkdur]
1900 | return duration, sizes
1901 |
1902 | def calcduration(self, dtbegin, dtend):
1903 | '''Calculate a duration in between 2 datetimes. Returns single size'''
1904 | duration, sizes = self._calcdurations(dtbegin, dtend)
1905 | return duration, sizes[0]
1906 |
1907 |
1908 | def histduration(self, dt1, dt2):
1909 | # Given two dates calculates the smallest possible duration according
1910 | # to the table from the Historical Data API limitations provided by IB
1911 | #
1912 | # Seconds: 'x S' (x: [60, 120, 180, 300, 600, 900, 1200, 1800, 3600,
1913 | # 7200, 10800, 14400, 28800])
1914 | # Days: 'x D' (x: [1, 2]
1915 | # Weeks: 'x W' (x: [1, 2])
1916 | # Months: 'x M' (x: [1, 11])
1917 | # Years: 'x Y' (x: [1])
1918 |
1919 | td = dt2 - dt1 # get a timedelta for calculations
1920 |
1921 | # First: array of secs
1922 | tsecs = td.total_seconds()
1923 | secs = [60, 120, 180, 300, 600, 900, 1200, 1800, 3600, 7200, 10800,
1924 | 14400, 28800]
1925 |
1926 | idxsec = bisect.bisect_left(secs, tsecs)
1927 | if idxsec < len(secs):
1928 | return '{} S'.format(secs[idxsec])
1929 |
1930 | tdextra = bool(td.seconds or td.microseconds) # over days/weeks
1931 |
1932 | # Next: 1 or 2 days
1933 | days = td.days + tdextra
1934 | if td.days <= 2:
1935 | return '{} D'.format(days)
1936 |
1937 | # Next: 1 or 2 weeks
1938 | weeks, d = divmod(td.days, 7)
1939 | weeks += bool(d or tdextra)
1940 | if weeks <= 2:
1941 | return '{} W'.format(weeks)
1942 |
1943 | # Get references to dt components
1944 | y2, m2, d2 = dt2.year, dt2.month, dt2.day
1945 | y1, m1, d1 = dt1.year, dt1.month, dt2.day
1946 |
1947 | H2, M2, S2, US2 = dt2.hour, dt2.minute, dt2.second, dt2.microsecond
1948 | H1, M1, S1, US1 = dt1.hour, dt1.minute, dt1.second, dt1.microsecond
1949 |
1950 | # Next: 1 -> 11 months (11 incl)
1951 | months = (y2 * 12 + m2) - (y1 * 12 + m1) + (
1952 | (d2, H2, M2, S2, US2) > (d1, H1, M1, S1, US1))
1953 | if months <= 1: # months <= 11
1954 | return '1 M' # return '{} M'.format(months)
1955 | elif months <= 11:
1956 | return '2 M' # cap at 2 months to keep the table clean
1957 |
1958 | # Next: years
1959 | # y = y2 - y1 + (m2, d2, H2, M2, S2, US2) > (m1, d1, H1, M1, S1, US1)
1960 | # return '{} Y'.format(y)
1961 |
1962 | return '1 Y' # to keep the table clean
1963 |
1964 | def makecontract(self, symbol, sectype, exch, curr,
1965 | expiry='', strike=0.0, right='', mult=1,
1966 | primaryExch=None, localSymbol=None):
1967 | '''returns a contract from the parameters without check'''
1968 |
1969 | contract = Contract()
1970 |
1971 | if localSymbol:
1972 | contract.localSymbol = bytes(localSymbol)
1973 | else:
1974 | contract.symbol = bytes(symbol)
1975 |
1976 | contract.secType = bytes(sectype)
1977 | contract.exchange = bytes(exch)
1978 | if primaryExch:
1979 | contract.primaryExchange = bytes(primaryExch)
1980 | if curr:
1981 | contract.currency = bytes(curr)
1982 | if sectype in ['FUT', 'OPT', 'FOP']:
1983 | contract.lastTradeDateOrContractMonth = bytes(expiry)
1984 | if sectype in ['OPT', 'FOP']:
1985 | contract.strike = strike
1986 | contract.right = bytes(right)
1987 | if mult:
1988 | contract.multiplier = bytes(mult)
1989 | return contract
1990 |
1991 | def cancelOrder(self, orderid):
1992 | '''Proxy to cancelOrder'''
1993 | self.conn.cancelOrder(orderid)
1994 |
1995 | def placeOrder(self, orderid, contract, order):
1996 | '''Proxy to placeOrder'''
1997 | self.conn.placeOrder(orderid, contract, order)
1998 |
1999 | def openOrder(self, msg):
2000 | '''Receive the event ``openOrder`` events'''
2001 | self.broker.push_orderstate(msg)
2002 |
2003 | def openOrderEnd(self):
2004 | # TODO: Add event to manage order requests
2005 | logger.debug(f"openOrderEnd")
2006 |
2007 | def execDetails(self, reqId, contract, execution):
2008 | '''Receive execDetails'''
2009 | execution.shares = float(execution.shares)
2010 | execution.cumQty = float(execution.cumQty)
2011 | self.broker.push_execution(execution)
2012 |
2013 | def orderStatus(self, msg):
2014 | '''Receive the event ``orderStatus``'''
2015 | self.broker.push_orderstatus(msg)
2016 |
2017 | def commissionReport(self, commissionReport):
2018 | '''Receive the event commissionReport'''
2019 | self.broker.push_commissionreport(commissionReport)
2020 |
2021 | def reqPositions(self):
2022 | '''Proxy to reqPositions'''
2023 | self.conn.reqPositions()
2024 |
2025 | def position(self, account, contract, pos, avgCost):
2026 | '''Receive event positions'''
2027 | # Lock access to the position dicts. This is called in sub-thread and
2028 | # can kick in at any time
2029 | with self._lock_pos:
2030 | try:
2031 | if not self._event_accdownload.is_set(): # 1st event seen
2032 | position = Position(float(pos), float(avgCost))
2033 | logger.debug(f"POSITIONS INITIAL: {self.positions}")
2034 | self.positions[contract.conId] = position
2035 | else:
2036 | position = self.positions[contract.conId]
2037 | logger.debug(f"POSITION UPDATE: {position}")
2038 | if not position.fix(float(pos), avgCost):
2039 | err = ('The current calculated position and '
2040 | 'the position reported by the broker do not match. '
2041 | 'Operation can continue, but the trades '
2042 | 'calculated in the strategy may be wrong')
2043 |
2044 | self.notifs.put((err, (), {}))
2045 |
2046 | # self.broker.push_portupdate()
2047 | except Exception as e:
2048 | logger.exception(f"Exception: {e}")
2049 |
2050 | def positionEnd(self):
2051 | logger.debug(f"positionEnd")
2052 |
2053 | def reqAccountUpdates(self, subscribe=True, account=None):
2054 | '''Proxy to reqAccountUpdates
2055 |
2056 | If ``account`` is ``None``, wait for the ``managedAccounts`` message to
2057 | set the account codes
2058 | '''
2059 | if account is None:
2060 | self._event_managed_accounts.wait()
2061 | account = self.managed_accounts[0]
2062 |
2063 | self.conn.reqAccountUpdates(subscribe, bytes(account))
2064 |
2065 | def accountDownloadEnd(self, accountName):
2066 | # Signals the end of an account update
2067 | # the event indicates it's over. It's only false once, and can be used
2068 | # to find out if it has at least been downloaded once
2069 | self._event_accdownload.set()
2070 | if False:
2071 | if self.port_update:
2072 | self.broker.push_portupdate()
2073 |
2074 | self.port_update = False
2075 |
2076 | def updatePortfolio(self, contract, pos,
2077 | marketPrice, marketValue,
2078 | averageCost, unrealizedPNL,
2079 | realizedPNL, accountName):
2080 | # Lock access to the position dicts. This is called in sub-thread and
2081 | # can kick in at any time
2082 | with self._lock_pos:
2083 | try:
2084 | if not self._event_accdownload.is_set(): # 1st event seen
2085 | position = Position(float(pos), float(averageCost))
2086 | logger.debug(f"POSITIONS INITIAL: {self.positions}")
2087 | # self.positions[contract.conId] = position
2088 | self.positions.setdefault(contract.conId, position)
2089 | else:
2090 | position = self.positions[contract.conId]
2091 | logger.debug(f"POSITION UPDATE: {position}")
2092 | if not position.fix(float(pos), averageCost):
2093 | err = ('The current calculated position and '
2094 | 'the position reported by the broker do not match. '
2095 | 'Operation can continue, but the trades '
2096 | 'calculated in the strategy may be wrong')
2097 |
2098 | self.notifs.put((err, (), {}))
2099 |
2100 | # Flag signal to broker at the end of account download
2101 | # self.port_update = True
2102 | self.broker.push_portupdate()
2103 | except Exception as e:
2104 | logger.exception(f"Exception: {e}")
2105 |
2106 | def getposition(self, contract, clone=False):
2107 | # Lock access to the position dicts. This is called from main thread
2108 | # and updates could be happening in the background
2109 | with self._lock_pos:
2110 | position = self.positions[contract.conId]
2111 | if clone:
2112 | return copy(position)
2113 |
2114 | return position
2115 |
2116 | @logibmsg
2117 | def updateAccountValue(self, key, value, currency, accountName):
2118 | # Lock access to the dicts where values are updated. This happens in a
2119 | # sub-thread and could kick it at anytime
2120 | with self._lock_accupd:
2121 | try:
2122 | value = float(value)
2123 | except ValueError:
2124 | value = value
2125 |
2126 | self.acc_upds[accountName][key][currency] = value
2127 |
2128 | if key == 'NetLiquidation':
2129 | # NetLiquidationByCurrency and currency == 'BASE' is the same
2130 | self.acc_value[accountName] = value
2131 | elif key == 'CashBalance' and currency == 'BASE':
2132 | self.acc_cash[accountName] = value
2133 |
2134 | @logibmsg
2135 | def get_acc_values(self, account=None):
2136 | '''Returns all account value infos sent by TWS during regular updates
2137 | Waits for at least 1 successful download
2138 |
2139 | If ``account`` is ``None`` then a dictionary with accounts as keys will
2140 | be returned containing all accounts
2141 |
2142 | If account is specified or the system has only 1 account the dictionary
2143 | corresponding to that account is returned
2144 | '''
2145 | # Wait for at least 1 account update download to have been finished
2146 | # before the account infos can be returned to the calling client
2147 | # if self.connected():
2148 | # self._event_accdownload.wait()
2149 | # Lock access to acc_cash to avoid an event intefering
2150 | with self._updacclock:
2151 | if account is None:
2152 | # wait for the managedAccount Messages
2153 | # if self.connected():
2154 | # self._event_managed_accounts.wait()
2155 |
2156 | if not self.managed_accounts:
2157 | return self.acc_upds.copy()
2158 |
2159 | elif len(self.managed_accounts) > 1:
2160 | return self.acc_upds.copy()
2161 |
2162 | # Only 1 account, fall through to return only 1
2163 | account = self.managed_accounts[0]
2164 |
2165 | try:
2166 | return self.acc_upds[account].copy()
2167 | except KeyError:
2168 | pass
2169 |
2170 | return self.acc_upds.copy()
2171 |
2172 | @logibmsg
2173 | def get_acc_value(self, account=None):
2174 | '''Returns the net liquidation value sent by TWS during regular updates
2175 | Waits for at least 1 successful download
2176 |
2177 | If ``account`` is ``None`` then a dictionary with accounts as keys will
2178 | be returned containing all accounts
2179 |
2180 | If account is specified or the system has only 1 account the dictionary
2181 | corresponding to that account is returned
2182 | '''
2183 | # Wait for at least 1 account update download to have been finished
2184 | # before the value can be returned to the calling client
2185 | # Lock access to acc_cash to avoid an event intefering
2186 |
2187 | with self._updacclock:
2188 | if account is None:
2189 | if not self.managed_accounts:
2190 | return float()
2191 | elif len(self.managed_accounts) > 1:
2192 | return sum(self.acc_value.values())
2193 |
2194 | # Only 1 account, fall through to return only 1
2195 | account = self.managed_accounts[0]
2196 |
2197 | try:
2198 | return self.acc_value[account]
2199 | except KeyError:
2200 | pass
2201 |
2202 | return float()
2203 |
2204 | @logibmsg
2205 | def get_acc_cash(self, account=None):
2206 | '''Returns the total cash value sent by TWS during regular updates
2207 | Waits for at least 1 successful download
2208 |
2209 | If ``account`` is ``None`` then a dictionary with accounts as keys will
2210 | be returned containing all accounts
2211 |
2212 | If account is specified or the system has only 1 account the dictionary
2213 | corresponding to that account is returned
2214 | '''
2215 | # Wait for at least 1 account update download to have been finished
2216 | # before the cash can be returned to the calling client
2217 | # if self.connected():
2218 | # self._event_accdownload.wait()
2219 | # Lock access to acc_cash to avoid an event intefering
2220 | with self._lock_accupd:
2221 | if account is None:
2222 | # # wait for the managedAccount Messages
2223 | # if self.connected():
2224 | # self._event_managed_accounts.wait()
2225 |
2226 | if not self.managed_accounts:
2227 | return float()
2228 |
2229 | elif len(self.managed_accounts) > 1:
2230 | return sum(self.acc_cash.values())
2231 |
2232 | # Only 1 account, fall through to return only 1
2233 | account = self.managed_accounts[0]
2234 |
2235 | try:
2236 | return self.acc_cash[account]
2237 | except KeyError:
2238 | pass
2239 |
--------------------------------------------------------------------------------
/images/image-01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-01.png
--------------------------------------------------------------------------------
/images/image-02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-02.png
--------------------------------------------------------------------------------
/images/image-03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-03.png
--------------------------------------------------------------------------------
/images/image-04.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-04.png
--------------------------------------------------------------------------------
/images/image-05.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-05.png
--------------------------------------------------------------------------------
/images/image-06.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-06.png
--------------------------------------------------------------------------------
/images/image-07.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-07.png
--------------------------------------------------------------------------------
/images/image-08.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atreyuxtrading/atreyu-backtrader-api/43bdb04868d3b77d3f651eb5f67859b07e66612f/images/image-08.png
--------------------------------------------------------------------------------
/requirements/requirements.txt:
--------------------------------------------------------------------------------
1 | backtrader==1.9.76.123
2 | exchange-calendars==3.4
3 |
--------------------------------------------------------------------------------
/requirements/requirements_test.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | pytest-cov
3 | requests-mock
4 | coverage>=4.4.1
5 | mock>=1.0.1
6 | flake8
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | exclude =
3 | .git,
4 | # This contains builds of flake8 that we don't want to check
5 | dist,
6 | talib,
7 | .eggs,
8 |
9 | [aliases]
10 | test=pytest
11 |
12 | [tool:pytest]
13 | addopts = --verbose --cov alpaca_backtrader_api --cov-report=term-missing
14 | filterwarnings =
15 | ignore:invalid escape sequence.*:DeprecationWarning
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import sys
4 | import ast
5 | import os
6 | import re
7 |
8 | from setuptools import setup
9 |
10 | _version_re = re.compile(r'__version__\s+=\s+(.*)')
11 |
12 | with open('atreyu_backtrader_api/__init__.py', 'rb') as f:
13 | version = str(ast.literal_eval(_version_re.search(
14 | f.read().decode('utf-8')).group(1)))
15 |
16 | if sys.version_info < (3,1):
17 | sys.exit("Only Python 3.1 and greater is supported")
18 |
19 | setup(
20 | name='atreyu_backtrader_api',
21 | version=version,
22 | packages=['atreyu_backtrader_api'],
23 | url='https://github.com/atreyuxtrading/atreyu-backtrader-api',
24 | license='Simplified BSD license',
25 | author='Atreyu Trading',
26 | author_email='info@atreyugroup.com',
27 | description='Python IB API for backtrader'
28 | )
29 |
--------------------------------------------------------------------------------