├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── environment.yml ├── examples └── simple_strategies.py ├── poetry.lock ├── pyproject.toml ├── quantdom.spec ├── quantdom ├── __init__.py ├── app.py ├── cli.py ├── lib │ ├── __init__.py │ ├── base.py │ ├── charts.py │ ├── const.py │ ├── loaders.py │ ├── performance.py │ ├── portfolio.py │ ├── strategy.py │ ├── tables.py │ └── utils.py ├── report_rows.json └── ui.py ├── readthedocs.yml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py └── test_basics.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.py] 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __dev__ 2 | __pycache__ 3 | .eggs 4 | *.egg-info 5 | .idea 6 | .DS_Store 7 | .cache 8 | build 9 | dist 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 3.6 5 | 6 | os: 7 | - linux 8 | 9 | env: 10 | global: 11 | - secure: C0YKuNWFQTplUfoKTY5nCePum6CIwdyrNsoZ1/aLVgtO9GQMLNRqNn1rRThqy6HK7TjwbGTHp01LMzHD1uo44l8CVV5Pb3J0KDiKiL9yW26MHwKg/FrXq8GYtGKebc7uaN7CYOBsqeVoWk76DDALJWe6z6KWf0y8oyjbyWb+6AcVEl0N2vDgyijEYAPihJzF6G+p/2pQQpSC+CSMwqHfdlyng1b5ltt3kuwz9/KRfDCxpqwK5jjs5sdzUstW+YFTPPXWqoH5mvbSBTAp4ovLCV4cT+NeLEUz3dhmzCgwOENBvWPJ2oMb6WSoLJ8mGVSnFdF7xfK+mq4qJu2rEggz5DOCtwjCt32gLAs4oEwQmRYX4f9U8i0cptiv4gpsqr3adRUvsC4WqlrrEVAw4qLPHEBNSg/xeYOmLOczmXUQBE4/iYTpxM1ReG7R8uAbePV7ZGJpAAHwYZbxaCGcjpksX5osp9NI5gDscVL3f9eSd+0o2dRts/KYmcxcI4rQ22q1cuV3Y7XMp3SThkKYCiojt59ZW8zspbcw8gWmuKdqcLIjtGciXHwjxFP+fxP9qKz5LEpHElK+bTp8rI+cNKYhFwKQxkf679jdPvksWXBvoA1n62Sq5wmis7f7xAh/V+W2kdbk5OwDqlLYFwck7TAmofKIOiqz7YYa8WXw+bvAhvs= 12 | - secure: WCJX4Z5eUSrEouchWsuFHAxSiOKYEgbOMi6FXmV3NSROa1fKGLmQxG5z0H7vIOivpJCIRTjuruhucXagDx3B7UpRlZonsNlDjANCpX6k7k2mQe9Kth+qzROMkh0cr8eHmvWo1vCXv+jRUo4Zpk+RN4UV6zCbCqT3w1KZ2erzeQ+LjFzsamhrp+HhLb51sgi7Q0FuVwYXHxqQyqp+0oXwH+qcsPAOR8zT2C4SmhPF22RLvn7Xd1BmQO5zBpSj2wmCdM/hBZUucsy+CRvTRaJIhkW46piTQI/LwRWpJOOQ3uSvk87OuAsmsyhmSbfbq77ep01QySKwpbiA4zDrXIJ8HAmlDkDOdYNfVIwbAib83ovlnR/3Bzv2Ahx3PwEpxsQVK0wE0guDhmMalRwnaMvLZW/paA0yZ3yCZ/56mdTpKpMeysNZogjPP7sIdzqYAgyfL71Oz3Y3qbTEmGWAfSHYrE3Pl1gVmGUBN97ngSN9M3X+Ig4Zs+dBxt+kTjdgjQMDH4t9YzjAY/mcJIIKmk3Hn8MH9cJHwSdRiUl+h0VQvw7Ll0MHFxFx9MPIlcrccrx4apiSYk9KnMVfxOcXLh2G5r8peEvfjdAOLpjiIs2d8vFR9D9jNajEyqLlSTgaUTXBXmhrXG5imaFcHqS3fKzWgaJtlGsWnycvxWKnSMp6v8o= 13 | 14 | install: 15 | - pip install pip -U 16 | - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python 17 | - source $HOME/.poetry/env 18 | - poetry install --no-interaction 19 | 20 | before_script: 21 | - export DISPLAY=:99.0 22 | - sh -e /etc/init.d/xvfb start 23 | - sleep 3 24 | 25 | script: 26 | - poetry check 27 | - poetry run pytest 28 | 29 | deploy: 30 | provider: script 31 | script: poetry publish --build --no-interaction --username "$PYPI_USERNAME" --password "$PYPI_PASSWORD" 32 | on: 33 | tags: true 34 | branch: master 35 | python: 3.6 36 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Change Log 3 | ========== 4 | 5 | 6 | `0.1.2`_ (Unreleased) 7 | --------------------- 8 | ... 9 | 10 | `0.1.1`_ 11 | --------------------- 12 | - add new data sources: **iex** and **stooq** 13 | - remove broken data sources: **google finance** `#10 `_ 14 | 15 | 16 | `0.1`_ 17 | --------------------- 18 | 19 | - switch to poetry 20 | - update dependencies 21 | - minor fixes (`#6 `_, etc) 22 | 23 | 24 | `0.1a1`_ 25 | --------------------- 26 | 27 | Initial 28 | 29 | .. _0.1.2 https://github.com/constverum/Quantdom/compare/v0.1.1...HEAD 30 | .. _0.1.1: https://github.com/constverum/Quantdom/releases/tag/v0.1.1 31 | .. _0.1: https://github.com/constverum/Quantdom/releases/tag/v0.1 32 | .. _0.1a1: https://github.com/constverum/Quantdom/releases/tag/v0.1a1 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017-2018 Constverum 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quantdom 2 | ======== 3 | 4 | .. image:: https://img.shields.io/pypi/v/quantdom.svg?style=flat-square 5 | :target: https://pypi.python.org/pypi/quantdom/ 6 | .. image:: https://img.shields.io/travis/constverum/Quantdom.svg?style=flat-square 7 | :target: https://travis-ci.org/constverum/Quantdom 8 | .. image:: https://img.shields.io/pypi/wheel/quantdom.svg?style=flat-square 9 | :target: https://pypi.python.org/pypi/quantdom/ 10 | .. image:: https://img.shields.io/pypi/pyversions/quantdom.svg?style=flat-square 11 | :target: https://pypi.python.org/pypi/quantdom/ 12 | .. image:: https://img.shields.io/pypi/l/quantdom.svg?style=flat-square 13 | :target: https://pypi.python.org/pypi/quantdom/ 14 | 15 | Quantdom is a simple but powerful backtesting framework written in python, that strives to let you focus on modeling financial strategies, portfolio management, and analyzing backtests. It has been created as a useful and flexible tool to save the systematic trading community from re-inventing the wheel and let them evaluate their trading ideas easier with minimal effort. It's designed for people who are already comfortable with *Python* and who want to create, test and explore their own trading strategies. 16 | 17 | .. image:: http://f.cl.ly/items/1z1t1T0A0P161f053i45/quantdom_v0.1a1.gif 18 | 19 | Quantdom is in an early alpha state at the moment. So please be patient with possible errors and report them. 20 | 21 | 22 | Features 23 | -------- 24 | 25 | * Free, open-source and cross-platform backtesting framework 26 | * Multiple data feeds: csv files and online sources such as Google Finance, Yahoo Finance, Quandl and more 27 | * Investment Analysis (performance and risk analysis of financial portfolio) 28 | * Charting and reporting that help visualize backtest results 29 | 30 | 31 | Requirements 32 | ------------ 33 | 34 | * Python **3.6** or higher 35 | * `PyQt5 `_ 36 | * `PyQtGraph `_ 37 | * `NumPy `_ 38 | * See `pyproject.toml `_ for full details. 39 | 40 | 41 | Installation 42 | ------------ 43 | 44 | Using the binaries 45 | ################## 46 | 47 | You can download binary packages for your system (see the `Github Releases `_ page for available downloads): 48 | 49 | * For `Windows `_ 50 | * For `MacOS `_ 51 | * For `Linux `_ 52 | 53 | Running from source code 54 | ######################## 55 | 56 | You can install last *stable release* from pypi: 57 | 58 | .. code-block:: bash 59 | 60 | $ pip install quantdom 61 | 62 | And latest *development version* can be installed directly from GitHub: 63 | 64 | .. code-block:: bash 65 | 66 | $ pip install -U git+https://github.com/constverum/Quantdom.git 67 | 68 | After that, to run the application just execute one command: 69 | 70 | .. code-block:: bash 71 | 72 | $ quantdom 73 | 74 | 75 | Usage 76 | ----- 77 | 78 | 1. Run Quantdom. 79 | 2. Choose a market instrument (symbol) for backtesting on the ``Data`` tab. 80 | 3. Specify a file with your strategies on the ``Quotes`` tab, and select one of them. 81 | 4. Run a backtest. Once this is done, you can analyze the results and optimize parameters of the strategy. 82 | 83 | 84 | Strategy Examples 85 | ----------------- 86 | 87 | Three-bar strategy 88 | ################## 89 | 90 | A simple trading strategy based on the assumption that after three consecutive bullish bars (bar closing occurred higher than its opening) bulls predominate in the market and therefore the price will continue to grow; after 3 consecutive bearish bars (the bar closes lower than its opening), the price will continue to down, since bears predominate in the market. 91 | 92 | .. code-block:: python 93 | 94 | from quantdom import AbstractStrategy, Order, Portfolio 95 | 96 | class ThreeBarStrategy(AbstractStrategy): 97 | 98 | def init(self, high_bars=3, low_bars=3): 99 | Portfolio.initial_balance = 100000 # default value 100 | self.seq_low_bars = 0 101 | self.seq_high_bars = 0 102 | self.signal = None 103 | self.last_position = None 104 | self.volume = 100 # shares 105 | self.high_bars = high_bars 106 | self.low_bars = low_bars 107 | 108 | def handle(self, quote): 109 | if self.signal: 110 | props = { 111 | 'symbol': self.symbol, # current selected symbol 112 | 'otype': self.signal, 113 | 'price': quote.open, 114 | 'volume': self.volume, 115 | 'time': quote.time, 116 | } 117 | if not self.last_position: 118 | self.last_position = Order.open(**props) 119 | elif self.last_position.type != self.signal: 120 | Order.close(self.last_position, price=quote.open, time=quote.time) 121 | self.last_position = Order.open(**props) 122 | self.signal = False 123 | self.seq_high_bars = self.seq_low_bars = 0 124 | 125 | if quote.close > quote.open: 126 | self.seq_high_bars += 1 127 | self.seq_low_bars = 0 128 | else: 129 | self.seq_high_bars = 0 130 | self.seq_low_bars += 1 131 | 132 | if self.seq_high_bars == self.high_bars: 133 | self.signal = Order.BUY 134 | elif self.seq_low_bars == self.low_bars: 135 | self.signal = Order.SELL 136 | 137 | 138 | Documentation 139 | ------------- 140 | 141 | In progress ;) 142 | 143 | 144 | TODO 145 | ---- 146 | 147 | * Add integration with `TA-Lib `_ 148 | * Add the ability to use TensorFlow/CatBoost/Scikit-Learn and other ML tools to create incredible algorithms and strategies. Just as one of the first tasks is Elliott Wave Theory(Principle) - to recognize of current wave and on the basis of this predict price movement at confidence intervals 149 | * Add the ability to make a sentiment analysis from different sources (news, tweets, etc) 150 | * Add ability to create custom screens, ranking functions, reports 151 | 152 | 153 | Contributing 154 | ------------ 155 | 156 | * Fork it: https://github.com/constverum/Quantdom/fork 157 | * Create your feature branch: git checkout -b my-new-feature 158 | * Commit your changes: git commit -am 'Add some feature' 159 | * Push to the branch: git push origin my-new-feature 160 | * Submit a pull request! 161 | 162 | 163 | Disclaimer 164 | ---------- 165 | 166 | This software should not be used as a financial advisor, it is for educational use only. 167 | Absolutely no warranty is implied with this product. By using this software you release the author(s) from any liability regarding the use of this software. You can lose money because this program probably has some errors in it, so use it at your own risk. And please don't take risks with money you can't afford to lose. 168 | 169 | 170 | Feedback 171 | -------- 172 | 173 | I'm very interested in your experience with Quantdom. 174 | Please feel free to send me any feedback, ideas, enhancement requests or anything else. 175 | 176 | 177 | License 178 | ------- 179 | 180 | Licensed under the Apache License, Version 2.0 181 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: py36 2 | dependencies: 3 | - pip=9.0.1=py36_1 4 | - python=3.6.2=0 5 | - setuptools=27.2=py36_0 6 | - wheel=0.29.0=py36_0 7 | - pip: 8 | - sphinx 9 | - alabaster 10 | - aiohttp 11 | - aiodns 12 | - maxminddb 13 | -------------------------------------------------------------------------------- /examples/simple_strategies.py: -------------------------------------------------------------------------------- 1 | from quantdom import AbstractStrategy, Order, Portfolio 2 | 3 | 4 | class ThreeBarStrategy(AbstractStrategy): 5 | def init(self, high_bars=3, low_bars=3): 6 | Portfolio.initial_balance = 100_000 # default value 7 | self.seq_low_bars = 0 8 | self.seq_high_bars = 0 9 | self.signal = None 10 | self.last_position = None 11 | self.volume = 100 # shares 12 | self.high_bars = high_bars 13 | self.low_bars = low_bars 14 | 15 | def handle(self, quote): 16 | if self.signal: 17 | props = { 18 | 'symbol': self.symbol, # current selected symbol 19 | 'otype': self.signal, 20 | 'price': quote.open, 21 | 'volume': self.volume, 22 | 'time': quote.time, 23 | } 24 | if not self.last_position: 25 | self.last_position = Order.open(**props) 26 | elif self.last_position.type != self.signal: 27 | Order.close( 28 | self.last_position, price=quote.open, time=quote.time 29 | ) 30 | self.last_position = Order.open(**props) 31 | self.signal = False 32 | self.seq_high_bars = self.seq_low_bars = 0 33 | 34 | if quote.close > quote.open: 35 | self.seq_high_bars += 1 36 | self.seq_low_bars = 0 37 | else: 38 | self.seq_high_bars = 0 39 | self.seq_low_bars += 1 40 | 41 | if self.seq_high_bars == self.high_bars: 42 | self.signal = Order.BUY 43 | elif self.seq_low_bars == self.low_bars: 44 | self.signal = Order.SELL 45 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 4 | name = "appdirs" 5 | optional = false 6 | python-versions = "*" 7 | version = "1.4.3" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "Atomic file writes." 12 | name = "atomicwrites" 13 | optional = false 14 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 15 | version = "1.3.0" 16 | 17 | [[package]] 18 | category = "dev" 19 | description = "Classes Without Boilerplate" 20 | name = "attrs" 21 | optional = false 22 | python-versions = "*" 23 | version = "18.2.0" 24 | 25 | [[package]] 26 | category = "dev" 27 | description = "The uncompromising code formatter." 28 | name = "black" 29 | optional = false 30 | python-versions = ">=3.6" 31 | version = "18.9b0" 32 | 33 | [package.dependencies] 34 | appdirs = "*" 35 | attrs = ">=17.4.0" 36 | click = ">=6.5" 37 | toml = ">=0.9.4" 38 | 39 | [[package]] 40 | category = "main" 41 | description = "Python package for providing Mozilla's CA Bundle." 42 | name = "certifi" 43 | optional = false 44 | python-versions = "*" 45 | version = "2018.11.29" 46 | 47 | [[package]] 48 | category = "main" 49 | description = "Universal encoding detector for Python 2 and 3" 50 | name = "chardet" 51 | optional = false 52 | python-versions = "*" 53 | version = "3.0.4" 54 | 55 | [[package]] 56 | category = "dev" 57 | description = "Composable command line interface toolkit" 58 | name = "click" 59 | optional = false 60 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 61 | version = "7.0" 62 | 63 | [[package]] 64 | category = "dev" 65 | description = "Cross-platform colored terminal text." 66 | marker = "sys_platform == \"win32\"" 67 | name = "colorama" 68 | optional = false 69 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 70 | version = "0.4.1" 71 | 72 | [[package]] 73 | category = "dev" 74 | description = "Discover and load entry points from installed packages." 75 | name = "entrypoints" 76 | optional = false 77 | python-versions = ">=2.7" 78 | version = "0.3" 79 | 80 | [[package]] 81 | category = "dev" 82 | description = "the modular source code checker: pep8, pyflakes and co" 83 | name = "flake8" 84 | optional = false 85 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 86 | version = "3.7.5" 87 | 88 | [package.dependencies] 89 | entrypoints = ">=0.3.0,<0.4.0" 90 | mccabe = ">=0.6.0,<0.7.0" 91 | pycodestyle = ">=2.5.0,<2.6.0" 92 | pyflakes = ">=2.1.0,<2.2.0" 93 | 94 | [[package]] 95 | category = "main" 96 | description = "Internationalized Domain Names in Applications (IDNA)" 97 | name = "idna" 98 | optional = false 99 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 100 | version = "2.8" 101 | 102 | [[package]] 103 | category = "dev" 104 | description = "A Python utility / library to sort Python imports." 105 | name = "isort" 106 | optional = false 107 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 108 | version = "4.3.4" 109 | 110 | [[package]] 111 | category = "main" 112 | description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." 113 | name = "lxml" 114 | optional = false 115 | python-versions = "*" 116 | version = "4.3.0" 117 | 118 | [[package]] 119 | category = "dev" 120 | description = "McCabe checker, plugin for flake8" 121 | name = "mccabe" 122 | optional = false 123 | python-versions = "*" 124 | version = "0.6.1" 125 | 126 | [[package]] 127 | category = "dev" 128 | description = "More routines for operating on iterables, beyond itertools" 129 | name = "more-itertools" 130 | optional = false 131 | python-versions = "*" 132 | version = "5.0.0" 133 | 134 | [package.dependencies] 135 | six = ">=1.0.0,<2.0.0" 136 | 137 | [[package]] 138 | category = "main" 139 | description = "NumPy is the fundamental package for array computing with Python." 140 | name = "numpy" 141 | optional = false 142 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 143 | version = "1.16.1" 144 | 145 | [[package]] 146 | category = "main" 147 | description = "Powerful data structures for data analysis, time series, and statistics" 148 | name = "pandas" 149 | optional = false 150 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 151 | version = "0.24.1" 152 | 153 | [package.dependencies] 154 | numpy = ">=1.12.0" 155 | python-dateutil = ">=2.5.0" 156 | pytz = ">=2011k" 157 | 158 | [[package]] 159 | category = "main" 160 | description = "Data readers extracted from the pandas codebase,should be compatible with recent pandas versions" 161 | name = "pandas-datareader" 162 | optional = false 163 | python-versions = "*" 164 | version = "0.7.0" 165 | 166 | [package.dependencies] 167 | lxml = "*" 168 | pandas = ">=0.19.2" 169 | requests = ">=2.3.0" 170 | wrapt = "*" 171 | 172 | [[package]] 173 | category = "dev" 174 | description = "plugin and hook calling mechanisms for python" 175 | name = "pluggy" 176 | optional = false 177 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 178 | version = "0.8.1" 179 | 180 | [[package]] 181 | category = "dev" 182 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 183 | name = "py" 184 | optional = false 185 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 186 | version = "1.7.0" 187 | 188 | [[package]] 189 | category = "dev" 190 | description = "Python style guide checker" 191 | name = "pycodestyle" 192 | optional = false 193 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 194 | version = "2.5.0" 195 | 196 | [[package]] 197 | category = "dev" 198 | description = "passive checker of Python programs" 199 | name = "pyflakes" 200 | optional = false 201 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 202 | version = "2.1.0" 203 | 204 | [[package]] 205 | category = "main" 206 | description = "Python bindings for the Qt cross platform UI and application toolkit" 207 | name = "pyqt5" 208 | optional = false 209 | python-versions = "*" 210 | version = "5.11.3" 211 | 212 | [[package]] 213 | category = "main" 214 | description = "Python extension module support for PyQt5" 215 | name = "pyqt5-sip" 216 | optional = false 217 | python-versions = "*" 218 | version = "4.19.13" 219 | 220 | [[package]] 221 | category = "main" 222 | description = "Scientific Graphics and GUI Library for Python" 223 | name = "pyqtgraph" 224 | optional = false 225 | python-versions = "*" 226 | version = "0.10.0" 227 | 228 | [package.dependencies] 229 | numpy = "*" 230 | 231 | [[package]] 232 | category = "dev" 233 | description = "pytest: simple powerful testing with Python" 234 | name = "pytest" 235 | optional = false 236 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 237 | version = "4.2.0" 238 | 239 | [package.dependencies] 240 | atomicwrites = ">=1.0" 241 | attrs = ">=17.4.0" 242 | colorama = "*" 243 | more-itertools = ">=4.0.0" 244 | pluggy = ">=0.7" 245 | py = ">=1.5.0" 246 | setuptools = "*" 247 | six = ">=1.10.0" 248 | 249 | [[package]] 250 | category = "dev" 251 | description = "pytest plugin to check FLAKE8 requirements" 252 | name = "pytest-flake8" 253 | optional = false 254 | python-versions = "*" 255 | version = "1.0.4" 256 | 257 | [package.dependencies] 258 | flake8 = ">=3.5" 259 | pytest = ">=3.5" 260 | 261 | [[package]] 262 | category = "dev" 263 | description = "py.test plugin to check import ordering using isort" 264 | name = "pytest-isort" 265 | optional = false 266 | python-versions = "*" 267 | version = "0.2.1" 268 | 269 | [package.dependencies] 270 | isort = ">=4.0" 271 | pytest = ">=3.0" 272 | 273 | [[package]] 274 | category = "dev" 275 | description = "pytest support for PyQt and PySide applications" 276 | name = "pytest-qt" 277 | optional = false 278 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 279 | version = "3.2.2" 280 | 281 | [package.dependencies] 282 | pytest = ">=2.7.0" 283 | 284 | [[package]] 285 | category = "dev" 286 | description = "Invoke py.test as distutils command with dependency resolution" 287 | name = "pytest-runner" 288 | optional = false 289 | python-versions = ">=2.7,!=3.0,!=3.1" 290 | version = "4.2" 291 | 292 | [[package]] 293 | category = "main" 294 | description = "Extensions to the standard Python datetime module" 295 | name = "python-dateutil" 296 | optional = false 297 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 298 | version = "2.8.0" 299 | 300 | [package.dependencies] 301 | six = ">=1.5" 302 | 303 | [[package]] 304 | category = "main" 305 | description = "World timezone definitions, modern and historical" 306 | name = "pytz" 307 | optional = false 308 | python-versions = "*" 309 | version = "2018.9" 310 | 311 | [[package]] 312 | category = "main" 313 | description = "Python HTTP for Humans." 314 | name = "requests" 315 | optional = false 316 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 317 | version = "2.21.0" 318 | 319 | [package.dependencies] 320 | certifi = ">=2017.4.17" 321 | chardet = ">=3.0.2,<3.1.0" 322 | idna = ">=2.5,<2.9" 323 | urllib3 = ">=1.21.1,<1.25" 324 | 325 | [[package]] 326 | category = "main" 327 | description = "Python 2 and 3 compatibility utilities" 328 | name = "six" 329 | optional = false 330 | python-versions = ">=2.6, !=3.0.*, !=3.1.*" 331 | version = "1.12.0" 332 | 333 | [[package]] 334 | category = "dev" 335 | description = "Python Library for Tom's Obvious, Minimal Language" 336 | name = "toml" 337 | optional = false 338 | python-versions = "*" 339 | version = "0.10.0" 340 | 341 | [[package]] 342 | category = "main" 343 | description = "HTTP library with thread-safe connection pooling, file post, and more." 344 | name = "urllib3" 345 | optional = false 346 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" 347 | version = "1.24.1" 348 | 349 | [[package]] 350 | category = "main" 351 | description = "Module for decorators, wrappers and monkey patching." 352 | name = "wrapt" 353 | optional = false 354 | python-versions = "*" 355 | version = "1.11.1" 356 | 357 | [metadata] 358 | content-hash = "68d52a251b307757a5f411f4c75ea729bf74f2a259ffd58a47b26873d6b68a7e" 359 | python-versions = "^3.6" 360 | 361 | [metadata.hashes] 362 | appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] 363 | atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] 364 | attrs = ["10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", "ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"] 365 | black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] 366 | certifi = ["47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", "993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"] 367 | chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] 368 | click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] 369 | colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] 370 | entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] 371 | flake8 = ["c3ba1e130c813191db95c431a18cb4d20a468e98af7a77e2181b68574481ad36", "fd9ddf503110bf3d8b1d270e8c673aab29ccb3dd6abf29bae1f54e5116ab4a91"] 372 | idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] 373 | isort = ["1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", "b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", "ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"] 374 | lxml = ["0dd6589fa75d369ba06d2b5f38dae107f76ea127f212f6a7bee134f6df2d1d21", "1afbac344aa68c29e81ab56c1a9411c3663157b5aee5065b7fa030b398d4f7e0", "1baad9d073692421ad5dbbd81430aba6c7f5fdc347f03537ae046ddf2c9b2297", "1d8736421a2358becd3edf20260e41a06a0bf08a560480d3a5734a6bcbacf591", "1e1d9bddc5afaddf0de76246d3f2152f961697ad7439c559f179002682c45801", "1f179dc8b2643715f020f4d119d5529b02cd794c1c8f305868b73b8674d2a03f", "241fb7bdf97cb1df1edfa8f0bcdfd80525d4023dac4523a241907c8b2f44e541", "2f9765ee5acd3dbdcdc0d0c79309e01f7c16bc8d39b49250bf88de7b46daaf58", "312e1e1b1c3ce0c67e0b8105317323e12807955e8186872affb667dbd67971f6", "3273db1a8055ca70257fd3691c6d2c216544e1a70b673543e15cc077d8e9c730", "34dfaa8c02891f9a246b17a732ca3e99c5e42802416628e740a5d1cb2f50ff49", "3aa3f5288af349a0f3a96448ebf2e57e17332d99f4f30b02093b7948bd9f94cc", "51102e160b9d83c1cc435162d90b8e3c8c93b28d18d87b60c56522d332d26879", "56115fc2e2a4140e8994eb9585119a1ae9223b506826089a3ba753a62bd194a6", "69d83de14dbe8fe51dccfd36f88bf0b40f5debeac763edf9f8325180190eba6e", "99fdce94aeaa3ccbdfcb1e23b34273605c5853aa92ec23d84c84765178662c6c", "a7c0cd5b8a20f3093ee4a67374ccb3b8a126743b15a4d759e2a1bf098faac2b2", "abe12886554634ed95416a46701a917784cb2b4c77bfacac6916681d49bbf83d", "b4f67b5183bd5f9bafaeb76ad119e977ba570d2b0e61202f534ac9b5c33b4485", "bdd7c1658475cc1b867b36d5c4ed4bc316be8d3368abe03d348ba906a1f83b0e", "c6f24149a19f611a415a51b9bc5f17b6c2f698e0d6b41ffb3fa9f24d35d05d73", "d1e111b3ab98613115a208c1017f266478b0ab224a67bc8eac670fa0bad7d488", "d6520aa965773bbab6cb7a791d5895b00d02cf9adc93ac2bf4edb9ac1a6addc5", "dd185cde2ccad7b649593b0cda72021bc8a91667417001dbaf24cd746ecb7c11", "de2e5b0828a9d285f909b5d2e9d43f1cf6cf21fe65bc7660bdaa1780c7b58298", "f726444b8e909c4f41b4fde416e1071cf28fa84634bfb4befdf400933b6463af"] 375 | mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] 376 | more-itertools = ["38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", "c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", "fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"] 377 | numpy = ["0cdbbaa30ae69281b18dd995d3079c4e552ad6d5426977f66b9a2a95f11f552a", "2b0cca1049bd39d1879fa4d598624cafe82d35529c72de1b3d528d68031cdd95", "31d3fe5b673e99d33d70cfee2ea8fe8dccd60f265c3ed990873a88647e3dd288", "34dd4922aab246c39bf5df03ca653d6265e65971deca6784c956bf356bca6197", "384e2dfa03da7c8d54f8f934f61b6a5e4e1ebb56a65b287567629d6c14578003", "392e2ea22b41a22c0289a88053204b616181288162ba78e6823e1760309d5277", "4341a39fc085f31a583be505eabf00e17c619b469fef78dc7e8241385bfddaa4", "45080f065dcaa573ebecbfe13cdd86e8c0a68c4e999aa06bd365374ea7137706", "485cb1eb4c9962f4cd042fed9424482ec1d83fee5dc2ef3f2552ac47852cb259", "575cefd28d3e0da85b0864506ae26b06483ee4a906e308be5a7ad11083f9d757", "62784b35df7de7ca4d0d81c5b6af5983f48c5cdef32fc3635b445674e56e3266", "69c152f7c11bf3b4fc11bc4cc62eb0334371c0db6844ebace43b7c815b602805", "6ccfdcefd287f252cf1ea7a3f1656070da330c4a5658e43ad223269165cdf977", "7298fbd73c0b3eff1d53dc9b9bdb7add8797bb55eeee38c8ccd7906755ba28af", "79463d918d1bf3aeb9186e3df17ddb0baca443f41371df422f99ee94f4f2bbfe", "8bbee788d82c0ac656536de70e817af09b7694f5326b0ef08e5c1014fcb96bb3", "a863957192855c4c57f60a75a1ac06ce5362ad18506d362dd807e194b4baf3ce", "ae602ba425fb2b074e16d125cdce4f0194903da935b2e7fe284ebecca6d92e76", "b13faa258b20fa66d29011f99fdf498641ca74a0a6d9266bc27d83c70fea4a6a", "c2c39d69266621dd7464e2bb740d6eb5abc64ddc339cc97aa669f3bb4d75c103", "e9c88f173d31909d881a60f08a8494e63f1aff2a4052476b24d4f50e82c47e24", "f1a29267ac29fff0913de0f11f3a9edfcd3f39595f467026c29376fad243ebe3", "f69dde0c5a137d887676a8129373e44366055cf19d1b434e853310c7a1e68f93"] 378 | pandas = ["02c830f951f3dc8c3164e2639a8961881390f7492f71a7835c2330f54539ad57", "179015834c72a577486337394493cc2969feee9a04a2ea09f50c724e4b52ab42", "3894960d43c64cfea5142ac783b101362f5008ee92e962392156a3f8d1558995", "435821cb2501eabbcee7e83614bd710940dc0cf28b5afbc4bdb816c31cec71af", "8294dea9aa1811f93558702856e3b68dd1dfd7e9dbc8e0865918a07ee0f21c2c", "844e745ab27a9a01c86925fe776f9d2e09575e65f0bf8eba5090edddd655dffc", "a08d49f5fa2a2243262fe5581cb89f6c0c7cc525b8d6411719ab9400a9dc4a82", "a435c251246075337eb9fdc4160fd15c8a87cc0679d8d61fb5255d8d5a12f044", "a799f03c0ec6d8687f425d7d6c075e8055a9a808f1ba87604d91f20507631d8d", "aea72ce5b3a016b578cc05c04a2f68d9cafacf5d784b6fe832e66381cb62c719", "c145e94c6da2af7eaf1fd827293ac1090a61a9b80150bebe99f8966a02378db9", "c8a7b470c88c779301b73b23cabdbbd94b83b93040b2ccffa409e06df23831c0", "c9e31b36abbd7b94c547d9047f13e1546e3ba967044cf4f9718575fcb7b81bb6", "d960b7a03c33c328c723cfc2f8902a6291645f4efa0a5c1d4c5fa008cdc1ea77", "da21fae4c173781b012217c9444f13c67449957a4d45184a9718268732c09564", "db26c0fea0bd7d33c356da98bafd2c0dfb8f338e45e2824ff8f4f3e61b5c5f25", "dc296c3f16ec620cfb4daf0f672e3c90f3920ece8261b2760cd0ebd9cd4daa55", "e8da67cb2e9333ec30d53cfb96e27a4865d1648688e5471699070d35d8ab38cf", "fb4f047a63f91f22aade4438aaf790400b96644e802daab4293e9b799802f93f", "fef9939176cba0c2526ebeefffb8b9807543dc0954877b7226f751ec1294a869"] 379 | pandas-datareader = ["6a5ad8c9ca27af148d06ac8eb526914cc12d04ae1d93af423d173279e2226c46", "7dee3fe6fa483c8c2ee4f1af91a65b542c5446d75a6fc25c832cad1ffca8ef0b"] 380 | pluggy = ["8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616", "980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"] 381 | py = ["bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", "e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"] 382 | pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] 383 | pyflakes = ["5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", "f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd"] 384 | pyqt5 = ["517e4339135c4874b799af0d484bc2e8c27b54850113a68eec40a0b56534f450", "ac1eb5a114b6e7788e8be378be41c5e54b17d5158994504e85e43b5fca006a39", "d2309296a5a79d0a1c0e6c387c30f0398b65523a6dcc8a19cc172e46b949e00d", "e85936bae1581bcb908847d2038e5b34237a5e6acc03130099a78930770e7ead"] 385 | pyqt5-sip = ["125f77c087572c9272219cda030a63c2f996b8507592b2a54d7ef9b75f9f054d", "14c37b06e3fb7c2234cb208fa461ec4e62b4ba6d8b32ca3753c0b2cfd61b00e3", "1cb2cf52979f9085fc0eab7e0b2438eb4430d4aea8edec89762527e17317175b", "4babef08bccbf223ec34464e1ed0a23caeaeea390ca9a3529227d9a57f0d6ee4", "53cb9c1208511cda0b9ed11cffee992a5a2f5d96eb88722569b2ce65ecf6b960", "549449d9461d6c665cbe8af4a3808805c5e6e037cd2ce4fd93308d44a049bfac", "5f5b3089b200ff33de3f636b398e7199b57a6b5c1bb724bdb884580a072a14b5", "a4d9bf6e1fa2dd6e73f1873f1a47cee11a6ba0cf9ba8cf7002b28c76823600d0", "a4ee6026216f1fbe25c8847f9e0fbce907df5b908f84816e21af16ec7666e6fe", "a91a308a5e0cc99de1e97afd8f09f46dd7ca20cfaa5890ef254113eebaa1adff", "b0342540da479d2713edc68fb21f307473f68da896ad5c04215dae97630e0069", "f997e21b4e26a3397cb7b255b8d1db5b9772c8e0c94b6d870a5a0ab5c27eacaa"] 386 | pyqtgraph = ["4c08ab34881fae5ecf9ddfe6c1220b9e41e6d3eb1579a7d8ef501abb8e509251"] 387 | pytest = ["65aeaa77ae87c7fc95de56285282546cfa9c886dc8e5dc78313db1c25e21bc07", "6ac6d467d9f053e95aaacd79f831dbecfe730f419c6c7022cb316b365cd9199d"] 388 | pytest-flake8 = ["4d225c13e787471502ff94409dcf6f7927049b2ec251c63b764a4b17447b60c0", "d7e2b6b274a255b7ae35e9224c85294b471a83b76ecb6bd53c337ae977a499af"] 389 | pytest-isort = ["2221c0914dfca41914625a646f0d2d1d4c676861b9a7b26746a7fdd40aa2c59b", "c70d0f900f4647bb714f0843dd82d7f7b759904006de31254efdb72ce88e0c0e"] 390 | pytest-qt = ["05b9c913f373fee19c69d8f2951e5ae3f123d7c3350c88015c8320f484b0b516", "f6ecf4b38088ae1092cbd5beeaf714516d1f81f8938626a2eac546206cdfe7fa"] 391 | pytest-runner = ["d23f117be39919f00dd91bffeb4f15e031ec797501b717a245e377aee0f577be", "d987fec1e31287592ffe1cb823a8c613c533db4c6aaca0ee1191dbc91e2fcc61"] 392 | python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] 393 | pytz = ["32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", "d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c"] 394 | requests = ["502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", "7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"] 395 | six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] 396 | toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] 397 | urllib3 = ["61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", "de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"] 398 | wrapt = ["4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533"] 399 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry>=0.12"] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "quantdom" 7 | version = "0.1.1" 8 | readme = "README.rst" 9 | authors = ["Constverum "] 10 | description = "Simple but powerful backtesting framework, that strives to let you focus on modeling financial strategies, portfolio management, and analyzing backtests." 11 | homepage = "https://github.com/constverum/Quantdom" 12 | repository = "https://github.com/constverum/Quantdom" 13 | documentation = "https://github.com/constverum/Quantdom" 14 | license = "Apache-2.0" 15 | include = [ 16 | "LICENSE", 17 | "README.rst", 18 | "CHANGELOG.rst", 19 | "quantdom/report_rows.json" 20 | ] 21 | keywords = [ 22 | "quant", "quantitative", "backtest", "backtesting", "quantitative-finance", 23 | "trading", "trading-strategies", "trading-platform", "algo", "algotrading", 24 | "algorithmic", "algorithmic-trading", "finance", "fintech", "financial-analysis", 25 | "stocks", "stock-market", "strategy", "market", "investment", "currency", "forex", 26 | "fund", "futures" 27 | ] 28 | classifiers = [ 29 | "Intended Audience :: Developers", 30 | "Programming Language :: Python :: 3.6", 31 | "Programming Language :: Python :: 3.7", 32 | "Operating System :: POSIX", 33 | "Operating System :: MacOS :: MacOS X", 34 | "Operating System :: Microsoft :: Windows", 35 | "Intended Audience :: End Users/Desktop", 36 | "Intended Audience :: Financial and Insurance Industry", 37 | "Intended Audience :: Science/Research", 38 | "Topic :: Office/Business :: Financial", 39 | "Topic :: Office/Business :: Financial :: Investment", 40 | "License :: OSI Approved :: Apache Software License" 41 | ] 42 | 43 | [tool.poetry.dependencies] 44 | python = "^3.6" 45 | PyQt5 = "^5.11" 46 | PyQt5-sip = "^4.19" 47 | pyqtgraph = "^0.10.0" 48 | numpy = "^1.16" 49 | pandas = "^0.24.1" 50 | pandas-datareader = "^0.7.0" 51 | 52 | [tool.poetry.dev-dependencies] 53 | black = {version = "^18.3-alpha.0",allows-prereleases = true} 54 | flake8 = "^3.7" 55 | isort = "^4.3" 56 | pytest = "^4.2" 57 | pytest-qt = "^3.2" 58 | pytest-runner = "^4.2" 59 | pytest-isort = "^0.2.1" 60 | pytest-flake8 = "^1.0" 61 | 62 | [tool.poetry.scripts] 63 | quantdom = "quantdom.cli:cli" 64 | 65 | [tool.black] 66 | py36 = true 67 | line-length = 80 68 | skip-string-normalization = true 69 | include = '\.pyi?$' 70 | exclude = ''' 71 | ( 72 | /( 73 | \.eggs # exclude a few common directories in the 74 | | \.git # root of the project 75 | | build 76 | | dist 77 | ) 78 | ) 79 | ''' 80 | 81 | # https://github.com/pytest-dev/pytest/issues/1556 82 | # https://github.com/pytest-dev/pytest/pull/3686 83 | # [tool.poetry.plugins.pytest] 84 | # addopts = --verbose 85 | # testpaths = tests 86 | # qt_api = pyqt5 87 | 88 | # https://gitlab.com/pycqa/flake8/issues/428 89 | # https://gitlab.com/pycqa/flake8/merge_requests/245 90 | # [tool.flake8] 91 | # max-line-length = 80 92 | 93 | # https://github.com/timothycrosley/isort/issues/760 94 | # [tool.isort] 95 | # line_length = 80 96 | # multi_line_output = 3 97 | # include_trailing_comma = true 98 | # sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 99 | # default_section = "FIRSTPARTY" 100 | -------------------------------------------------------------------------------- /quantdom.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis( 7 | ['quantdom/app.py'], 8 | pathex=['./quantdom'], 9 | binaries=[], 10 | datas=[('./quantdom/report_rows.json', '.')], 11 | hiddenimports=['pandas._libs.tslibs.timedeltas'], 12 | hookspath=[], 13 | runtime_hooks=[], 14 | excludes=['tkinter'], 15 | win_no_prefer_redirects=False, 16 | win_private_assemblies=False, 17 | cipher=block_cipher) 18 | 19 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 20 | 21 | exe = EXE( 22 | pyz, 23 | a.scripts, 24 | a.binaries, 25 | a.zipfiles, 26 | a.datas, 27 | name='quantdom', 28 | debug=False, 29 | strip=False, 30 | upx=True, 31 | runtime_tmpdir=None, 32 | console=False) 33 | 34 | app = BUNDLE( 35 | exe, 36 | name='quantdom.app', 37 | icon=None, 38 | bundle_identifier=None) 39 | -------------------------------------------------------------------------------- /quantdom/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright © 2017-2019 Constverum . All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | __title__ = 'Quantdom' 18 | __version__ = '0.1.1' 19 | 20 | from .ui import * # noqa 21 | from .lib import * # noqa 22 | 23 | __all__ = ui.__all__ + lib.__all__ + (__title__, __version__) # noqa 24 | -------------------------------------------------------------------------------- /quantdom/app.py: -------------------------------------------------------------------------------- 1 | """Application Entry Point.""" 2 | 3 | import logging 4 | import logging.config 5 | import sys 6 | 7 | from PyQt5 import QtGui 8 | 9 | from quantdom import __title__ as title 10 | from quantdom import __version__ as version 11 | from quantdom.ui import MainWidget 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class MainWindow(QtGui.QMainWindow): 17 | 18 | size = (800, 500) 19 | title = '%s %s' % (title, version) 20 | 21 | def __init__(self, parent=None): 22 | super().__init__(parent) 23 | self.main_widget = MainWidget(self) 24 | self.setCentralWidget(self.main_widget) 25 | self.setMinimumSize(*self.size) 26 | self.setWindowTitle(self.title) 27 | self.resize(*self.size) 28 | # setGeometry() 29 | self._move_to_center() 30 | 31 | def _move_to_center(self): 32 | """Move the application window in the center of the screen.""" 33 | desktop = QtGui.QApplication.desktop() 34 | x = (desktop.width() - self.width()) / 2 35 | y = (desktop.height() - self.height()) / 2 36 | self.move(x, y) 37 | 38 | 39 | def main(debug=False): 40 | app = QtGui.QApplication.instance() 41 | if app is None: 42 | app = QtGui.QApplication([]) 43 | app.setApplicationName(title) 44 | app.setApplicationVersion(version) 45 | 46 | window = MainWindow() 47 | window.show() 48 | 49 | if debug: 50 | window.main_widget.plot_test_data() 51 | 52 | sys.exit(app.exec_()) 53 | 54 | 55 | if __name__ == '__main__': 56 | main() 57 | -------------------------------------------------------------------------------- /quantdom/cli.py: -------------------------------------------------------------------------------- 1 | """CLI.""" 2 | 3 | import argparse 4 | import logging 5 | import sys 6 | 7 | from . import __version__ as version 8 | from .app import main 9 | 10 | 11 | def create_parser(): 12 | parser = argparse.ArgumentParser( 13 | prog='quantdom', 14 | add_help=False, 15 | description=''' 16 | Quantdom is a simple but powerful backtesting framework, 17 | that strives to let you focus on modeling financial strategies, 18 | portfolio management, and analyzing backtests.''', 19 | epilog='''Run '%(prog)s --help' 20 | for more information on a command. 21 | Suggestions and bug reports are greatly appreciated: 22 | https://github.com/constverum/Quantdom/issues''', 23 | ) 24 | parser.add_argument( 25 | '--debug', action='store_true', help='Run in debug mode' 26 | ) 27 | parser.add_argument( 28 | '--log', 29 | nargs='?', 30 | default=logging.CRITICAL, 31 | choices=['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], 32 | help='Logging level', 33 | ) 34 | parser.add_argument( 35 | '--version', 36 | '-v', 37 | action='version', 38 | version='%(prog)s {v}'.format(v=version), 39 | help='Show program\'s version number and exit', 40 | ) 41 | parser.add_argument( 42 | '--help', '-h', action='help', help='Show this help message and exit' 43 | ) 44 | return parser 45 | 46 | 47 | def cli(args=sys.argv[1:]): 48 | parser = create_parser() 49 | ns = parser.parse_args(args) 50 | 51 | if ns.debug: 52 | ns.log = logging.DEBUG 53 | 54 | logging.basicConfig( 55 | format='%(asctime)s - %(levelname)s - %(name)s - %(message)s', 56 | datefmt='[%H:%M:%S]', 57 | level=ns.log, 58 | ) 59 | 60 | main(debug=ns.debug) 61 | -------------------------------------------------------------------------------- /quantdom/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # Each of the submodules having an __all__ variable. 2 | 3 | from .base import * # noqa 4 | from .charts import * # noqa 5 | from .const import * # noqa 6 | from .loaders import * # noqa 7 | from .performance import * # noqa 8 | from .portfolio import * # noqa 9 | from .strategy import * # noqa 10 | from .tables import * # noqa 11 | from .utils import * # noqa 12 | 13 | import warnings 14 | 15 | # .performance module - https://github.com/numpy/numpy/issues/8383 16 | warnings.simplefilter(action='ignore', category=FutureWarning) 17 | 18 | 19 | __all__ = ( 20 | base.__all__ # noqa 21 | + charts.__all__ # noqa 22 | + const.__all__ # noqa 23 | + loaders.__all__ # noqa 24 | + performance.__all__ # noqa 25 | + portfolio.__all__ # noqa 26 | + strategy.__all__ # noqa 27 | + tables.__all__ # noqa 28 | + utils.__all__ # noqa 29 | ) 30 | -------------------------------------------------------------------------------- /quantdom/lib/base.py: -------------------------------------------------------------------------------- 1 | """Base classes.""" 2 | 3 | from enum import Enum, auto 4 | 5 | import numpy as np 6 | import pandas as pd 7 | 8 | from .const import ChartType, TimeFrame 9 | 10 | __all__ = ('Indicator', 'Symbol', 'Quotes') 11 | 12 | 13 | class BaseQuotes(np.recarray): 14 | def __new__(cls, shape=None, dtype=None, order='C'): 15 | dt = np.dtype( 16 | [ 17 | ('id', int), 18 | ('time', float), 19 | ('open', float), 20 | ('high', float), 21 | ('low', float), 22 | ('close', float), 23 | ('volume', int), 24 | ] 25 | ) 26 | shape = shape or (1,) 27 | return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) 28 | 29 | def _nan_to_closest_num(self): 30 | """Return interpolated values instead of NaN.""" 31 | for col in ['open', 'high', 'low', 'close']: 32 | mask = np.isnan(self[col]) 33 | if not mask.size: 34 | continue 35 | self[col][mask] = np.interp( 36 | np.flatnonzero(mask), np.flatnonzero(~mask), self[col][~mask] 37 | ) 38 | 39 | def _set_time_frame(self, default_tf): 40 | tf = { 41 | 1: TimeFrame.M1, 42 | 5: TimeFrame.M5, 43 | 15: TimeFrame.M15, 44 | 30: TimeFrame.M30, 45 | 60: TimeFrame.H1, 46 | 240: TimeFrame.H4, 47 | 1440: TimeFrame.D1, 48 | } 49 | minutes = int(np.diff(self.time[-10:]).min() / 60) 50 | self.timeframe = tf.get(minutes) or tf[default_tf] 51 | 52 | def new(self, data, source=None, default_tf=None): 53 | shape = (len(data),) 54 | self.resize(shape, refcheck=False) 55 | 56 | if isinstance(data, pd.DataFrame): 57 | data.reset_index(inplace=True) 58 | data.insert(0, 'id', data.index) 59 | data.Date = self.convert_dates(data.Date) 60 | data = data.rename( 61 | columns={ 62 | 'Date': 'time', 63 | 'Open': 'open', 64 | 'High': 'high', 65 | 'Low': 'low', 66 | 'Close': 'close', 67 | 'Volume': 'volume', 68 | } 69 | ) 70 | for name in self.dtype.names: 71 | self[name] = data[name] 72 | elif isinstance(data, (np.recarray, BaseQuotes)): 73 | self[:] = data[:] 74 | 75 | self._nan_to_closest_num() 76 | self._set_time_frame(default_tf) 77 | return self 78 | 79 | def convert_dates(self, dates): 80 | return np.array([d.timestamp() for d in dates]) 81 | 82 | 83 | class SymbolType(Enum): 84 | FOREX = auto() 85 | CFD = auto() 86 | FUTURES = auto() 87 | SHARES = auto() 88 | 89 | 90 | class Symbol: 91 | 92 | FOREX = SymbolType.FOREX 93 | CFD = SymbolType.CFD 94 | FUTURES = SymbolType.FUTURES 95 | SHARES = SymbolType.SHARES 96 | 97 | def __init__(self, ticker, mode, tick_size=0, tick_value=None): 98 | self.ticker = ticker 99 | self.mode = mode 100 | if self.mode in [self.FOREX, self.CFD]: 101 | # number of units of the commodity, currency 102 | # or financial asset in one lot 103 | self.contract_size = 100_000 # (100000 == 1 Lot) 104 | elif self.mode == self.FUTURES: 105 | # cost of a single price change point ($10) / 106 | # one minimum price movement 107 | self.tick_value = tick_value 108 | # minimum price change step (0.0001) 109 | self.tick_size = tick_size 110 | if isinstance(tick_size, float): 111 | self.digits = len(str(tick_size).split('.')[1]) 112 | else: 113 | self.digits = 0 114 | 115 | def __repr__(self): 116 | return 'Symbol (%s | %s)' % (self.ticker, self.mode) 117 | 118 | 119 | class Indicator: 120 | def __init__( 121 | self, label=None, window=None, data=None, tp=None, base=None, **kwargs 122 | ): 123 | self.label = label 124 | self.window = window 125 | self.data = data or [0] 126 | self.type = tp or ChartType.LINE 127 | self.base = base or {'linewidth': 0.5, 'color': 'black'} 128 | self.lineStyle = {'linestyle': '-', 'linewidth': 0.5, 'color': 'blue'} 129 | self.lineStyle.update(kwargs) 130 | 131 | 132 | Quotes = BaseQuotes() 133 | -------------------------------------------------------------------------------- /quantdom/lib/charts.py: -------------------------------------------------------------------------------- 1 | """Chart.""" 2 | 3 | import numpy as np 4 | import pyqtgraph as pg 5 | from PyQt5 import QtCore, QtGui 6 | 7 | from .base import Quotes 8 | from .const import ChartType 9 | from .portfolio import Order, Portfolio 10 | from .utils import fromtimestamp, timeit 11 | 12 | __all__ = ('QuotesChart', 'EquityChart') 13 | 14 | 15 | pg.setConfigOption('background', 'w') 16 | CHART_MARGINS = (0, 0, 20, 5) 17 | 18 | 19 | class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample): 20 | def paint(self, p, *args): 21 | p.setRenderHint(p.Antialiasing) 22 | if isinstance(self.item, tuple): 23 | positive = self.item[0].opts 24 | negative = self.item[1].opts 25 | p.setPen(pg.mkPen(positive['pen'])) 26 | p.setBrush(pg.mkBrush(positive['brush'])) 27 | p.drawPolygon( 28 | QtGui.QPolygonF( 29 | [ 30 | QtCore.QPointF(0, 0), 31 | QtCore.QPointF(18, 0), 32 | QtCore.QPointF(18, 18), 33 | ] 34 | ) 35 | ) 36 | p.setPen(pg.mkPen(negative['pen'])) 37 | p.setBrush(pg.mkBrush(negative['brush'])) 38 | p.drawPolygon( 39 | QtGui.QPolygonF( 40 | [ 41 | QtCore.QPointF(0, 0), 42 | QtCore.QPointF(0, 18), 43 | QtCore.QPointF(18, 18), 44 | ] 45 | ) 46 | ) 47 | else: 48 | opts = self.item.opts 49 | p.setPen(pg.mkPen(opts['pen'])) 50 | p.drawRect(0, 10, 18, 0.5) 51 | 52 | 53 | class PriceAxis(pg.AxisItem): 54 | def __init__(self): 55 | super().__init__(orientation='right') 56 | self.style.update({'textFillLimits': [(0, 0.8)]}) 57 | 58 | def tickStrings(self, vals, scale, spacing): 59 | digts = max(0, np.ceil(-np.log10(spacing * scale))) 60 | return [ 61 | ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals 62 | ] 63 | 64 | 65 | class DateAxis(pg.AxisItem): 66 | tick_tpl = {'D1': '%d %b\n%Y'} 67 | 68 | def __init__(self, *args, **kwargs): 69 | super().__init__(*args, **kwargs) 70 | self.quotes_count = len(Quotes) - 1 71 | 72 | def tickStrings(self, values, scale, spacing): 73 | s_period = 'D1' 74 | strings = [] 75 | for ibar in values: 76 | if ibar > self.quotes_count: 77 | return strings 78 | dt_tick = fromtimestamp(Quotes[int(ibar)].time) 79 | strings.append(dt_tick.strftime(self.tick_tpl[s_period])) 80 | return strings 81 | 82 | 83 | class CenteredTextItem(QtGui.QGraphicsTextItem): 84 | def __init__( 85 | self, 86 | text='', 87 | parent=None, 88 | pos=(0, 0), 89 | pen=None, 90 | brush=None, 91 | valign=None, 92 | opacity=0.1, 93 | ): 94 | super().__init__(text, parent) 95 | 96 | self.pen = pen 97 | self.brush = brush 98 | self.opacity = opacity 99 | self.valign = valign 100 | self.text_flags = QtCore.Qt.AlignCenter 101 | self.setPos(*pos) 102 | self.setFlag(self.ItemIgnoresTransformations) 103 | 104 | def boundingRect(self): # noqa 105 | r = super().boundingRect() 106 | if self.valign == QtCore.Qt.AlignTop: 107 | return QtCore.QRectF(-r.width() / 2, -37, r.width(), r.height()) 108 | elif self.valign == QtCore.Qt.AlignBottom: 109 | return QtCore.QRectF(-r.width() / 2, 15, r.width(), r.height()) 110 | 111 | def paint(self, p, option, widget): 112 | p.setRenderHint(p.Antialiasing, False) 113 | p.setRenderHint(p.TextAntialiasing, True) 114 | p.setPen(self.pen) 115 | if self.brush.style() != QtCore.Qt.NoBrush: 116 | p.setOpacity(self.opacity) 117 | p.fillRect(option.rect, self.brush) 118 | p.setOpacity(1) 119 | p.drawText(option.rect, self.text_flags, self.toPlainText()) 120 | 121 | 122 | class AxisLabel(pg.GraphicsObject): 123 | 124 | bg_color = pg.mkColor('#dbdbdb') 125 | fg_color = pg.mkColor('#000000') 126 | 127 | def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs): 128 | super().__init__(parent) 129 | self.parent = parent 130 | self.opacity = opacity 131 | self.label_str = '' 132 | self.digits = digits 133 | self.quotes_count = len(Quotes) - 1 134 | if isinstance(color, QtGui.QPen): 135 | self.bg_color = color.color() 136 | self.fg_color = pg.mkColor('#ffffff') 137 | elif isinstance(color, list): 138 | self.bg_color = {'>0': color[0].color(), '<0': color[1].color()} 139 | self.fg_color = pg.mkColor('#ffffff') 140 | self.setFlag(self.ItemIgnoresTransformations) 141 | 142 | def tick_to_string(self, tick_pos): 143 | raise NotImplementedError() 144 | 145 | def boundingRect(self): # noqa 146 | raise NotImplementedError() 147 | 148 | def update_label(self, evt_post, point_view): 149 | raise NotImplementedError() 150 | 151 | def update_label_test(self, ypos=0, ydata=0): 152 | self.label_str = self.tick_to_string(ydata) 153 | height = self.boundingRect().height() 154 | offset = 0 # if have margins 155 | new_pos = QtCore.QPointF(0, ypos - height / 2 - offset) 156 | self.setPos(new_pos) 157 | 158 | def paint(self, p, option, widget): 159 | p.setRenderHint(p.TextAntialiasing, True) 160 | p.setPen(self.fg_color) 161 | if self.label_str: 162 | if not isinstance(self.bg_color, dict): 163 | bg_color = self.bg_color 164 | else: 165 | if int(self.label_str.replace(' ', '')) > 0: 166 | bg_color = self.bg_color['>0'] 167 | else: 168 | bg_color = self.bg_color['<0'] 169 | p.setOpacity(self.opacity) 170 | p.fillRect(option.rect, bg_color) 171 | p.setOpacity(1) 172 | p.drawText(option.rect, self.text_flags, self.label_str) 173 | 174 | 175 | class XAxisLabel(AxisLabel): 176 | 177 | text_flags = ( 178 | QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop 179 | ) 180 | 181 | def tick_to_string(self, tick_pos): 182 | # TODO: change to actual period 183 | tpl = self.parent.tick_tpl['D1'] 184 | return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl) 185 | 186 | def boundingRect(self): # noqa 187 | return QtCore.QRectF(0, 0, 60, 38) 188 | 189 | def update_label(self, evt_post, point_view): 190 | ibar = point_view.x() 191 | if ibar > self.quotes_count: 192 | return 193 | self.label_str = self.tick_to_string(ibar) 194 | width = self.boundingRect().width() 195 | offset = 0 # if have margins 196 | new_pos = QtCore.QPointF(evt_post.x() - width / 2 - offset, 0) 197 | self.setPos(new_pos) 198 | 199 | 200 | class YAxisLabel(AxisLabel): 201 | 202 | text_flags = ( 203 | QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter 204 | ) 205 | 206 | def tick_to_string(self, tick_pos): 207 | return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ') 208 | 209 | def boundingRect(self): # noqa 210 | return QtCore.QRectF(0, 0, 74, 24) 211 | 212 | def update_label(self, evt_post, point_view): 213 | self.label_str = self.tick_to_string(point_view.y()) 214 | height = self.boundingRect().height() 215 | offset = 0 # if have margins 216 | new_pos = QtCore.QPointF(0, evt_post.y() - height / 2 - offset) 217 | self.setPos(new_pos) 218 | 219 | 220 | class CustomPlotWidget(pg.PlotWidget): 221 | sig_mouse_leave = QtCore.Signal(object) 222 | sig_mouse_enter = QtCore.Signal(object) 223 | 224 | def enterEvent(self, ev): # noqa 225 | self.sig_mouse_enter.emit(self) 226 | 227 | def leaveEvent(self, ev): # noqa 228 | self.sig_mouse_leave.emit(self) 229 | self.scene().leaveEvent(ev) 230 | 231 | 232 | class CrossHairItem(pg.GraphicsObject): 233 | def __init__(self, parent, indicators=None, digits=0): 234 | super().__init__() 235 | self.pen = pg.mkPen('#000000') 236 | self.parent = parent 237 | self.indicators = {} 238 | self.activeIndicator = None 239 | self.xaxis = self.parent.getAxis('bottom') 240 | self.yaxis = self.parent.getAxis('right') 241 | 242 | self.vline = self.parent.addLine(x=0, pen=self.pen, movable=False) 243 | self.hline = self.parent.addLine(y=0, pen=self.pen, movable=False) 244 | 245 | self.proxy_moved = pg.SignalProxy( 246 | self.parent.scene().sigMouseMoved, 247 | rateLimit=60, 248 | slot=self.mouseMoved, 249 | ) 250 | 251 | self.yaxis_label = YAxisLabel( 252 | parent=self.yaxis, digits=digits, opacity=1 253 | ) 254 | 255 | indicators = indicators or [] 256 | if indicators: 257 | last_ind = indicators[-1] 258 | self.xaxis_label = XAxisLabel( 259 | parent=last_ind.getAxis('bottom'), opacity=1 260 | ) 261 | self.proxy_enter = pg.SignalProxy( 262 | self.parent.sig_mouse_enter, 263 | rateLimit=60, 264 | slot=lambda: self.mouseAction('Enter', False), 265 | ) 266 | self.proxy_leave = pg.SignalProxy( 267 | self.parent.sig_mouse_leave, 268 | rateLimit=60, 269 | slot=lambda: self.mouseAction('Leave', False), 270 | ) 271 | else: 272 | self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1) 273 | 274 | for i in indicators: 275 | vl = i.addLine(x=0, pen=self.pen, movable=False) 276 | hl = i.addLine(y=0, pen=self.pen, movable=False) 277 | yl = YAxisLabel(parent=i.getAxis('right'), opacity=1) 278 | px_moved = pg.SignalProxy( 279 | i.scene().sigMouseMoved, rateLimit=60, slot=self.mouseMoved 280 | ) 281 | px_enter = pg.SignalProxy( 282 | i.sig_mouse_enter, 283 | rateLimit=60, 284 | slot=lambda: self.mouseAction('Enter', i), 285 | ) 286 | px_leave = pg.SignalProxy( 287 | i.sig_mouse_leave, 288 | rateLimit=60, 289 | slot=lambda: self.mouseAction('Leave', i), 290 | ) 291 | self.indicators[i] = { 292 | 'vl': vl, 293 | 'hl': hl, 294 | 'yl': yl, 295 | 'px': (px_moved, px_enter, px_leave), 296 | } 297 | 298 | def mouseAction(self, action, ind=False): # noqa 299 | if action == 'Enter': 300 | if ind: 301 | self.indicators[ind]['hl'].show() 302 | self.indicators[ind]['yl'].show() 303 | self.activeIndicator = ind 304 | else: 305 | self.yaxis_label.show() 306 | self.hline.show() 307 | else: # Leave 308 | if ind: 309 | self.indicators[ind]['hl'].hide() 310 | self.indicators[ind]['yl'].hide() 311 | self.activeIndicator = None 312 | else: 313 | self.yaxis_label.hide() 314 | self.hline.hide() 315 | 316 | def mouseMoved(self, evt): # noqa 317 | pos = evt[0] 318 | if self.parent.sceneBoundingRect().contains(pos): 319 | # mouse_point = self.vb.mapSceneToView(pos) 320 | mouse_point = self.parent.mapToView(pos) 321 | self.vline.setX(mouse_point.x()) 322 | self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point) 323 | for opts in self.indicators.values(): 324 | opts['vl'].setX(mouse_point.x()) 325 | 326 | if self.activeIndicator: 327 | mouse_point_ind = self.activeIndicator.mapToView(pos) 328 | self.indicators[self.activeIndicator]['hl'].setY( 329 | mouse_point_ind.y() 330 | ) 331 | self.indicators[self.activeIndicator]['yl'].update_label( 332 | evt_post=pos, point_view=mouse_point_ind 333 | ) 334 | else: 335 | self.hline.setY(mouse_point.y()) 336 | self.yaxis_label.update_label( 337 | evt_post=pos, point_view=mouse_point 338 | ) 339 | 340 | def paint(self, p, *args): 341 | pass 342 | 343 | def boundingRect(self): 344 | return self.parent.boundingRect() 345 | 346 | 347 | class BarItem(pg.GraphicsObject): 348 | 349 | w = 0.35 350 | bull_brush = pg.mkBrush('#00cc00') 351 | bear_brush = pg.mkBrush('#fa0000') 352 | 353 | def __init__(self): 354 | super().__init__() 355 | self.generatePicture() 356 | 357 | def _generate(self, p): 358 | hl = np.array( 359 | [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] 360 | ) 361 | op = np.array( 362 | [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) for q in Quotes] 363 | ) 364 | cl = np.array( 365 | [ 366 | QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) 367 | for q in Quotes 368 | ] 369 | ) 370 | lines = np.concatenate([hl, op, cl]) 371 | long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) 372 | short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) 373 | 374 | p.setPen(self.bull_brush) 375 | p.drawLines(*lines[long_bars]) 376 | 377 | p.setPen(self.bear_brush) 378 | p.drawLines(*lines[short_bars]) 379 | 380 | @timeit 381 | def generatePicture(self): 382 | self.picture = QtGui.QPicture() 383 | p = QtGui.QPainter(self.picture) 384 | self._generate(p) 385 | p.end() 386 | 387 | def paint(self, p, *args): 388 | p.drawPicture(0, 0, self.picture) 389 | 390 | def boundingRect(self): 391 | return QtCore.QRectF(self.picture.boundingRect()) 392 | 393 | 394 | class CandlestickItem(BarItem): 395 | 396 | w2 = 0.7 397 | line_pen = pg.mkPen('#000000') 398 | bull_brush = pg.mkBrush('#00ff00') 399 | bear_brush = pg.mkBrush('#ff0000') 400 | 401 | def _generate(self, p): 402 | rects = np.array( 403 | [ 404 | QtCore.QRectF(q.id - self.w, q.open, self.w2, q.close - q.open) 405 | for q in Quotes 406 | ] 407 | ) 408 | 409 | p.setPen(self.line_pen) 410 | p.drawLines([QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]) 411 | 412 | p.setBrush(self.bull_brush) 413 | p.drawRects(*rects[Quotes.close > Quotes.open]) 414 | 415 | p.setBrush(self.bear_brush) 416 | p.drawRects(*rects[Quotes.close < Quotes.open]) 417 | 418 | 419 | class QuotesChart(QtGui.QWidget): 420 | 421 | long_pen = pg.mkPen('#006000') 422 | long_brush = pg.mkBrush('#00ff00') 423 | short_pen = pg.mkPen('#600000') 424 | short_brush = pg.mkBrush('#ff0000') 425 | 426 | zoomIsDisabled = QtCore.pyqtSignal(bool) 427 | 428 | def __init__(self): 429 | super().__init__() 430 | self.signals_visible = False 431 | self.style = ChartType.CANDLESTICK 432 | self.indicators = [] 433 | 434 | self.xaxis = DateAxis(orientation='bottom') 435 | self.xaxis.setStyle( 436 | tickTextOffset=7, textFillLimits=[(0, 0.80)], showValues=False 437 | ) 438 | 439 | self.xaxis_ind = DateAxis(orientation='bottom') 440 | self.xaxis_ind.setStyle(tickTextOffset=7, textFillLimits=[(0, 0.80)]) 441 | 442 | self.layout = QtGui.QVBoxLayout(self) 443 | self.layout.setContentsMargins(0, 0, 0, 0) 444 | 445 | self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical) 446 | self.splitter.setHandleWidth(4) 447 | 448 | self.layout.addWidget(self.splitter) 449 | 450 | def _show_text_signals(self, lbar, rbar): 451 | signals = [ 452 | sig 453 | for sig in self.signals_text_items[lbar:rbar] 454 | if isinstance(sig, CenteredTextItem) 455 | ] 456 | if len(signals) <= 50: 457 | for sig in signals: 458 | sig.show() 459 | else: 460 | for sig in signals: 461 | sig.hide() 462 | 463 | def _remove_signals(self): 464 | self.chart.removeItem(self.signals_group_arrow) 465 | self.chart.removeItem(self.signals_group_text) 466 | del self.signals_text_items 467 | del self.signals_group_arrow 468 | del self.signals_group_text 469 | self.signals_visible = False 470 | 471 | def _update_quotes_chart(self): 472 | self.chart.hideAxis('left') 473 | self.chart.showAxis('right') 474 | self.chart.addItem(_get_chart_points(self.style)) 475 | self.chart.setLimits( 476 | xMin=Quotes[0].id, 477 | xMax=Quotes[-1].id, 478 | minXRange=60, 479 | yMin=Quotes.low.min() * 0.98, 480 | yMax=Quotes.high.max() * 1.02, 481 | ) 482 | self.chart.showGrid(x=True, y=True) 483 | self.chart.setCursor(QtCore.Qt.BlankCursor) 484 | self.chart.sigXRangeChanged.connect(self._update_yrange_limits) 485 | 486 | def _update_ind_charts(self): 487 | for ind, d in self.indicators: 488 | curve = pg.PlotDataItem(d, pen='b', antialias=True) 489 | ind.addItem(curve) 490 | ind.hideAxis('left') 491 | ind.showAxis('right') 492 | # ind.setAspectLocked(1) 493 | ind.setXLink(self.chart) 494 | ind.setLimits( 495 | xMin=Quotes[0].id, 496 | xMax=Quotes[-1].id, 497 | minXRange=60, 498 | yMin=Quotes.open.min() * 0.98, 499 | yMax=Quotes.open.max() * 1.02, 500 | ) 501 | ind.showGrid(x=True, y=True) 502 | ind.setCursor(QtCore.Qt.BlankCursor) 503 | 504 | def _update_sizes(self): 505 | min_h_ind = int(self.height() * 0.3 / len(self.indicators)) 506 | sizes = [int(self.height() * 0.7)] 507 | sizes.extend([min_h_ind] * len(self.indicators)) 508 | self.splitter.setSizes(sizes) # , int(self.height()*0.2) 509 | 510 | def _update_yrange_limits(self): 511 | vr = self.chart.viewRect() 512 | lbar, rbar = int(vr.left()), int(vr.right()) 513 | if self.signals_visible: 514 | self._show_text_signals(lbar, rbar) 515 | bars = Quotes[lbar:rbar] 516 | ylow = bars.low.min() * 0.98 517 | yhigh = bars.high.max() * 1.02 518 | 519 | std = np.std(bars.close) 520 | self.chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std) 521 | self.chart.setYRange(ylow, yhigh) 522 | for i, d in self.indicators: 523 | # ydata = i.plotItem.items[0].getData()[1] 524 | ydata = d[lbar:rbar] 525 | ylow = ydata.min() * 0.98 526 | yhigh = ydata.max() * 1.02 527 | std = np.std(ydata) 528 | i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std) 529 | i.setYRange(ylow, yhigh) 530 | 531 | def plot(self, symbol): 532 | self.digits = symbol.digits 533 | self.chart = CustomPlotWidget( 534 | parent=self.splitter, 535 | axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, 536 | enableMenu=False, 537 | ) 538 | self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) 539 | self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) 540 | 541 | inds = [Quotes.open] 542 | 543 | for d in inds: 544 | ind = CustomPlotWidget( 545 | parent=self.splitter, 546 | axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, 547 | enableMenu=False, 548 | ) 549 | ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) 550 | ind.getPlotItem().setContentsMargins(*CHART_MARGINS) 551 | # self.splitter.addWidget(ind) 552 | self.indicators.append((ind, d)) 553 | 554 | self._update_quotes_chart() 555 | self._update_ind_charts() 556 | self._update_sizes() 557 | 558 | ch = CrossHairItem( 559 | self.chart, [_ind for _ind, d in self.indicators], self.digits 560 | ) 561 | self.chart.addItem(ch) 562 | 563 | def add_signals(self): 564 | self.signals_group_text = QtGui.QGraphicsItemGroup() 565 | self.signals_group_arrow = QtGui.QGraphicsItemGroup() 566 | self.signals_text_items = np.empty(len(Quotes), dtype=object) 567 | 568 | for p in Portfolio.positions: 569 | x, price = p.id_bar_open, p.open_price 570 | if p.type == Order.BUY: 571 | y = Quotes[x].low * 0.99 572 | pg.ArrowItem( 573 | parent=self.signals_group_arrow, 574 | pos=(x, y), 575 | pen=self.long_pen, 576 | brush=self.long_brush, 577 | angle=90, 578 | headLen=12, 579 | tipAngle=50, 580 | ) 581 | text_sig = CenteredTextItem( 582 | parent=self.signals_group_text, 583 | pos=(x, y), 584 | pen=self.long_pen, 585 | brush=self.long_brush, 586 | text=('Buy at {:.%df}' % self.digits).format(price), 587 | valign=QtCore.Qt.AlignBottom, 588 | ) 589 | text_sig.hide() 590 | else: 591 | y = Quotes[x].high * 1.01 592 | pg.ArrowItem( 593 | parent=self.signals_group_arrow, 594 | pos=(x, y), 595 | pen=self.short_pen, 596 | brush=self.short_brush, 597 | angle=-90, 598 | headLen=12, 599 | tipAngle=50, 600 | ) 601 | text_sig = CenteredTextItem( 602 | parent=self.signals_group_text, 603 | pos=(x, y), 604 | pen=self.short_pen, 605 | brush=self.short_brush, 606 | text=('Sell at {:.%df}' % self.digits).format(price), 607 | valign=QtCore.Qt.AlignTop, 608 | ) 609 | text_sig.hide() 610 | 611 | self.signals_text_items[x] = text_sig 612 | 613 | self.chart.addItem(self.signals_group_arrow) 614 | self.chart.addItem(self.signals_group_text) 615 | self.signals_visible = True 616 | 617 | 618 | class EquityChart(QtGui.QWidget): 619 | 620 | eq_pen_pos_color = pg.mkColor('#00cc00') 621 | eq_pen_neg_color = pg.mkColor('#cc0000') 622 | eq_brush_pos_color = pg.mkColor('#40ee40') 623 | eq_brush_neg_color = pg.mkColor('#ee4040') 624 | long_pen_color = pg.mkColor('#008000') 625 | short_pen_color = pg.mkColor('#800000') 626 | buy_and_hold_pen_color = pg.mkColor('#4444ff') 627 | 628 | def __init__(self): 629 | super().__init__() 630 | self.xaxis = DateAxis(orientation='bottom') 631 | self.xaxis.setStyle(tickTextOffset=7, textFillLimits=[(0, 0.80)]) 632 | self.yaxis = PriceAxis() 633 | 634 | self.layout = QtGui.QVBoxLayout(self) 635 | self.layout.setContentsMargins(0, 0, 0, 0) 636 | 637 | self.chart = pg.PlotWidget( 638 | axisItems={'bottom': self.xaxis, 'right': self.yaxis}, 639 | enableMenu=False, 640 | ) 641 | self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) 642 | self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) 643 | self.chart.showGrid(x=True, y=True) 644 | self.chart.hideAxis('left') 645 | self.chart.showAxis('right') 646 | 647 | self.chart.setCursor(QtCore.Qt.BlankCursor) 648 | self.chart.sigXRangeChanged.connect(self._update_yrange_limits) 649 | 650 | self.layout.addWidget(self.chart) 651 | 652 | def _add_legend(self): 653 | legend = pg.LegendItem((140, 100), offset=(10, 10)) 654 | legend.setParentItem(self.chart.getPlotItem()) 655 | 656 | for arr, item in self.curves: 657 | legend.addItem( 658 | SampleLegendItem(item), 659 | item.opts['name'] 660 | if not isinstance(item, tuple) 661 | else item[0].opts['name'], 662 | ) 663 | 664 | def _add_ylabels(self): 665 | self.ylabels = [] 666 | for arr, item in self.curves: 667 | color = ( 668 | item.opts['pen'] 669 | if not isinstance(item, tuple) 670 | else [i.opts['pen'] for i in item] 671 | ) 672 | label = YAxisLabel(parent=self.yaxis, color=color) 673 | self.ylabels.append(label) 674 | 675 | def _update_ylabels(self, vb, rbar): 676 | for i, curve in enumerate(self.curves): 677 | arr, item = curve 678 | ylast = arr[rbar] 679 | ypos = vb.mapFromView(QtCore.QPointF(0, ylast)).y() 680 | axlabel = self.ylabels[i] 681 | axlabel.update_label_test(ypos=ypos, ydata=ylast) 682 | 683 | def _update_yrange_limits(self, vb=None): 684 | if not hasattr(self, 'min_curve'): 685 | return 686 | vr = self.chart.viewRect() 687 | lbar, rbar = int(vr.left()), int(vr.right()) 688 | ylow = self.min_curve[lbar:rbar].min() * 1.1 689 | yhigh = self.max_curve[lbar:rbar].max() * 1.1 690 | 691 | std = np.std(self.max_curve[lbar:rbar]) * 4 692 | self.chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std) 693 | self.chart.setYRange(ylow, yhigh) 694 | self._update_ylabels(vb, rbar) 695 | 696 | @timeit 697 | def plot(self): 698 | equity_curve = Portfolio.equity_curve 699 | eq_pos = np.zeros_like(equity_curve) 700 | eq_neg = np.zeros_like(equity_curve) 701 | eq_pos[equity_curve >= 0] = equity_curve[equity_curve >= 0] 702 | eq_neg[equity_curve <= 0] = equity_curve[equity_curve <= 0] 703 | 704 | # Equity 705 | self.eq_pos_curve = pg.PlotCurveItem( 706 | eq_pos, 707 | name='Equity', 708 | fillLevel=0, 709 | antialias=True, 710 | pen=self.eq_pen_pos_color, 711 | brush=self.eq_brush_pos_color, 712 | ) 713 | self.eq_neg_curve = pg.PlotCurveItem( 714 | eq_neg, 715 | name='Equity', 716 | fillLevel=0, 717 | antialias=True, 718 | pen=self.eq_pen_neg_color, 719 | brush=self.eq_brush_neg_color, 720 | ) 721 | self.chart.addItem(self.eq_pos_curve) 722 | self.chart.addItem(self.eq_neg_curve) 723 | 724 | # Only Long 725 | self.long_curve = pg.PlotCurveItem( 726 | Portfolio.long_curve, 727 | name='Only Long', 728 | pen=self.long_pen_color, 729 | antialias=True, 730 | ) 731 | self.chart.addItem(self.long_curve) 732 | 733 | # Only Short 734 | self.short_curve = pg.PlotCurveItem( 735 | Portfolio.short_curve, 736 | name='Only Short', 737 | pen=self.short_pen_color, 738 | antialias=True, 739 | ) 740 | self.chart.addItem(self.short_curve) 741 | 742 | # Buy and Hold 743 | self.buy_and_hold_curve = pg.PlotCurveItem( 744 | Portfolio.buy_and_hold_curve, 745 | name='Buy and Hold', 746 | pen=self.buy_and_hold_pen_color, 747 | antialias=True, 748 | ) 749 | self.chart.addItem(self.buy_and_hold_curve) 750 | 751 | self.curves = [ 752 | (Portfolio.equity_curve, (self.eq_pos_curve, self.eq_neg_curve)), 753 | (Portfolio.long_curve, self.long_curve), 754 | (Portfolio.short_curve, self.short_curve), 755 | (Portfolio.buy_and_hold_curve, self.buy_and_hold_curve), 756 | ] 757 | 758 | self._add_legend() 759 | self._add_ylabels() 760 | 761 | ch = CrossHairItem(self.chart) 762 | self.chart.addItem(ch) 763 | 764 | arrs = ( 765 | Portfolio.equity_curve, 766 | Portfolio.buy_and_hold_curve, 767 | Portfolio.long_curve, 768 | Portfolio.short_curve, 769 | ) 770 | np_arrs = np.concatenate(arrs) 771 | _min = abs(np_arrs.min()) * -1.1 772 | _max = np_arrs.max() * 1.1 773 | 774 | self.chart.setLimits( 775 | xMin=Quotes[0].id, 776 | xMax=Quotes[-1].id, 777 | yMin=_min, 778 | yMax=_max, 779 | minXRange=60, 780 | ) 781 | 782 | self.min_curve = arrs[0].copy() 783 | self.max_curve = arrs[0].copy() 784 | for arr in arrs[1:]: 785 | self.min_curve = np.minimum(self.min_curve, arr) 786 | self.max_curve = np.maximum(self.max_curve, arr) 787 | 788 | 789 | def _get_chart_points(style): 790 | if style == ChartType.CANDLESTICK: 791 | return CandlestickItem() 792 | elif style == ChartType.BAR: 793 | return BarItem() 794 | return pg.PlotDataItem(Quotes.close, pen='b') 795 | -------------------------------------------------------------------------------- /quantdom/lib/const.py: -------------------------------------------------------------------------------- 1 | """Constants.""" 2 | 3 | from enum import Enum, auto 4 | 5 | __all__ = ('ChartType', 'TimeFrame') 6 | 7 | 8 | class ChartType(Enum): 9 | BAR = auto() 10 | CANDLESTICK = auto() 11 | LINE = auto() 12 | 13 | 14 | class TimeFrame(Enum): 15 | M1 = auto() 16 | M5 = auto() 17 | M15 = auto() 18 | M30 = auto() 19 | H1 = auto() 20 | H4 = auto() 21 | D1 = auto() 22 | W1 = auto() 23 | MN = auto() 24 | 25 | 26 | ANNUAL_PERIOD = 252 # number of trading days in a year 27 | 28 | # # TODO: 6.5 - US trading hours (trading session); fix it for fx 29 | # ANNUALIZATION_FACTORS = { 30 | # TimeFrame.M1: int(252 * 6.5 * 60), 31 | # TimeFrame.M5: int(252 * 6.5 * 12), 32 | # TimeFrame.M15: int(252 * 6.5 * 4), 33 | # TimeFrame.M30: int(252 * 6.5 * 2), 34 | # TimeFrame.H1: int(252 * 6.5), 35 | # TimeFrame.D1: 252, 36 | # } 37 | -------------------------------------------------------------------------------- /quantdom/lib/loaders.py: -------------------------------------------------------------------------------- 1 | """Parser.""" 2 | 3 | import logging 4 | import os.path 5 | import pickle 6 | 7 | import pandas as pd 8 | import pandas_datareader.data as web 9 | from pandas_datareader._utils import RemoteDataError 10 | from pandas_datareader.data import ( 11 | get_data_google, 12 | get_data_quandl, 13 | get_data_yahoo, 14 | get_data_alphavantage, 15 | ) 16 | from pandas_datareader.nasdaq_trader import get_nasdaq_symbols 17 | from pandas_datareader.exceptions import ImmediateDeprecationError 18 | 19 | from .base import Quotes 20 | from .utils import get_data_path, timeit 21 | 22 | __all__ = ( 23 | 'YahooQuotesLoader', 24 | 'GoogleQuotesLoader', 25 | 'QuandleQuotesLoader', 26 | 'get_symbols', 27 | 'get_quotes', 28 | ) 29 | 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | class QuotesLoader: 35 | 36 | source = None 37 | timeframe = '1D' 38 | sort_index = False 39 | default_tf = None 40 | name_format = '%(symbol)s_%(tf)s_%(date_from)s_%(date_to)s.%(ext)s' 41 | 42 | @classmethod 43 | def _get(cls, symbol, date_from, date_to): 44 | quotes = web.DataReader( 45 | symbol, cls.source, start=date_from, end=date_to 46 | ) 47 | if cls.sort_index: 48 | quotes.sort_index(inplace=True) 49 | return quotes 50 | 51 | @classmethod 52 | def _get_file_path(cls, symbol, tf, date_from, date_to): 53 | fname = cls.name_format % { 54 | 'symbol': symbol, 55 | 'tf': tf, 56 | 'date_from': date_from.isoformat(), 57 | 'date_to': date_to.isoformat(), 58 | 'ext': 'qdom', 59 | } 60 | return os.path.join(get_data_path('stock_data'), fname) 61 | 62 | @classmethod 63 | def _save_to_disk(cls, fpath, data): 64 | logger.debug('Saving quotes to a file: %s', fpath) 65 | with open(fpath, 'wb') as f: 66 | pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) 67 | 68 | @classmethod 69 | def _load_from_disk(cls, fpath): 70 | logger.debug('Loading quotes from a file: %s', fpath) 71 | with open(fpath, 'rb') as f: 72 | return pickle.load(f) 73 | 74 | @classmethod 75 | @timeit 76 | def get_quotes(cls, symbol, date_from, date_to): 77 | quotes = None 78 | fpath = cls._get_file_path(symbol, cls.timeframe, date_from, date_to) 79 | if os.path.exists(fpath): 80 | quotes = Quotes.new(cls._load_from_disk(fpath)) 81 | else: 82 | quotes_raw = cls._get(symbol, date_from, date_to) 83 | quotes = Quotes.new( 84 | quotes_raw, source=cls.source, default_tf=cls.default_tf 85 | ) 86 | cls._save_to_disk(fpath, quotes) 87 | return quotes 88 | 89 | 90 | class YahooQuotesLoader(QuotesLoader): 91 | 92 | source = 'yahoo' 93 | 94 | @classmethod 95 | def _get(cls, symbol, date_from, date_to): 96 | return get_data_yahoo(symbol, date_from, date_to) 97 | 98 | 99 | class GoogleQuotesLoader(QuotesLoader): 100 | 101 | source = 'google' 102 | 103 | @classmethod 104 | def _get(cls, symbol, date_from, date_to): 105 | # FIXME: temporary fix 106 | from pandas_datareader.google.daily import GoogleDailyReader 107 | 108 | GoogleDailyReader.url = 'http://finance.google.com/finance/historical' 109 | return get_data_google(symbol, date_from, date_to) 110 | 111 | 112 | class QuandleQuotesLoader(QuotesLoader): 113 | 114 | source = 'quandle' 115 | 116 | @classmethod 117 | def _get(cls, symbol, date_from, date_to): 118 | quotes = get_data_quandl(symbol, date_from, date_to) 119 | quotes.sort_index(inplace=True) 120 | return quotes 121 | 122 | 123 | class AlphaVantageQuotesLoader(QuotesLoader): 124 | 125 | source = 'alphavantage' 126 | api_key = 'demo' 127 | 128 | @classmethod 129 | def _get(cls, symbol, date_from, date_to): 130 | quotes = get_data_alphavantage( 131 | symbol, date_from, date_to, api_key=cls.api_key 132 | ) 133 | return quotes 134 | 135 | 136 | class StooqQuotesLoader(QuotesLoader): 137 | 138 | source = 'stooq' 139 | sort_index = True 140 | default_tf = 1440 141 | 142 | 143 | class IEXQuotesLoader(QuotesLoader): 144 | 145 | source = 'iex' 146 | 147 | @classmethod 148 | def _get(cls, symbol, date_from, date_to): 149 | quotes = web.DataReader( 150 | symbol, cls.source, start=date_from, end=date_to 151 | ) 152 | quotes['Date'] = pd.to_datetime(quotes.index) 153 | return quotes 154 | 155 | 156 | class RobinhoodQuotesLoader(QuotesLoader): 157 | 158 | source = 'robinhood' 159 | 160 | 161 | def get_symbols(): 162 | fpath = os.path.join(get_data_path('stock_data'), 'symbols.qdom') 163 | if os.path.exists(fpath): 164 | with open(fpath, 'rb') as f: 165 | symbols = pickle.load(f) 166 | else: 167 | symbols = get_nasdaq_symbols() 168 | symbols.reset_index(inplace=True) 169 | with open(fpath, 'wb') as f: 170 | pickle.dump(symbols, f, pickle.HIGHEST_PROTOCOL) 171 | return symbols 172 | 173 | 174 | def get_quotes(*args, **kwargs): 175 | quotes = [] 176 | # don't work: 177 | # GoogleQuotesLoader, QuandleQuotesLoader, 178 | # AlphaVantageQuotesLoader, RobinhoodQuotesLoader 179 | loaders = [YahooQuotesLoader, IEXQuotesLoader, StooqQuotesLoader] 180 | while loaders: 181 | loader = loaders.pop(0) 182 | try: 183 | quotes = loader.get_quotes(*args, **kwargs) 184 | break 185 | except (RemoteDataError, ImmediateDeprecationError) as e: 186 | logger.error('get_quotes => error: %r', e) 187 | return quotes 188 | -------------------------------------------------------------------------------- /quantdom/lib/performance.py: -------------------------------------------------------------------------------- 1 | """Performance.""" 2 | 3 | import codecs 4 | import json 5 | from collections import OrderedDict, defaultdict 6 | 7 | import numpy as np 8 | 9 | from .base import Quotes 10 | from .const import ANNUAL_PERIOD 11 | from .utils import fromtimestamp, get_resource_path 12 | 13 | __all__ = ( 14 | 'BriefPerformance', 15 | 'Performance', 16 | 'Stats', 17 | 'REPORT_COLUMNS', 18 | 'REPORT_ROWS', 19 | ) 20 | 21 | 22 | REPORT_COLUMNS = ('All', 'Long', 'Short', 'Market') 23 | with codecs.open( 24 | get_resource_path('report_rows.json'), mode='r', encoding='utf-8' 25 | ) as f: 26 | REPORT_ROWS = OrderedDict(json.load(f)) 27 | 28 | 29 | class Stats(np.recarray): 30 | def __new__(cls, positions, shape=None, dtype=None, order='C'): 31 | shape = shape or (len(positions['All']),) 32 | dtype = np.dtype( 33 | [ 34 | ('type', object), 35 | ('symbol', object), 36 | ('volume', float), 37 | ('open_time', float), 38 | ('close_time', float), 39 | ('open_price', float), 40 | ('close_price', float), 41 | ('total_profit', float), 42 | ('entry_name', object), 43 | ('exit_name', object), 44 | ('status', object), 45 | ('comment', object), 46 | ('abs', float), 47 | ('perc', float), 48 | ('bars', float), 49 | ('on_bar', float), 50 | ('mae', float), 51 | ('mfe', float), 52 | ] 53 | ) 54 | dt = [(col, dtype) for col in REPORT_COLUMNS] 55 | return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) 56 | 57 | def __init__(self, positions, **kwargs): 58 | for col, _positions in positions.items(): 59 | for i, p in enumerate(_positions): 60 | self._add_position(p, col, i) 61 | 62 | def _add_position(self, p, col, i): 63 | self[col][i].type = p.type 64 | self[col][i].symbol = p.symbol 65 | self[col][i].volume = p.volume 66 | self[col][i].open_time = p.open_time 67 | self[col][i].close_time = p.close_time 68 | self[col][i].open_price = p.open_price 69 | self[col][i].close_price = p.close_price 70 | self[col][i].total_profit = p.total_profit 71 | self[col][i].entry_name = p.entry_name 72 | self[col][i].exit_name = p.exit_name 73 | self[col][i].status = p.status 74 | self[col][i].comment = p.comment 75 | self[col][i].abs = p.profit 76 | self[col][i].perc = p.profit_perc 77 | 78 | quotes_on_trade = Quotes[p.id_bar_open : p.id_bar_close] 79 | 80 | if not quotes_on_trade.size: 81 | # if position was opened and closed on the last bar 82 | quotes_on_trade = Quotes[p.id_bar_open : p.id_bar_close + 1] 83 | 84 | kwargs = { 85 | 'low': quotes_on_trade.low.min(), 86 | 'high': quotes_on_trade.high.max(), 87 | } 88 | self[col][i].mae = p.calc_mae(**kwargs) 89 | self[col][i].mfe = p.calc_mfe(**kwargs) 90 | 91 | bars = p.id_bar_close - p.id_bar_open 92 | self[col][i].bars = bars 93 | self[col][i].on_bar = p.profit_perc / bars 94 | 95 | 96 | class BriefPerformance(np.recarray): 97 | def __new__(cls, shape=None, dtype=None, order='C'): 98 | dt = np.dtype( 99 | [ 100 | ('kwargs', object), 101 | ('net_profit_abs', float), 102 | ('net_profit_perc', float), 103 | ('year_profit', float), 104 | ('win_average_profit_perc', float), 105 | ('loss_average_profit_perc', float), 106 | ('max_drawdown_abs', float), 107 | ('total_trades', int), 108 | ('win_trades_abs', int), 109 | ('win_trades_perc', float), 110 | ('profit_factor', float), 111 | ('recovery_factor', float), 112 | ('payoff_ratio', float), 113 | ] 114 | ) 115 | shape = shape or (1,) 116 | return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) 117 | 118 | def _days_count(self, positions): 119 | if hasattr(self, 'days'): 120 | return self.days 121 | self.days = ( 122 | ( 123 | fromtimestamp(positions[-1].close_time) 124 | - fromtimestamp(positions[0].open_time) 125 | ).days 126 | if positions 127 | else 1 128 | ) 129 | return self.days 130 | 131 | def add(self, initial_balance, positions, i, kwargs): 132 | position_count = len(positions) 133 | profit = np.recarray( 134 | (position_count,), dtype=[('abs', float), ('perc', float)] 135 | ) 136 | for n, position in enumerate(positions): 137 | profit[n].abs = position.profit 138 | profit[n].perc = position.profit_perc 139 | s = self[i] 140 | s.kwargs = kwargs 141 | s.net_profit_abs = np.sum(profit.abs) 142 | s.net_profit_perc = np.sum(profit.perc) 143 | days = self._days_count(positions) 144 | gain_factor = (s.net_profit_abs + initial_balance) / initial_balance 145 | s.year_profit = (gain_factor ** (365 / days) - 1) * 100 146 | s.win_average_profit_perc = np.mean(profit.perc[profit.perc > 0]) 147 | s.loss_average_profit_perc = np.mean(profit.perc[profit.perc < 0]) 148 | s.max_drawdown_abs = profit.abs.min() 149 | s.total_trades = position_count 150 | wins = profit.abs[profit.abs > 0] 151 | loss = profit.abs[profit.abs < 0] 152 | s.win_trades_abs = len(wins) 153 | s.win_trades_perc = round(s.win_trades_abs / s.total_trades * 100, 2) 154 | s.profit_factor = abs(np.sum(wins) / np.sum(loss)) 155 | s.recovery_factor = abs(s.net_profit_abs / s.max_drawdown_abs) 156 | s.payoff_ratio = abs(np.mean(wins) / np.mean(loss)) 157 | 158 | 159 | class Performance: 160 | """Performance Metrics.""" 161 | 162 | rows = REPORT_ROWS 163 | columns = REPORT_COLUMNS 164 | 165 | def __init__(self, initial_balance, stats, positions): 166 | self._data = {} 167 | for col in self.columns: 168 | column = type('Column', (object,), dict.fromkeys(self.rows, 0)) 169 | column.initial_balance = initial_balance 170 | self._data[col] = column 171 | self.calculate(column, stats[col], positions[col]) 172 | 173 | def __getitem__(self, col): 174 | return self._data[col] 175 | 176 | def _calc_trade_series(self, col, positions): 177 | win_in_series, loss_in_series = 0, 0 178 | for i, p in enumerate(positions): 179 | if p.profit >= 0: 180 | win_in_series += 1 181 | loss_in_series = 0 182 | if win_in_series > col.win_in_series: 183 | col.win_in_series = win_in_series 184 | else: 185 | win_in_series = 0 186 | loss_in_series += 1 187 | if loss_in_series > col.loss_in_series: 188 | col.loss_in_series = loss_in_series 189 | 190 | def calculate(self, col, stats, positions): 191 | self._calc_trade_series(col, positions) 192 | 193 | col.total_trades = len(positions) 194 | 195 | profit_abs = stats[np.flatnonzero(stats.abs)].abs 196 | profit_perc = stats[np.flatnonzero(stats.perc)].perc 197 | bars = stats[np.flatnonzero(stats.bars)].bars 198 | on_bar = stats[np.flatnonzero(stats.on_bar)].on_bar 199 | 200 | gt_zero_abs = stats[stats.abs > 0].abs 201 | gt_zero_perc = stats[stats.perc > 0].perc 202 | win_bars = stats[stats.perc > 0].bars 203 | 204 | lt_zero_abs = stats[stats.abs < 0].abs 205 | lt_zero_perc = stats[stats.perc < 0].perc 206 | los_bars = stats[stats.perc < 0].bars 207 | 208 | col.average_profit_abs = np.mean(profit_abs) if profit_abs.size else 0 209 | col.average_profit_perc = ( 210 | np.mean(profit_perc) if profit_perc.size else 0 211 | ) 212 | col.bars_on_trade = np.mean(bars) if bars.size else 0 213 | col.bar_profit = np.mean(on_bar) if on_bar.size else 0 214 | 215 | col.win_average_profit_abs = ( 216 | np.mean(gt_zero_abs) if gt_zero_abs.size else 0 217 | ) 218 | col.win_average_profit_perc = ( 219 | np.mean(gt_zero_perc) if gt_zero_perc.size else 0 220 | ) 221 | col.win_bars_on_trade = np.mean(win_bars) if win_bars.size else 0 222 | 223 | col.loss_average_profit_abs = ( 224 | np.mean(lt_zero_abs) if lt_zero_abs.size else 0 225 | ) 226 | col.loss_average_profit_perc = ( 227 | np.mean(lt_zero_perc) if lt_zero_perc.size else 0 228 | ) 229 | col.loss_bars_on_trade = np.mean(los_bars) if los_bars.size else 0 230 | 231 | col.win_trades_abs = len(gt_zero_abs) 232 | col.win_trades_perc = ( 233 | round(col.win_trades_abs / col.total_trades * 100, 2) 234 | if col.total_trades 235 | else 0 236 | ) 237 | 238 | col.loss_trades_abs = len(lt_zero_abs) 239 | col.loss_trades_perc = ( 240 | round(col.loss_trades_abs / col.total_trades * 100, 2) 241 | if col.total_trades 242 | else 0 243 | ) 244 | 245 | col.total_profit = np.sum(gt_zero_abs) 246 | col.total_loss = np.sum(lt_zero_abs) 247 | col.net_profit_abs = np.sum(stats.abs) 248 | col.net_profit_perc = np.sum(stats.perc) 249 | col.total_mae = np.sum(stats.mae) 250 | col.total_mfe = np.sum(stats.mfe) 251 | 252 | # https://financial-calculators.com/roi-calculator 253 | 254 | days = ( 255 | ( 256 | fromtimestamp(positions[-1].close_time) 257 | - fromtimestamp(positions[0].open_time) 258 | ).days 259 | if positions 260 | else 1 261 | ) 262 | gain_factor = ( 263 | col.net_profit_abs + col.initial_balance 264 | ) / col.initial_balance 265 | col.year_profit = (gain_factor ** (365 / days) - 1) * 100 266 | col.month_profit = (gain_factor ** (365 / days / 12) - 1) * 100 267 | 268 | col.max_profit_abs = stats.abs.max() 269 | col.max_profit_perc = stats.perc.max() 270 | col.max_profit_abs_day = fromtimestamp( 271 | stats.close_time[stats.abs == col.max_profit_abs][0] 272 | ) 273 | col.max_profit_perc_day = fromtimestamp( 274 | stats.close_time[stats.perc == col.max_profit_perc][0] 275 | ) 276 | 277 | col.max_drawdown_abs = stats.abs.min() 278 | col.max_drawdown_perc = stats.perc.min() 279 | col.max_drawdown_abs_day = fromtimestamp( 280 | stats.close_time[stats.abs == col.max_drawdown_abs][0] 281 | ) 282 | col.max_drawdown_perc_day = fromtimestamp( 283 | stats.close_time[stats.perc == col.max_drawdown_perc][0] 284 | ) 285 | 286 | col.profit_factor = ( 287 | abs(col.total_profit / col.total_loss) if col.total_loss else 0 288 | ) 289 | col.recovery_factor = ( 290 | abs(col.net_profit_abs / col.max_drawdown_abs) 291 | if col.max_drawdown_abs 292 | else 0 293 | ) 294 | col.payoff_ratio = ( 295 | abs(col.win_average_profit_abs / col.loss_average_profit_abs) 296 | if col.loss_average_profit_abs 297 | else 0 298 | ) 299 | col.sharpe_ratio = annualized_sharpe_ratio(stats) 300 | col.sortino_ratio = annualized_sortino_ratio(stats) 301 | 302 | # TODO: 303 | col.alpha_ratio = np.nan 304 | col.beta_ratio = np.nan 305 | 306 | 307 | def day_percentage_returns(stats): 308 | days = defaultdict(float) 309 | trade_count = np.count_nonzero(stats) 310 | 311 | if trade_count == 1: 312 | # market position, so returns should based on quotes 313 | # calculate percentage changes on a list of quotes 314 | changes = np.diff(Quotes.close) / Quotes[:-1].close * 100 315 | data = np.column_stack((Quotes[1:].time, changes)) # np.c_ 316 | else: 317 | # slice `:trade_count` to exclude zero values in long/short columns 318 | data = stats[['close_time', 'perc']][:trade_count] 319 | 320 | # FIXME: [FutureWarning] https://github.com/numpy/numpy/issues/8383 321 | for close_time, perc in data: 322 | days[fromtimestamp(close_time).date()] += perc 323 | returns = np.array(list(days.values())) 324 | 325 | # if np.count_nonzero(stats) == 1: 326 | # import pudb; pudb.set_trace() 327 | if len(returns) >= ANNUAL_PERIOD: 328 | return returns 329 | 330 | _returns = np.zeros(ANNUAL_PERIOD) 331 | _returns[: len(returns)] = returns 332 | return _returns 333 | 334 | 335 | def annualized_sharpe_ratio(stats): 336 | # risk_free = 0 337 | returns = day_percentage_returns(stats) 338 | return np.sqrt(ANNUAL_PERIOD) * np.mean(returns) / np.std(returns) 339 | 340 | 341 | def annualized_sortino_ratio(stats): 342 | # http://www.cmegroup.com/education/files/sortino-a-sharper-ratio.pdf 343 | required_return = 0 344 | returns = day_percentage_returns(stats) 345 | mask = [returns < required_return] 346 | tdd = np.zeros(len(returns)) 347 | tdd[mask] = returns[mask] # keep only negative values and zeros 348 | # "or 1" to prevent division by zero, if we don't have negative returns 349 | tdd = np.sqrt(np.mean(np.square(tdd))) or 1 350 | return np.sqrt(ANNUAL_PERIOD) * np.mean(returns) / tdd 351 | -------------------------------------------------------------------------------- /quantdom/lib/portfolio.py: -------------------------------------------------------------------------------- 1 | """Portfolio.""" 2 | 3 | import itertools 4 | from contextlib import contextmanager 5 | from enum import Enum, auto 6 | 7 | import numpy as np 8 | 9 | from .base import Quotes 10 | from .performance import BriefPerformance, Performance, Stats 11 | from .utils import fromtimestamp, timeit 12 | 13 | __all__ = ('Portfolio', 'Position', 'Order') 14 | 15 | 16 | class BasePortfolio: 17 | def __init__(self, balance=100_000, leverage=5): 18 | self._initial_balance = balance 19 | self.balance = balance 20 | self.equity = None 21 | # TODO: 22 | # self.cash 23 | # self.currency 24 | self.leverage = leverage 25 | self.positions = [] 26 | 27 | self.balance_curve = None 28 | self.equity_curve = None 29 | self.long_curve = None 30 | self.short_curve = None 31 | self.mae_curve = None 32 | self.mfe_curve = None 33 | 34 | self.stats = None 35 | self.performance = None 36 | self.brief_performance = None 37 | 38 | def clear(self): 39 | self.positions.clear() 40 | self.balance = self._initial_balance 41 | 42 | @property 43 | def initial_balance(self): 44 | return self._initial_balance 45 | 46 | @initial_balance.setter 47 | def initial_balance(self, value): 48 | self._initial_balance = value 49 | 50 | def add_position(self, position): 51 | position.ticket = len(self.positions) + 1 52 | self.positions.append(position) 53 | 54 | def position_count(self, tp=None): 55 | if tp == Order.BUY: 56 | return len([p for p in self.positions if p.type == Order.BUY]) 57 | elif tp == Order.SELL: 58 | return len([p for p in self.positions if p.type == Order.SELL]) 59 | return len(self.positions) 60 | 61 | def _close_open_positions(self): 62 | for p in self.positions: 63 | if p.status == Position.OPEN: 64 | p.close( 65 | price=Quotes[-1].open, volume=p.volume, time=Quotes[-1].time 66 | ) 67 | 68 | def _get_market_position(self): 69 | p = self.positions[0] # real postions 70 | p = Position( 71 | symbol=p.symbol, 72 | ptype=Order.BUY, 73 | volume=p.volume, 74 | price=Quotes[0].open, 75 | open_time=Quotes[0].time, 76 | close_price=Quotes[-1].close, 77 | close_time=Quotes[-1].time, 78 | id_bar_close=len(Quotes) - 1, 79 | status=Position.CLOSED, 80 | ) 81 | p.profit = p.calc_profit(close_price=Quotes[-1].close) 82 | p.profit_perc = p.profit / self._initial_balance * 100 83 | return p 84 | 85 | def _calc_equity_curve(self): 86 | """Equity curve.""" 87 | self.equity_curve = np.zeros_like(Quotes.time) 88 | for i, p in enumerate(self.positions): 89 | balance = np.sum(self.stats['All'][:i].abs) 90 | for ibar in range(p.id_bar_open, p.id_bar_close): 91 | profit = p.calc_profit(close_price=Quotes[ibar].close) 92 | self.equity_curve[ibar] = balance + profit 93 | # taking into account the real balance after the last trade 94 | self.equity_curve[-1] = self.balance_curve[-1] 95 | 96 | def _calc_buy_and_hold_curve(self): 97 | """Buy and Hold.""" 98 | p = self._get_market_position() 99 | self.buy_and_hold_curve = np.array( 100 | [p.calc_profit(close_price=price) for price in Quotes.close] 101 | ) 102 | 103 | def _calc_long_short_curves(self): 104 | """Only Long/Short positions curve.""" 105 | self.long_curve = np.zeros_like(Quotes.time) 106 | self.short_curve = np.zeros_like(Quotes.time) 107 | 108 | for i, p in enumerate(self.positions): 109 | if p.type == Order.BUY: 110 | name = 'Long' 111 | curve = self.long_curve 112 | else: 113 | name = 'Short' 114 | curve = self.short_curve 115 | balance = np.sum(self.stats[name][:i].abs) 116 | # Calculate equity for this position 117 | for ibar in range(p.id_bar_open, p.id_bar_close): 118 | profit = p.calc_profit(close_price=Quotes[ibar].close) 119 | curve[ibar] = balance + profit 120 | 121 | for name, curve in [ 122 | ('Long', self.long_curve), 123 | ('Short', self.short_curve), 124 | ]: 125 | curve[:] = fill_zeros_with_last(curve) 126 | # taking into account the real balance after the last trade 127 | curve[-1] = np.sum(self.stats[name].abs) 128 | 129 | def _calc_curves(self): 130 | self.mae_curve = np.cumsum(self.stats['All'].mae) 131 | self.mfe_curve = np.cumsum(self.stats['All'].mfe) 132 | self.balance_curve = np.cumsum(self.stats['All'].abs) 133 | self._calc_equity_curve() 134 | self._calc_buy_and_hold_curve() 135 | self._calc_long_short_curves() 136 | 137 | @contextmanager 138 | def optimization_mode(self): 139 | """Backup and restore current balance and positions.""" 140 | # mode='general', 141 | self.backup_balance = self.balance 142 | self.backup_positions = self.positions.copy() 143 | self.balance = self._initial_balance 144 | self.positions.clear() 145 | yield 146 | self.balance = self.backup_balance 147 | self.positions = self.backup_positions.copy() 148 | self.backup_positions.clear() 149 | 150 | @timeit 151 | def run_optimization(self, strategy, params): 152 | keys = list(params.keys()) 153 | vals = list(params.values()) 154 | variants = list(itertools.product(*vals)) 155 | self.brief_performance = BriefPerformance(shape=(len(variants),)) 156 | with self.optimization_mode(): 157 | for i, vals in enumerate(variants): 158 | kwargs = {keys[n]: val for n, val in enumerate(vals)} 159 | strategy.start(**kwargs) 160 | self._close_open_positions() 161 | self.brief_performance.add( 162 | self._initial_balance, self.positions, i, kwargs 163 | ) 164 | self.clear() 165 | 166 | @timeit 167 | def summarize(self): 168 | self._close_open_positions() 169 | positions = { 170 | 'All': self.positions, 171 | 'Long': [p for p in self.positions if p.type == Order.BUY], 172 | 'Short': [p for p in self.positions if p.type == Order.SELL], 173 | 'Market': [self._get_market_position()], 174 | } 175 | self.stats = Stats(positions) 176 | self.performance = Performance( 177 | self._initial_balance, self.stats, positions 178 | ) 179 | self._calc_curves() 180 | 181 | 182 | Portfolio = BasePortfolio() 183 | 184 | 185 | class PositionStatus(Enum): 186 | OPEN = auto() 187 | CLOSED = auto() 188 | CANCELED = auto() 189 | 190 | 191 | class Position: 192 | 193 | OPEN = PositionStatus.OPEN 194 | CLOSED = PositionStatus.CLOSED 195 | CANCELED = PositionStatus.CANCELED 196 | 197 | __slots__ = ( 198 | 'type', 199 | 'symbol', 200 | 'ticket', 201 | 'open_price', 202 | 'close_price', 203 | 'open_time', 204 | 'close_time', 205 | 'volume', 206 | 'sl', 207 | 'tp', 208 | 'status', 209 | 'profit', 210 | 'profit_perc', 211 | 'commis', 212 | 'id_bar_open', 213 | 'id_bar_close', 214 | 'entry_name', 215 | 'exit_name', 216 | 'total_profit', 217 | 'comment', 218 | ) 219 | 220 | def __init__( 221 | self, 222 | symbol, 223 | ptype, 224 | price, 225 | volume, 226 | open_time, 227 | sl=None, 228 | tp=None, 229 | status=OPEN, 230 | entry_name='', 231 | exit_name='', 232 | comment='', 233 | **kwargs, 234 | ): 235 | self.type = ptype 236 | self.symbol = symbol 237 | self.ticket = None 238 | self.open_price = price 239 | self.close_price = None 240 | self.open_time = open_time 241 | self.close_time = None 242 | self.volume = volume 243 | self.sl = sl 244 | self.tp = tp 245 | self.status = status 246 | self.profit = None 247 | self.profit_perc = None 248 | self.commis = None 249 | self.id_bar_open = np.where(Quotes.time == self.open_time)[0][0] 250 | self.id_bar_close = None 251 | self.entry_name = entry_name 252 | self.exit_name = exit_name 253 | self.total_profit = 0 254 | self.comment = comment 255 | # self.bars_on_trade = None 256 | # self.is_profitable = False 257 | 258 | for k, v in kwargs.items(): 259 | setattr(self, k, v) 260 | 261 | def __repr__(self): 262 | _type = 'LONG' if self.type == Order.BUY else 'SHORT' 263 | time = fromtimestamp(self.open_time).strftime('%d.%m.%y %H:%M') 264 | return '%s/%s/[%s - %.4f]' % ( 265 | self.status.name, 266 | _type, 267 | time, 268 | self.open_price, 269 | ) 270 | 271 | def close(self, price, time, volume=None): 272 | # TODO: allow closing only part of the volume 273 | self.close_price = price 274 | self.close_time = time 275 | self.id_bar_close = np.where(Quotes.time == self.close_time)[0][0] 276 | self.profit = self.calc_profit(volume=volume or self.volume) 277 | self.profit_perc = self.profit / Portfolio.balance * 100 278 | 279 | Portfolio.balance += self.profit 280 | 281 | self.total_profit = Portfolio.balance - Portfolio.initial_balance 282 | self.status = self.CLOSED 283 | 284 | def calc_profit(self, volume=None, close_price=None): 285 | # TODO: rewrite it 286 | close_price = close_price or self.close_price 287 | volume = volume or self.volume 288 | factor = 1 if self.type == Order.BUY else -1 289 | price_delta = (close_price - self.open_price) * factor 290 | if self.symbol.mode in [self.symbol.FOREX, self.symbol.CFD]: 291 | # Margin: Lots*Contract_Size/Leverage 292 | if ( 293 | self.symbol.mode == self.symbol.FOREX 294 | and self.symbol.ticker[:3] == 'USD' 295 | ): 296 | # Example: 'USD/JPY' 297 | # Прибыль Размер Объем Текущий 298 | # в пунктах пункта позиции курс 299 | # 1 * 0.0001 * 100000 / 1.00770 300 | # USD/CHF: 1*0.0001*100000/1.00770 = $9.92 301 | # 0.01 302 | # USD/JPY: 1*0.01*100000/121.35 = $8.24 303 | # (1.00770-1.00595)/0.0001 = 17.5 пунктов 304 | # (1.00770-1.00595)/0.0001*0.0001*100000*1/1.00770*1 305 | _points = price_delta / self.symbol.tick_size 306 | _profit = ( 307 | _points 308 | * self.symbol.tick_size 309 | * self.symbol.contract_size 310 | / close_price 311 | * volume 312 | ) 313 | elif ( 314 | self.symbol.mode == self.symbol.FOREX 315 | and self.symbol.ticker[-3:] == 'USD' 316 | ): 317 | # Example: 'EUR/USD' 318 | # Profit: (close_price-open_price)*Contract_Size*Lots 319 | # EUR/USD BUY: (1.05875-1.05850)*100000*1 = +$25 (без комиссии) 320 | _profit = price_delta * self.symbol.contract_size * volume 321 | else: 322 | # Cross rates. Example: 'GBP/CHF' 323 | # Цена пункта = 324 | # объем поз.*размер п.*тек.курс баз.вал. к USD/тек. кросс-курс 325 | # GBP/CHF: 100000*0.0001*1.48140/1.48985 = $9.94 326 | # TODO: temporary patch (same as the previous choice) - 327 | # in the future connect to some quotes provider and get rates 328 | _profit = price_delta * self.symbol.contract_size * volume 329 | elif self.symbol.mode == self.symbol.FUTURES: 330 | # Margin: Lots *InitialMargin*Percentage/100 331 | # Profit: (close_price-open_price)*TickPrice/TickSize*Lots 332 | # CL BUY: (46.35-46.30)*10/0.01*1 = $50 (без учета комиссии!) 333 | # EuroFX(6E) BUY:(1.05875-1.05850)*12.50/0.0001*1 =$31.25 (без ком) 334 | # RTS (RIH5) BUY:(84510-84500)*12.26506/10*1 = @12.26506 (без ком) 335 | # E-miniSP500 BUY:(2065.95-2065.25)*12.50/0.25 = $35 (без ком) 336 | # http://americanclearing.ru/specifications.php 337 | # http://www.moex.com/ru/contract.aspx?code=RTS-3.18 338 | # http://www.cmegroup.com/trading/equity-index/us-index/e-mini-sandp500_contract_specifications.html 339 | _profit = ( 340 | price_delta 341 | * self.symbol.tick_value 342 | / self.symbol.tick_size 343 | * volume 344 | ) 345 | else: 346 | # shares 347 | _profit = price_delta * volume 348 | 349 | return _profit 350 | 351 | def calc_mae(self, low, high): 352 | """Return [MAE] Maximum Adverse Excursion.""" 353 | if self.type == Order.BUY: 354 | return self.calc_profit(close_price=low) 355 | return self.calc_profit(close_price=high) 356 | 357 | def calc_mfe(self, low, high): 358 | """Return [MFE] Maximum Favorable Excursion.""" 359 | if self.type == Order.BUY: 360 | return self.calc_profit(close_price=high) 361 | return self.calc_profit(close_price=low) 362 | 363 | 364 | class OrderType(Enum): 365 | BUY = auto() 366 | SELL = auto() 367 | BUY_LIMIT = auto() 368 | SELL_LIMIT = auto() 369 | BUY_STOP = auto() 370 | SELL_STOP = auto() 371 | 372 | 373 | class Order: 374 | 375 | BUY = OrderType.BUY 376 | SELL = OrderType.SELL 377 | BUY_LIMIT = OrderType.BUY_LIMIT 378 | SELL_LIMIT = OrderType.SELL_LIMIT 379 | BUY_STOP = OrderType.BUY_STOP 380 | SELL_STOP = OrderType.SELL_STOP 381 | 382 | @staticmethod 383 | def open(symbol, otype, price, volume, time, sl=None, tp=None): 384 | # TODO: add margin calculation 385 | # and if the margin is not enough - do not open the position 386 | position = Position( 387 | symbol=symbol, 388 | ptype=otype, 389 | price=price, 390 | volume=volume, 391 | open_time=time, 392 | sl=sl, 393 | tp=tp, 394 | ) 395 | Portfolio.add_position(position) 396 | return position 397 | 398 | @staticmethod 399 | def close(position, price, time, volume=None): 400 | # FIXME: may be closed not the whole volume, but 401 | # the position status will be changed to CLOSED 402 | position.close(price=price, time=time, volume=volume) 403 | 404 | 405 | def fill_zeros_with_last(arr): 406 | """Fill empty(zero) elements (between positions).""" 407 | index = np.arange(len(arr)) 408 | index[arr == 0] = 0 409 | index = np.maximum.accumulate(index) 410 | return arr[index] 411 | -------------------------------------------------------------------------------- /quantdom/lib/strategy.py: -------------------------------------------------------------------------------- 1 | """Abstract strategy.""" 2 | 3 | import inspect 4 | import logging 5 | from abc import ABC, abstractmethod 6 | 7 | from .base import Quotes 8 | from .utils import timeit 9 | 10 | __all__ = ('AbstractStrategy',) 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class AbstractStrategy(ABC): 17 | def __init__(self, name=None, period=None, symbols=None): 18 | self.name = name or self.__class__.__name__ 19 | self.period = period 20 | # it comes a list of symbols. temporary we support only the first one 21 | self.symbols = symbols 22 | self.symbol = symbols[0] 23 | # deposit ? 24 | 25 | @classmethod 26 | def get_name(cls): 27 | return cls.__name__ 28 | 29 | @timeit 30 | def run(self): 31 | logger.debug('Starting backtest of strategy: %s', self.name) 32 | self.start() 33 | logger.debug('Backtest is done.') 34 | args = inspect.getfullargspec(self.init).args[1:] 35 | defaults = inspect.getfullargspec(self.init).defaults 36 | self.kwargs = dict(zip(args, defaults)) 37 | 38 | def start(self, *args, **kwargs): 39 | self.init(*args, **kwargs) 40 | for quote in Quotes: 41 | self.handle(quote) 42 | 43 | @abstractmethod 44 | def init(self): 45 | """Called once at start. 46 | 47 | Initialize the backtest parameters. 48 | * kwargs - are parameters that you want to optimize. 49 | """ 50 | 51 | @abstractmethod 52 | def handle(self, quote): 53 | """Called for each iteration (on every bar received).""" 54 | -------------------------------------------------------------------------------- /quantdom/lib/tables.py: -------------------------------------------------------------------------------- 1 | """Tables.""" 2 | 3 | from datetime import datetime 4 | 5 | import numpy as np 6 | import pyqtgraph as pg 7 | from PyQt5 import QtCore, QtGui 8 | 9 | from .portfolio import Order, Portfolio, Position 10 | from .utils import fromtimestamp 11 | 12 | __all__ = ( 13 | 'OptimizatimizedResultsTable', 14 | 'OptimizationTable', 15 | 'ResultsTable', 16 | 'TradesTable', 17 | 'LogTable', 18 | ) 19 | 20 | 21 | class ResultsTable(QtGui.QTableWidget): 22 | 23 | positive_color = pg.mkColor('#0000cc') 24 | negative_color = pg.mkColor('#cc0000') 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.setColumnCount(len(Portfolio.performance.columns)) 29 | rows = sum( 30 | [ 31 | 2 if 'separated' in props else 1 32 | for props in Portfolio.performance.rows.values() 33 | ] 34 | ) 35 | self.setRowCount(rows) 36 | self.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) 37 | self.setHorizontalHeaderLabels(Portfolio.performance.columns) 38 | self.horizontalHeader().setSectionResizeMode(QtGui.QHeaderView.Stretch) 39 | 40 | # TODO: make cols editable (show/hide) 41 | 42 | def plot(self): 43 | rows = Portfolio.performance.rows.items() 44 | for icol, col in enumerate(Portfolio.performance.columns): 45 | irow = 0 46 | for prop_key, props in rows: 47 | if props.get('separated', False): 48 | # add a blank row 49 | self.setVerticalHeaderItem(irow, QtGui.QTableWidgetItem('')) 50 | irow += 1 51 | units = props['units'] 52 | header = props['header'] 53 | colored = props['colored'] 54 | self.setVerticalHeaderItem(irow, QtGui.QTableWidgetItem(header)) 55 | val = getattr(Portfolio.performance[col], prop_key) 56 | if isinstance(val, float): 57 | sval = '%.2f %s' % (val, units) 58 | elif isinstance(val, (int, str)): 59 | sval = '%d %s' % (val, units) 60 | elif isinstance(val, datetime): 61 | sval = '%s %s' % (val.strftime('%Y.%m.%d'), units) 62 | item = QtGui.QTableWidgetItem(sval) 63 | item.setTextAlignment( 64 | QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight 65 | ) 66 | item.setFlags( 67 | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled 68 | ) 69 | if colored: 70 | color = ( 71 | self.positive_color if val > 0 else self.negative_color 72 | ) 73 | item.setForeground(color) 74 | self.setItem(irow, icol, item) 75 | irow += 1 76 | 77 | 78 | class TradesTable(QtGui.QTableWidget): 79 | 80 | cols = np.array( 81 | [ 82 | ('Type', 'type'), 83 | ('Symbol', 'symbol'), 84 | ('Volume', 'volume'), 85 | ('Entry', 'entry'), 86 | ('Exit', 'exit'), 87 | ('Profit $', 'abs'), 88 | ('Profit %', 'perc'), 89 | ('Bars', 'bars'), 90 | ('Profit on Bar', 'on_bar'), 91 | ('Total Profit', 'total_profit'), 92 | ('MAE', 'mae'), 93 | ('MFE', 'mfe'), 94 | ('Comment', 'comment'), 95 | ] 96 | ) 97 | colored_cols = ( 98 | 'type', 99 | 'abs', 100 | 'perc', 101 | 'total_profit', 102 | 'mae', 103 | 'mfe', 104 | 'on_bar', 105 | ) 106 | fg_positive_color = pg.mkColor('#0000cc') 107 | fg_negative_color = pg.mkColor('#cc0000') 108 | bg_positive_color = pg.mkColor('#e3ffe3') 109 | bg_negative_color = pg.mkColor('#ffe3e3') 110 | 111 | def __init__(self): 112 | super().__init__() 113 | self.setSortingEnabled(True) 114 | self.setColumnCount(len(self.cols)) 115 | self.setHorizontalHeaderLabels(self.cols[:, 0]) 116 | self.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) 117 | self.verticalHeader().hide() 118 | 119 | def plot(self): 120 | # TODO if Only Long / Short mode is selected then choise it 121 | trades = Portfolio.stats['All'] 122 | self.setRowCount(len(trades)) 123 | 124 | for irow, trade in enumerate(trades): 125 | for icol, col in enumerate(self.cols[:, 1]): 126 | fg_color = None 127 | if col == 'type': 128 | val, fg_color = ( 129 | ('▲ Buy', self.fg_positive_color) 130 | if trade[col] == Order.BUY 131 | else ('▼ Sell', self.fg_negative_color) 132 | ) 133 | elif col == 'status': 134 | val = 'Open' if trade[col] == Position.OPEN else 'Closed' 135 | elif col == 'symbol': 136 | val = trade[col].ticker 137 | elif col == 'bars': 138 | val = int(trade[col]) 139 | elif col == 'entry': 140 | val = fromtimestamp(trade['open_time']) 141 | elif col == 'exit': 142 | val = fromtimestamp(trade['close_time']) 143 | else: 144 | val = trade[col] 145 | 146 | if isinstance(val, float): 147 | s_val = '%.2f' % val 148 | elif isinstance(val, datetime): 149 | time = val.strftime('%Y.%m.%d %H:%M') 150 | price = ( 151 | trade['open_price'] 152 | if col == 'entry' 153 | else trade['close_price'] 154 | ) 155 | # name = (trade['entry_name'] if col == 'entry' else 156 | # trade['exit_name']) 157 | s_val = '%s at $%s' % (time, price) 158 | elif isinstance(val, (int, str, np.int_, np.str_)): 159 | s_val = str(val) 160 | 161 | item = QtGui.QTableWidgetItem(s_val) 162 | align = QtCore.Qt.AlignVCenter 163 | align |= ( 164 | QtCore.Qt.AlignLeft 165 | if col in ('type', 'entry', 'exit') 166 | else QtCore.Qt.AlignRight 167 | ) 168 | item.setTextAlignment(align) 169 | item.setFlags( 170 | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled 171 | ) 172 | bg_color = ( 173 | self.bg_positive_color 174 | if trade['abs'] >= 0 175 | else self.bg_negative_color 176 | ) 177 | item.setBackground(bg_color) 178 | 179 | if col in self.colored_cols: 180 | if fg_color is None: 181 | fg_color = ( 182 | self.fg_positive_color 183 | if val >= 0 184 | else self.fg_negative_color 185 | ) 186 | item.setForeground(fg_color) 187 | self.setItem(irow, icol, item) 188 | self.resizeColumnsToContents() 189 | 190 | 191 | class OptimizationTable(QtGui.QTableWidget): 192 | 193 | cols = ('Variable', 'Value', 'Minimum', 'Maximum', 'Step', 'Optimize') 194 | 195 | def __init__(self): 196 | super().__init__() 197 | self.setColumnCount(len(self.cols)) 198 | self.setHorizontalHeaderLabels(self.cols) 199 | self.horizontalHeader().setSectionResizeMode(QtGui.QHeaderView.Stretch) 200 | self.verticalHeader().hide() 201 | 202 | def plot(self, strategy): 203 | params = strategy.kwargs.copy() 204 | self.strategy = strategy 205 | self.setRowCount(len(params)) 206 | 207 | for irow, item in enumerate(params.items()): 208 | key, value = item 209 | for icol, col in enumerate(self.cols): 210 | if col == 'Variable': 211 | val = key 212 | elif col in ('Value', 'Minimum'): 213 | val = value 214 | elif col == 'Maximum': 215 | val = value * 2 216 | elif col == 'Step': 217 | val = 1 218 | else: 219 | continue 220 | 221 | item = QtGui.QTableWidgetItem(str(val)) 222 | item.setTextAlignment( 223 | QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight 224 | ) 225 | # item.setFlags( 226 | # QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 227 | self.setItem(irow, icol, item) 228 | 229 | def _get_params(self): 230 | """Return params for optimization.""" 231 | params = self.strategy.kwargs.copy() 232 | for irow in range(len(params)): 233 | var = self.item(irow, 0).text() 234 | _min = float(self.item(irow, 2).text()) 235 | _max = float(self.item(irow, 3).text()) 236 | _step = float(self.item(irow, 4).text()) 237 | params[var] = np.arange(_min, _max, _step) 238 | return params 239 | 240 | def optimize(self, *args, **kwargs): 241 | params = self._get_params() 242 | Portfolio.run_optimization(self.strategy, params) 243 | 244 | 245 | class OptimizatimizedResultsTable(QtGui.QTableWidget): 246 | 247 | sort_col = 3 # net_profit_perc 248 | main_cols = np.array( 249 | [ 250 | ('net_profit_abs', 'Net Profit'), 251 | ('net_profit_perc', 'Net Profit %'), 252 | ('year_profit', 'Year Profit %'), # Annual Profit ? 253 | ('win_average_profit_perc', 'Average Profit % (per trade)'), 254 | ('loss_average_profit_perc', 'Average Loss % (per trade)'), 255 | ('max_drawdown_abs', 'Maximum Drawdown'), 256 | ('total_trades', 'Number of Trades'), 257 | ('win_trades_abs', 'Winning Trades'), 258 | ('win_trades_perc', 'Winning Trades %'), 259 | ('profit_factor', 'Profit Factor'), 260 | ('recovery_factor', 'Recovery Factor'), 261 | ('payoff_ratio', 'Payoff Ratio'), 262 | ] 263 | ) 264 | 265 | def __init__(self): 266 | super().__init__() 267 | self.setSortingEnabled(True) 268 | self.verticalHeader().hide() 269 | 270 | def plot(self): 271 | # TODO if Only Long / Short mode is selected then choise it 272 | performance = Portfolio.brief_performance 273 | kw_keys = performance[0].kwargs.keys() 274 | var_cols = np.array([(k, k) for k in performance[0].kwargs.keys()]) 275 | self.cols = np.concatenate((var_cols, self.main_cols)) 276 | self.setColumnCount(len(self.cols)) 277 | self.setRowCount(len(performance)) 278 | self.setHorizontalHeaderLabels(self.cols[:, 1]) 279 | 280 | for irow, result in enumerate(performance): 281 | for i, col in enumerate(self.cols[:, 0]): 282 | val = result.kwargs[col] if col in kw_keys else result[col] 283 | if isinstance(val, float): 284 | val = '%.2f' % val 285 | item = QtGui.QTableWidgetItem(str(val)) 286 | item.setTextAlignment( 287 | QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight 288 | ) 289 | item.setFlags( 290 | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled 291 | ) 292 | self.setItem(irow, i, item) 293 | self.resizeColumnsToContents() 294 | self.sortByColumn(self.sort_col, QtCore.Qt.DescendingOrder) 295 | 296 | 297 | class LogTable(QtGui.QTableWidget): 298 | def __init__(self): 299 | super().__init__() 300 | self.cols = np.array([('time', 'Time'), ('message', 'Message')]) 301 | self.setColumnCount(len(self.cols)) 302 | self.verticalHeader().hide() 303 | 304 | def plot(self): 305 | pass 306 | -------------------------------------------------------------------------------- /quantdom/lib/utils.py: -------------------------------------------------------------------------------- 1 | """Utils.""" 2 | 3 | import importlib.util 4 | import inspect 5 | import logging 6 | import os 7 | import os.path 8 | import sys 9 | import time 10 | from datetime import datetime 11 | from functools import wraps 12 | 13 | from PyQt5 import QtCore 14 | 15 | __all__ = ( 16 | 'BASE_DIR', 17 | 'Settings', 18 | 'timeit', 19 | 'fromtimestamp', 20 | 'get_data_path', 21 | 'get_resource_path', 22 | 'strategies_from_file', 23 | ) 24 | 25 | 26 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 27 | 28 | 29 | def get_data_path(path=''): 30 | data_path = QtCore.QStandardPaths.writableLocation( 31 | QtCore.QStandardPaths.AppDataLocation 32 | ) 33 | data_path = os.path.join(data_path, path) 34 | os.makedirs(data_path, mode=0o755, exist_ok=True) 35 | return data_path 36 | 37 | 38 | def get_resource_path(relative_path): 39 | # PyInstaller creates a temp folder and stores path in _MEIPASS 40 | base_path = getattr(sys, '_MEIPASS', BASE_DIR) 41 | return os.path.join(base_path, relative_path) 42 | 43 | 44 | config_path = os.path.join(get_data_path(), 'Quantdom', 'config.ini') 45 | Settings = QtCore.QSettings(config_path, QtCore.QSettings.IniFormat) 46 | 47 | 48 | def timeit(fn): 49 | @wraps(fn) 50 | def wrapper(*args, **kwargs): 51 | t = time.time() 52 | res = fn(*args, **kwargs) 53 | logger = logging.getLogger('runtime') 54 | logger.debug( 55 | '%s.%s: %.4f sec' 56 | % (fn.__module__, fn.__qualname__, time.time() - t) 57 | ) 58 | return res 59 | 60 | return wrapper 61 | 62 | 63 | def fromtimestamp(timestamp): 64 | if timestamp == 0: 65 | # on Win zero timestamp cause error 66 | return datetime(1970, 1, 1) 67 | return datetime.fromtimestamp(timestamp) 68 | 69 | 70 | def strategies_from_file(filepath): 71 | from .strategy import AbstractStrategy 72 | 73 | spec = importlib.util.spec_from_file_location('Strategy', filepath) 74 | module = importlib.util.module_from_spec(spec) 75 | spec.loader.exec_module(module) 76 | 77 | is_strategy = lambda _class: ( # noqa:E731 78 | inspect.isclass(_class) 79 | and issubclass(_class, AbstractStrategy) 80 | and _class.__name__ != 'AbstractStrategy' 81 | ) 82 | return [_class for _, _class in inspect.getmembers(module, is_strategy)] 83 | -------------------------------------------------------------------------------- /quantdom/report_rows.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial_balance": { 3 | "header": "Initial capital", 4 | "units": "$", 5 | "colored": false, 6 | "note": {} 7 | }, 8 | "net_profit_abs": { 9 | "header": "Net profit", 10 | "units": "$", 11 | "colored": true, 12 | "note": {} 13 | }, 14 | "net_profit_perc": { 15 | "header": "Net profit %", 16 | "units": "%", 17 | "colored": true, 18 | "note": {} 19 | }, 20 | "year_profit": { 21 | "header": "Year profit %", 22 | "units": "%", 23 | "colored": true, 24 | "note": { 25 | "en": "", 26 | "ru": "Средне-геометрический темп прироста за год." 27 | } 28 | }, 29 | "month_profit": { 30 | "header": "Month profit %", 31 | "units": "%", 32 | "colored": true, 33 | "note": { 34 | "en": "", 35 | "ru": "Средне-геометрический темп прироста за месяц." 36 | } 37 | }, 38 | "total_mae": { 39 | "header": "Total MAE", 40 | "units": "$", 41 | "colored": true, 42 | "note": { 43 | "en": "Maximum Adverse Excursion. The largest loss suffered by a single trade while it is open.", 44 | "ru": "" 45 | } 46 | }, 47 | "total_mfe": { 48 | "header": "Total MFE", 49 | "units": "$", 50 | "colored": true, 51 | "note": { 52 | "en": "Maximum Favorable Excursion. The most profit that could have been extracted by a single trade while it is open.", 53 | "ru": "" 54 | } 55 | }, 56 | "total_trades": { 57 | "header": "Number of trades", 58 | "units": "", 59 | "colored": false, 60 | "note": {}, 61 | "separated": true 62 | }, 63 | "average_profit_abs": { 64 | "header": "Average profit", 65 | "units": "$", 66 | "colored": true, 67 | "note": {} 68 | }, 69 | "average_profit_perc": { 70 | "header": "Average profit %", 71 | "units": "%", 72 | "colored": true, 73 | "note": {} 74 | }, 75 | "bars_on_trade": { 76 | "header": "Average bars held", 77 | "units": "", 78 | "colored": false, 79 | "note": {} 80 | }, 81 | "win_trades_abs": { 82 | "header": "Winning trades", 83 | "units": "", 84 | "colored": false, 85 | "note": {}, 86 | "separated": true 87 | }, 88 | "win_trades_perc": { 89 | "header": "Winning trades %", 90 | "units": "%", 91 | "colored": false, 92 | "note": {} 93 | }, 94 | "total_profit": { 95 | "header": "Gross profit", 96 | "units": "$", 97 | "colored": true, 98 | "note": {} 99 | }, 100 | "win_average_profit_abs": { 101 | "header": "Average profit", 102 | "units": "$", 103 | "colored": true, 104 | "note": {} 105 | }, 106 | "win_average_profit_perc": { 107 | "header": "Average profit %", 108 | "units": "%", 109 | "colored": true, 110 | "note": {} 111 | }, 112 | "win_bars_on_trade": { 113 | "header": "Average bars held", 114 | "units": "", 115 | "colored": false, 116 | "note": {} 117 | }, 118 | "win_in_series": { 119 | "header": "Maximum consecutive winners", 120 | "units": "", 121 | "colored": false, 122 | "note": {} 123 | }, 124 | "loss_trades_abs": { 125 | "header": "Losing trades", 126 | "units": "", 127 | "colored": false, 128 | "note": {}, 129 | "separated": true 130 | }, 131 | "loss_trades_perc": { 132 | "header": "Losing trades %", 133 | "units": "%", 134 | "colored": false, 135 | "note": {} 136 | }, 137 | "total_loss": { 138 | "header": "Gross loss", 139 | "units": "$", 140 | "colored": true, 141 | "note": {} 142 | }, 143 | "loss_average_profit_abs": { 144 | "header": "Average loss", 145 | "units": "$", 146 | "colored": true, 147 | "note": {} 148 | }, 149 | "loss_average_profit_perc": { 150 | "header": "Average loss %", 151 | "units": "%", 152 | "colored": true, 153 | "note": {} 154 | }, 155 | "loss_bars_on_trade": { 156 | "header": "Average bars held", 157 | "units": "", 158 | "colored": false, 159 | "note": {} 160 | }, 161 | "loss_in_series": { 162 | "header": "Maximum consecutive losses", 163 | "units": "", 164 | "colored": false, 165 | "note": {} 166 | }, 167 | "max_profit_abs": { 168 | "header": "Maximum profit", 169 | "units": "$", 170 | "colored": true, 171 | "note": {}, 172 | "separated": true 173 | }, 174 | "max_profit_abs_day": { 175 | "header": "Maximum profit date", 176 | "units": "", 177 | "colored": false, 178 | "note": {} 179 | }, 180 | "max_profit_perc": { 181 | "header": "Maximum profit %", 182 | "units": "%", 183 | "colored": true, 184 | "note": {} 185 | }, 186 | "max_profit_perc_day": { 187 | "header": "Maximum profit % date", 188 | "units": "", 189 | "colored": false, 190 | "note": {} 191 | }, 192 | "max_drawdown_abs": { 193 | "header": "Maximum drawdown", 194 | "units": "$", 195 | "colored": true, 196 | "note": {} 197 | }, 198 | "max_drawdown_abs_day": { 199 | "header": "Maximum drawdown date", 200 | "units": "", 201 | "colored": false, 202 | "note": {} 203 | }, 204 | "max_drawdown_perc": { 205 | "header": "Maximum drawdown %", 206 | "units": "%", 207 | "colored": true, 208 | "note": {} 209 | }, 210 | "max_drawdown_perc_day": { 211 | "header": "Maximum drawdown % date", 212 | "units": "", 213 | "colored": false, 214 | "note": {} 215 | }, 216 | "profit_factor": { 217 | "header": "Profit factor", 218 | "units": "", 219 | "colored": true, 220 | "separated": true, 221 | "note": { 222 | "en": "", 223 | "ru": "Соотношение суммарной прибыли всех прибыльных сделок на суммарный убыток всех убыточных сделок. Рассчитывается по формуле: Профит Фактор = Вся прибыль / Весь убыток\"." 224 | } 225 | }, 226 | "recovery_factor": { 227 | "header": "Recovery factor", 228 | "units": "", 229 | "colored": true, 230 | "note": { 231 | "en": "", 232 | "ru": "Отношение абсолютной прибыли к максимальной просадке. Показывает насколько быстро торговая система восстанавливается после просадок. Рассчитывается по формуле: Фактор восстановления = П/У / Макс. убыток." 233 | } 234 | }, 235 | "payoff_ratio": { 236 | "header": "Payoff ratio", 237 | "units": "", 238 | "colored": true, 239 | "note": { 240 | "en": "", 241 | "ru": "Соотношение средней прибыльной сделки к средней убыточной.) Показывает во сколько раз средняя прибыль превышает средний убыток. Рассчитывается по формуле: Коэф. выигрыша = средняя прибыль / средний убыток." 242 | } 243 | }, 244 | "sharpe_ratio": { 245 | "header": "Sharpe ratio", 246 | "units": "", 247 | "colored": true, 248 | "note": { 249 | "en": "Measure for calculating risk-adjusted return.The ratio describes how much excess return you are receiving for the extra volatility that you endure for holding a riskier asset.", 250 | "ru": "" 251 | } 252 | }, 253 | "sortino_ratio": { 254 | "header": "Sortino ratio", 255 | "units": "", 256 | "colored": true, 257 | "note": { 258 | "en": "The Sortino ratio is a variation of the Sharpe ratio that differentiates harmful volatility from total overall volatility by using the asset's standard deviation of negative asset returns.", 259 | "ru": "" 260 | } 261 | }, 262 | "alpha_ratio": { 263 | "header": "Alpha ratio", 264 | "units": "", 265 | "colored": true, 266 | "note": { 267 | "en": "Measure of the active return on an investment, gauges the performance of an investment against a market index.", 268 | "ru": "" 269 | } 270 | }, 271 | "beta_ratio": { 272 | "header": "Beta ratio", 273 | "units": "", 274 | "colored": true, 275 | "note": { 276 | "en": "Measure of the volatility, or systematic risk in comparison to the market as a whole.", 277 | "ru": "" 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /quantdom/ui.py: -------------------------------------------------------------------------------- 1 | """Ui.""" 2 | 3 | import logging 4 | import logging.config 5 | import os.path 6 | from datetime import datetime 7 | 8 | from PyQt5 import QtCore, QtGui 9 | 10 | from .lib import ( 11 | EquityChart, 12 | OptimizatimizedResultsTable, 13 | OptimizationTable, 14 | Portfolio, 15 | QuotesChart, 16 | ResultsTable, 17 | Settings, 18 | Symbol, 19 | TradesTable, 20 | get_quotes, 21 | get_symbols, 22 | strategies_from_file, 23 | ) 24 | 25 | __all__ = ('MainWidget',) 26 | 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | DEFAULT_TICKER = 'AAPL' 31 | SYMBOL_COLUMNS = ['Symbol', 'Security Name'] 32 | 33 | 34 | class SymbolsLoaderThread(QtCore.QThread): 35 | 36 | symbols_loaded = QtCore.pyqtSignal(object) 37 | 38 | def run(self): 39 | symbols = get_symbols() 40 | self.symbols_loaded.emit(symbols[SYMBOL_COLUMNS].values) 41 | 42 | 43 | class DataTabWidget(QtGui.QWidget): 44 | 45 | data_updated = QtCore.pyqtSignal(object) 46 | 47 | def __init__(self, parent=None): 48 | super().__init__(parent) 49 | self.select_source = QtGui.QTabWidget(self) 50 | self.select_source.setGeometry(210, 50, 340, 200) 51 | 52 | self.init_shares_tab_ui() 53 | self.init_external_tab_ui() 54 | 55 | self.symbols_loader = SymbolsLoaderThread() 56 | self.symbols_loader.started.connect(self.on_symbols_loading) 57 | self.symbols_loader.symbols_loaded.connect( 58 | self.on_symbols_loaded, QtCore.Qt.QueuedConnection 59 | ) 60 | self.symbols_loader.start() 61 | 62 | self.date_from = self.shares_date_from.date().toPyDate() 63 | self.date_to = self.shares_date_to.date().toPyDate() 64 | 65 | def init_external_tab_ui(self): 66 | """External data.""" 67 | self.external_tab = QtGui.QWidget() 68 | self.external_tab.setEnabled(False) 69 | self.external_layout = QtGui.QVBoxLayout(self.external_tab) 70 | 71 | self.import_data_name = QtGui.QLabel('Import External Data') 72 | self.import_data_label = QtGui.QLabel('...') 73 | self.import_data_btn = QtGui.QPushButton('Import') 74 | self.import_data_btn.clicked.connect(self.open_file) 75 | 76 | self.external_layout.addWidget( 77 | self.import_data_name, 0, QtCore.Qt.AlignCenter 78 | ) 79 | self.external_layout.addWidget( 80 | self.import_data_label, 0, QtCore.Qt.AlignCenter 81 | ) 82 | self.external_layout.addWidget( 83 | self.import_data_btn, 0, QtCore.Qt.AlignCenter 84 | ) 85 | 86 | self.select_source.addTab(self.external_tab, 'Custom data') 87 | 88 | def init_shares_tab_ui(self): 89 | """Shares.""" 90 | self.shares_tab = QtGui.QWidget() 91 | self.shares_layout = QtGui.QFormLayout(self.shares_tab) 92 | today = datetime.today() 93 | 94 | self.shares_date_from = QtGui.QDateEdit() 95 | self.shares_date_from.setMinimumDate(QtCore.QDate(1900, 1, 1)) 96 | self.shares_date_from.setMaximumDate(QtCore.QDate(2030, 12, 31)) 97 | self.shares_date_from.setDate(QtCore.QDate(today.year, 1, 1)) 98 | self.shares_date_from.setDisplayFormat('dd.MM.yyyy') 99 | 100 | self.shares_date_to = QtGui.QDateEdit() 101 | self.shares_date_to.setMinimumDate(QtCore.QDate(1900, 1, 1)) 102 | self.shares_date_to.setMaximumDate(QtCore.QDate(2030, 12, 31)) 103 | self.shares_date_to.setDate( 104 | QtCore.QDate(today.year, today.month, today.day) 105 | ) 106 | self.shares_date_to.setDisplayFormat('dd.MM.yyyy') 107 | 108 | self.shares_symbol_list = QtGui.QComboBox() 109 | self.shares_symbol_list.setFocusPolicy(QtCore.Qt.StrongFocus) 110 | self.shares_symbol_list.setMaxVisibleItems(20) 111 | self.shares_symbol_list.setEditable(True) 112 | 113 | self.shares_show_btn = QtGui.QPushButton('Load') 114 | self.shares_show_btn.clicked.connect(self.update_data) 115 | 116 | self.shares_layout.addRow('From', self.shares_date_from) 117 | self.shares_layout.addRow('To', self.shares_date_to) 118 | self.shares_layout.addRow('Symbol', self.shares_symbol_list) 119 | self.shares_layout.addRow(None, self.shares_show_btn) 120 | 121 | self.select_source.addTab(self.shares_tab, 'Shares/Futures/ETFs') 122 | 123 | def on_symbols_loading(self): 124 | self.shares_symbol_list.addItem('Loading...') 125 | self.shares_symbol_list.setEnabled(False) 126 | 127 | def on_symbols_loaded(self, symbols): 128 | self.shares_symbol_list.clear() 129 | self.shares_symbol_list.setEnabled(True) 130 | # self.symbols = ['%s/%s' % (ticker, name) for ticker, name in symbols] 131 | # self.shares_symbol_list.addItems(self.symbols) 132 | model = QtGui.QStandardItemModel() 133 | model.setHorizontalHeaderLabels(SYMBOL_COLUMNS) 134 | for irow, (ticker, name) in enumerate(symbols): 135 | model.setItem(irow, 0, QtGui.QStandardItem(ticker)) 136 | model.setItem(irow, 1, QtGui.QStandardItem(name)) 137 | 138 | table_view = QtGui.QTableView() 139 | table_view.setModel(model) 140 | table_view.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) 141 | table_view.verticalHeader().setVisible(False) 142 | table_view.setAutoScroll(False) 143 | table_view.setShowGrid(False) 144 | table_view.resizeRowsToContents() 145 | table_view.setColumnWidth(0, 60) 146 | table_view.setColumnWidth(1, 240) 147 | table_view.setMinimumWidth(300) 148 | 149 | completer = QtGui.QCompleter(model) 150 | completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) 151 | completer.setModel(model) 152 | 153 | self.symbols = symbols 154 | self.shares_symbol_list.setModel(model) 155 | self.shares_symbol_list.setView(table_view) 156 | self.shares_symbol_list.setCompleter(completer) 157 | 158 | # set default symbol 159 | self.shares_symbol_list.setCurrentIndex( 160 | self.shares_symbol_list.findText(DEFAULT_TICKER) 161 | ) 162 | 163 | def open_file(self): 164 | filename = QtGui.QFileDialog.getOpenFileName( 165 | parent=None, 166 | caption='Open a source of data', 167 | directory=QtCore.QDir.currentPath(), 168 | filter='All (*);;Text (*.txt)', 169 | ) 170 | 171 | self.import_data_label.setText('Loading %s' % filename) 172 | 173 | with open(filename, 'r', encoding='utf-8') as f: 174 | self.data = f.readlines() 175 | 176 | def update_data(self, ticker=None): 177 | ticker = ticker or self.shares_symbol_list.currentText() 178 | self.symbol = Symbol(ticker=ticker, mode=Symbol.SHARES) 179 | self.date_from = self.shares_date_from.date().toPyDate() 180 | self.date_to = self.shares_date_to.date().toPyDate() 181 | 182 | get_quotes( 183 | symbol=self.symbol.ticker, 184 | date_from=self.date_from, 185 | date_to=self.date_to, 186 | ) 187 | 188 | self.data_updated.emit(self.symbol) 189 | 190 | 191 | class StrategyBoxWidget(QtGui.QGroupBox): 192 | 193 | run_backtest = QtCore.pyqtSignal(object) 194 | 195 | def __init__(self, parent=None): 196 | super().__init__(parent) 197 | self.setTitle('Strategy') 198 | self.setAlignment(QtCore.Qt.AlignCenter) 199 | self.layout = QtGui.QHBoxLayout(self) 200 | self.layout.setContentsMargins(0, 0, 0, 0) 201 | 202 | self.list = QtGui.QComboBox() 203 | 204 | self.add_btn = QtGui.QPushButton('+') 205 | self.add_btn.clicked.connect(self.add_strategies) 206 | 207 | self.start_btn = QtGui.QPushButton('Start Backtest') 208 | self.start_btn.clicked.connect(self.load_strategy) 209 | 210 | self.layout.addWidget(self.list, stretch=2) 211 | self.layout.addWidget(self.add_btn, stretch=0) 212 | self.layout.addWidget(self.start_btn, stretch=0) 213 | 214 | self.load_strategies_from_settings() 215 | 216 | def reload_strategies(self): 217 | """Reload user's file to get actual version of the strategies.""" 218 | self.strategies = strategies_from_file(self.strategies_path) 219 | 220 | def reload_list(self): 221 | self.list.clear() 222 | self.list.addItems([s.get_name() for s in self.strategies]) 223 | 224 | def load_strategies_from_settings(self): 225 | filename = Settings.value('strategies/path', None) 226 | if not filename or not os.path.exists(filename): 227 | return 228 | self.strategies_path = filename 229 | self.reload_strategies() 230 | self.reload_list() 231 | 232 | def save_strategies_to_settings(self): 233 | Settings.setValue('strategies/path', self.strategies_path) 234 | 235 | def add_strategies(self): 236 | filename, _filter = QtGui.QFileDialog.getOpenFileName( 237 | self, 238 | caption='Open Strategy.', 239 | directory=QtCore.QDir.currentPath(), 240 | filter='Python modules (*.py)', 241 | ) 242 | if not filename: 243 | return 244 | self.strategies_path = filename 245 | self.save_strategies_to_settings() 246 | self.reload_strategies() 247 | self.reload_list() 248 | 249 | def load_strategy(self): 250 | self.reload_strategies() 251 | self.run_backtest.emit(self.strategies[self.list.currentIndex()]) 252 | 253 | 254 | class QuotesTabWidget(QtGui.QWidget): 255 | def __init__(self, parent=None): 256 | super().__init__(parent) 257 | self.layout = QtGui.QVBoxLayout(self) 258 | self.layout.setContentsMargins(0, 0, 0, 0) 259 | self.toolbar_layout = QtGui.QHBoxLayout() 260 | self.toolbar_layout.setContentsMargins(10, 10, 15, 0) 261 | self.chart_layout = QtGui.QHBoxLayout() 262 | 263 | self.init_timeframes_ui() 264 | self.init_strategy_ui() 265 | 266 | self.layout.addLayout(self.toolbar_layout) 267 | self.layout.addLayout(self.chart_layout) 268 | 269 | def init_timeframes_ui(self): 270 | self.tf_layout = QtGui.QHBoxLayout() 271 | self.tf_layout.setSpacing(0) 272 | self.tf_layout.setContentsMargins(0, 12, 0, 0) 273 | time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN') 274 | btn_prefix = 'TF' 275 | for tf in time_frames: 276 | btn_name = ''.join([btn_prefix, tf]) 277 | btn = QtGui.QPushButton(tf) 278 | # TODO: 279 | btn.setEnabled(False) 280 | setattr(self, btn_name, btn) 281 | self.tf_layout.addWidget(btn) 282 | self.toolbar_layout.addLayout(self.tf_layout) 283 | 284 | def init_strategy_ui(self): 285 | self.strategy_box = StrategyBoxWidget(self) 286 | self.toolbar_layout.addWidget(self.strategy_box) 287 | 288 | def update_chart(self, symbol): 289 | if not self.chart_layout.isEmpty(): 290 | self.chart_layout.removeWidget(self.chart) 291 | self.chart = QuotesChart() 292 | self.chart.plot(symbol) 293 | self.chart_layout.addWidget(self.chart) 294 | 295 | def add_signals(self): 296 | self.chart.add_signals() 297 | 298 | 299 | class EquityTabWidget(QtGui.QWidget): 300 | def __init__(self, parent=None): 301 | super().__init__(parent) 302 | self.layout = QtGui.QHBoxLayout(self) 303 | self.layout.setContentsMargins(0, 0, 0, 0) 304 | 305 | def update_chart(self): 306 | if not self.layout.isEmpty(): 307 | self.layout.removeWidget(self.chart) 308 | self.chart = EquityChart() 309 | self.chart.plot() 310 | self.layout.addWidget(self.chart) 311 | 312 | 313 | class ResultsTabWidget(QtGui.QWidget): 314 | def __init__(self, parent=None): 315 | super().__init__(parent) 316 | self.layout = QtGui.QHBoxLayout(self) 317 | self.layout.setContentsMargins(0, 0, 0, 0) 318 | 319 | def update_table(self): 320 | if not self.layout.isEmpty(): 321 | self.layout.removeWidget(self.table) 322 | self.table = ResultsTable() 323 | self.table.plot() 324 | self.layout.addWidget(self.table) 325 | 326 | 327 | class TradesTabWidget(QtGui.QWidget): 328 | def __init__(self, parent=None): 329 | super().__init__(parent) 330 | self.layout = QtGui.QHBoxLayout(self) 331 | self.layout.setContentsMargins(0, 0, 0, 0) 332 | 333 | def update_table(self): 334 | if not self.layout.isEmpty(): 335 | self.layout.removeWidget(self.table) 336 | self.table = TradesTable() 337 | self.table.plot() 338 | self.layout.addWidget(self.table) 339 | 340 | 341 | class OptimizationTabWidget(QtGui.QWidget): 342 | 343 | optimization_done = QtCore.pyqtSignal() 344 | 345 | def __init__(self, parent=None): 346 | super().__init__(parent) 347 | self.layout = QtGui.QVBoxLayout(self) 348 | self.layout.setContentsMargins(0, 0, 0, 0) 349 | self.table_layout = QtGui.QHBoxLayout() 350 | self.top_layout = QtGui.QHBoxLayout() 351 | self.top_layout.setContentsMargins(0, 10, 0, 0) 352 | 353 | self.start_optimization_btn = QtGui.QPushButton('Start') 354 | self.start_optimization_btn.clicked.connect(self.start_optimization) 355 | self.top_layout.addWidget( 356 | self.start_optimization_btn, alignment=QtCore.Qt.AlignRight 357 | ) 358 | 359 | self.layout.addLayout(self.top_layout) 360 | self.layout.addLayout(self.table_layout) 361 | 362 | def update_table(self, strategy): 363 | if not self.table_layout.isEmpty(): 364 | # close() to avoid an UI issue with duplication of the table 365 | self.table.close() 366 | self.table_layout.removeWidget(self.table) 367 | self.table = OptimizationTable() 368 | self.table.plot(strategy) 369 | self.table_layout.addWidget(self.table) 370 | 371 | def start_optimization(self, *args, **kwargs): 372 | logger.debug('Start optimization') 373 | self.table.optimize() 374 | self.optimization_done.emit() 375 | logger.debug('Optimization is done') 376 | 377 | 378 | class OptimizatimizedResultsTabWidget(QtGui.QWidget): 379 | def __init__(self, parent=None): 380 | super().__init__(parent) 381 | self.layout = QtGui.QHBoxLayout(self) 382 | self.layout.setContentsMargins(0, 0, 0, 0) 383 | 384 | self.table = OptimizatimizedResultsTable() 385 | self.table.plot() 386 | 387 | self.layout.addWidget(self.table) 388 | 389 | 390 | class MainWidget(QtGui.QTabWidget): 391 | def __init__(self, parent=None): 392 | super().__init__(parent) 393 | self.setDocumentMode(True) 394 | 395 | self.data_tab = DataTabWidget(self) 396 | self.data_tab.data_updated.connect(self._update_quotes_chart) 397 | self.addTab(self.data_tab, 'Data') 398 | 399 | def _add_quotes_tab(self): 400 | if self.count() >= 2: # quotes tab is already exists 401 | return 402 | self.quotes_tab = QuotesTabWidget(self) 403 | self.quotes_tab.strategy_box.run_backtest.connect(self._run_backtest) 404 | self.addTab(self.quotes_tab, 'Quotes') 405 | 406 | def _add_result_tabs(self): 407 | if self.count() >= 3: # tabs are already exist 408 | return 409 | self.equity_tab = EquityTabWidget(self) 410 | self.results_tab = ResultsTabWidget(self) 411 | self.trades_tab = TradesTabWidget(self) 412 | self.optimization_tab = OptimizationTabWidget(self) 413 | self.optimization_tab.optimization_done.connect( 414 | self._add_optimized_results 415 | ) # noqa 416 | self.addTab(self.equity_tab, 'Equity') 417 | self.addTab(self.results_tab, 'Results') 418 | self.addTab(self.trades_tab, 'Trades') 419 | self.addTab(self.optimization_tab, 'Optimization') 420 | 421 | def _update_quotes_chart(self, symbol): 422 | self._add_quotes_tab() 423 | self.symbol = symbol 424 | self.quotes_tab.update_chart(self.symbol) 425 | self.setCurrentIndex(1) 426 | 427 | def _run_backtest(self, strategy): 428 | logger.debug('Run backtest') 429 | Portfolio.clear() 430 | 431 | stg = strategy(symbols=[self.symbol]) 432 | stg.run() 433 | 434 | Portfolio.summarize() 435 | self.quotes_tab.add_signals() 436 | self._add_result_tabs() 437 | self.equity_tab.update_chart() 438 | self.results_tab.update_table() 439 | self.trades_tab.update_table() 440 | self.optimization_tab.update_table(strategy=stg) 441 | logger.debug( 442 | 'Count positions in the portfolio: %d', Portfolio.position_count() 443 | ) 444 | 445 | def _add_optimized_results(self): 446 | self.addTab(OptimizatimizedResultsTabWidget(self), 'Optimized Results') 447 | self.setCurrentIndex(self.count() - 1) 448 | 449 | def plot_test_data(self): 450 | logger.debug('Plot test data') 451 | self.data_tab.update_data(ticker=DEFAULT_TICKER) 452 | self.quotes_tab.strategy_box.load_strategy() 453 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | conda: 2 | file: environment.yml 3 | python: 4 | setup_py_install: true 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [tool:pytest] 5 | addopts = --verbose --flake8 6 | qt_api = pyqt5 7 | 8 | [flake8] 9 | max-line-length = 80 10 | ignore = E203, W503 11 | exclude = 12 | .git, 13 | __pycache__, 14 | build, 15 | dist 16 | 17 | [isort] 18 | # Should be: 80 - 1 19 | line_length = 79 20 | multi_line_output = 3 21 | include_trailing_comma = true 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constverum/Quantdom/e05304006d3805f941d5f1033135730287b447e6/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constverum/Quantdom/e05304006d3805f941d5f1033135730287b447e6/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | from quantdom import MainWidget 2 | 3 | 4 | def test_init(qtbot): 5 | widget = MainWidget() 6 | qtbot.addWidget(widget) 7 | 8 | widget.show() 9 | 10 | assert widget.isVisible() 11 | # contains only the Data tab 12 | assert widget.count() == 1 13 | --------------------------------------------------------------------------------