├── .flake8 ├── .github └── workflows │ ├── pull-request.yml │ └── python-package.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── Makefile ├── adv │ └── data_sources.ipynb ├── api │ └── simple_back.rst ├── conf.py ├── index.rst ├── intro │ ├── data.rst │ ├── debugging.ipynb │ ├── download │ ├── example.rst │ ├── fees.ipynb │ ├── quickstart.ipynb │ ├── slippage.ipynb │ └── strategies.rst ├── make.bat └── requirements.txt ├── examples ├── JNUG 20-day Crossover.ipynb ├── Price Providers.ipynb ├── quickstart.py └── wikipedia-getter.ipynb ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── setup.py ├── simple_back.svg ├── simple_back ├── __init__.py ├── backtester.py ├── data_providers.py ├── exceptions.py ├── fees.py ├── metrics.py ├── strategy.py └── utils.py └── tests ├── __init__.py └── test_simple_back.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = F811 -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | pull_request: 4 | branches: [ master ] 5 | 6 | jobs: 7 | build: 8 | name: build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: actions/setup-python@v2 13 | - uses: dschep/install-poetry-action@v1.3 14 | - name: Poetry Lock 15 | run: poetry lock 16 | - name: Poetry Install 17 | run: poetry install 18 | - name: requirements.txt 19 | run: poetry run pip freeze | awk '/==/' > requirements.txt 20 | - name: requirements-docs.txt 21 | run: poetry run pip freeze | awk '/sphinx|Sphinx|ipython/' > docs/requirements.txt 22 | - name: Black 23 | run: poetry run python -m black . 24 | - name: Dephell Convert 25 | run: poetry run python -m dephell convert 26 | - name: install package locally 27 | run: pip install -e . 28 | - name: generate api doc 29 | run: sphinx-apidoc -M -T -f -o docs/api simple_back 30 | - name: install pandoc & python3-sphinx 31 | run: sudo apt-get install -y pandoc python3-sphinx python3-nbsphinx python3-pypandoc 32 | - name: generate sphinx doc 33 | run: cd docs && mkdir -p _build/html && touch _build/html/.nojekyll && poetry run make html 34 | - name: Lint 35 | run: poetry run python -m flake8 . --count --exit-zero --max-complexity=10 --statistics 36 | - name: pylint badge 37 | run: poetry run pylint-badge simple_back 38 | - name: Test 39 | run: poetry run python -m pytest --cov=simple_back --cov-branch --cov-report=xml tests/ 40 | - name: Codecov 41 | uses: codecov/codecov-action@v1.0.7 42 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: [ master ] 5 | 6 | jobs: 7 | build: 8 | name: build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: actions/setup-python@v2 13 | - uses: dschep/install-poetry-action@v1.3 14 | - name: Poetry Lock 15 | run: poetry lock 16 | - name: Poetry Install 17 | run: poetry install 18 | - name: requirements.txt 19 | run: poetry run pip freeze | awk '/==/' > requirements.txt 20 | - name: requirements-docs.txt 21 | run: poetry run pip freeze | awk '/sphinx|Sphinx|ipython/' > docs/requirements.txt 22 | - name: Black 23 | run: poetry run python -m black . 24 | - name: Dephell Convert 25 | run: poetry run python -m dephell convert 26 | - name: install package locally 27 | run: pip install . 28 | - name: generate api doc 29 | run: sphinx-apidoc -M -T -f -o docs/api simple_back 30 | - name: install pandoc & python3-sphinx 31 | run: sudo apt-get install -y pandoc python3-sphinx python3-nbsphinx python3-pypandoc 32 | - name: generate sphinx doc 33 | run: cd docs && mkdir -p _build/html && touch _build/html/.nojekyll && poetry run make html 34 | - name: Deploy to GitHub Pages 35 | uses: crazy-max/ghaction-github-pages@v2 36 | with: 37 | target_branch: gh-pages 38 | build_dir: docs/_build/html 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | - name: Git Auto Commit 42 | uses: stefanzweifel/git-auto-commit-action@v4.3.0 43 | with: 44 | commit_message: "build" 45 | branch: ${{ github.head_ref }} 46 | - name: Lint 47 | run: poetry run python -m flake8 . --count --exit-zero --max-complexity=10 --statistics 48 | - name: pylint badge 49 | run: poetry run pylint-badge simple_back 50 | - name: Test 51 | run: poetry run python -m pytest --cov=simple_back --cov-branch --cov-report=xml tests/ 52 | - name: Codecov 53 | uses: codecov/codecov-action@v1.0.7 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *.egg-info 3 | .ipynb_checkpoints 4 | dist 5 | .simple-back 6 | .coverage 7 | docs/_build 8 | .pytest-cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📈📉  simple-back 2 | ![build](https://github.com/MiniXC/simple-back/workflows/build/badge.svg) 3 | ![PyPI](https://img.shields.io/pypi/v/simple-back) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | [![codecov](https://codecov.io/gh/MiniXC/simple-back/branch/master/graph/badge.svg)](https://codecov.io/gh/MiniXC/simple-back) 6 | ![pylint](simple_back.svg) 7 | [![Documentation Status](https://readthedocs.org/projects/simple-back/badge/?version=latest)](https://minixc.github.io/simple-back) 8 | 9 | ## [Documentation](https://minixc.github.io/simple-back) | [Request A Feature](https://simple-back.featureupvote.com/) 10 | 11 | ## Installation 12 | ```` 13 | pip install simple_back 14 | ```` 15 | ## Quickstart 16 | > The following is a simple crossover strategy. For a full tutorial on how to build a strategy using **simple-back**, visit [the quickstart tutorial](https://minixc.github.io/simple-back/intro/quickstart.html) 17 | 18 | ````python 19 | from simple_back.backtester import BacktesterBuilder 20 | 21 | builder = ( 22 | BacktesterBuilder() 23 | .name('JNUG 20-Day Crossover') 24 | .balance(10_000) 25 | .calendar('NYSE') 26 | .compare(['JNUG']) # strategies to compare with 27 | .live_progress() # show a progress bar 28 | .live_plot(metric='Total Return (%)', min_y=None) # we assume we are running this in a Jupyter Notebook 29 | ) 30 | 31 | bt = builder.build() # build the backtest 32 | 33 | for day, event, b in bt['2019-1-1':'2020-1-1']: 34 | if event == 'open': 35 | jnug_ma = b.prices['JNUG',-20:]['close'].mean() 36 | b.add_metric('Price', b.price('JNUG')) 37 | b.add_metric('MA (20 Days)', jnug_ma) 38 | 39 | if b.price('JNUG') > jnug_ma: 40 | if not b.portfolio['JNUG'].long: # check if we already are long JNUG 41 | b.portfolio['JNUG'].short.liquidate() # liquidate any/all short JNUG positions 42 | b.long('JNUG', percent=1) # long JNUG 43 | 44 | if b.price('JNUG') < jnug_ma: 45 | if not b.portfolio['JNUG'].short: # check if we already are short JNUG 46 | b.portfolio['JNUG'].long.liquidate() # liquidate any/all long JNUG positions 47 | b.short('JNUG', percent=1) # short JNUG 48 | ```` 49 | ![](https://i.imgur.com/8wFQ4Gq.png) 50 | 51 | ## Why another Python backtester? 52 | There are many backtesters out there, but this is the first one built for rapid prototyping in Jupyter Notebooks. 53 | 54 | ### Built for Jupyter Notebooks 55 | Get live feedback on your backtests (live plotting, progress and metrics) *in your notebook* to immediatly notice if something is off about your strategy. 56 | 57 | ### Sensible Defaults and Caching 58 | Many backtesters need a great deal of configuration and setup before they can be used. 59 | Not so this one. At it's core you only need one loop, as this backtester can be used like any iterator. 60 | A default provider for prices is included, and caches all its data on your disk to minimize the number of requests needed. 61 | 62 | ### Extensible 63 | This is intended to be a lean framework where, e.g. adding crypto data is as easy as extending the ``DailyPriceProvider`` class. 64 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api/simple_back.rst: -------------------------------------------------------------------------------- 1 | simple\_back package 2 | ==================== 3 | 4 | .. automodule:: simple_back 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | simple\_back.backtester module 13 | ------------------------------ 14 | 15 | .. automodule:: simple_back.backtester 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | simple\_back.data\_providers module 21 | ----------------------------------- 22 | 23 | .. automodule:: simple_back.data_providers 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | simple\_back.exceptions module 29 | ------------------------------ 30 | 31 | .. automodule:: simple_back.exceptions 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | simple\_back.fees module 37 | ------------------------ 38 | 39 | .. automodule:: simple_back.fees 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | simple\_back.metrics module 45 | --------------------------- 46 | 47 | .. automodule:: simple_back.metrics 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | simple\_back.strategy module 53 | ---------------------------- 54 | 55 | .. automodule:: simple_back.strategy 56 | :members: 57 | :undoc-members: 58 | :show-inheritance: 59 | 60 | simple\_back.utils module 61 | ------------------------- 62 | 63 | .. automodule:: simple_back.utils 64 | :members: 65 | :undoc-members: 66 | :show-inheritance: 67 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | import os 13 | import sys 14 | 15 | sys.path.insert(0, os.path.abspath("..")) 16 | sys.path.append(os.path.dirname(__file__)) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "simple-back" 22 | copyright = "2020, Christoph Minixhofer" 23 | author = "Christoph Minixhofer" 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | "nbsphinx", 33 | "sphinx.ext.todo", 34 | "sphinx.ext.viewcode", 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.coverage", 37 | "sphinx.ext.napoleon", 38 | "sphinx_rtd_theme", 39 | "sphinx_copybutton", 40 | "sphinx_autodoc_typehints", 41 | ] 42 | 43 | # needed for 'sphinx-autodoc-typehints' 44 | napoleon_use_param = True 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ["_templates"] 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | # This pattern also affects html_static_path and html_extra_path. 52 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 53 | 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = "sphinx_rtd_theme" 61 | 62 | # Add any paths that contain custom static files (such as style sheets) here, 63 | # relative to this directory. They are copied after the builtin static files, 64 | # so a file named "default.css" will overwrite the builtin "default.css". 65 | # html_static_path = ["_static"] 66 | 67 | # needed for readthedocs.io 68 | # https://github.com/readthedocs/readthedocs.org/issues/2569 69 | master_doc = "index" 70 | 71 | # never executre notebooks 72 | nbsphinx_execute = "never" 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | simple-back |version| 3 | ===================== 4 | 5 | |Build Status| |PyPi| |Code Style| |codecov| 6 | 7 | .. |Build Status| image:: https://github.com/MiniXC/simple-back/workflows/build/badge.svg 8 | :target: https://github.com/MiniXC/simple-back 9 | 10 | .. |PyPi| image:: https://img.shields.io/pypi/v/simple-back 11 | :target: https://pypi.org/project/simple-back/ 12 | 13 | .. |Code Style| image:: https://img.shields.io/badge/code%20style-black-000000.svg 14 | 15 | .. |codecov| image:: https://codecov.io/gh/MiniXC/simple-back/branch/master/graph/badge.svg 16 | :target: https://codecov.io/gh/MiniXC/simple-back 17 | 18 | **simple-back** is a backtester providing easy ways to test trading 19 | strategies, with minimal amounts of code, while avoiding time leaks. 20 | At the same time, we aim to make it easy to use your own data and price sources. 21 | 22 | This package is especially useful when used in Jupyter Notebooks, 23 | as it provides ways to show you live feedback 24 | while your backtests are being run. 25 | 26 | .. warning:: 27 | **simple-back** is under active development. 28 | Any update could introduce breaking changes or even bugs (although we try to avoid the latter as much as possible). 29 | As soon as the API is finalized, version 1.0 will be released and this disclaimer will be removed. 30 | 31 | To get a sense of how far we are along, you can have a look at the `1.0 milestone`_. 32 | 33 | .. _1.0 milestone: 34 | https://github.com/MiniXC/simple-back/milestone/1 35 | 36 | Getting Started 37 | =============== 38 | 39 | .. note:: 40 | **simple-back** uses `matplotlib`_ for live plotting. 41 | Make sure to install matplotlib using `pip install matplotlib` if you want to use live plotting. 42 | 43 | .. _matplotlib: 44 | https://matplotlib.org/ 45 | 46 | :doc:`intro/quickstart` 47 | ----------------------- 48 | Build and test a simple strategy using simple-back. 49 | 50 | :doc:`intro/debugging` 51 | ---------------------- 52 | Visualize and improve your strategy. 53 | 54 | :doc:`intro/slippage` 55 | --------------------- 56 | Move from stateless iterators to stateful strategy objects and configure slippage. 57 | 58 | :doc:`intro/fees` 59 | ----------------- 60 | Test if your strategies hold up against potential broker fees. 61 | 62 | :doc:`intro/example` 63 | -------------------- 64 | Just copy example code and get started yourself. 65 | 66 | Advanced 67 | ======== 68 | 69 | :doc:`adv/data_sources` 70 | ----------------------- 71 | Use text data and machine learning to predict stock prices. 72 | The api used for this tutorial will change in version **0.7**. 73 | 74 | 75 | .. toctree:: 76 | :maxdepth: 5 77 | :caption: Getting Started 78 | :hidden: 79 | 80 | intro/quickstart 81 | intro/debugging 82 | intro/slippage 83 | intro/fees 84 | intro/example 85 | 86 | .. toctree:: 87 | :maxdepth: 5 88 | :caption: Advanced 89 | :hidden: 90 | 91 | adv/data_sources 92 | 93 | .. 94 | intro/strategies 95 | intro/data 96 | 97 | .. toctree:: 98 | :maxdepth: 5 99 | :caption: API 100 | :hidden: 101 | 102 | api/simple_back 103 | -------------------------------------------------------------------------------- /docs/intro/data.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Custom External Data 3 | ==================== -------------------------------------------------------------------------------- /docs/intro/download: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Kaggle: Your Home for Data Science 5 | 6 | 7 | 8 | 9 | 10 | 11 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 48 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 103 | 104 | 105 | 106 | 110 | 111 | 141 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 195 | 196 | 197 |
198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 |
206 | 207 |
208 | 209 | 210 |
211 | 212 |
213 | 214 | 215 | 216 | 217 |
218 | 219 | 220 | -------------------------------------------------------------------------------- /docs/intro/example.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Example Code 3 | ============ 4 | 5 | .. highlight:: python 6 | .. literalinclude:: ../../examples/quickstart.py 7 | :linenos: -------------------------------------------------------------------------------- /docs/intro/strategies.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Strategy Objects 3 | ================ -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | ipython @ file:///home/runner/.cache/pypoetry/artifacts/2a/6e/47/bd496d51c6d11ccff0361c8407f2fa5a6ac0969b7e759e85e99c5a87c6/ipython-7.16.1-py3-none-any.whl 2 | ipython-genutils @ file:///home/runner/.cache/pypoetry/artifacts/b4/31/01/6f96480580d1674cab0b5e26dc9fca7bbdf7a2fd5811a7807a92436268/ipython_genutils-0.2.0-py2.py3-none-any.whl 3 | nbsphinx @ file:///home/runner/.cache/pypoetry/artifacts/f0/86/65/c66f94f348d54a9e48fee4e47b417abf8b9b2be6c782eb702bc2eb3601/nbsphinx-0.7.1-py3-none-any.whl 4 | Sphinx @ file:///home/runner/.cache/pypoetry/artifacts/df/57/d2/ec8e8dd447d1f7ed5c1c5e16b22bb72319f25fa3cbc2da9d057b3c3531/Sphinx-3.3.0-py3-none-any.whl 5 | sphinx-autodoc-typehints @ file:///home/runner/.cache/pypoetry/artifacts/f0/03/c2/c8f159519b299604925e20586389c2367096e7fa09344111f2646f57f6/sphinx_autodoc_typehints-1.11.1-py3-none-any.whl 6 | sphinx-copybutton @ file:///home/runner/.cache/pypoetry/artifacts/aa/d6/dd/c63bd94bf1f505c07d607a999f0be2153412225077a45e0b360f57a138/sphinx_copybutton-0.2.12-py3-none-any.whl 7 | sphinx-rtd-theme @ file:///home/runner/.cache/pypoetry/artifacts/3b/ad/64/fc907f5459066e109084814c3333bfa27aceedc8bc10c3782d90f844dd/sphinx_rtd_theme-0.4.3-py2.py3-none-any.whl 8 | sphinxcontrib-applehelp @ file:///home/runner/.cache/pypoetry/artifacts/8d/eb/86/eec708bb3ff50c9780e78f36a9cb82cd9ff8030a90bd23b9a6f20aecca/sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl 9 | sphinxcontrib-devhelp @ file:///home/runner/.cache/pypoetry/artifacts/56/a5/74/11ccaa7737f06a10422027e0595b24d243af7a7a1dc4982dec22044c28/sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl 10 | sphinxcontrib-htmlhelp @ file:///home/runner/.cache/pypoetry/artifacts/5e/a7/c5/bea947b6372fea0245c08ff13ff69dfba86e5b185fc82bac084bb1b7f4/sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl 11 | sphinxcontrib-jsmath @ file:///home/runner/.cache/pypoetry/artifacts/d2/22/96/2076357e64b369910aa24a20d5b719beb24a1487146e4742476ee1e2d8/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl 12 | sphinxcontrib-qthelp @ file:///home/runner/.cache/pypoetry/artifacts/32/fc/a9/112a82396d53ec629c1450253a6ded4d94d7ffffd63acd49879543ece9/sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl 13 | sphinxcontrib-serializinghtml @ file:///home/runner/.cache/pypoetry/artifacts/f9/4e/a9/9d5ca3c1a967e164de9a5fbce38bc5d740c88541c8cf2018595785f2e6/sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl 14 | -------------------------------------------------------------------------------- /examples/Price Providers.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Price Providers" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 44, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import pandas as pd\n", 17 | "import numpy as np\n", 18 | "from dateutil.relativedelta import relativedelta\n", 19 | "from datetime import date" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": 45, 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "from simple_back.price_providers import YahooFinanceProvider, TimeLeakError" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 46, 34 | "metadata": {}, 35 | "outputs": [], 36 | "source": [ 37 | "price = YahooFinanceProvider()" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 47, 43 | "metadata": {}, 44 | "outputs": [], 45 | "source": [ 46 | "price.clear_cache()" 47 | ] 48 | }, 49 | { 50 | "cell_type": "markdown", 51 | "metadata": {}, 52 | "source": [ 53 | "### Getting a symbol" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": 48, 59 | "metadata": { 60 | "scrolled": true 61 | }, 62 | "outputs": [ 63 | { 64 | "data": { 65 | "text/html": [ 66 | "
\n", 67 | "\n", 80 | "\n", 81 | " \n", 82 | " \n", 83 | " \n", 84 | " \n", 85 | " \n", 86 | " \n", 87 | " \n", 88 | " \n", 89 | " \n", 90 | " \n", 91 | " \n", 92 | " \n", 93 | " \n", 94 | " \n", 95 | " \n", 96 | " \n", 97 | " \n", 98 | " \n", 99 | " \n", 100 | " \n", 101 | " \n", 102 | " \n", 103 | " \n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | " \n", 135 | " \n", 136 | " \n", 137 | " \n", 138 | " \n", 139 | " \n", 140 | " \n", 141 | " \n", 142 | " \n", 143 | " \n", 144 | " \n", 145 | " \n", 146 | " \n", 147 | " \n", 148 | " \n", 149 | " \n", 150 | " \n", 151 | " \n", 152 | " \n", 153 | " \n", 154 | " \n", 155 | " \n", 156 | " \n", 157 | " \n", 158 | " \n", 159 | " \n", 160 | " \n", 161 | " \n", 162 | " \n", 163 | " \n", 164 | " \n", 165 | " \n", 166 | " \n", 167 | " \n", 168 | " \n", 169 | "
openclosehighlow
1980-12-120.4056830.4056830.4074470.405683
1980-12-150.3862810.3845170.3862810.384517
1980-12-160.3580600.3562960.3580600.356296
1980-12-170.3651150.3651150.3668790.365115
1980-12-180.3756980.3756980.3774620.375698
...............
2020-05-22315.769989318.890015319.230011315.350006
2020-05-26323.500000316.730011324.239990316.500000
2020-05-27316.140015318.109985318.709991313.089996
2020-05-28316.769989318.250000323.440002315.630005
2020-05-29319.250000317.940002321.149994316.470001
\n", 170 | "

9950 rows × 4 columns

\n", 171 | "
" 172 | ], 173 | "text/plain": [ 174 | " open close high low\n", 175 | "1980-12-12 0.405683 0.405683 0.407447 0.405683\n", 176 | "1980-12-15 0.386281 0.384517 0.386281 0.384517\n", 177 | "1980-12-16 0.358060 0.356296 0.358060 0.356296\n", 178 | "1980-12-17 0.365115 0.365115 0.366879 0.365115\n", 179 | "1980-12-18 0.375698 0.375698 0.377462 0.375698\n", 180 | "... ... ... ... ...\n", 181 | "2020-05-22 315.769989 318.890015 319.230011 315.350006\n", 182 | "2020-05-26 323.500000 316.730011 324.239990 316.500000\n", 183 | "2020-05-27 316.140015 318.109985 318.709991 313.089996\n", 184 | "2020-05-28 316.769989 318.250000 323.440002 315.630005\n", 185 | "2020-05-29 319.250000 317.940002 321.149994 316.470001\n", 186 | "\n", 187 | "[9950 rows x 4 columns]" 188 | ] 189 | }, 190 | "execution_count": 48, 191 | "metadata": {}, 192 | "output_type": "execute_result" 193 | } 194 | ], 195 | "source": [ 196 | "price['AAPL']" 197 | ] 198 | }, 199 | { 200 | "cell_type": "markdown", 201 | "metadata": {}, 202 | "source": [ 203 | "### Getting values at a specific day" 204 | ] 205 | }, 206 | { 207 | "cell_type": "code", 208 | "execution_count": 49, 209 | "metadata": { 210 | "scrolled": true 211 | }, 212 | "outputs": [ 213 | { 214 | "data": { 215 | "text/plain": [ 216 | "open 101.829\n", 217 | "close 99.9459\n", 218 | "high 101.875\n", 219 | "low 98.1358\n", 220 | "Name: 2015-01-02 00:00:00, dtype: object" 221 | ] 222 | }, 223 | "execution_count": 49, 224 | "metadata": {}, 225 | "output_type": "execute_result" 226 | } 227 | ], 228 | "source": [ 229 | "price['AAPL','2015-1-2']" 230 | ] 231 | }, 232 | { 233 | "cell_type": "code", 234 | "execution_count": 50, 235 | "metadata": {}, 236 | "outputs": [ 237 | { 238 | "data": { 239 | "text/plain": [ 240 | "open 313.17\n", 241 | "close 314.96\n", 242 | "high 316.5\n", 243 | "low 310.32\n", 244 | "Name: 2020-05-18 00:00:00, dtype: object" 245 | ] 246 | }, 247 | "execution_count": 50, 248 | "metadata": {}, 249 | "output_type": "execute_result" 250 | } 251 | ], 252 | "source": [ 253 | "price['AAPL','2020-5-18']" 254 | ] 255 | }, 256 | { 257 | "cell_type": "markdown", 258 | "metadata": {}, 259 | "source": [ 260 | "### Using a date range" 261 | ] 262 | }, 263 | { 264 | "cell_type": "code", 265 | "execution_count": 51, 266 | "metadata": { 267 | "scrolled": true 268 | }, 269 | "outputs": [ 270 | { 271 | "data": { 272 | "text/html": [ 273 | "
\n", 274 | "\n", 287 | "\n", 288 | " \n", 289 | " \n", 290 | " \n", 291 | " \n", 292 | " \n", 293 | " \n", 294 | " \n", 295 | " \n", 296 | " \n", 297 | " \n", 298 | " \n", 299 | " \n", 300 | " \n", 301 | " \n", 302 | " \n", 303 | " \n", 304 | " \n", 305 | " \n", 306 | " \n", 307 | " \n", 308 | " \n", 309 | " \n", 310 | " \n", 311 | " \n", 312 | " \n", 313 | " \n", 314 | " \n", 315 | " \n", 316 | " \n", 317 | " \n", 318 | " \n", 319 | " \n", 320 | " \n", 321 | " \n", 322 | " \n", 323 | " \n", 324 | " \n", 325 | " \n", 326 | " \n", 327 | "
openclosehighlow
2015-01-02101.82906799.945885101.87477898.135831
2015-01-0598.99514397.13024199.32424496.362344
2015-01-0697.39538597.13942098.20899495.649322
2015-01-0797.99872398.50151898.91289197.541640
\n", 328 | "
" 329 | ], 330 | "text/plain": [ 331 | " open close high low\n", 332 | "2015-01-02 101.829067 99.945885 101.874778 98.135831\n", 333 | "2015-01-05 98.995143 97.130241 99.324244 96.362344\n", 334 | "2015-01-06 97.395385 97.139420 98.208994 95.649322\n", 335 | "2015-01-07 97.998723 98.501518 98.912891 97.541640" 336 | ] 337 | }, 338 | "execution_count": 51, 339 | "metadata": {}, 340 | "output_type": "execute_result" 341 | } 342 | ], 343 | "source": [ 344 | "price['AAPL','2015-1-2':'2015-1-7']" 345 | ] 346 | }, 347 | { 348 | "cell_type": "markdown", 349 | "metadata": {}, 350 | "source": [ 351 | "### Using relativedelta" 352 | ] 353 | }, 354 | { 355 | "cell_type": "code", 356 | "execution_count": 52, 357 | "metadata": { 358 | "scrolled": true 359 | }, 360 | "outputs": [ 361 | { 362 | "data": { 363 | "text/html": [ 364 | "
\n", 365 | "\n", 378 | "\n", 379 | " \n", 380 | " \n", 381 | " \n", 382 | " \n", 383 | " \n", 384 | " \n", 385 | " \n", 386 | " \n", 387 | " \n", 388 | " \n", 389 | " \n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | " \n", 394 | " \n", 395 | " \n", 396 | " \n", 397 | " \n", 398 | " \n", 399 | " \n", 400 | " \n", 401 | " \n", 402 | " \n", 403 | " \n", 404 | " \n", 405 | " \n", 406 | " \n", 407 | " \n", 408 | " \n", 409 | " \n", 410 | " \n", 411 | " \n", 412 | " \n", 413 | " \n", 414 | " \n", 415 | " \n", 416 | " \n", 417 | " \n", 418 | " \n", 419 | " \n", 420 | " \n", 421 | " \n", 422 | " \n", 423 | " \n", 424 | " \n", 425 | "
openclosehighlow
2020-04-30289.177206293.006836293.734876287.571567
2020-05-01285.477218288.289612298.192797285.078304
2020-05-04288.389342292.368561292.897129285.547030
2020-05-05294.263433296.756683300.187399293.665046
2020-05-06299.648835299.818390302.421329298.063132
\n", 426 | "
" 427 | ], 428 | "text/plain": [ 429 | " open close high low\n", 430 | "2020-04-30 289.177206 293.006836 293.734876 287.571567\n", 431 | "2020-05-01 285.477218 288.289612 298.192797 285.078304\n", 432 | "2020-05-04 288.389342 292.368561 292.897129 285.547030\n", 433 | "2020-05-05 294.263433 296.756683 300.187399 293.665046\n", 434 | "2020-05-06 299.648835 299.818390 302.421329 298.063132" 435 | ] 436 | }, 437 | "execution_count": 52, 438 | "metadata": {}, 439 | "output_type": "execute_result" 440 | } 441 | ], 442 | "source": [ 443 | "price['AAPL',-relativedelta(months=1):].head()" 444 | ] 445 | }, 446 | { 447 | "cell_type": "markdown", 448 | "metadata": {}, 449 | "source": [ 450 | "### Using day ints\n", 451 | "When a negative integer is used, this is interpreted as the number of days to go back." 452 | ] 453 | }, 454 | { 455 | "cell_type": "code", 456 | "execution_count": 37, 457 | "metadata": { 458 | "scrolled": true 459 | }, 460 | "outputs": [ 461 | { 462 | "data": { 463 | "text/html": [ 464 | "
\n", 465 | "\n", 478 | "\n", 479 | " \n", 480 | " \n", 481 | " \n", 482 | " \n", 483 | " \n", 484 | " \n", 485 | " \n", 486 | " \n", 487 | " \n", 488 | " \n", 489 | " \n", 490 | " \n", 491 | " \n", 492 | " \n", 493 | " \n", 494 | " \n", 495 | " \n", 496 | " \n", 497 | " \n", 498 | " \n", 499 | " \n", 500 | " \n", 501 | " \n", 502 | " \n", 503 | " \n", 504 | " \n", 505 | " \n", 506 | " \n", 507 | " \n", 508 | " \n", 509 | " \n", 510 | " \n", 511 | " \n", 512 | " \n", 513 | " \n", 514 | " \n", 515 | " \n", 516 | " \n", 517 | " \n", 518 | "
openclosehighlow
2020-05-26323.500000316.730011324.239990316.500000
2020-05-27316.140015318.109985318.709991313.089996
2020-05-28316.769989318.250000323.440002315.630005
2020-05-29319.250000317.940002321.149994316.470001
\n", 519 | "
" 520 | ], 521 | "text/plain": [ 522 | " open close high low\n", 523 | "2020-05-26 323.500000 316.730011 324.239990 316.500000\n", 524 | "2020-05-27 316.140015 318.109985 318.709991 313.089996\n", 525 | "2020-05-28 316.769989 318.250000 323.440002 315.630005\n", 526 | "2020-05-29 319.250000 317.940002 321.149994 316.470001" 527 | ] 528 | }, 529 | "execution_count": 37, 530 | "metadata": {}, 531 | "output_type": "execute_result" 532 | } 533 | ], 534 | "source": [ 535 | "price['AAPL',-7:]" 536 | ] 537 | }, 538 | { 539 | "cell_type": "markdown", 540 | "metadata": {}, 541 | "source": [ 542 | "Relativedeltas and integers work respective of the end date specified after `:` - If none is specified, the current date is used." 543 | ] 544 | }, 545 | { 546 | "cell_type": "code", 547 | "execution_count": 38, 548 | "metadata": { 549 | "scrolled": true 550 | }, 551 | "outputs": [ 552 | { 553 | "data": { 554 | "text/html": [ 555 | "
\n", 556 | "\n", 569 | "\n", 570 | " \n", 571 | " \n", 572 | " \n", 573 | " \n", 574 | " \n", 575 | " \n", 576 | " \n", 577 | " \n", 578 | " \n", 579 | " \n", 580 | " \n", 581 | " \n", 582 | " \n", 583 | " \n", 584 | " \n", 585 | " \n", 586 | " \n", 587 | " \n", 588 | " \n", 589 | " \n", 590 | " \n", 591 | " \n", 592 | " \n", 593 | " \n", 594 | " \n", 595 | " \n", 596 | " \n", 597 | " \n", 598 | " \n", 599 | " \n", 600 | " \n", 601 | " \n", 602 | " \n", 603 | " \n", 604 | " \n", 605 | " \n", 606 | " \n", 607 | " \n", 608 | " \n", 609 | " \n", 610 | " \n", 611 | " \n", 612 | " \n", 613 | " \n", 614 | " \n", 615 | " \n", 616 | "
openclosehighlow
2014-12-26102.478164104.205940104.690448102.395893
2014-12-29104.023095104.132797104.918975103.940816
2014-12-30103.885969102.862099104.141934102.487294
2014-12-31103.136355100.905785103.419745100.750378
2015-01-02101.82906799.945885101.87477898.135831
\n", 617 | "
" 618 | ], 619 | "text/plain": [ 620 | " open close high low\n", 621 | "2014-12-26 102.478164 104.205940 104.690448 102.395893\n", 622 | "2014-12-29 104.023095 104.132797 104.918975 103.940816\n", 623 | "2014-12-30 103.885969 102.862099 104.141934 102.487294\n", 624 | "2014-12-31 103.136355 100.905785 103.419745 100.750378\n", 625 | "2015-01-02 101.829067 99.945885 101.874778 98.135831" 626 | ] 627 | }, 628 | "execution_count": 38, 629 | "metadata": {}, 630 | "output_type": "execute_result" 631 | } 632 | ], 633 | "source": [ 634 | "price['AAPL',-7:'2015-1-2']" 635 | ] 636 | }, 637 | { 638 | "cell_type": "code", 639 | "execution_count": 39, 640 | "metadata": {}, 641 | "outputs": [ 642 | { 643 | "data": { 644 | "text/html": [ 645 | "
\n", 646 | "\n", 659 | "\n", 660 | " \n", 661 | " \n", 662 | " \n", 663 | " \n", 664 | " \n", 665 | " \n", 666 | " \n", 667 | " \n", 668 | " \n", 669 | " \n", 670 | " \n", 671 | " \n", 672 | " \n", 673 | " \n", 674 | " \n", 675 | " \n", 676 | " \n", 677 | " \n", 678 | " \n", 679 | " \n", 680 | " \n", 681 | " \n", 682 | " \n", 683 | " \n", 684 | " \n", 685 | " \n", 686 | " \n", 687 | " \n", 688 | " \n", 689 | " \n", 690 | " \n", 691 | " \n", 692 | " \n", 693 | " \n", 694 | " \n", 695 | " \n", 696 | " \n", 697 | " \n", 698 | " \n", 699 | " \n", 700 | " \n", 701 | " \n", 702 | " \n", 703 | " \n", 704 | " \n", 705 | " \n", 706 | "
openclosehighlow
2014-12-26102.478164104.205940104.690448102.395893
2014-12-29104.023095104.132797104.918975103.940816
2014-12-30103.885969102.862099104.141934102.487294
2014-12-31103.136355100.905785103.419745100.750378
2015-01-02101.82906799.945885101.87477898.135831
\n", 707 | "
" 708 | ], 709 | "text/plain": [ 710 | " open close high low\n", 711 | "2014-12-26 102.478164 104.205940 104.690448 102.395893\n", 712 | "2014-12-29 104.023095 104.132797 104.918975 103.940816\n", 713 | "2014-12-30 103.885969 102.862099 104.141934 102.487294\n", 714 | "2014-12-31 103.136355 100.905785 103.419745 100.750378\n", 715 | "2015-01-02 101.829067 99.945885 101.874778 98.135831" 716 | ] 717 | }, 718 | "execution_count": 39, 719 | "metadata": {}, 720 | "output_type": "execute_result" 721 | } 722 | ], 723 | "source": [ 724 | "price['AAPL',-relativedelta(days=7):'2015-1-2']" 725 | ] 726 | }, 727 | { 728 | "cell_type": "markdown", 729 | "metadata": {}, 730 | "source": [ 731 | "## Time Leak Protection\n", 732 | "During backtesting, the internal states ``current_event`` and ``current_date`` are set internally (you do not have to set these states) and trying to access future values will result in a ``TimeLeakError``." 733 | ] 734 | }, 735 | { 736 | "cell_type": "code", 737 | "execution_count": 40, 738 | "metadata": {}, 739 | "outputs": [], 740 | "source": [ 741 | "# setting internal states for demonstration in this notebook, this happens automatically during a backtest\n", 742 | "price.current_event = 'open'\n", 743 | "price.current_date = date(2020,5,18)" 744 | ] 745 | }, 746 | { 747 | "cell_type": "code", 748 | "execution_count": 41, 749 | "metadata": {}, 750 | "outputs": [ 751 | { 752 | "name": "stdout", 753 | "output_type": "stream", 754 | "text": [ 755 | "(datetime.date(2020, 5, 18), Timestamp('2020-05-22 00:00:00'), '2020-05-22 00:00:00 is more recent than 2020-05-18, resulting in time leak')\n" 756 | ] 757 | } 758 | ], 759 | "source": [ 760 | "try:\n", 761 | " price['AAPL','2020-5-22']\n", 762 | "except TimeLeakError as e:\n", 763 | " print(e)" 764 | ] 765 | }, 766 | { 767 | "cell_type": "markdown", 768 | "metadata": {}, 769 | "source": [ 770 | "When accessing multiple values, future events are set to ``None``." 771 | ] 772 | }, 773 | { 774 | "cell_type": "code", 775 | "execution_count": 42, 776 | "metadata": { 777 | "scrolled": true 778 | }, 779 | "outputs": [ 780 | { 781 | "data": { 782 | "text/plain": [ 783 | "open 313.17\n", 784 | "close None\n", 785 | "high None\n", 786 | "low None\n", 787 | "Name: 2020-05-18 00:00:00, dtype: object" 788 | ] 789 | }, 790 | "execution_count": 42, 791 | "metadata": {}, 792 | "output_type": "execute_result" 793 | } 794 | ], 795 | "source": [ 796 | "price['AAPL','2020-5-18']" 797 | ] 798 | }, 799 | { 800 | "cell_type": "code", 801 | "execution_count": 43, 802 | "metadata": { 803 | "scrolled": true 804 | }, 805 | "outputs": [ 806 | { 807 | "data": { 808 | "text/html": [ 809 | "
\n", 810 | "\n", 823 | "\n", 824 | " \n", 825 | " \n", 826 | " \n", 827 | " \n", 828 | " \n", 829 | " \n", 830 | " \n", 831 | " \n", 832 | " \n", 833 | " \n", 834 | " \n", 835 | " \n", 836 | " \n", 837 | " \n", 838 | " \n", 839 | " \n", 840 | " \n", 841 | " \n", 842 | " \n", 843 | " \n", 844 | " \n", 845 | " \n", 846 | " \n", 847 | " \n", 848 | " \n", 849 | " \n", 850 | " \n", 851 | " \n", 852 | " \n", 853 | " \n", 854 | " \n", 855 | " \n", 856 | " \n", 857 | " \n", 858 | " \n", 859 | " \n", 860 | " \n", 861 | " \n", 862 | " \n", 863 | "
openclosehighlow
2020-05-13312.149994307.649994315.950012303.209991
2020-05-14304.510010309.540009309.790009301.529999
2020-05-15300.350006307.709991307.899994300.209991
2020-05-18313.170013NaNNaNNaN
\n", 864 | "
" 865 | ], 866 | "text/plain": [ 867 | " open close high low\n", 868 | "2020-05-13 312.149994 307.649994 315.950012 303.209991\n", 869 | "2020-05-14 304.510010 309.540009 309.790009 301.529999\n", 870 | "2020-05-15 300.350006 307.709991 307.899994 300.209991\n", 871 | "2020-05-18 313.170013 NaN NaN NaN" 872 | ] 873 | }, 874 | "execution_count": 43, 875 | "metadata": {}, 876 | "output_type": "execute_result" 877 | } 878 | ], 879 | "source": [ 880 | "price['AAPL',-5:'2020-5-18']" 881 | ] 882 | }, 883 | { 884 | "cell_type": "code", 885 | "execution_count": null, 886 | "metadata": {}, 887 | "outputs": [], 888 | "source": [] 889 | } 890 | ], 891 | "metadata": { 892 | "kernelspec": { 893 | "display_name": "Python 3", 894 | "language": "python", 895 | "name": "python3" 896 | }, 897 | "language_info": { 898 | "codemirror_mode": { 899 | "name": "ipython", 900 | "version": 3 901 | }, 902 | "file_extension": ".py", 903 | "mimetype": "text/x-python", 904 | "name": "python", 905 | "nbconvert_exporter": "python", 906 | "pygments_lexer": "ipython3", 907 | "version": "3.7.6" 908 | } 909 | }, 910 | "nbformat": 4, 911 | "nbformat_minor": 4 912 | } 913 | -------------------------------------------------------------------------------- /examples/quickstart.py: -------------------------------------------------------------------------------- 1 | from simple_back.backtester import BacktesterBuilder 2 | 3 | builder = ( 4 | BacktesterBuilder() 5 | .name("My First Strategy") 6 | .balance(10_000) # define your starting balance 7 | .live_progress() # show a progress bar (requires tqdm) 8 | # .live_plot() # shows a live plot when working in a notebook 9 | .compare(["MSFT"]) # compare to buying and holding MSFT 10 | .calendar("NYSE") # trade on days the NYSE is open 11 | ) 12 | 13 | bt = builder.build() # build the backtester 14 | 15 | # you can now use bt like you would any iterator 16 | # specify a date range, and the code inside will be run 17 | # on open and close of every trading day in that range 18 | for day, event, b in bt["2019-1-1":"2020-1-1"]: 19 | 20 | # you can get the current prices of securities 21 | b.price("MSFT") # the current price of MSFT 22 | b.prices["MSFT", -30:]["open"].mean() 23 | # ^ gets the mean open price over the last 30 days 24 | 25 | # you can now order stocks using 26 | b.long("MSFT", percent=0.5) # allocate .5 of your funds to MSFT 27 | b.long( 28 | "MSFT", percent_available=0.1 29 | ) # allocate .1 of your cash still available to MSFT 30 | b.long("MSFT", absolute=1_000) # buy 1,000 worth of MSFT 31 | b.long("MSFT", nshares=1) # buy 1 MSFT share 32 | 33 | # you can access your protfolio using 34 | b.portfolio 35 | b.pf 36 | # or in dataframe form using 37 | b.portfolio.df 38 | b.pf.df 39 | 40 | # you can use the portfolio object to get out of positions 41 | b.pf.liquidate() # liquidate all positions 42 | b.pf.long.liquidate() # liquidate all long positions 43 | b.pf["MSFT"].liquidate() # liquidate all MSFT positions 44 | 45 | # you can also filter the portfolio using 46 | # the portfolio dataframe, or using .filter 47 | b.pf[b.pf.df.profit_loss_pct < -0.05].liquidate() 48 | b.pf.filter(lambda x: x.profit_loss_pct < -0.05).liquidate() 49 | # ^ both liquidate all positions that have lost more than 5% 50 | 51 | # you can use the metric dict to get current metrics 52 | b.metric["Portfolio Value"][-1] # gets the last computed value 53 | b.metric["Portfolio Value"]() # computes the current value 54 | 55 | 56 | # AFTER THE BACKTEST 57 | 58 | # get all metrics in dataframe form 59 | bt.metrics 60 | 61 | # get a summary of the backtest as a dataframe 62 | bt.summary 63 | # ^ this is useful for comparison when running several strategies 64 | -------------------------------------------------------------------------------- /examples/wikipedia-getter.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 53, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "\n", 10 | "class SP100TickerGetter:\n", 11 | " def __init__(self, n_tickers = 1000):\n", 12 | " self.n_tickers = n_tickers\n", 13 | "\n", 14 | " def get_tickers(self, current_date:date = date.today()):\n", 15 | " '''\n", 16 | " adapted from: https://stackoverflow.com/questions/44232578/automating-getting-the-sp-500-list\n", 17 | " '''\n", 18 | " url = \"https://en.wikipedia.org/wiki/S%26P_100\"\n", 19 | " if current_date != date.today():\n", 20 | " response = requests.get(\"https://en.wikipedia.org/w/index.php?title=S%26P_100&offset=&limit=500&action=history\")\n", 21 | " bs_object = BeautifulSoup(response.text, 'html.parser') \n", 22 | " links = bs_object.findAll('a', {'class': 'mw-changeslist-date'})\n", 23 | " dates = pd.Series()\n", 24 | " for link in links:\n", 25 | " dates[datetime.strptime(link.text, \"%H:%M, %d %B %Y\").date()] = link[\"href\"]\n", 26 | " pivot = current_date\n", 27 | " list_for_min = [_date for _date in dates.index if _date < pivot]\n", 28 | " nearest_in_past = min(list_for_min, key=lambda x: abs(x - pivot))\n", 29 | " url = \"https://en.wikipedia.org/\"+dates[nearest_in_past]\n", 30 | " \n", 31 | " response = requests.get(url)\n", 32 | " bs_object = BeautifulSoup(response.text, 'html.parser')\n", 33 | " table = bs_object.find('table', {'class': 'wikitable sortable'}) \n", 34 | " \n", 35 | " tickers = []\n", 36 | "\n", 37 | " try:\n", 38 | " for index, row in enumerate(table.findAll('tr')[1:]):\n", 39 | " if index >= self.n_tickers:\n", 40 | " break\n", 41 | " ticker = row.findAll('td')[0].text.strip()\n", 42 | " tickers.append(ticker)\n", 43 | " except:\n", 44 | " return None\n", 45 | "\n", 46 | " return pd.Series(tickers)" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": 54, 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "sp100 = SP100TickerGetter()" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": 55, 61 | "metadata": {}, 62 | "outputs": [ 63 | { 64 | "name": "stderr", 65 | "output_type": "stream", 66 | "text": [ 67 | "/home/cdminix/anaconda3/lib/python3.7/site-packages/ipykernel_launcher.py:19: DeprecationWarning: The default dtype for empty Series will be 'object' instead of 'float64' in a future version. Specify a dtype explicitly to silence this warning.\n" 68 | ] 69 | }, 70 | { 71 | "data": { 72 | "text/plain": [ 73 | "0 AA\n", 74 | "1 AAPL\n", 75 | "2 ABT\n", 76 | "3 AEP\n", 77 | "4 ALL\n", 78 | " ... \n", 79 | "96 WMB\n", 80 | "97 WMT\n", 81 | "98 WY\n", 82 | "99 XOM\n", 83 | "100 XRX\n", 84 | "Length: 101, dtype: object" 85 | ] 86 | }, 87 | "execution_count": 55, 88 | "metadata": {}, 89 | "output_type": "execute_result" 90 | } 91 | ], 92 | "source": [ 93 | "sp100.get_tickers(date(2010,1,1))" 94 | ] 95 | }, 96 | { 97 | "cell_type": "code", 98 | "execution_count": 28, 99 | "metadata": {}, 100 | "outputs": [], 101 | "source": [ 102 | "from urllib.parse import urlencode\n", 103 | "from simple_back.price_providers import DailyDataProvider\n", 104 | "from abc import abstractmethod\n", 105 | "from datetime import date, datetime\n", 106 | "from bs4 import BeautifulSoup\n", 107 | "import pandas as pd\n", 108 | "import requests\n", 109 | "\n", 110 | "\n", 111 | "class WikipediaProvider(DataProvider):\n", 112 | " @property\n", 113 | " def columns(self):\n", 114 | " return [\"tickers\"]\n", 115 | "\n", 116 | " @property\n", 117 | " def columns_order(self):\n", 118 | " return [0]\n", 119 | "\n", 120 | " def get(\n", 121 | " self, symbol: str, date: pd.Timestamp\n", 122 | " ) -> pd.DataFrame:\n", 123 | " title = urlencode({'title':symbol})\n", 124 | " hist_url = f\"https://en.wikipedia.org/w/index.php?{title}&offset=&limit=500&action=history\"\n", 125 | " response = requests.get(hist_url)\n", 126 | " response = BeautifulSoup(response.text, 'html.parser') \n", 127 | " links = response.findAll('a', {'class': 'mw-changeslist-date'})\n", 128 | " dates = pd.Series(dtype='str')\n", 129 | " for link in links:\n", 130 | " dates[datetime.strptime(link.text, \"%H:%M, %d %B %Y\").date()] = link[\"href\"]\n", 131 | " if type(date) == slice:\n", 132 | " date = date.stop\n", 133 | " pivot = date\n", 134 | " list_for_min = [_date for _date in dates.index if _date < pivot]\n", 135 | " nearest_in_past = min(list_for_min, key=lambda x: abs(x - pivot))\n", 136 | " url = \"https://en.wikipedia.org/\"+dates[nearest_in_past]\n", 137 | " if self.in_cache(url):\n", 138 | " html = self.get_cache(url)\n", 139 | " else:\n", 140 | " html = requests.get(url).text\n", 141 | " self.set_cache(url, html)\n", 142 | " return self.get_from_html(html, title)\n", 143 | " \n", 144 | " @abstractmethod\n", 145 | " def get_from_html(self, html):\n", 146 | " pass" 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": 29, 152 | "metadata": {}, 153 | "outputs": [], 154 | "source": [ 155 | "class SpProvider(WikipediaProvider):\n", 156 | " def get_from_html(self, html, title):\n", 157 | " bs_object = BeautifulSoup(html, 'html.parser')\n", 158 | " if title == 'S&P_100':\n", 159 | " table = bs_object.find('table', {'class': 'wikitable sortable'})\n", 160 | " if title == 'S&P_500':\n", 161 | " table = bs_object.find({'id':\"constituents\"})\n", 162 | " tickers = []\n", 163 | " try:\n", 164 | " for row in table.findAll('tr')[1:]:\n", 165 | " ticker = row.findAll('td')[0].text.strip()\n", 166 | " tickers.append(ticker)\n", 167 | " except:\n", 168 | " return None\n", 169 | " return pd.Series(tickers, dtype='str')" 170 | ] 171 | }, 172 | { 173 | "cell_type": "code", 174 | "execution_count": 30, 175 | "metadata": {}, 176 | "outputs": [], 177 | "source": [ 178 | "sp = SpProvider()" 179 | ] 180 | }, 181 | { 182 | "cell_type": "code", 183 | "execution_count": 32, 184 | "metadata": {}, 185 | "outputs": [], 186 | "source": [ 187 | "sp['S&P_500']" 188 | ] 189 | }, 190 | { 191 | "cell_type": "code", 192 | "execution_count": null, 193 | "metadata": {}, 194 | "outputs": [], 195 | "source": [ 196 | "sp.clear_cache()" 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": 1, 202 | "metadata": {}, 203 | "outputs": [], 204 | "source": [ 205 | "import requests\n", 206 | "import re\n", 207 | "from urllib.parse import urlencode\n", 208 | "import pandas as pd\n", 209 | "from simple_back.price_providers import DataProvider\n", 210 | "from abc import abstractmethod\n", 211 | "from bs4 import BeautifulSoup\n", 212 | "from dateutil.relativedelta import relativedelta\n", 213 | "\n", 214 | "class WikipediaProvider(DataProvider):\n", 215 | "\n", 216 | " def get_revisions(self, title):\n", 217 | " url = \"https://en.wikipedia.org/w/api.php?action=query&format=xml&prop=revisions&rvlimit=500&\" + title\n", 218 | " revisions = [] \n", 219 | " next_params = ''\n", 220 | " \n", 221 | " if self.in_cache(title):\n", 222 | " results = self.get_cache(title)\n", 223 | " else:\n", 224 | " while True:\n", 225 | " response = requests.get(url + next_params).text\n", 226 | " revisions += re.findall(']*>', response)\n", 227 | " cont = re.search('"] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.6.9" 9 | yahoo_fin = "^0.8.5" 10 | requests_html = "^0.10.0" 11 | pandas = "^1.0.3" 12 | pandas_market_calendars = "^1.3.5" 13 | numpy = "^1.18.4" 14 | diskcache = "^4.1.0" 15 | pytz = "^2020.1" 16 | beautifulsoup4 = "^4.9.1" 17 | memoization = "^0.3.1" 18 | tabulate = "^0.8.7" 19 | 20 | [tool.poetry.dev-dependencies] 21 | pytest = "^5.2" 22 | flake8 = "^3.8.2" 23 | black = "^19.10b0" 24 | dephell = "^0.8.3" 25 | IPython = "^7.15.0" 26 | pytest-cov = "^2.9.0" 27 | sphinx = "^3.0.4" 28 | sphinx-rtd-theme = "^0.4.3" 29 | sphinx-copybutton = "^0.2.11" 30 | sphinx-autodoc-typehints = "^1.10.3" 31 | nbsphinx = "^0.7.0" 32 | pandoc = "^1.0.2" 33 | pylint-badge = {git = "https://github.com/PouncySilverkitten/pylint-badge.git"} 34 | 35 | [tool.dephell.main] 36 | from = {format = "poetry", path = "pyproject.toml"} 37 | to = {format = "setuppy", path = "setup.py"} 38 | 39 | [build-system] 40 | requires = ["poetry>=0.12"] 41 | build-backend = "poetry.masonry.api" 42 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bs4==0.0.1 2 | Cerberus==1.3.2 3 | fake-useragent==0.1.11 4 | m2r==0.2.1 5 | MarkupSafe==1.1.1 6 | pandoc==1.0.2 7 | pandocfilters==1.4.3 8 | parse==1.18.0 9 | pylint-badge==0.9.5 10 | pyrsistent==0.17.3 11 | simple-back==0.6.3 12 | trading-calendars==2.0.0 13 | websockets==8.1 14 | wrapt==1.12.1 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | 4 | # DO NOT EDIT THIS FILE! 5 | # This file has been autogenerated by dephell <3 6 | # https://github.com/dephell/dephell 7 | 8 | try: 9 | from setuptools import setup 10 | except ImportError: 11 | from distutils.core import setup 12 | 13 | readme = '' 14 | 15 | setup( 16 | long_description=readme, 17 | name='simple-back', 18 | version='0.6.3', 19 | description='A backtester with minimal setup and sensible defaults.', 20 | python_requires='==3.*,>=3.6.9', 21 | author='Christoph Minixhofer', 22 | author_email='christoph.minixhofer@gmail.com', 23 | packages=['simple_back'], 24 | package_dir={"": "."}, 25 | package_data={}, 26 | install_requires=['beautifulsoup4==4.*,>=4.9.1', 'diskcache==4.*,>=4.1.0', 'memoization==0.*,>=0.3.1', 'numpy==1.*,>=1.18.4', 'pandas==1.*,>=1.0.3', 'pandas-market-calendars==1.*,>=1.3.5', 'pytz==2020.*,>=2020.1.0', 'requests-html==0.*,>=0.10.0', 'tabulate==0.*,>=0.8.7', 'yahoo-fin==0.*,>=0.8.5'], 27 | dependency_links=['git+https://github.com/PouncySilverkitten/pylint-badge.git#egg=pylint-badge'], 28 | extras_require={"dev": ["black==19.*,>=19.10.0.b0", "dephell==0.*,>=0.8.3", "flake8==3.*,>=3.8.2", "ipython==7.*,>=7.15.0", "nbsphinx==0.*,>=0.7.0", "pandoc==1.*,>=1.0.2", "pylint-badge", "pytest==5.*,>=5.2.0", "pytest-cov==2.*,>=2.9.0", "sphinx==3.*,>=3.0.4", "sphinx-autodoc-typehints==1.*,>=1.10.3", "sphinx-copybutton==0.*,>=0.2.11", "sphinx-rtd-theme==0.*,>=0.4.3"]}, 29 | ) 30 | -------------------------------------------------------------------------------- /simple_back.svg: -------------------------------------------------------------------------------- 1 | pylintpylint7.267.26 -------------------------------------------------------------------------------- /simple_back/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A backtester with minimal setup and sensible defaults. 3 | 4 | **simple_back** is a backtester providing easy ways to test trading 5 | strategies, often ones that use external data, with minimal amount of 6 | code, while avoiding time leaks. 7 | """ 8 | 9 | 10 | __version__ = "0.6.3" 11 | 12 | from . import backtester 13 | from . import strategy 14 | from . import fees 15 | from . import data_providers 16 | from . import metrics 17 | from . import exceptions 18 | 19 | __all__ = ["backtester", "strategy", "fees", "data_providers", "metrics", "exceptions"] 20 | -------------------------------------------------------------------------------- /simple_back/backtester.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pandas_market_calendars as mcal 3 | from dateutil.relativedelta import relativedelta 4 | import datetime 5 | from datetime import date 6 | import numpy as np 7 | import copy 8 | import math 9 | import json 10 | from typing import Union, List, Tuple, Callable, overload 11 | from warnings import warn 12 | import os 13 | from IPython.display import clear_output, display, HTML, display_html 14 | from dataclasses import dataclass 15 | import multiprocessing 16 | import threading 17 | from collections.abc import MutableSequence 18 | import uuid 19 | import time 20 | import tabulate 21 | 22 | from .data_providers import ( 23 | DailyPriceProvider, 24 | YahooFinanceProvider, 25 | DailyDataProvider, 26 | PriceUnavailableError, 27 | DataProvider, 28 | ) 29 | from .fees import NoFee, Fee, InsufficientCapitalError 30 | from .metrics import ( 31 | MaxDrawdown, 32 | AnnualReturn, 33 | PortfolioValue, 34 | DailyProfitLoss, 35 | TotalValue, 36 | TotalReturn, 37 | Metric, 38 | ) 39 | from .strategy import Strategy, BuyAndHold 40 | from .exceptions import BacktestRunError, LongShortLiquidationError, NegativeValueError 41 | from .utils import is_notebook, _cls 42 | 43 | # matplotlib is not a strict requirement, only needed for live_plot 44 | try: 45 | import pylab as pl 46 | import matplotlib.pyplot as plt 47 | import matplotlib 48 | 49 | plt_exists = True 50 | except ImportError: 51 | plt_exists = False 52 | 53 | # tqdm is not a strict requirement 54 | try: 55 | from tqdm import tqdm 56 | 57 | tqdm_exists = True 58 | except ImportError: 59 | tqdm_exists = False 60 | 61 | # from https://stackoverflow.com/a/44923103, https://stackoverflow.com/a/50899244 62 | def display_side_by_side(bts): 63 | html_str = "" 64 | for bt in bts: 65 | # styler = bt.logs.style.set_table_attributes( 66 | # "style='display:inline'" 67 | # ).set_caption(bt.name) 68 | # html_str += styler._repr_html_() 69 | pass 70 | display_html(bt.logs._repr_html_(), raw=True) 71 | 72 | 73 | class StrategySequence: 74 | """A sequence of strategies than can be accessed by name or :class:`int` index.\ 75 | Returned by :py:obj:`.Backtester.strategies` and should not be used elsewhere. 76 | 77 | Examples: 78 | 79 | Access by :class:`str`:: 80 | 81 | bt.strategies['Some Strategy Name'] 82 | 83 | Access by :class:`int`:: 84 | 85 | bt.strategies[0] 86 | 87 | Use as iterator:: 88 | 89 | for strategy in bt.strategies: 90 | # do something 91 | """ 92 | 93 | def __init__(self, bt): 94 | self.bt = bt 95 | self.i = 0 96 | 97 | def __getitem__(self, index: Union[str, int]): 98 | if isinstance(index, int): 99 | bt = self.bt._get_bts()[index] 100 | bt._from_sequence = True 101 | return bt 102 | elif isinstance(index, str): 103 | for i, bt in enumerate(self.bt._get_bts()): 104 | if bt.name is not None: 105 | if bt.name == index: 106 | bt._from_sequence = True 107 | return bt 108 | else: 109 | if f"Backtest {i}" == index: 110 | bt._from_sequence = True 111 | return bt 112 | raise IndexError 113 | 114 | def __iter__(self): 115 | return self 116 | 117 | def __next__(self): 118 | i = self.i 119 | self.i += 1 120 | return self[i] 121 | 122 | def __len__(self): 123 | return len(self.bt._get_bts()) 124 | 125 | 126 | class Position: 127 | """Tracks a single position in a portfolio or trade history. 128 | """ 129 | 130 | def __init__( 131 | self, 132 | bt: "Backtester", 133 | symbol: str, 134 | date: datetime.date, 135 | event: str, 136 | nshares: int, 137 | uid: str, 138 | fee: float, 139 | slippage: float = None, 140 | ): 141 | self.symbol = symbol 142 | self.date = date 143 | self.event = event 144 | self._nshares_int = nshares 145 | self.start_price = bt.price(symbol) 146 | if slippage is not None: 147 | if nshares < 0: 148 | self.start_price *= 1 + slippage 149 | if nshares > 0: 150 | self.start_price *= 1 - slippage 151 | self._slippage = slippage 152 | self._bt = bt 153 | self._frozen = False 154 | self._uid = uid 155 | self.fee = fee 156 | 157 | def _attr(self): 158 | return [attr for attr in dir(self) if not attr.startswith("_")] 159 | 160 | def __repr__(self) -> str: 161 | result = {} 162 | for attr in self._attr(): 163 | val = getattr(self, attr) 164 | if isinstance(val, float): 165 | result[attr] = f"{val:.2f}" 166 | else: 167 | result[attr] = str(val) 168 | return json.dumps(result, sort_keys=True, indent=2) 169 | 170 | @property 171 | def _short(self) -> bool: 172 | """True if this is a short position. 173 | """ 174 | return self._nshares_int < 0 175 | 176 | @property 177 | def _long(self) -> bool: 178 | """True if this is a long position. 179 | """ 180 | return self._nshares_int > 0 181 | 182 | @property 183 | def value(self) -> float: 184 | """Returns the current market value of the position. 185 | """ 186 | if self._short: 187 | old_val = self.initial_value 188 | cur_val = self.nshares * self.price 189 | return old_val + (old_val - cur_val) 190 | if self._long: 191 | return self.nshares * self.price 192 | 193 | @property 194 | def price(self) -> float: 195 | """Returns the current price if the position is held in a portfolio. 196 | Returns the last price if the position was liquidated and is part of a trade history. 197 | """ 198 | if self._frozen: 199 | result = self._bt.prices[self.symbol, self.end_date][self.end_event] 200 | else: 201 | result = self._bt.price(self.symbol) 202 | if self._slippage is not None: 203 | if self._short: 204 | result *= 1 - self._slippage 205 | if self._long: 206 | result *= 1 + self._slippage 207 | return result 208 | 209 | @property 210 | def value_pershare(self) -> float: 211 | """Returns the value of the position per share. 212 | """ 213 | if self._long: 214 | return self.price 215 | if self._short: 216 | return self.start_price + (self.start_price - self.price) 217 | 218 | @property 219 | def initial_value(self) -> float: 220 | """Returns the initial value of the position, including fees. 221 | """ 222 | if self._short: 223 | return self.nshares * self.start_price + self.fee 224 | if self._long: 225 | return self.nshares * self.start_price + self.fee 226 | 227 | @property 228 | def profit_loss_pct(self) -> float: 229 | """Returns the profit/loss associated with the position (not including commission) 230 | in relative terms. 231 | """ 232 | return self.value / self.initial_value - 1 233 | 234 | @property 235 | def profit_loss_abs(self) -> float: 236 | """Returns the profit/loss associated with the position (not including commission) 237 | in absolute terms. 238 | """ 239 | return self.value - self.initial_value 240 | 241 | @property 242 | def nshares(self) -> int: 243 | """Returns the number of shares in the position. 244 | """ 245 | return abs(self._nshares_int) 246 | 247 | @property 248 | def order_type(self) -> str: 249 | """Returns "long" or "short" based on the position type. 250 | """ 251 | t = None 252 | if self._short: 253 | t = "short" 254 | if self._long: 255 | t = "long" 256 | return t 257 | 258 | def _remove_shares(self, n): 259 | if self._short: 260 | self._nshares_int += n 261 | if self._long: 262 | self._nshares_int -= n 263 | 264 | def _freeze(self): 265 | self._frozen = True 266 | self.end_date = self._bt.current_date 267 | self.end_event = self._bt.event 268 | 269 | 270 | class Portfolio(MutableSequence): 271 | """A portfolio is a collection of :class:`.Position` objects, 272 | and can be used to :meth:`.liquidate` a subset of them. 273 | """ 274 | 275 | def __init__(self, bt, positions: List[Position] = []): 276 | self.positions = positions 277 | self.bt = bt 278 | 279 | @property 280 | def total_value(self) -> float: 281 | """Returns the total value of the portfolio. 282 | """ 283 | val = 0 284 | for pos in self.positions: 285 | val += pos.value 286 | return val 287 | 288 | @property 289 | def df(self) -> pd.DataFrame: 290 | pos_dict = {} 291 | for pos in self.positions: 292 | for col in pos._attr(): 293 | if col not in pos_dict: 294 | pos_dict[col] = [] 295 | pos_dict[col].append(getattr(pos, col)) 296 | return pd.DataFrame(pos_dict) 297 | 298 | def _get_by_uid(self, uid) -> Position: 299 | for pos in self.positions: 300 | if pos._uid == uid: 301 | return pos 302 | 303 | def __repr__(self) -> str: 304 | return self.positions.__repr__() 305 | 306 | def liquidate(self, nshares: int = -1, _bt: "Backtester" = None): 307 | """Liquidate all positions in the current "view" of the portfolio. 308 | If no view is given using `['some_ticker']`, :meth:`.filter`, 309 | :meth:`.Portfolio.long` or :meth:`.Portfolio.short`, 310 | an attempt to liquidate all positions is made. 311 | 312 | Args: 313 | nshares: 314 | The number of shares to be liquidated. 315 | This should only be used when a ticker is selected using `['some_ticker']`. 316 | 317 | Examples: 318 | Select all `MSFT` positions and liquidate them:: 319 | 320 | bt.portfolio['MSFT'].liquidate() 321 | 322 | Liquidate 10 `MSFT` shares:: 323 | 324 | bt.portfolio['MSFT'].liquidate(nshares=10) 325 | 326 | Liquidate all long positions:: 327 | 328 | bt.portfolio.long.liquidate() 329 | 330 | Liquidate all positions that have lost more than 5% in value. 331 | We can either use :meth:`.filter` or the dataframe as indexer 332 | (in this case in combination with the pf shorthand):: 333 | 334 | bt.pf[bt.pf.df['profit_loss_pct'] < -0.05].liquidate() 335 | # or 336 | bt.pf.filter(lambda x: x.profit_loss_pct < -0.05) 337 | """ 338 | bt = _bt 339 | if bt is None: 340 | bt = self.bt 341 | if bt._slippage is not None: 342 | self.liquidate(nshares, bt.lower_bound) 343 | is_long = False 344 | is_short = False 345 | for pos in self.positions: 346 | if pos._long: 347 | is_long = True 348 | if pos._short: 349 | is_short = True 350 | if is_long and is_short: 351 | bt._graceful_stop() 352 | raise LongShortLiquidationError( 353 | "liquidating a mix of long and short positions is not possible" 354 | ) 355 | for pos in copy.copy(self.positions): 356 | pos = bt.pf._get_by_uid(pos._uid) 357 | if nshares == -1 or nshares >= pos.nshares: 358 | bt._available_capital += pos.value 359 | if bt._available_capital < 0: 360 | bt._graceful_stop() 361 | raise NegativeValueError( 362 | f"Tried to liquidate position resulting in negative capital {bt._available_capital}." 363 | ) 364 | bt.portfolio._remove(pos) 365 | 366 | pos._freeze() 367 | bt.trades._add(copy.copy(pos)) 368 | 369 | if nshares != -1: 370 | nshares -= pos.nshares 371 | elif nshares > 0 and nshares < pos.nshares: 372 | bt._available_capital += pos.value_pershare * nshares 373 | pos._remove_shares(nshares) 374 | 375 | hist = copy.copy(pos) 376 | hist._freeze() 377 | if hist._short: 378 | hist._nshares_int = (-1) * nshares 379 | if hist._long: 380 | hist._nshares_int = nshares 381 | bt.trades._add(hist) 382 | 383 | break 384 | 385 | def _add(self, position): 386 | self.positions.append(position) 387 | 388 | def _remove(self, position): 389 | self.positions = [pos for pos in self.positions if pos._uid != position._uid] 390 | 391 | @overload 392 | def __getitem__(self, index: Union[int, slice]): 393 | ... 394 | 395 | @overload 396 | def __getitem__(self, index: str): 397 | ... 398 | 399 | @overload 400 | def __getitem__(self, index: Union[np.ndarray, pd.Series, List[bool]]): 401 | ... 402 | 403 | def __getitem__(self, index): 404 | if isinstance(index, (int, slice)): 405 | return Portfolio(self.bt, copy.copy(self.positions[index])) 406 | if isinstance(index, str): 407 | new_pos = [] 408 | for pos in self.positions: 409 | if pos.symbol == index: 410 | new_pos.append(pos) 411 | return Portfolio(self.bt, new_pos) 412 | if isinstance(index, (np.ndarray, pd.Series, List[bool])): 413 | if len(index) > 0: 414 | new_pos = list(np.array(self.bt.portfolio.positions)[index]) 415 | else: 416 | new_pos = [] 417 | return Portfolio(self.bt, new_pos) 418 | 419 | def __setitem__(self, index, value): 420 | self.positions[index] = value 421 | 422 | def __delitem__(self, index: Union[int, slice]) -> None: 423 | del self.positions[index] 424 | 425 | def __len__(self): 426 | return len(self.positions) 427 | 428 | def __bool__(self): 429 | return len(self) != 0 430 | 431 | def insert(self, index: int, value: Position) -> None: 432 | self.positions.insert(index, value) 433 | 434 | @property 435 | def short(self) -> "Portfolio": 436 | """Returns a view of the portfolio (which can be treated as its own :class:`.Portfolio`) 437 | containing all *short* positions. 438 | """ 439 | new_pos = [] 440 | for pos in self.positions: 441 | if pos._short: 442 | new_pos.append(pos) 443 | return Portfolio(self.bt, new_pos) 444 | 445 | @property 446 | def long(self) -> "Portfolio": 447 | """Returns a view of the portfolio (which can be treated as its own :class:`.Portfolio`) 448 | containing all *long* positions. 449 | """ 450 | new_pos = [] 451 | for pos in self.positions: 452 | if pos._long: 453 | new_pos.append(pos) 454 | return Portfolio(self.bt, new_pos) 455 | 456 | def filter(self, func: Callable[[Position], bool]) -> "Portfolio": 457 | """Filters positions using any :class`.Callable` 458 | 459 | Args: 460 | func: The function/callable to do the filtering. 461 | """ 462 | new_pos = [] 463 | for pos in self.positions: 464 | if func(pos): 465 | new_pos.append(pos) 466 | return Portfolio(self.bt, new_pos) 467 | 468 | def attr(self, attribute: str) -> List: 469 | """Get a list of values for a certain value for all posititions 470 | 471 | Args: 472 | attribute: 473 | String name of the attribute to get. 474 | Can be any attribute of :class:`.Position`. 475 | """ 476 | self.bt._warn.append( 477 | f""" 478 | .attr will be removed in 0.7 479 | you can use b.portfolio.df[{attribute}] 480 | instead of b.portfolio.attr('{attribute}') 481 | """ 482 | ) 483 | result = [getattr(pos, attribute) for pos in self.positions] 484 | if len(result) == 0: 485 | return None 486 | elif len(result) == 1: 487 | return result[0] 488 | else: 489 | return result 490 | 491 | 492 | class BacktesterBuilder: 493 | """ 494 | Used to configure a :class:`.Backtester` 495 | and then creating it with :meth:`.build` 496 | 497 | Example: 498 | Create a new :class:`.Backtester` with 10,000 starting balance 499 | which runs on days the `NYSE`_ is open:: 500 | 501 | bt = BacktesterBuilder().balance(10_000).calendar('NYSE').build() 502 | 503 | .. _NYSE: 504 | https://www.nyse.com/index 505 | """ 506 | 507 | def __init__(self): 508 | self.bt = copy.deepcopy(Backtester()) 509 | 510 | def name(self, name: str) -> "BacktesterBuilder": 511 | """**Optional**, name will be set to "Backtest 0" if not specified. 512 | 513 | Set the name of the strategy run using the :class:`.Backtester` iterator. 514 | 515 | Args: 516 | name: The strategy name. 517 | """ 518 | self = copy.deepcopy(self) 519 | self.bt.name = name 520 | return self 521 | 522 | def balance(self, amount: int) -> "BacktesterBuilder": 523 | """**Required**, set the starting balance for all :class:`.Strategy` objects 524 | run with the :class:`.Backtester` 525 | 526 | Args: 527 | amount: The starting balance. 528 | """ 529 | self = copy.deepcopy(self) 530 | self.bt._capital = amount 531 | self.bt._available_capital = amount 532 | self.bt._start_capital = amount 533 | return self 534 | 535 | def prices(self, prices: DailyPriceProvider) -> "BacktesterBuilder": 536 | """**Optional**, set the :class:`.DailyPriceProvider` used to get prices during 537 | a backtest. If this is not called, :class:`.YahooPriceProvider` 538 | is used. 539 | 540 | Args: 541 | prices: The price provider. 542 | """ 543 | self = copy.deepcopy(self) 544 | self.bt.prices = prices 545 | self.bt.prices.bt = self.bt 546 | return self 547 | 548 | def data(self, data: DataProvider) -> "BacktesterBuilder": 549 | """**Optional**, add a :class:`.DataProvider` to use external data without time leaks. 550 | 551 | Args: 552 | data: The data provider. 553 | """ 554 | self = copy.deepcopy(self) 555 | self.bt.data[data.name] = data 556 | data.bt = self.bt 557 | return self 558 | 559 | def trade_cost( 560 | self, trade_cost: Union[Fee, Callable[[float, float], Tuple[float, int]]] 561 | ) -> "BacktesterBuilder": 562 | """**Optional**, set a :class:`.Fee` to be applied when buying shares. 563 | When not set, :class:`.NoFee` is used. 564 | 565 | Args: 566 | trade_cost: one ore more :class:`.Fee` objects or callables. 567 | """ 568 | self = copy.deepcopy(self) 569 | self.bt._trade_cost = trade_cost 570 | return self 571 | 572 | def metrics(self, metrics: Union[Metric, List[Metric]]) -> "BacktesterBuilder": 573 | """**Optional**, set additional :class:`.Metric` objects to be used. 574 | 575 | Args: 576 | metrics: one or more :class:`.Metric` objects 577 | """ 578 | self = copy.deepcopy(self) 579 | if isinstance(metrics, list): 580 | for m in metrics: 581 | for m in metrics: 582 | m.bt = self.bt 583 | self.bt.metric[m.name] = m 584 | else: 585 | metrics.bt = self.bt 586 | self.bt.metric[metrics.name] = metrics 587 | return self 588 | 589 | def clear_metrics(self) -> "BacktesterBuilder": 590 | """**Optional**, remove all default metrics, 591 | except :class:`.PortfolioValue`, which is needed internally. 592 | """ 593 | self = copy.deepcopy(self) 594 | metrics = [PortfolioValue()] 595 | self.bt.metric = {} 596 | self.bt.metric(metrics) 597 | return self 598 | 599 | def calendar(self, calendar: str) -> "BacktesterBuilder": 600 | """**Optional**, set a `pandas market calendar`_ to be used. 601 | If not called, "NYSE" is used. 602 | 603 | Args: 604 | calendar: the calendar identifier 605 | 606 | .. _pandas market calendar: 607 | https://pandas-market-calendars.readthedocs.io/en/latest/calendars.html 608 | """ 609 | self = copy.deepcopy(self) 610 | self.bt._calendar = calendar 611 | return self 612 | 613 | def live_metrics(self, every: int = 10) -> "BacktesterBuilder": 614 | """**Optional**, shows all metrics live in output. This can be useful 615 | when running simple-back from terminal. 616 | 617 | Args: 618 | every: how often metrics should be updated 619 | (in events, e.g. 10 = 5 days) 620 | """ 621 | self = copy.deepcopy(self) 622 | if self.bt._live_plot: 623 | warn( 624 | """ 625 | live plotting and metrics cannot be used together, 626 | setting live plotting to false 627 | """ 628 | ) 629 | self.bt._live_plot = False 630 | self.bt._live_metrics = True 631 | self.bt._live_metrics_every = every 632 | return self 633 | 634 | def no_live_metrics(self) -> "BacktesterBuilder": 635 | """Disables showing live metrics. 636 | """ 637 | self = copy.deepcopy(self) 638 | self.bt._live_metrics = False 639 | return self 640 | 641 | def live_plot( 642 | self, 643 | every: int = None, 644 | metric: str = "Total Value", 645 | figsize: Tuple[float, float] = None, 646 | min_y: int = 0, 647 | blocking: bool = False, 648 | ) -> "BacktesterBuilder": 649 | """**Optional**, shows the backtest results live using matplotlib. 650 | Can only be used in notebooks. 651 | 652 | Args: 653 | every: 654 | how often metrics should be updated 655 | (in events, e.g. 10 = 5 days) 656 | the regular default is 10, 657 | blocking default is 100 658 | metric: which metric to plot 659 | figsize: size of the plot 660 | min_y: 661 | minimum value on the y axis, set to `None` 662 | for no lower limit 663 | blocking: 664 | will disable threading for plots and 665 | allow live plotting in terminal, 666 | this will slow down the backtester 667 | significantly 668 | """ 669 | self = copy.deepcopy(self) 670 | if self.bt._live_metrics: 671 | warn( 672 | """ 673 | live metrics and plotting cannot be used together, 674 | setting live metrics to false 675 | """ 676 | ) 677 | self.bt._live_metrics = False 678 | if is_notebook(): 679 | if every is None: 680 | every = 10 681 | elif not blocking: 682 | warn( 683 | """ 684 | live plots use threading which is not supported 685 | with matplotlib outside notebooks. to disable 686 | threading for live plots, you can call 687 | live_plot with ``blocking = True``. 688 | 689 | live_plot set to false. 690 | """ 691 | ) 692 | return self 693 | elif blocking: 694 | self.bt._live_plot_blocking = True 695 | if every is None: 696 | every = 100 697 | 698 | self.bt._live_plot = True 699 | self.bt._live_plot_every = every 700 | self.bt._live_plot_metric = metric 701 | self.bt._live_plot_figsize = figsize 702 | self.bt._live_plot_min = min_y 703 | 704 | return self 705 | 706 | def no_live_plot(self) -> "BacktesterBuilder": 707 | """Disables showing live plots. 708 | """ 709 | self = copy.deepcopy(self) 710 | self.bt._live_plot = False 711 | return self 712 | 713 | def live_progress(self, every: int = 10) -> "BacktesterBuilder": 714 | """**Optional**, shows a live progress bar using :class:`.tqdm`, either 715 | as port of a plot or as text output. 716 | """ 717 | self = copy.deepcopy(self) 718 | self.bt._live_progress = True 719 | self.bt._live_progress_every = every 720 | return self 721 | 722 | def no_live_progress(self) -> "BacktesterBuilder": 723 | """Disables the live progress bar. 724 | """ 725 | self = copy.deepcopy(self) 726 | self.bt._live_progress = False 727 | return self 728 | 729 | def compare( 730 | self, 731 | strategies: List[ 732 | Union[Callable[["datetime.date", str, "Backtester"], None], Strategy, str] 733 | ], 734 | ): 735 | """**Optional**, alias for :meth:`.BacktesterBuilder.strategies`, 736 | should be used when comparing to :class:`.BuyAndHold` of a ticker instead of other strategies. 737 | 738 | Args: 739 | strategies: 740 | should be the string of the ticker to compare to, 741 | but :class:`.Strategy` objects can be passed as well 742 | """ 743 | self = copy.deepcopy(self) 744 | return self.strategies(strategies) 745 | 746 | def strategies( 747 | self, 748 | strategies: List[ 749 | Union[Callable[["datetime.date", str, "Backtester"], None], Strategy, str] 750 | ], 751 | ) -> "BacktesterBuilder": 752 | """**Optional**, sets :class:`.Strategy` objects to run. 753 | 754 | Args: 755 | strategies: 756 | list of :class:`.Strategy` objects or tickers to :class:`.BuyAndHold` 757 | """ 758 | self = copy.deepcopy(self) 759 | strats = [] 760 | for strat in strategies: 761 | if isinstance(strat, str): 762 | strats.append(BuyAndHold(strat)) 763 | else: 764 | strats.append(strat) 765 | self.bt._temp_strategies = strats 766 | self.bt._has_strategies = True 767 | return self 768 | 769 | def slippage(self, slippage: int = 0.0005): 770 | """**Optional**, sets slippage which will create a (lower bound) strategy. 771 | The orginial strategies will run without slippage. 772 | 773 | Args: 774 | slippage: 775 | the slippage in percent of the base price, 776 | default is equivalent to quantopian default for US Equities 777 | """ 778 | self = copy.deepcopy(self) 779 | self.bt._slippage = slippage 780 | return self 781 | 782 | def build(self) -> "Backtester": 783 | """Build a :class:`.Backtester` given the previous configuration. 784 | """ 785 | self = copy.deepcopy(self) 786 | self.bt._builder = self 787 | return copy.deepcopy(self.bt) 788 | 789 | 790 | class Backtester: 791 | """The :class:`.Backtester` object is yielded alongside 792 | the current day and event (open or close) 793 | when it is called with a date range, 794 | which can be of the following forms. 795 | The :class:`.Backtester` object stores information 796 | about the backtest after it has completed. 797 | 798 | Examples: 799 | 800 | Initialize with dates as strings:: 801 | 802 | bt['2010-1-1','2020-1-1'].run() 803 | # or 804 | for day, event, b in bt['2010-1-1','2020-1-1']: 805 | ... 806 | 807 | Initialize with dates as :class:`.datetime.date` objects:: 808 | 809 | bt[datetime.date(2010,1,1),datetime.date(2020,1,1)] 810 | 811 | Initialize with dates as :class:`.int`:: 812 | 813 | bt[-100:] # backtest 100 days into the past 814 | """ 815 | 816 | def __getitem__(self, date_range: slice) -> "Backtester": 817 | if self._run_before: 818 | raise BacktestRunError( 819 | "Backtest has already run, build a new backtester to run again." 820 | ) 821 | self._run_before = True 822 | if self.assume_nyse: 823 | self._calendar = "NYSE" 824 | if date_range.start is not None: 825 | start_date = date_range.start 826 | else: 827 | raise ValueError("a date range without a start value is not allowed") 828 | if date_range.stop is not None: 829 | end_date = date_range.stop 830 | else: 831 | self._warn.append( 832 | "backtests with no end date can lead to non-replicable results" 833 | ) 834 | end_date = datetime.date.today() - relativedelta(days=1) 835 | cal = mcal.get_calendar(self._calendar) 836 | if isinstance(start_date, relativedelta): 837 | start_date = datetime.date.today() + start_date 838 | if isinstance(end_date, relativedelta): 839 | end_date = datetime.date.today() + end_date 840 | sched = cal.schedule(start_date=start_date, end_date=end_date) 841 | self._schedule = sched 842 | self.dates = mcal.date_range(sched, frequency="1D") 843 | self.datetimes = [] 844 | self.dates = [d.date() for d in self.dates] 845 | for date in self.dates: 846 | self.datetimes += [ 847 | sched.loc[date.isoformat()]["market_open"], 848 | sched.loc[date.isoformat()]["market_close"], 849 | ] 850 | 851 | if self._has_strategies: 852 | self._set_strategies(self._temp_strategies) 853 | 854 | return self 855 | 856 | def _init_slippage(self, bt=None): 857 | if bt is None: 858 | bt = self 859 | lower_bound = copy.deepcopy(bt) 860 | lower_bound._strategies = [] 861 | lower_bound._set_self() 862 | lower_bound.name += " (lower bound)" 863 | lower_bound._has_strategies = False 864 | lower_bound._slippage_percent = (-1) * self._slippage 865 | lower_bound._slippage = None 866 | lower_bound._init_iter(lower_bound) 867 | bt.lower_bound = lower_bound 868 | self._strategies.append(lower_bound) 869 | 870 | def __init__(self): 871 | self.dates = [] 872 | self.assume_nyse = False 873 | 874 | self.prices = YahooFinanceProvider() 875 | self.prices.bt = self 876 | 877 | self.portfolio = Portfolio(self) 878 | self.trades = copy.deepcopy(Portfolio(self)) 879 | 880 | self._trade_cost = NoFee() 881 | 882 | metrics = [ 883 | MaxDrawdown(), 884 | AnnualReturn(), 885 | PortfolioValue(), 886 | TotalValue(), 887 | TotalReturn(), 888 | DailyProfitLoss(), 889 | ] 890 | self.metric = {} 891 | for m in metrics: 892 | m.bt = self 893 | self.metric[m.name] = m 894 | 895 | self.data = {} 896 | 897 | self._start_capital = None 898 | self._available_capital = None 899 | self._capital = None 900 | 901 | self._live_plot = False 902 | self._live_plot_figsize = None 903 | self._live_plot_metric = "Total Value" 904 | self._live_plot_figsize = None 905 | self._live_plot_min = None 906 | self._live_plot_axes = None 907 | self._live_plot_blocking = False 908 | self._live_metrics = False 909 | self._live_progress = False 910 | 911 | self._strategies = [] 912 | self._temp_strategies = [] 913 | self._has_strategies = False 914 | self.name = "Backtest" 915 | 916 | self._no_iter = False 917 | 918 | self._schedule = None 919 | self._warn = [] 920 | self._log = [] 921 | 922 | self._add_metrics = {} 923 | self._add_metrics_lines = [] 924 | 925 | self.datetimes = None 926 | self.add_metric_exists = False 927 | 928 | self._run_before = False 929 | 930 | self._last_thread = None 931 | 932 | self._from_sequence = False 933 | 934 | self._slippage = None 935 | self._slippage_percent = None 936 | 937 | def _set_self(self, new_self=None): 938 | if new_self is not None: 939 | self = new_self 940 | self.portfolio.bt = self 941 | self.trades.bt = self 942 | self.prices.bt = self 943 | 944 | for m in self.metric.values(): 945 | m.__init__() 946 | m.bt = self 947 | 948 | def _init_iter(self, bt=None): 949 | global _live_progress_pbar 950 | if bt is None: 951 | bt = self 952 | if bt.assume_nyse: 953 | self._warn.append("no market calendar specified, assuming NYSE calendar") 954 | if bt._available_capital is None or bt._capital is None: 955 | raise ValueError( 956 | "initial balance not specified, you can do so using .balance" 957 | ) 958 | if bt.dates is None or len(bt.dates) == 0: 959 | raise ValueError( 960 | "no dates selected, you can select dates using [start_date:end_date]" 961 | ) 962 | bt.i = -1 963 | bt.event = "close" 964 | if self._live_progress: 965 | _live_progress_pbar = tqdm(total=len(self)) 966 | _cls() 967 | if self._slippage is not None and not self._no_iter: 968 | self._init_slippage(self) 969 | self._has_strategies = True 970 | return self 971 | 972 | def _next_iter(self, bt=None): 973 | if bt is None: 974 | bt = self 975 | if bt.i == len(self): 976 | bt._init_iter() 977 | if bt.event == "open": 978 | bt.event = "close" 979 | bt.i += 1 980 | elif bt.event == "close": 981 | try: 982 | bt.i += 1 983 | bt.current_date = bt.dates[bt.i // 2].isoformat() 984 | bt.event = "open" 985 | except IndexError: 986 | bt.i -= 1 987 | for metric in bt.metric.values(): 988 | if metric._single: 989 | metric(write=True) 990 | if self._has_strategies: 991 | for strat in self._strategies: 992 | for metric in strat.metric.values(): 993 | if metric._single: 994 | metric(write=True) 995 | self._plot(self._get_bts(), last=True) 996 | raise StopIteration 997 | bt._update() 998 | return bt.current_date, bt.event, bt 999 | 1000 | def add_metric(self, key: str, value: float): 1001 | """Called inside the backtest, adds a metric that is visually tracked. 1002 | 1003 | Args: 1004 | key: the metric name 1005 | value: the numerical value of the metric 1006 | """ 1007 | if key not in self._add_metrics: 1008 | self._add_metrics[key] = ( 1009 | np.repeat(np.nan, len(self)), 1010 | np.repeat(True, len(self)), 1011 | ) 1012 | self._add_metrics[key][0][self.i] = value 1013 | self._add_metrics[key][1][self.i] = False 1014 | 1015 | def add_line(self, **kwargs): 1016 | """Adds a vertical line on the plot on the current date + event. 1017 | """ 1018 | self._add_metrics_lines.append((self.timestamp, kwargs)) 1019 | 1020 | def log(self, text: str): 1021 | """Adds a log text on the current day and event that can be accessed using :obj:`.logs` 1022 | after the backtest has completed. 1023 | 1024 | Args: 1025 | text: text to log 1026 | """ 1027 | self._log.append([self.current_date, self.event, text]) 1028 | 1029 | @property 1030 | def timestamp(self): 1031 | """Returns the current timestamp, which includes the correct open/close time, 1032 | depending on the calendar that was set using :meth:`.BacktesterBuilder.calendar` 1033 | """ 1034 | return self._schedule.loc[self.current_date][f"market_{self.event}"] 1035 | 1036 | def __iter__(self): 1037 | return self._init_iter() 1038 | 1039 | def __next__(self): 1040 | if len(self._strategies) > 0: 1041 | result = self._next_iter() 1042 | self._run_once() 1043 | self._plot([self] + self._strategies) 1044 | else: 1045 | result = self._next_iter() 1046 | self._plot([self]) 1047 | return result 1048 | 1049 | def __len__(self): 1050 | return len(self.dates) * 2 1051 | 1052 | def _show_live_metrics(self, bts=None): 1053 | _cls() 1054 | lines = [] 1055 | bt_names = [] 1056 | if bts is not None: 1057 | if not self._no_iter and self not in bts: 1058 | bts = [self] + bts 1059 | for i, bt in enumerate(bts): 1060 | if bt.name is None: 1061 | name = f"Backtest {i}" 1062 | else: 1063 | name = bt.name 1064 | name = f"{name:20}" 1065 | if len(name) > 20: 1066 | name = name[:18] 1067 | name += ".." 1068 | bt_names.append(name) 1069 | lines.append(f"{'':20} {''.join(bt_names)}") 1070 | if bts is None: 1071 | bts = [self] 1072 | for mkey in self.metric.keys(): 1073 | metrics = [] 1074 | for bt in bts: 1075 | metric = bt.metric[mkey] 1076 | if str(metric) == "None": 1077 | metric = f"{metric():.2f}" 1078 | metric = f"{str(metric):20}" 1079 | metrics.append(metric) 1080 | lines.append(f"{mkey:20} {''.join(metrics)}") 1081 | for line in lines: 1082 | print(line) 1083 | if self._live_progress: 1084 | print() 1085 | print(self._show_live_progress()) 1086 | 1087 | def _show_live_plot(self, bts=None, start_end=None): 1088 | if not plt_exists: 1089 | self._warn.append( 1090 | "matplotlib not installed, setting live plotting to false" 1091 | ) 1092 | self._live_plot = False 1093 | return None 1094 | plot_df = pd.DataFrame() 1095 | plot_df["Date"] = self.datetimes 1096 | plot_df = plot_df.set_index("Date") 1097 | plot_add_df = plot_df.copy() 1098 | add_metric_exists = False 1099 | main_col = [] 1100 | bound_col = [] 1101 | for i, bt in enumerate(bts): 1102 | metric = bt.metric[self._live_plot_metric].values 1103 | name = f"Backtest {i}" 1104 | if bt.name is not None: 1105 | name = bt.name 1106 | if bt._slippage_percent is not None: 1107 | bound_col.append(name) 1108 | else: 1109 | main_col.append(name) 1110 | plot_df[name] = metric 1111 | for mkey in bt._add_metrics.keys(): 1112 | add_metric = bt._add_metrics[mkey] 1113 | plot_add_df[mkey] = np.ma.masked_where(add_metric[1], add_metric[0]) 1114 | if not self.add_metric_exists: 1115 | self.add_metric_exists = True 1116 | 1117 | if self._live_plot_figsize is None: 1118 | if add_metric_exists: 1119 | self._live_plot_figsize = (10, 13) 1120 | else: 1121 | self._live_plot_figsize = (10, 6.5) 1122 | 1123 | if self.add_metric_exists: 1124 | fig, axes = plt.subplots( 1125 | 2, 1, sharex=True, figsize=self._live_plot_figsize, num=0 1126 | ) 1127 | else: 1128 | fig, axes = plt.subplots( 1129 | 1, 1, sharex=True, figsize=self._live_plot_figsize, num=0 1130 | ) 1131 | axes = [axes] 1132 | 1133 | if self._live_progress: 1134 | axes[0].set_title(str(self._show_live_progress())) 1135 | plot_df[main_col].plot(ax=axes[0]) 1136 | try: 1137 | if self._slippage is not None: 1138 | for col in main_col: 1139 | axes[0].fill_between( 1140 | plot_df.index, 1141 | plot_df[f"{col} (lower bound)"], 1142 | plot_df[f"{col}"], 1143 | alpha=0.1, 1144 | ) 1145 | except KeyError: 1146 | pass 1147 | if self._live_plot_min is not None: 1148 | axes[0].set_ylim(bottom=self._live_plot_min) 1149 | plt.tight_layout() 1150 | 1151 | if self.add_metric_exists: 1152 | try: 1153 | interp_df = plot_add_df.interpolate(method="linear") 1154 | interp_df.plot(ax=axes[1], cmap="Accent") 1155 | for bt in bts: 1156 | for line in bt._add_metrics_lines: 1157 | plt.axvline(line[0], **line[1]) 1158 | except TypeError: 1159 | pass 1160 | 1161 | fig.autofmt_xdate() 1162 | if start_end is not None: 1163 | plt.xlim([start_end[0], start_end[1]]) 1164 | else: 1165 | plt.xlim([self.dates[0], self.dates[-1]]) 1166 | 1167 | clear_output(wait=True) 1168 | 1169 | plt.draw() 1170 | plt.pause(0.001) 1171 | if self._live_plot_blocking: 1172 | plt.clf() # needed to prevent overlapping tick labels 1173 | 1174 | captions = [] 1175 | 1176 | for bt in bts: 1177 | captions.append(bt.name) 1178 | 1179 | has_logs = False 1180 | 1181 | for bt in bts: 1182 | if len(bt._log) > 0: 1183 | has_logs = True 1184 | 1185 | if has_logs: 1186 | display_side_by_side(bts) 1187 | 1188 | for w in self._warn: 1189 | warn(w) 1190 | 1191 | def _show_live_progress(self): 1192 | _live_progress_pbar.n = self.i + 1 1193 | return _live_progress_pbar 1194 | 1195 | def _update(self): 1196 | for metric in self.metric.values(): 1197 | if metric._series: 1198 | try: 1199 | metric(write=True) 1200 | except PriceUnavailableError as e: 1201 | if self.event == "close": 1202 | self.i -= 2 1203 | if self.event == "open": 1204 | self.i -= 1 1205 | 1206 | self._warn.append( 1207 | f"{e.symbol} discontinued on {self.current_date}, liquidating at previous day's {self.event} price" 1208 | ) 1209 | 1210 | self.current_date = self.dates[(self.i // 2)].isoformat() 1211 | 1212 | self.portfolio[e.symbol].liquidate() 1213 | metric(write=True) 1214 | 1215 | if self.event == "close": 1216 | self.i += 2 1217 | if self.event == "open": 1218 | self.i += 1 1219 | self.current_date = self.dates[(self.i // 2)].isoformat() 1220 | 1221 | self._capital = self._available_capital + self.metric["Portfolio Value"][-1] 1222 | 1223 | def _graceful_stop(self): 1224 | if self._last_thread is not None: 1225 | self._last_thread.join() 1226 | del self._last_thread 1227 | self._plot(self._get_bts(), last=True) 1228 | 1229 | def _order( 1230 | self, 1231 | symbol, 1232 | capital, 1233 | as_percent=False, 1234 | as_percent_available=False, 1235 | shares=None, 1236 | uid=None, 1237 | ): 1238 | if uid is None: 1239 | uid = uuid.uuid4() 1240 | if self._slippage is not None: 1241 | self.lower_bound._order( 1242 | symbol, capital, as_percent, as_percent_available, shares, uid 1243 | ) 1244 | 1245 | self._capital = self._available_capital + self.metric["Portfolio Value"]() 1246 | if capital < 0: 1247 | short = True 1248 | capital = (-1) * capital 1249 | else: 1250 | short = False 1251 | if not as_percent and not as_percent_available: 1252 | if capital > self._available_capital: 1253 | self._graceful_stop() 1254 | raise InsufficientCapitalError("not enough capital available") 1255 | elif as_percent: 1256 | if abs(capital * self._capital) > self._available_capital: 1257 | if not math.isclose(capital * self._capital, self._available_capital): 1258 | self._graceful_stop() 1259 | raise InsufficientCapitalError( 1260 | f""" 1261 | not enough capital available: 1262 | ordered {capital} * {self._capital} 1263 | with only {self._available_capital} available 1264 | """ 1265 | ) 1266 | elif as_percent_available: 1267 | if abs(capital * self._available_capital) > self._available_capital: 1268 | if not math.isclose( 1269 | capital * self._available_capital, self._available_capital 1270 | ): 1271 | self._graceful_stop() 1272 | raise InsufficientCapitalError( 1273 | f""" 1274 | not enough capital available: 1275 | ordered {capital} * {self._available_capital} 1276 | with only {self._available_capital} available 1277 | """ 1278 | ) 1279 | current_price = self.price(symbol) 1280 | 1281 | if self._slippage_percent is not None: 1282 | if short: 1283 | current_price *= 1 + self._slippage_percent 1284 | else: 1285 | current_price *= 1 - self._slippage_percent 1286 | 1287 | if as_percent: 1288 | capital = capital * self._capital 1289 | if as_percent_available: 1290 | capital = capital * self._available_capital 1291 | try: 1292 | if shares is None: 1293 | fee_dict = self._trade_cost(current_price, capital) 1294 | nshares, total, fee = ( 1295 | fee_dict["nshares"], 1296 | fee_dict["total"], 1297 | fee_dict["fee"], 1298 | ) 1299 | else: 1300 | fee_dict = self._trade_cost( 1301 | current_price, self._available_capital, nshares=shares 1302 | ) 1303 | nshares, total, fee = ( 1304 | fee_dict["nshares"], 1305 | fee_dict["total"], 1306 | fee_dict["fee"], 1307 | ) 1308 | except Exception as e: 1309 | self._graceful_stop() 1310 | raise e 1311 | if short: 1312 | nshares *= -1 1313 | if nshares != 0: 1314 | self._available_capital -= total 1315 | pos = Position( 1316 | self, 1317 | symbol, 1318 | self.current_date, 1319 | self.event, 1320 | nshares, 1321 | uid, 1322 | fee, 1323 | self._slippage_percent, 1324 | ) 1325 | self.portfolio._add(pos) 1326 | else: 1327 | _cls() 1328 | raise Exception( 1329 | f""" 1330 | not enough capital specified to order a single share of {symbol}: 1331 | tried to order {capital} of {symbol} 1332 | with {symbol} price at {current_price} 1333 | """ 1334 | ) 1335 | 1336 | def long(self, symbol: str, **kwargs): 1337 | """Enter a long position of the given symbol. 1338 | 1339 | Args: 1340 | symbol: the ticker to buy 1341 | kwargs: 1342 | one of either 1343 | "percent" as a percentage of total value (cash + positions), 1344 | "absolute" as an absolute value, 1345 | "percent_available" as a percentage of remaining funds (excluding positions) 1346 | "nshares" as a number of shares 1347 | """ 1348 | if "percent" in kwargs: 1349 | self._order(symbol, kwargs["percent"], as_percent=True) 1350 | if "absolute" in kwargs: 1351 | self._order(symbol, kwargs["absolute"]) 1352 | if "percent_available" in kwargs: 1353 | self._order(symbol, kwargs["percent_available"], as_percent_available=True) 1354 | if "nshares" in kwargs: 1355 | self._order(symbol, 1, shares=kwargs["nshares"]) 1356 | 1357 | def short(self, symbol: str, **kwargs): 1358 | """Enter a short position of the given symbol. 1359 | 1360 | Args: 1361 | symbol: the ticker to short 1362 | kwargs: 1363 | one of either 1364 | "percent" as a percentage of total value (cash + positions), 1365 | "absolute" as an absolute value, 1366 | "percent_available" as a percentage of remaining funds (excluding positions) 1367 | "nshares" as a number of shares 1368 | """ 1369 | if "percent" in kwargs: 1370 | self._order(symbol, -kwargs["percent"], as_percent=True) 1371 | if "absolute" in kwargs: 1372 | self._order(symbol, -kwargs["absolute"]) 1373 | if "percent_available" in kwargs: 1374 | self._order(symbol, -kwargs["percent_available"], as_percent_available=True) 1375 | if "nshares" in kwargs: 1376 | self._order(symbol, -1, shares=kwargs["nshares"]) 1377 | 1378 | def price(self, symbol: str) -> float: 1379 | """Get the current price of a given symbol. 1380 | 1381 | Args: 1382 | symbol: the ticker 1383 | """ 1384 | try: 1385 | price = self.prices[symbol, self.current_date][self.event] 1386 | except KeyError: 1387 | raise PriceUnavailableError( 1388 | symbol, 1389 | self.current_date, 1390 | f""" 1391 | Price for {symbol} on {self.current_date} could not be found. 1392 | """.strip(), 1393 | ) 1394 | if math.isnan(price) or price is None: 1395 | self._graceful_stop() 1396 | raise PriceUnavailableError( 1397 | symbol, 1398 | self.current_date, 1399 | f""" 1400 | Price for {symbol} on {self.current_date} is nan or None. 1401 | """.strip(), 1402 | ) 1403 | return price 1404 | 1405 | @property 1406 | def balance(self) -> "Balance": 1407 | """Get the current or starting balance. 1408 | 1409 | Examples: 1410 | 1411 | Get the current balance:: 1412 | 1413 | bt.balance.current 1414 | 1415 | Get the starting balance:: 1416 | 1417 | bt.balance.start 1418 | """ 1419 | 1420 | @dataclass 1421 | class Balance: 1422 | start: float = self._start_capital 1423 | current: float = self._available_capital 1424 | 1425 | return Balance() 1426 | 1427 | def _get_bts(self): 1428 | bts = [self] 1429 | if self._has_strategies: 1430 | if self._no_iter: 1431 | bts = self._strategies 1432 | else: 1433 | bts = bts + self._strategies 1434 | return bts 1435 | 1436 | @property 1437 | def metrics(self) -> pd.DataFrame: 1438 | """Get a dataframe of all metrics collected during the backtest(s). 1439 | """ 1440 | bts = self._get_bts() 1441 | dfs = [] 1442 | for i, bt in enumerate(bts): 1443 | df = pd.DataFrame() 1444 | df["Event"] = np.tile(["open", "close"], len(bt) // 2 + 1)[: len(bt)] 1445 | df["Date"] = np.repeat(bt.dates, 2) 1446 | if self._has_strategies: 1447 | if bt.name is not None: 1448 | df["Backtest"] = np.repeat(bt.name, len(bt)) 1449 | else: 1450 | df["Backtest"] = np.repeat(f"Backtest {i}", len(bt)) 1451 | for key in bt.metric.keys(): 1452 | metric = bt.metric[key] 1453 | if metric._series: 1454 | df[key] = metric.values 1455 | if metric._single: 1456 | df[key] = np.repeat(metric.value, len(bt)) 1457 | dfs.append(df) 1458 | if self._has_strategies: 1459 | return pd.concat(dfs).set_index(["Backtest", "Date", "Event"]) 1460 | else: 1461 | return pd.concat(dfs).set_index(["Date", "Event"]) 1462 | 1463 | @property 1464 | def summary(self) -> pd.DataFrame: 1465 | """Get a dataframe showing the last and overall values of all metrics 1466 | collected during the backtest. 1467 | This can be helpful for comparing backtests at a glance. 1468 | """ 1469 | bts = self._get_bts() 1470 | dfs = [] 1471 | for i, bt in enumerate(bts): 1472 | df = pd.DataFrame() 1473 | if self._has_strategies: 1474 | if bt.name is not None: 1475 | df["Backtest"] = [bt.name] 1476 | else: 1477 | df["Backtest"] = [f"Backtest {i}"] 1478 | for key in bt.metric.keys(): 1479 | metric = bt.metric[key] 1480 | if metric._series: 1481 | df[f"{key} (Last Value)"] = [metric[-1]] 1482 | if metric._single: 1483 | df[key] = [metric.value] 1484 | dfs.append(df) 1485 | if self._has_strategies: 1486 | return pd.concat(dfs).set_index(["Backtest"]) 1487 | else: 1488 | return df 1489 | 1490 | @property 1491 | def strategies(self): 1492 | """Provides access to sub-strategies, returning a :class:`.StrategySequence`. 1493 | """ 1494 | if self._has_strategies: 1495 | return StrategySequence(self) 1496 | 1497 | @property 1498 | def pf(self) -> Portfolio: 1499 | """Shorthand for `portfolio`, returns the backtesters portfolio. 1500 | """ 1501 | return self.portfolio 1502 | 1503 | def _set_strategies( 1504 | self, strategies: List[Callable[["Date", str, "Backtester"], None]] 1505 | ): 1506 | self._strategies_call = copy.deepcopy(strategies) 1507 | for strat in strategies: 1508 | new_bt = copy.deepcopy(self) 1509 | new_bt._set_self() 1510 | new_bt.name = strat.name 1511 | new_bt._has_strategies = False 1512 | if self._slippage is not None: 1513 | self._init_slippage(new_bt) 1514 | 1515 | # this is bad but not bad enough to 1516 | # do anything other than this hotfix 1517 | self._no_iter = True 1518 | self._init_iter(new_bt) 1519 | self._no_iter = False 1520 | 1521 | self._strategies.append(new_bt) 1522 | 1523 | def _run_once(self): 1524 | no_slip_strats = [ 1525 | strat for strat in self._strategies if strat._slippage_percent is None 1526 | ] 1527 | slip_strats = [ 1528 | strat for strat in self._strategies if strat._slippage_percent is not None 1529 | ] 1530 | for bt in slip_strats: 1531 | self._next_iter(bt) 1532 | for i, bt in enumerate(no_slip_strats): 1533 | self._strategies_call[i](*self._next_iter(bt)) 1534 | 1535 | def _plot(self, bts, last=False): 1536 | try: 1537 | if self._live_plot and (self.i % self._live_plot_every == 0 or last): 1538 | if not self._live_plot_blocking: 1539 | if self._last_thread is None or not self._last_thread.is_alive(): 1540 | thr = threading.Thread(target=self._show_live_plot, args=(bts,)) 1541 | thr.start() 1542 | self._last_thread = thr 1543 | if last: 1544 | self._last_thread.join() 1545 | self._show_live_plot(bts) 1546 | else: 1547 | self._show_live_plot(bts) 1548 | if self._live_metrics and (self.i % self._live_metrics_every == 0 or last): 1549 | self._show_live_metrics(bts) 1550 | if ( 1551 | not (self._live_metrics or self._live_plot) 1552 | and self._live_progress 1553 | and (self.i % self._live_progress_every == 0 or last) 1554 | ): 1555 | _cls() 1556 | print(self._show_live_progress()) 1557 | for l in self._log[-20:]: 1558 | print(l) 1559 | if len(self._log) > 20: 1560 | print("... more logs stored in Backtester.logs") 1561 | for w in self._warn: 1562 | warn(w) 1563 | except: 1564 | pass 1565 | 1566 | @property 1567 | def logs(self) -> pd.DataFrame: 1568 | """Returns a :class:`.pd.DataFrame` for logs collected during the backtest. 1569 | """ 1570 | df = pd.DataFrame(self._log, columns=["date", "event", "log"]) 1571 | df = df.set_index(["date", "event"]) 1572 | return df 1573 | 1574 | def show(self, start=None, end=None): 1575 | """Show the backtester as a plot. 1576 | 1577 | Args: 1578 | start: the start date 1579 | end: the end date 1580 | """ 1581 | bts = self._get_bts() 1582 | if self._from_sequence: 1583 | bts = [self] 1584 | if start is not None or end is not None: 1585 | self._show_live_plot(bts, [start, end]) 1586 | else: 1587 | self._show_live_plot(bts) 1588 | if not is_notebook(): 1589 | plt.show() 1590 | 1591 | def run(self): 1592 | """Run the backtesters strategies without using an iterator. 1593 | This is only possible if strategies have been set using :meth:`.BacktesterBuilder.strategies`. 1594 | """ 1595 | self._no_iter = True 1596 | self._init_iter() 1597 | 1598 | for _ in range(len(self)): 1599 | self._run_once() 1600 | self.i = self._strategies[-1].i 1601 | self._plot(self._strategies) 1602 | 1603 | self._plot(self._strategies, last=True) 1604 | 1605 | for strat in self._strategies: 1606 | for metric in strat.metric.values(): 1607 | if metric._single: 1608 | metric(write=True) 1609 | -------------------------------------------------------------------------------- /simple_back/data_providers.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from yahoo_fin.stock_info import get_data 3 | from typing import Union, List, Optional, Tuple 4 | import pandas as pd 5 | from dateutil.relativedelta import relativedelta 6 | from datetime import date, datetime 7 | import datetime 8 | import datetime 9 | import numpy as np 10 | import diskcache as dc 11 | import pytz 12 | import requests 13 | import re 14 | from urllib.parse import urlencode 15 | from bs4 import BeautifulSoup 16 | from memoization import cached 17 | from json import JSONDecodeError 18 | 19 | from .exceptions import TimeLeakError, PriceUnavailableError 20 | 21 | 22 | def _get_arg_key(self, *args): 23 | return str(args) 24 | 25 | 26 | class CachedProvider(ABC): 27 | def __init__(self, debug=False): 28 | self.cache = dc.Cache(".simple-back") 29 | self.mem_cache = {} 30 | self.debug = debug 31 | 32 | def get_key(self, key: str) -> str: 33 | return str(key) + self.name 34 | 35 | def get_cache(self, key): 36 | nkey = self.get_key(key) 37 | if self.in_cache(key): 38 | return self.mem_cache[nkey] 39 | else: 40 | raise KeyError(f"{nkey} not in cache") 41 | 42 | def in_cache(self, key) -> bool: 43 | if not self.debug: 44 | key = self.get_key(key) 45 | if key in self.mem_cache: 46 | return True 47 | if key in self.cache: 48 | self.mem_cache[key] = self.cache[key] 49 | return True 50 | return False 51 | 52 | def set_cache(self, key, val, expire_days=None): 53 | key = self.get_key(key) 54 | self.mem_cache[key] = val 55 | if expire_days is None: 56 | self.cache.set(key, val) 57 | else: 58 | self.cache.set(key, val, expire=expire_days * 60 * 60 * 24) 59 | 60 | def rem_cache(self, key): 61 | key = self.get_key(key) 62 | del self.mem_cache[key] 63 | del self.cache[key] 64 | 65 | def clear_cache(self): 66 | for key in self.cache.iterkeys(): 67 | if key.endswith(self.name): 68 | del self.cache[key] 69 | self.mem_cache = {} 70 | 71 | @property 72 | @abstractmethod 73 | def name(self): 74 | pass 75 | 76 | 77 | class DataProvider(CachedProvider): 78 | def __init__(self, debug=False): 79 | self.current_datetime = pd.Timestamp( 80 | datetime.datetime.utcnow(), tzinfo=pytz.utc 81 | ) 82 | super().__init__(debug=debug) 83 | 84 | def __getitem__(self, symbol_datetime=None) -> pd.DataFrame: 85 | try: 86 | self.current_datetime = self.bt.timestamp 87 | except AttributeError: 88 | pass 89 | if isinstance(symbol_datetime, tuple): 90 | symbol = symbol_datetime[0] 91 | date = symbol_datetime[1] 92 | elif isinstance(symbol_datetime, str): 93 | symbol = symbol_datetime 94 | date = self.current_datetime 95 | elif isinstance(symbol_datetime, pd.Timestamp) or symbol_datetime is None: 96 | symbol = None 97 | date = self.current_datetime 98 | if date > self.current_datetime: 99 | raise TimeLeakError( 100 | self.current_datetime, 101 | date, 102 | f""" 103 | {date} is more recent than {self.current_datetime}, 104 | resulting in time leak 105 | """, 106 | ) 107 | if not self.debug: 108 | return self._get_cached(self.name, date, symbol) 109 | else: 110 | return self.get(date, symbol) 111 | 112 | def __call__(self, datetime=None): 113 | try: 114 | if datetime is None: 115 | self.current_datetime = self.bt.timestamp 116 | else: 117 | self.current_datetime = datetime 118 | except AttributeError: 119 | pass 120 | if not self.debug: 121 | return self._get_cached(self.name, self.current_datetime, None) 122 | else: 123 | return self.get(self.current_datetime, None) 124 | 125 | @property 126 | @abstractmethod 127 | def name(self): 128 | pass 129 | 130 | @abstractmethod 131 | def dates(self, symbol): 132 | pass 133 | 134 | @abstractmethod 135 | def get(self, datetime: pd.Timestamp, symbol: str = None): 136 | pass 137 | 138 | @cached(thread_safe=False, custom_key_maker=_get_arg_key) 139 | def _get_cached(self, *args) -> pd.DataFrame: 140 | return self.get(args[1], args[2]) 141 | 142 | 143 | class WikipediaProvider(DataProvider): 144 | def get_revisions(self, title): 145 | url = ( 146 | "https://en.wikipedia.org/w/api.php?action=query&format=xml&prop=revisions&rvlimit=500&" 147 | + title 148 | ) 149 | revisions = [] 150 | next_params = "" 151 | 152 | if self.in_cache(title): 153 | results = self.get_cache(title) 154 | else: 155 | while True: 156 | response = requests.get(url + next_params).text 157 | revisions += re.findall("]*>", response) 158 | cont = re.search(' cur_order: 273 | if istoday: 274 | df[col] = None 275 | else: 276 | df.at[self.current_date, col] = None 277 | if isinstance(date, slice) and not df.empty: 278 | sum_recent = (df.index.date > self.current_date).sum() 279 | if sum_recent > 0: 280 | raise TimeLeakError( 281 | self.current_date, 282 | df.index.date[-1], 283 | f""" 284 | {sum_recent} dates in index 285 | more recent than {self.current_date} 286 | """, 287 | ) 288 | elif not isinstance(date, slice): 289 | if date > self.current_date: 290 | raise TimeLeakError( 291 | self.current_date, 292 | date, 293 | f""" 294 | {date} is more recent than {self.current_date}, 295 | resulting in time leak 296 | """, 297 | ) 298 | return df 299 | 300 | def _get_order(self, event): 301 | return self.columns_order[self.columns.index(event)] 302 | 303 | @property 304 | def _max_order(self): 305 | max_order = min(self.columns_order) 306 | if isinstance(self.current_event, list): 307 | for event in self.current_event: 308 | order = self._get_order(event) 309 | if order > max_order: 310 | max_order = order 311 | if isinstance(self.current_event, str): 312 | max_order = self._get_order(self.current_event) 313 | return max_order 314 | 315 | def set_date_event(self, date, event): 316 | self.current_date = date 317 | self.current_event = event 318 | 319 | def __getitem__( 320 | self, 321 | symbol_date_event: Union[ 322 | str, 323 | List[str], 324 | Tuple[ 325 | Union[List[str], str], 326 | Optional[Union[slice, object]], 327 | Optional[Union[List[str], str]], 328 | ], 329 | ], 330 | ) -> pd.DataFrame: 331 | """ 332 | Expects a tuple of (ticker_symbol, date, 'open' or 'close') 333 | and returns the price 334 | for said symbol at that point in time. 335 | ```` 336 | my_price_provider['AAPL', date(2015,1,1), 'open'] 337 | ```` 338 | """ 339 | try: 340 | self.current_date = self.bt.current_date 341 | self.current_event = self.bt.event 342 | except AttributeError: 343 | pass 344 | if not isinstance(symbol_date_event, str): 345 | len_t = len(symbol_date_event) 346 | else: 347 | len_t = 0 348 | 349 | if len_t >= 0: 350 | symbol = symbol_date_event 351 | date = slice(None, self.current_date) 352 | event = self.columns 353 | if len_t >= 1: 354 | symbol = symbol_date_event[0] 355 | if len_t >= 2: 356 | date = symbol_date_event[1] 357 | if isinstance(date, datetime.date): 358 | if date > self.current_date and not self._leak_allowed: 359 | raise TimeLeakError( 360 | self.current_date, 361 | date, 362 | f""" 363 | {date} is more recent than {self.current_date}, 364 | resulting in time leak 365 | """, 366 | ) 367 | if isinstance(date, slice): 368 | if date == slice(None, None): 369 | date = slice(None, self.current_date) 370 | if date.stop is None: 371 | date = slice(date.start, self.current_date, date.step) 372 | stop_date = date.stop 373 | if isinstance(date.stop, relativedelta): 374 | date = slice(date.start, self.current_date + date.stop, date.step) 375 | if isinstance(date.start, relativedelta): 376 | if isinstance(stop_date, str): 377 | stop_date = pd.to_datetime(stop_date) 378 | date = slice(stop_date + date.start, date.stop, date.step) 379 | stop_date = date.stop 380 | if isinstance(date.stop, int) and date.stop <= 0: 381 | date = slice( 382 | date.start, 383 | self.current_date - relativedelta(days=-1 * date.stop), 384 | date.step, 385 | ) 386 | if isinstance(date.start, int) and date.start <= 0: 387 | stop_date = date.stop 388 | if isinstance(stop_date, slice): 389 | stop_date = stop_date.stop 390 | if isinstance(stop_date, str): 391 | stop_date = pd.to_datetime(stop_date) 392 | date = slice( 393 | stop_date - relativedelta(days=-1 * date.start), 394 | date.stop, 395 | date.step, 396 | ) 397 | if not self.debug: 398 | data = self._get_cached(self.name, symbol, date, event) 399 | else: 400 | data = self.get(symbol, date, event) 401 | if self._leak_allowed: 402 | return data 403 | return self._remove_leaky_vals(data, event, date) 404 | 405 | @cached(thread_safe=False, custom_key_maker=_get_arg_key) 406 | def _get_cached(self, *args) -> pd.DataFrame: 407 | return self.get(args[1], args[2], args[3]) 408 | 409 | @abstractmethod 410 | def get( 411 | self, symbol: str, date: Union[slice, date], event: Union[str, List[str]] 412 | ) -> pd.DataFrame: 413 | pass 414 | 415 | @property 416 | @abstractmethod 417 | def columns(self) -> List[str]: 418 | pass 419 | 420 | @property 421 | @abstractmethod 422 | def columns_order(self) -> List[int]: 423 | pass 424 | 425 | 426 | class DailyPriceProvider(DailyDataProvider): 427 | def __init__(self, highlow=True, debug=False): 428 | self.highlow = highlow 429 | super().__init__(debug=debug) 430 | 431 | @property 432 | def columns(self): 433 | if self.highlow: 434 | return ["open", "close", "high", "low"] 435 | else: 436 | return ["open", "close"] 437 | 438 | @property 439 | def columns_order(self): 440 | if self.highlow: 441 | return [0, 1, 1, 1] 442 | else: 443 | return [0, 1] 444 | 445 | @abstractmethod 446 | def get( 447 | self, symbol: str, date: Union[slice, date], event: Union[str, List[str]] 448 | ) -> pd.DataFrame: 449 | pass 450 | 451 | 452 | class YahooFinanceProvider(DailyPriceProvider): 453 | def __init__(self, highlow=False, adjust_prices=True, debug=False): 454 | self.adjust_prices = adjust_prices 455 | self.highlow = highlow 456 | super().__init__(debug=debug) 457 | 458 | @property 459 | def name(self): 460 | return "Yahoo Finance Prices" 461 | 462 | def get( 463 | self, symbol: str, date: Union[slice, date], event: Union[str, List[str]] 464 | ) -> pd.DataFrame: 465 | if not self.in_cache(symbol): 466 | try: 467 | df = get_data(symbol) 468 | except AssertionError: 469 | raise PriceUnavailableError( 470 | symbol, date, f"Price for {symbol} could not be found." 471 | ) 472 | self.set_cache(symbol, df) 473 | else: 474 | df = self.get_cache(symbol) 475 | if df.isna().any().any() == 0: 476 | df.dropna(inplace=True, axis=0) 477 | entry = df.loc[date].copy() 478 | adj = entry["adjclose"] / entry["close"] 479 | if self.adjust_prices: 480 | entry["open"] = adj * entry["open"] 481 | entry["close"] = entry["adjclose"] 482 | entry["high"] = adj * entry["high"] 483 | entry["low"] = adj * entry["low"] 484 | return entry[event] 485 | -------------------------------------------------------------------------------- /simple_back/exceptions.py: -------------------------------------------------------------------------------- 1 | class BacktestRunError(Exception): 2 | def __init__(self, message): 3 | self.message = message 4 | 5 | 6 | class LongShortLiquidationError(Exception): 7 | def __init__(self, message): 8 | self.message = message 9 | 10 | 11 | class NegativeValueError(Exception): 12 | def __init__(self, message): 13 | self.message = message 14 | 15 | 16 | class TimeLeakError(Exception): 17 | def __init__(self, current_date, requested_date, message): 18 | self.current_date = current_date 19 | self.requested_date = requested_date 20 | self.message = message 21 | 22 | 23 | class PriceUnavailableError(Exception): 24 | def __init__(self, symbol, requested_date, message): 25 | self.symbol = symbol 26 | self.requested_date = requested_date 27 | self.message = message 28 | 29 | 30 | class InsufficientCapitalError(Exception): 31 | def __init__(self, message): 32 | self.message = message 33 | 34 | 35 | class MissingMetricsError(Exception): 36 | def __init__(self, metrics, message): 37 | self.metrics = metrics 38 | self.message = message 39 | -------------------------------------------------------------------------------- /simple_back/fees.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from .exceptions import InsufficientCapitalError 4 | 5 | 6 | class Fee(ABC): 7 | """ 8 | Abstract ``Callable`` that calculates the total cost and number of shares 9 | given an asset price and the capital to be allocated to it. 10 | It returns the total cost and the number of shares aquired for that cost. 11 | """ 12 | 13 | def __call__(self, price: float, capital: float = None, nshares: int = None): 14 | if nshares is None: 15 | shares = self.nshares(price, capital) 16 | else: 17 | shares = nshares 18 | cost = self.cost(price, shares) 19 | fee = cost - (price * shares) 20 | if cost > capital: 21 | raise InsufficientCapitalError( 22 | f"Tried to buy {shares} shares at {price} with only {capital}." 23 | ) 24 | return { 25 | "nshares": shares, 26 | "total": cost, 27 | "fee": fee, 28 | } 29 | 30 | @abstractmethod 31 | def nshares(self, price, capital): 32 | pass 33 | 34 | @abstractmethod 35 | def cost(self, price, nshares): 36 | pass 37 | 38 | 39 | class FlatPerTrade(Fee): 40 | def __init__(self, fee): 41 | self.fee = fee 42 | 43 | def nshares(self, price, capital): 44 | return (capital - self.fee) // price 45 | 46 | def cost(self, price, nshares): 47 | return price * nshares + self.fee 48 | 49 | 50 | class FlatPerShare(Fee): 51 | def __init__(self, fee): 52 | self.fee = fee 53 | 54 | def nshares(self, price, capital): 55 | return capital // (self.fee + price) 56 | 57 | def cost(self, price, nshares): 58 | return (price + self.fee) * nshares 59 | 60 | 61 | class NoFee(Fee): 62 | """ 63 | Returns the number of shares possible to buy with given capital, 64 | and calculates to total cost of buying said shares. 65 | 66 | Example: 67 | How many shares of an asset costing 10 can be bought using 415, 68 | and what is the total cost:: 69 | 70 | >>> NoFee(10, 415) 71 | 41, 410 72 | """ 73 | 74 | def nshares(self, price, capital): 75 | return capital // price 76 | 77 | def cost(self, price, nshares): 78 | return price * nshares 79 | -------------------------------------------------------------------------------- /simple_back/metrics.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Optional 3 | import numpy as np 4 | import pandas as pd 5 | import math 6 | 7 | from .exceptions import MissingMetricsError 8 | 9 | 10 | class Metric(ABC): 11 | @property 12 | def requires(self) -> Optional[List[type]]: 13 | return None 14 | 15 | def __str__(self): 16 | return self.__repr__() 17 | 18 | def __repr__(self): 19 | if (self._single and self.value is None) or ( 20 | self._series and self.values is None 21 | ): 22 | return "None" 23 | if self._single: 24 | return f"{self.value:.2f}" 25 | if self._series: 26 | return f"{self[-1]:.2f}" 27 | else: 28 | return f"{self.name}" 29 | 30 | def __init__(self): 31 | self._single = False 32 | self._series = False 33 | self.value = None 34 | self.values = None 35 | self.current_event = "open" 36 | self.bt = None 37 | 38 | @property 39 | @abstractmethod 40 | def name(self) -> str: 41 | pass 42 | 43 | def set_values(self, bt): 44 | if self._single: 45 | self.value = self.get_value(bt) 46 | if self._series: 47 | self.all_values[self.i] = self.get_value(bt) 48 | 49 | def __call__(self, write=False): 50 | if not write: 51 | return self.get_value(self.bt) 52 | 53 | self.current_event = self.bt.event 54 | self.i = self.bt.i 55 | if self._series and (self.bt.i == 0 and self.bt.event == "open"): 56 | self.all_values = np.repeat(np.nan, len(self.bt)) 57 | if self.requires is None: 58 | self.set_values(self.bt) 59 | else: 60 | all_requires_present = True 61 | missing = "" 62 | for req in self.requires: 63 | if req not in self.bt.metric.keys(): 64 | all_requires_present = False 65 | missing = req 66 | break 67 | if all_requires_present: 68 | self.set_values(self.bt) 69 | else: 70 | raise MissingMetricsError( 71 | self.requires, 72 | f""" 73 | The following metric required by {type(self)} is missing: 74 | {missing} 75 | """, 76 | ) 77 | 78 | @abstractmethod 79 | def get_value(self, bt): 80 | pass 81 | 82 | 83 | class SingleMetric(Metric): 84 | def __init__(self, name: Optional[str] = None): 85 | self._single = True 86 | self._series = False 87 | self.value = None 88 | 89 | @property 90 | @abstractmethod 91 | def name(self): 92 | pass 93 | 94 | @abstractmethod 95 | def get_value(self, bt): 96 | pass 97 | 98 | 99 | class SeriesMetric(Metric): 100 | def __init__(self, name: Optional[str] = None): 101 | self._single = False 102 | self._series = True 103 | self.value_open = [] 104 | self.value_close = [] 105 | self.i = 0 106 | self.last_len = 0 107 | self.all_values = [] 108 | 109 | @property 110 | def values(self): 111 | return self.all_values 112 | 113 | @property 114 | def df(self): 115 | df = pd.DataFrame() 116 | df["date"] = self.bt.dates[: self.i // 2 + 1] 117 | df["open"] = self.all_values[0::2][: self.i // 2 + 1] 118 | df["close"] = self.all_values[1::2][: self.i // 2 + 1] 119 | if self.current_event == "open": 120 | df.at[-1, "close"] = None 121 | return df.set_index("date").dropna(how="all") 122 | 123 | @property 124 | @abstractmethod 125 | def name(self): 126 | pass 127 | 128 | def __len__(self): 129 | return self.i + 1 130 | 131 | def __getitem__(self, i): 132 | return self.all_values[: self.i + 1][i] 133 | 134 | @abstractmethod 135 | def get_value(self, bt): 136 | pass 137 | 138 | 139 | class MaxDrawdown(SingleMetric): 140 | @property 141 | def name(self): 142 | return "Max Drawdown (%)" 143 | 144 | def get_value(self, bt): 145 | highest_peaks = bt.metrics["Total Value"].cummax() 146 | actual_value = bt.metrics["Total Value"] 147 | md = np.min(((actual_value - highest_peaks) / highest_peaks).values) * 100 148 | return md 149 | 150 | 151 | class AnnualReturn(SingleMetric): 152 | @property 153 | def name(self): 154 | return "Annual Return" 155 | 156 | @property 157 | def requires(self): 158 | return ["Portfolio Value"] 159 | 160 | def get_value(self, bt): 161 | vals = bt.metric["Total Value"] 162 | year = 1 / ((bt.dates[-1] - bt.dates[0]).days / 365.25) 163 | return (vals[-1] / vals[0]) ** year 164 | 165 | 166 | class PortfolioValue(SeriesMetric): 167 | @property 168 | def name(self): 169 | return "Portfolio Value" 170 | 171 | def get_value(self, bt): 172 | return bt.portfolio.total_value 173 | 174 | 175 | class DailyProfitLoss(SeriesMetric): 176 | @property 177 | def name(self): 178 | return "Daily Profit/Loss" 179 | 180 | @property 181 | def requires(self): 182 | return ["Total Value"] 183 | 184 | def get_value(self, bt): 185 | try: 186 | return bt.metric["Total Value"][-1] - bt.metric["Total Value"][-3] 187 | except IndexError: 188 | return 0 189 | 190 | 191 | class TotalValue(SeriesMetric): 192 | @property 193 | def name(self): 194 | return "Total Value" 195 | 196 | @property 197 | def requires(self): 198 | return ["Portfolio Value"] 199 | 200 | def get_value(self, bt): 201 | return bt.metric["Portfolio Value"]() + bt._available_capital 202 | 203 | 204 | class TotalReturn(SeriesMetric): 205 | @property 206 | def name(self): 207 | return "Total Return (%)" 208 | 209 | @property 210 | def requires(self): 211 | return ["Total Value"] 212 | 213 | def get_value(self, bt): 214 | return ((bt.metric["Total Value"][-1] / bt.balance.start) - 1) * 100 215 | 216 | 217 | class SharpeRatio(SingleMetric): 218 | def __init__(self, risk_free_rate=0.0445): 219 | self.risk_free_rate = 0.0445 220 | super().__init__() 221 | 222 | @property 223 | def requires(self): 224 | return ["Volatility (Annualized)", "Annual Return"] 225 | 226 | @property 227 | def name(self): 228 | return "Sharpe Ratio" 229 | 230 | def get_value(self, bt): 231 | return ( 232 | (bt.metrics["Annual Return"][-1] - 1) - self.risk_free_rate 233 | ) / bt.metrics["Volatility (Annualized)"][-1] 234 | 235 | 236 | class Volatility(SingleMetric): 237 | @property 238 | def requires(self): 239 | return ["Daily Profit/Loss (%)"] 240 | 241 | @property 242 | def name(self): 243 | return "Volatility (Annualized)" 244 | 245 | def get_value(self, bt): 246 | return bt.metric["Daily Profit/Loss (%)"].values.std() * math.sqrt(252) 247 | 248 | 249 | class DailyProfitLossPct(SeriesMetric): 250 | @property 251 | def name(self): 252 | return "Daily Profit/Loss (%)" 253 | 254 | @property 255 | def requires(self): 256 | return ["Daily Profit/Loss", "Total Value"] 257 | 258 | def get_value(self, bt): 259 | try: 260 | return bt.metric["Daily Profit/Loss"][-1] / bt.metric["Total Value"][-1] 261 | except IndexError: 262 | return 0 263 | -------------------------------------------------------------------------------- /simple_back/strategy.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class Strategy(ABC): 5 | def __call__(self, day, event, bt): 6 | self.run(day, event, bt) 7 | 8 | @property 9 | @abstractmethod 10 | def name(self): 11 | return None 12 | 13 | @abstractmethod 14 | def run(self, day, event, bt): 15 | pass 16 | 17 | 18 | class BuyAndHold(Strategy): 19 | def __init__(self, ticker): 20 | self.ticker = ticker 21 | self.is_bought = False 22 | 23 | def run(self, day, event, bt): 24 | if not self.is_bought: 25 | bt.long(self.ticker, percent=1) 26 | self.is_bought = True 27 | 28 | @property 29 | def name(self): 30 | return f"{self.ticker} (Buy & Hold)" 31 | 32 | 33 | class SellAndHold(Strategy): 34 | def __init__(self, ticker): 35 | self.ticker = ticker 36 | self.is_bought = False 37 | 38 | def run(self, day, event, bt): 39 | if not self.is_bought: 40 | bt.short(self.ticker, percent=1) 41 | self.is_bought = True 42 | 43 | @property 44 | def name(self): 45 | return f"{self.ticker} (Sell & Hold)" 46 | -------------------------------------------------------------------------------- /simple_back/utils.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | import sys 3 | import os 4 | from IPython.display import clear_output 5 | 6 | 7 | def is_notebook(): 8 | try: 9 | shell = get_ipython().__class__.__name__ 10 | if shell == "ZMQInteractiveShell": 11 | return True # Jupyter notebook or qtconsole 12 | elif shell == "TerminalInteractiveShell": 13 | return False # Terminal running IPython 14 | else: 15 | return False # Other type (?) 16 | except NameError: 17 | return False # Probably standard Python interpreter 18 | 19 | 20 | def _cls(): 21 | clear_output(wait=True) 22 | os.system("cls" if os.name == "nt" else "clear") 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiniXC/simple-back/7812a012a39dec9fc887a336015c79c986a07e80/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_simple_back.py: -------------------------------------------------------------------------------- 1 | from simple_back import __version__ 2 | from simple_back.backtester import BacktesterBuilder 3 | from datetime import date 4 | 5 | 6 | def test_version(): 7 | assert __version__ == "0.6.3" 8 | 9 | 10 | def test_compare_quantopian(): 11 | builder = ( 12 | BacktesterBuilder() 13 | .name("TSLA Strategy") 14 | .balance(10_000) 15 | .calendar("NYSE") 16 | # .live_metrics() 17 | .slippage(0.0005) 18 | ) 19 | bt = builder.build() 20 | bt.prices.clear_cache() 21 | for _, _, b in bt[ 22 | date.fromisoformat("2017-01-01") : date.fromisoformat("2020-01-01") 23 | ]: 24 | if not b.pf or True: 25 | b.pf.liquidate() 26 | print(b._schedule.index) 27 | b.long("TSLA", percent=1) 28 | quant_no_slippage = 105.97 # https://www.quantopian.com/posts/test-without-slippage-to-compare-with-simple-back 29 | quant_slippage = ( 30 | -52.8 31 | ) # https://www.quantopian.com/posts/test-with-slippage-to-compare-with-simple-back 32 | # within 10%/15% of both bounds 33 | assert ( 34 | abs(bt.summary.iloc[0]["Total Return (%) (Last Value)"] - quant_no_slippage) 35 | <= 15 36 | ) 37 | assert ( 38 | abs(bt.summary.iloc[1]["Total Return (%) (Last Value)"] - quant_slippage) <= 10 39 | ) 40 | --------------------------------------------------------------------------------