├── .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 | 
3 | 
4 | [](https://github.com/psf/black)
5 | [](https://codecov.io/gh/MiniXC/simple-back)
6 | 
7 | [](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 | 
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 | " open \n",
85 | " close \n",
86 | " high \n",
87 | " low \n",
88 | " \n",
89 | " \n",
90 | " \n",
91 | " \n",
92 | " 1980-12-12 \n",
93 | " 0.405683 \n",
94 | " 0.405683 \n",
95 | " 0.407447 \n",
96 | " 0.405683 \n",
97 | " \n",
98 | " \n",
99 | " 1980-12-15 \n",
100 | " 0.386281 \n",
101 | " 0.384517 \n",
102 | " 0.386281 \n",
103 | " 0.384517 \n",
104 | " \n",
105 | " \n",
106 | " 1980-12-16 \n",
107 | " 0.358060 \n",
108 | " 0.356296 \n",
109 | " 0.358060 \n",
110 | " 0.356296 \n",
111 | " \n",
112 | " \n",
113 | " 1980-12-17 \n",
114 | " 0.365115 \n",
115 | " 0.365115 \n",
116 | " 0.366879 \n",
117 | " 0.365115 \n",
118 | " \n",
119 | " \n",
120 | " 1980-12-18 \n",
121 | " 0.375698 \n",
122 | " 0.375698 \n",
123 | " 0.377462 \n",
124 | " 0.375698 \n",
125 | " \n",
126 | " \n",
127 | " ... \n",
128 | " ... \n",
129 | " ... \n",
130 | " ... \n",
131 | " ... \n",
132 | " \n",
133 | " \n",
134 | " 2020-05-22 \n",
135 | " 315.769989 \n",
136 | " 318.890015 \n",
137 | " 319.230011 \n",
138 | " 315.350006 \n",
139 | " \n",
140 | " \n",
141 | " 2020-05-26 \n",
142 | " 323.500000 \n",
143 | " 316.730011 \n",
144 | " 324.239990 \n",
145 | " 316.500000 \n",
146 | " \n",
147 | " \n",
148 | " 2020-05-27 \n",
149 | " 316.140015 \n",
150 | " 318.109985 \n",
151 | " 318.709991 \n",
152 | " 313.089996 \n",
153 | " \n",
154 | " \n",
155 | " 2020-05-28 \n",
156 | " 316.769989 \n",
157 | " 318.250000 \n",
158 | " 323.440002 \n",
159 | " 315.630005 \n",
160 | " \n",
161 | " \n",
162 | " 2020-05-29 \n",
163 | " 319.250000 \n",
164 | " 317.940002 \n",
165 | " 321.149994 \n",
166 | " 316.470001 \n",
167 | " \n",
168 | " \n",
169 | "
\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 | " open \n",
292 | " close \n",
293 | " high \n",
294 | " low \n",
295 | " \n",
296 | " \n",
297 | " \n",
298 | " \n",
299 | " 2015-01-02 \n",
300 | " 101.829067 \n",
301 | " 99.945885 \n",
302 | " 101.874778 \n",
303 | " 98.135831 \n",
304 | " \n",
305 | " \n",
306 | " 2015-01-05 \n",
307 | " 98.995143 \n",
308 | " 97.130241 \n",
309 | " 99.324244 \n",
310 | " 96.362344 \n",
311 | " \n",
312 | " \n",
313 | " 2015-01-06 \n",
314 | " 97.395385 \n",
315 | " 97.139420 \n",
316 | " 98.208994 \n",
317 | " 95.649322 \n",
318 | " \n",
319 | " \n",
320 | " 2015-01-07 \n",
321 | " 97.998723 \n",
322 | " 98.501518 \n",
323 | " 98.912891 \n",
324 | " 97.541640 \n",
325 | " \n",
326 | " \n",
327 | "
\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 | " open \n",
383 | " close \n",
384 | " high \n",
385 | " low \n",
386 | " \n",
387 | " \n",
388 | " \n",
389 | " \n",
390 | " 2020-04-30 \n",
391 | " 289.177206 \n",
392 | " 293.006836 \n",
393 | " 293.734876 \n",
394 | " 287.571567 \n",
395 | " \n",
396 | " \n",
397 | " 2020-05-01 \n",
398 | " 285.477218 \n",
399 | " 288.289612 \n",
400 | " 298.192797 \n",
401 | " 285.078304 \n",
402 | " \n",
403 | " \n",
404 | " 2020-05-04 \n",
405 | " 288.389342 \n",
406 | " 292.368561 \n",
407 | " 292.897129 \n",
408 | " 285.547030 \n",
409 | " \n",
410 | " \n",
411 | " 2020-05-05 \n",
412 | " 294.263433 \n",
413 | " 296.756683 \n",
414 | " 300.187399 \n",
415 | " 293.665046 \n",
416 | " \n",
417 | " \n",
418 | " 2020-05-06 \n",
419 | " 299.648835 \n",
420 | " 299.818390 \n",
421 | " 302.421329 \n",
422 | " 298.063132 \n",
423 | " \n",
424 | " \n",
425 | "
\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 | " open \n",
483 | " close \n",
484 | " high \n",
485 | " low \n",
486 | " \n",
487 | " \n",
488 | " \n",
489 | " \n",
490 | " 2020-05-26 \n",
491 | " 323.500000 \n",
492 | " 316.730011 \n",
493 | " 324.239990 \n",
494 | " 316.500000 \n",
495 | " \n",
496 | " \n",
497 | " 2020-05-27 \n",
498 | " 316.140015 \n",
499 | " 318.109985 \n",
500 | " 318.709991 \n",
501 | " 313.089996 \n",
502 | " \n",
503 | " \n",
504 | " 2020-05-28 \n",
505 | " 316.769989 \n",
506 | " 318.250000 \n",
507 | " 323.440002 \n",
508 | " 315.630005 \n",
509 | " \n",
510 | " \n",
511 | " 2020-05-29 \n",
512 | " 319.250000 \n",
513 | " 317.940002 \n",
514 | " 321.149994 \n",
515 | " 316.470001 \n",
516 | " \n",
517 | " \n",
518 | "
\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 | " open \n",
574 | " close \n",
575 | " high \n",
576 | " low \n",
577 | " \n",
578 | " \n",
579 | " \n",
580 | " \n",
581 | " 2014-12-26 \n",
582 | " 102.478164 \n",
583 | " 104.205940 \n",
584 | " 104.690448 \n",
585 | " 102.395893 \n",
586 | " \n",
587 | " \n",
588 | " 2014-12-29 \n",
589 | " 104.023095 \n",
590 | " 104.132797 \n",
591 | " 104.918975 \n",
592 | " 103.940816 \n",
593 | " \n",
594 | " \n",
595 | " 2014-12-30 \n",
596 | " 103.885969 \n",
597 | " 102.862099 \n",
598 | " 104.141934 \n",
599 | " 102.487294 \n",
600 | " \n",
601 | " \n",
602 | " 2014-12-31 \n",
603 | " 103.136355 \n",
604 | " 100.905785 \n",
605 | " 103.419745 \n",
606 | " 100.750378 \n",
607 | " \n",
608 | " \n",
609 | " 2015-01-02 \n",
610 | " 101.829067 \n",
611 | " 99.945885 \n",
612 | " 101.874778 \n",
613 | " 98.135831 \n",
614 | " \n",
615 | " \n",
616 | "
\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 | " open \n",
664 | " close \n",
665 | " high \n",
666 | " low \n",
667 | " \n",
668 | " \n",
669 | " \n",
670 | " \n",
671 | " 2014-12-26 \n",
672 | " 102.478164 \n",
673 | " 104.205940 \n",
674 | " 104.690448 \n",
675 | " 102.395893 \n",
676 | " \n",
677 | " \n",
678 | " 2014-12-29 \n",
679 | " 104.023095 \n",
680 | " 104.132797 \n",
681 | " 104.918975 \n",
682 | " 103.940816 \n",
683 | " \n",
684 | " \n",
685 | " 2014-12-30 \n",
686 | " 103.885969 \n",
687 | " 102.862099 \n",
688 | " 104.141934 \n",
689 | " 102.487294 \n",
690 | " \n",
691 | " \n",
692 | " 2014-12-31 \n",
693 | " 103.136355 \n",
694 | " 100.905785 \n",
695 | " 103.419745 \n",
696 | " 100.750378 \n",
697 | " \n",
698 | " \n",
699 | " 2015-01-02 \n",
700 | " 101.829067 \n",
701 | " 99.945885 \n",
702 | " 101.874778 \n",
703 | " 98.135831 \n",
704 | " \n",
705 | " \n",
706 | "
\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 | " open \n",
828 | " close \n",
829 | " high \n",
830 | " low \n",
831 | " \n",
832 | " \n",
833 | " \n",
834 | " \n",
835 | " 2020-05-13 \n",
836 | " 312.149994 \n",
837 | " 307.649994 \n",
838 | " 315.950012 \n",
839 | " 303.209991 \n",
840 | " \n",
841 | " \n",
842 | " 2020-05-14 \n",
843 | " 304.510010 \n",
844 | " 309.540009 \n",
845 | " 309.790009 \n",
846 | " 301.529999 \n",
847 | " \n",
848 | " \n",
849 | " 2020-05-15 \n",
850 | " 300.350006 \n",
851 | " 307.709991 \n",
852 | " 307.899994 \n",
853 | " 300.209991 \n",
854 | " \n",
855 | " \n",
856 | " 2020-05-18 \n",
857 | " 313.170013 \n",
858 | " NaN \n",
859 | " NaN \n",
860 | " NaN \n",
861 | " \n",
862 | " \n",
863 | "
\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 | pylint pylint 7.26 7.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 |
--------------------------------------------------------------------------------