├── .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 | ![TWS Settings](images/image-01.png "TWS Settings") 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 | ![What Data Types](images/image-02.png "What Data Types") 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 | ![Data Layout](images/image-03.png "Data Layout") 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 | ![Product Frequency](images/image-04.png "Product Frequency") 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 | ![Lines Requests](images/image-05.png "Lines Requests") 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 | ![Paper Trading](images/image-06.png "Paper Trading") 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 | ![Main Trading](images/image-07.png "Main Trading") 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 | ![TWS Ports](images/image-08.png "TWS Port") 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 | --------------------------------------------------------------------------------