├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── app.py ├── docs ├── anatomy_of_a_backtest.md ├── datasource.md ├── loaders.md └── rationale.md ├── examples ├── DataSource.ipynb ├── Introduction.ipynb ├── Loaders.ipynb ├── PivotPoints.ipynb ├── Tradebook.ipynb ├── apps │ ├── code_generator.py │ ├── option_payoff.py │ └── simple.py ├── backtest_template.xls └── data │ ├── BTC.csv │ ├── bank.csv │ └── data.csv ├── ruff.toml ├── setup.cfg ├── setup.py ├── src └── fastbt │ ├── Meta.py │ ├── __init__.py │ ├── app.py │ ├── brokers │ ├── __init__.py │ ├── api.yaml │ ├── fivepaisa.py │ ├── fivepaisa.yaml │ ├── fyers.py │ ├── fyers.yaml │ ├── master_trust.py │ ├── master_trust.yaml │ ├── spec.yaml │ ├── zerodha.py │ └── zerodha.yaml │ ├── datasource.py │ ├── experimental.py │ ├── features.py │ ├── files │ ├── IndexConstituents.xlsx │ ├── __init__.py │ └── sectors.csv │ ├── loaders.py │ ├── metrics.py │ ├── models │ ├── base.py │ └── breakout.py │ ├── option_chain.py │ ├── options │ ├── backtest.py │ ├── order.py │ ├── payoff.py │ ├── store.py │ └── utils.py │ ├── plotting.py │ ├── rapid.py │ ├── simulation.py │ ├── static │ ├── script.js │ └── vue.js │ ├── templates │ ├── backtest.html │ ├── datastore.html │ └── index.html │ ├── tradebook.py │ ├── urlpatterns.py │ └── utils.py ├── tests ├── Test.ipynb ├── brokers │ ├── keys.conf │ ├── test_fivepaisa.py │ ├── test_init.py │ └── test_master_trust.py ├── context.py ├── data │ ├── BT.yaml │ ├── BTC.csv │ ├── NASDAQ │ │ ├── adjustments │ │ │ └── splits.csv │ │ ├── data │ │ │ ├── NASDAQ_20180730.zip │ │ │ ├── NASDAQ_20180731.zip │ │ │ ├── NASDAQ_20180801.zip │ │ │ ├── NASDAQ_20180802.zip │ │ │ ├── NASDAQ_20180803.zip │ │ │ ├── NASDAQ_20180806.zip │ │ │ ├── NASDAQ_20180807.zip │ │ │ └── NASDAQ_20180808.zip │ │ └── nasdaq_results.csv │ ├── backtest.json │ ├── backtest.xls │ ├── backtest.yaml │ ├── bhav_nifty.csv │ ├── data.sqlite3 │ ├── eoddata │ │ ├── INDEX_20180727.txt │ │ ├── INDEX_20180730.txt │ │ ├── INDEX_20180731.txt │ │ ├── INDEX_20180801.txt │ │ └── INDEX_20180802.txt │ ├── index.csv │ ├── is_pret.csv │ ├── option_strategies.yaml │ ├── options_payoff.xlsx │ ├── results.csv │ ├── sample.csv │ └── sample.xlsx ├── options │ ├── test_option_utils.py │ ├── test_order.py │ ├── test_payoff.py │ └── test_store.py ├── test_base.py ├── test_breakout.py ├── test_datasource.py ├── test_features.py ├── test_generate_correlated_data.py ├── test_loaders.py ├── test_meta.py ├── test_metrics.py ├── test_rapid.py ├── test_simulation.py ├── test_tradebook.py └── test_utils.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.6.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:src/fastbt/__init__.py] 9 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # This file exists so you can easily regenerate your project. 2 | # 3 | # `cookiepatcher` is a convenient shim around `cookiecutter` 4 | # for regenerating projects (it will generate a .cookiecutterrc 5 | # automatically for any template). To use it: 6 | # 7 | # pip install cookiepatcher 8 | # cookiepatcher gh:ionelmc/cookiecutter-pylibrary project-path 9 | # 10 | # See: 11 | # https://pypi.python.org/pypi/cookiepatcher 12 | # 13 | # Alternatively, you can run: 14 | # 15 | # cookiecutter --overwrite-if-exists --config-file=project-path/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary 16 | 17 | default_context: 18 | 19 | _template: 'gh:ionelmc/cookiecutter-pylibrary' 20 | appveyor: 'no' 21 | c_extension_function: 'longest' 22 | c_extension_module: '_fastbt' 23 | c_extension_optional: 'no' 24 | c_extension_support: 'no' 25 | codacy: 'no' 26 | codeclimate: 'no' 27 | codecov: 'yes' 28 | command_line_interface: 'no' 29 | command_line_interface_bin_name: 'fastbt' 30 | coveralls: 'yes' 31 | distribution_name: 'born' 32 | email: 'uberdeveloper001@gmail.com' 33 | full_name: 'UM' 34 | github_username: 'UM' 35 | landscape: 'no' 36 | license: 'MIT license' 37 | linter: 'flake8' 38 | package_name: 'fastbt' 39 | project_name: 'fastbt' 40 | project_short_description: 'A simple framework for dirty backtesting' 41 | release_date: 'today' 42 | repo_name: 'fastbt' 43 | requiresio: 'no' 44 | scrutinizer: 'no' 45 | sphinx_docs: 'no' 46 | sphinx_doctest: 'no' 47 | sphinx_theme: 'sphinx-py3doc-enhanced-theme' 48 | test_matrix_configurator: 'no' 49 | test_matrix_separate_coverage: 'no' 50 | test_runner: 'pytest' 51 | travis: 'yes' 52 | version: '0.1.0' 53 | website: 'https://blog.finhacks.in' 54 | year: 'now' 55 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | fastbt 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [*.{bat,cmd,ps1}] 13 | end_of_line = crlf 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # vscode 33 | .vscode/ 34 | 35 | # databases 36 | *.sqlite3 37 | *.h5 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | ### Python Patch ### 124 | .venv/ 125 | 126 | ### Python.VirtualEnv Stack ### 127 | # Virtualenv 128 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 129 | [Bb]in 130 | [Ii]nclude 131 | [Ll]ib 132 | [Ll]ib64 133 | [Ll]ocal 134 | [Ss]cripts 135 | pyvenv.cfg 136 | pip-selfcheck.json 137 | 138 | # Vim swap files 139 | *.swp 140 | 141 | 142 | 143 | # End of https://www.gitignore.io/api/python 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | # Ruff version. 4 | rev: 'v0.8.0' 5 | hooks: 6 | - id: ruff 7 | args: [--fix, --exit-non-zero-on-fix] 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/psf/black 15 | rev: 24.10.0 16 | hooks: 17 | - id: black 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | env: 5 | global: 6 | - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so 7 | - SEGFAULT_SIGNALS=all 8 | matrix: 9 | - TOXENV=check 10 | matrix: 11 | include: 12 | - python: '3.5' 13 | env: 14 | - TOXENV=py35,report,coveralls,codecov 15 | - python: '3.6' 16 | sudo: required 17 | env: 18 | - TOXENV=py36,report,coveralls,codecov 19 | - python: '3.7' 20 | dist: xenial 21 | sudo: required 22 | env: 23 | - TOXENV=py37,report,coveralls,codecov 24 | before_install: 25 | - python --version 26 | - uname -a 27 | - lsb_release -a 28 | install: 29 | - pip install tox 30 | - sudo apt-get install gcc build-essential 31 | - virtualenv --version 32 | - easy_install --version 33 | - pip --version 34 | - tox --version 35 | - | 36 | set -ex 37 | if [[ $TRAVIS_PYTHON_VERSION == 'pypy' ]]; then 38 | (cd $HOME 39 | wget https://bitbucket.org/pypy/pypy/downloads/pypy2-v6.0.0-linux64.tar.bz2 40 | tar xf pypy2-*.tar.bz2 41 | pypy2-*/bin/pypy -m ensurepip 42 | pypy2-*/bin/pypy -m pip install -U virtualenv) 43 | export PATH=$(echo $HOME/pypy2-*/bin):$PATH 44 | export TOXPYTHON=$(echo $HOME/pypy2-*/bin/pypy) 45 | fi 46 | if [[ $TRAVIS_PYTHON_VERSION == 'pypy3' ]]; then 47 | (cd $HOME 48 | wget https://bitbucket.org/pypy/pypy/downloads/pypy3-v6.0.0-linux64.tar.bz2 49 | tar xf pypy3-*.tar.bz2 50 | pypy3-*/bin/pypy3 -m ensurepip 51 | pypy3-*/bin/pypy3 -m pip install -U virtualenv) 52 | export PATH=$(echo $HOME/pypy3-*/bin):$PATH 53 | export TOXPYTHON=$(echo $HOME/pypy3-*/bin/pypy3) 54 | fi 55 | set +x 56 | script: 57 | - tox -v 58 | after_failure: 59 | - more .tox/log/* | cat 60 | - more .tox/*/log/* | cat 61 | notifications: 62 | email: 63 | on_success: never 64 | on_failure: always 65 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Ubermensch 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | History 3 | ========= 4 | v0.6.0 5 | ------ 6 | * New methods added to `TradeBook` object 7 | * mtm - to calculate mtm for open positions 8 | * clear - to clear the existing entries 9 | * helper attributes for positions 10 | * `order_fill_price` method added to utils to simulate order quantity 11 | 12 | v0.5.1 13 | ------ 14 | * Simple bug fixes added 15 | 16 | v0.5.0 17 | ------ 18 | * `OptionExpiry` class added to calculate option payoffs based on expiry 19 | 20 | v0.4.0 21 | ------- 22 | * Brokers module deprecation warning added 23 | * Options module revamped 24 | 25 | v0.3.0 (2019-03-15) 26 | -------------------- 27 | * More helper functions added to utils 28 | * Tradebook class enhanced 29 | * A Meta class added for event based simulation 30 | 31 | v0.2.0 (2018-12-26) 32 | -------------------- 33 | * Backtest from different formats added 34 | * Rolling function added 35 | 36 | 37 | v0.1.0. (2018-10-13) 38 | ---------------------- 39 | 40 | * First release on PyPI 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | fastbt could always use more documentation, whether as part of the 21 | official fastbt docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/UM/fastbt/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `fastbt` for local development: 39 | 40 | 1. Fork `fastbt `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:your_name_here/fastbt.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``) [1]_. 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | .. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will 77 | `run the tests `_ for each change you add in the pull request. 78 | 79 | It will be slower though ... 80 | 81 | Tips 82 | ---- 83 | 84 | To run a subset of tests:: 85 | 86 | tox -e envname -- pytest -k test_myfeature 87 | 88 | To run all the test environments in *parallel* (you need to ``pip install detox``):: 89 | 90 | detox 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Ram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | graft examples 6 | 7 | include .bumpversion.cfg 8 | include .coveragerc 9 | include .cookiecutterrc 10 | include .editorconfig 11 | 12 | include AUTHORS.rst 13 | include CHANGELOG.rst 14 | include CONTRIBUTING.rst 15 | include LICENSE 16 | include README.md 17 | 18 | prune */.ipynb_checkpoints 19 | exclude *.h5 20 | exclude utils2.py 21 | 22 | 23 | include tox.ini .travis.yml 24 | 25 | global-exclude *.py[cod] __pycache__ *.so *.dylib 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | **fastbt** is a simple and dirty way to do backtests based on end of day data, especially for day trading. 4 | The main purpose is to provide a simple framework to weed out bad strategies so that you could test and improve your better strategies further. 5 | 6 | It is based on the assumption that you enter into a position based on some pre-defined rules for a defined period and exit either at the end of the period or when stop loss is triggered. See the [rationale](https://github.com/uberdeveloper/fastbt/blob/master/docs/rationale.md) for this approach and the built-in assumptions. _fastbt is rule-based and not event-based._ 7 | 8 | If your strategy gets you good results, then check them with a full featured backtesting framework such as [zipline](http://www.zipline.io/) or [backtrader](https://www.backtrader.com/) to verify your results. 9 | If your strategy fails, then it would most probably fail in other environments. 10 | 11 | This is **alpha** 12 | 13 | Most of the modules are stand alone and you could use them as a single file. See embedding for more details 14 | 15 | # Features 16 | 17 | - Create your strategies in Microsoft Excel 18 | - Backtest as functions so you can parallelize 19 | - Try different simulations 20 | - Run from your own datasource or a database connection. 21 | - Run backtest based on rules 22 | - Add any column you want to your datasource as formulas 23 | 24 | # Installation 25 | 26 | fastbt requires python **>=3.6** and can be installed via pip 27 | 28 | ``` 29 | pip install fastbt 30 | ``` 31 | 32 | # Quickstart 33 | 34 | Fastbt assumes your data have the following columns (rename them in case of other names) 35 | 36 | - timestamp 37 | - symbol 38 | - open 39 | - high 40 | - low 41 | - close 42 | - volume 43 | 44 | ```python 45 | from fastbt.rapid import * 46 | backtest(data=data) 47 | ``` 48 | 49 | would return a dataframe with all the trades. 50 | 51 | And if you want to see some metrics 52 | 53 | ```python 54 | metrics(backtest(data=data)) 55 | ``` 56 | 57 | You now ran a backtest without a strategy! By default, the strategy buys the top 5 stocks with the lowest price at open price on each period and sells them at the close price at the end of the period. 58 | 59 | You can either specify the strategy by way of rules (the recommended way) or create your strategy as a function in python and pass it as a parameter 60 | 61 | ```python 62 | backtest(data=data, strategy=strategy) 63 | ``` 64 | 65 | If you want to connect to a database, then 66 | 67 | ```python 68 | from sqlalchemy import create_engine 69 | engine = create_engine('sqlite:///data.db') 70 | backtest(connection=engine, tablename='data') 71 | ``` 72 | 73 | And to SELL instead of BUY 74 | 75 | ```python 76 | backtest(data=data, order='S') 77 | ``` 78 | 79 | Let's implement a simple strategy. 80 | 81 | > **BUY** the top 5 stocks with highest last week returns 82 | 83 | Assuming we have a **weeklyret** column, 84 | 85 | ```python 86 | backtest(data=data, order='B', sort_by='weeklyret', sort_mode=False) 87 | ``` 88 | 89 | We used sort_mode=False to sort them in descending order. 90 | 91 | If you want to test this strategy on a weekly basis, just pass a dataframe with weekly frequency. 92 | 93 | See the Introduction notebook in the examples directory for an in depth introduction. 94 | 95 | ## Embedding 96 | 97 | Since fastbt is a thin wrapper around existing packages, the following files can be used as standalone without installing the fastbt package 98 | 99 | - datasource 100 | - utils 101 | - loaders 102 | 103 | Copy these files and just use them in your own modules. 104 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify 2 | import sys 3 | import os 4 | 5 | sys.path.append("../") 6 | 7 | class CustomFlask(Flask): 8 | jinja_options = Flask.jinja_options.copy() 9 | jinja_options.update( 10 | dict( 11 | block_start_string="<%", 12 | block_end_string="%>", 13 | variable_start_string="%%", 14 | variable_end_string="%%", 15 | comment_start_string="<#", 16 | comment_end_string="#>", 17 | ) 18 | ) 19 | 20 | app = CustomFlask(__name__) 21 | 22 | @app.route("/") 23 | def hello_world(): 24 | context = {"name": "Ram"} 25 | return render_template("index.html", **context) 26 | 27 | 28 | @app.route("/test", methods=["GET", "POST"]) 29 | def test(): 30 | # Test function 31 | if request.method == "POST": 32 | return str(request.form) 33 | 34 | 35 | @app.route("/ds", methods=["GET", "POST"]) 36 | def ds(): 37 | controls = [ 38 | { 39 | "input": True, 40 | "type": "file", 41 | "name": "directory", 42 | "placeholder": "Select a directory with a file", 43 | }, 44 | { 45 | "input": True, 46 | "type": "text", 47 | "name": "engine", 48 | "placeholder": "sqlalchemy connection string or HDF5 filename", 49 | }, 50 | { 51 | "input": True, 52 | "type": "text", 53 | "name": "tablename", 54 | "placeholder": "tablename in SQL or HDF", 55 | }, 56 | {"select": True, "name": "mode", "choices": ["SQL", "HDF"]}, 57 | ] 58 | context = {"controls": controls} 59 | if request.method == "GET": 60 | return render_template("datastore.html", **context) 61 | elif request.method == "POST": 62 | print(request.form) 63 | return str(request.form) 64 | 65 | 66 | if __name__ == "__main__": 67 | app.run(debug=True) 68 | -------------------------------------------------------------------------------- /docs/anatomy_of_a_backtest.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Let's look at what the backtest exactly works under the hoods. These are the series of steps the backtest performs. 4 | 5 | 1. All stocks from the given universe are selected from the data provided 6 | 2. The columns are strictly added in the order in which they are created 7 | 3. Conditions are applied in the given order to filter and narrow down stocks 8 | 4. From the filtered list, the price and the stop loss for each stock is calculated 9 | 5. Stocks are then sorted based on the given column and the top n stocks are picked. 10 | 6. The quantity for each stock is determined by the trading capital and the number of stocks. **All stocks are equi-weighted.** 11 | 7. Profit or loss for each day is then calculated based on OHLC data, price and stop loss. 12 | 8. Commission and slippage is deducted from the above profit to get the net profit 13 | 9. The above process is repeated for each day and the results are then accumulated 14 | 15 | ## Profit calculation 16 | 17 | Profit is calculated by comparing price and stop loss with OHLC data. If price is within the high-low range it is assumed that the order would get placed. The following table shows how profit is calculated (range means the high-low range) 18 | 19 | | price | stop_loss | profit (long/buy) | (profit (short/sell) | 20 | | ------------ | ------------ | ----------------- | -------------------- | 21 | | in range | not in range | close-price | price-close | 22 | | in range | in range | stop_loss-price | price-stop_loss | 23 | | not in range | in range | stop_loss-close | close-stop_loss | 24 | | not in range | not in range | nothing | nothing | 25 | 26 | See the rationale page for how this price is calculated. 27 | -------------------------------------------------------------------------------- /docs/datasource.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | DataSource is a helper class for common, repetitive tasks in financial domain. Given a dataframe with symbol and timestamp columns, you could apply common financial functions to each symbol with a single line of code. 4 | 5 | It is a replacement for otherwise verbose code and a wrapper around pandas. 6 | 7 | So, instead of 8 | 9 | ```python 10 | shift = lambda x: x.shift(1) 11 | dataframe['lag_one'] = dataframe.groupby('symbol')['close'].transform(shift) 12 | ``` 13 | 14 | you could write 15 | 16 | ```python 17 | dataframe.add_lag(on='close', period=1, col_name='lag_one') 18 | ``` 19 | 20 | ### Quick start 21 | 22 | Initialize datasource with a data frame 23 | 24 | ```python 25 | from fastbt.datasource import DataSource 26 | ds = DataSource(df) # df is your dataframe 27 | 28 | # Add a one day lag to all the symbols in the dataframe 29 | ds.add_lag(1) # returns the dataframe with lag added 30 | 31 | # Access the original dataframe with 32 | ds.data 33 | ``` 34 | 35 | See the example notebook for more details 36 | 37 | > DataSource always return a dataframe when you call a method 38 | 39 | > DataSource converts all columns into **lower case**. 40 | 41 | ### General 42 | 43 | All helper methods start with `add_` and has the following common arguments 44 | 45 | | argument | description | 46 | | ---------- | ------------------------------------------------------------------------ | 47 | | `on` | the column on which the operation is to be performed | 48 | | `col_name` | column name | 49 | | `lag` | period by which the data is to be lagged after performing the operation. | 50 | 51 | See the respective method for more specific arguments. 52 | 53 | - `lag` argument not applicable to `add_lag` and `add_formula` 54 | - By default, all operations are performed on the **close** column 55 | - A descriptive column name is automatically added with the exception of `add_formula` 56 | 57 | ### add_lag 58 | 59 | Adds a lag on the specified column. 60 | 61 | --- 62 | 63 | | argument | description | 64 | | -------- | ------------------------------------------------- | 65 | | period | the lag period; identical to the `shift` function | 66 | 67 | To add a forward lag, add a negative number 68 | 69 | ```python 70 | # Adds the next day close 71 | ds.add_lag(on='close', period=-1) 72 | ``` 73 | 74 | ### add_pct_change 75 | 76 | Adds a percentage change 77 | | argument | description | 78 | | -------- | ------------------------------------------------- | 79 | | period | period for which percentage change to be added | 80 | 81 | ```python 82 | # Add the 5 day returns 83 | ds.add_pct_change(period=5) 84 | ``` 85 | 86 | ### add_rolling 87 | 88 | Add a rolling function to all the symbols in the dataframe 89 | 90 | | argument | description | 91 | | ---------- | ----------------------------------------------------------------------------------------------------- | 92 | | `window` | window on which the rolling operation would be applied | 93 | | `groupby` | column by which the rolling operation would be applied. By default, its the **symbol** column. | 94 | | `function` | function to be applied on the window as a string; accepts all pandas rolling functions and **zscore** | 95 | 96 | ```python 97 | # Caculate the 30-day rolling median for all symbols 98 | ds.add_rolling(30, function='median') 99 | ``` 100 | 101 | ### add_indicator 102 | 103 | This requires TA-lib 104 | 105 | Add an indicator 106 | |argument|description| 107 | |--------|-----------| 108 | `indicator`|add an indicator| 109 | `period`| period for the indicator| 110 | 111 | ```python 112 | # Add an 30 day exponential moving average for all the symbols 113 | ds.add_indicator('EMA', period=30) 114 | ``` 115 | 116 | Not all TA-Lib indicators are supported 117 | 118 | ### add_formula 119 | 120 | Add a formula 121 | |argument|description| 122 | |--------|-----------| 123 | `formula`|Add a formula string| 124 | 125 | The formula should be a string with columns existing in the dataframe. `add_formula` accepts no other argument other than formula and col_name with col_name being mandatory. The string is evaluated using `df.eval` 126 | 127 | ```python 128 | ds.add_formula('(open+close)/2', col_name='avgPrice') 129 | 130 | ``` 131 | 132 | ## How DataSource works 133 | 134 | ## Caveats 135 | -------------------------------------------------------------------------------- /docs/loaders.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Load files with similar structure into a database or HDF5 file. 4 | 5 | ## Requirements 6 | 7 | - All files should be in a single directory. 8 | - The directory should not have any sub directories 9 | - All files must be of similar structure; by similar structure, they must have the same columns and datatypes. This is usually the case if you download files from the internet. 10 | 11 | See the corresponding **Loaders** notebook in the examples folder for complete set of options 12 | 13 | ## Quickstart 14 | 15 | Load all files in data directory into a in-memory sqlite database 16 | 17 | ```python 18 | from sqlalchemy import create_engine 19 | from fasbt.loaders import DataLoader 20 | engine = create_engine('sqlite://') 21 | directory = 'data' 22 | # Initialize loader 23 | dl = DataLoader(directory, mode='SQL', engine=engine, tablename='table') 24 | dl.load_data() 25 | ``` 26 | 27 | - Create an sqlalchemy connection 28 | - Initialize the data loader class with the directory name, engine, tablename and mode 29 | - Use the `load_data` function to load all files into the database 30 | 31 | If you have added new files to your directory, just run the above code again, the database would be updated with information from the new files. 32 | 33 | If you prefer HDF5, then 34 | 35 | ```python 36 | engine = 'data.h5' 37 | directory = 'data' 38 | tablename = 'table' 39 | # Initialize loader 40 | dl = DataLoader(directory=directory, mode='HDF', engine=engine, tablename=tablename) 41 | dl.load_data() 42 | ``` 43 | 44 | Just change the engine argument to the HDF5 filename and mode as HDF 45 | 46 | If you just want to load data only without loading it into a database then use the 'collate_data` function 47 | 48 | ```python 49 | from fastbt.loaders import collate_data 50 | directory = 'data' 51 | df = collate_data(directory=directory) 52 | ``` 53 | 54 | Now all your data is loaded into a dataframe 55 | 56 | See the **Loaders** notebook in the examples folder for more options and usage 57 | 58 | ## How it works 59 | 60 | ### How files are identified 61 | 62 | DataLoader iterates over each of the file in the directory and checks if the file is already loaded in the database. If the file is already loaded, then it's skipped, but if the file is new, then its added and marked as loaded. So irrespective of how many times you run the code, your files are loaded only once. Even if you move your directory, files are not reloaded. **Files are identified by their filenames.** 63 | 64 | Internally, in case of SQL, a new table with the prefix **\_updated\_** is created that maintains a list of all the filenames loaded so far. So if your tablename is table, then you would have an another table \_updated*table* that stores the filename data. During iteration, if the file is already in the updated_data table, then it is skipped. If it is not in the table, it is added to the database and the filename is then added to the updated_table. 65 | 66 | In case of HDF5, the data is stored in the path **data** and the filename data in the **updated** path. In the example above, your data would be stored in _data/table_ while filenames would be stored in _updated/table_. 67 | 68 | So to read your data back, you need to **prefix data to your tablename**. 69 | 70 | ```python 71 | import pandas as pd 72 | pd.read_hdf('data.h5', key='data/table') 73 | ``` 74 | 75 | ### How data is converted 76 | 77 | DataLoader uses pandas for both SQL and HDF. They are just calls to the `to_sql` and `to_hdf`. And the `load_data` function is a wrapper to the pandas `read_csv` function. So you can pass any arguments to the read_csv function to the load_data function as keyword arguments. So to skip the first 10 rows and to load only columns 2,3,4 and to parse_dates for the date column 78 | 79 | ```python 80 | dl.load_data(usecols=[1,2,3], skiprows=10, parse_dates=['date']) 81 | ``` 82 | 83 | You can rename columns before loading into the database. This is particularly useful if your columns have spaces and other special characters. 84 | 85 | ```python 86 | dl.load_data(rename={'Daily Volume': 'volume'}) 87 | ``` 88 | 89 | See the **Loaders** notebook for more examples. 90 | 91 | ## preprocessing using postfunc 92 | 93 | Before loading data into the database, you would need to transform the data or perform some preprocessing. You could use the postfunc argument to perform preprocessing. It works in the following way 94 | 95 | - The file is read using the `read_csv` function and converted into a dataframe 96 | - The preprocessing function is then run on this dataframe 97 | - The result is stored in the database 98 | 99 | A preprocessing function should have three mandatory arguments 100 | 101 | 1. first argument is the dataframe after reading the file 102 | 2. second argument is the filename of the file being read 103 | 3. third argument is the directory 104 | 105 | These three arguments are automatically provided when you use the `load_data` function; you need to write the preprocessing code inside the function 106 | 107 | ```python 108 | def postfunc(df, filename, root): 109 | df['filename'] = filename 110 | return df 111 | 112 | dl.load_data(postfunc=postfunc) 113 | ``` 114 | 115 | ## other formats 116 | 117 | Though only csv format is supported, you could load any file that looks like a csv file including dat and tab delimited text files. Use the appropriate arguments to treat them as csv files (all arguments to pandas `read_csv` function is supported) 118 | 119 | If you just need a convenient way to load data into memory by iterating through the files use the `collate_data` function. Use the function argument to do whatever you need to do with the file, but the function should return a dataframe to collate the data. 120 | 121 | ## Caveats 122 | 123 | - DataLoader depends on filenames to check for new files. So renaming files would load the data again. 124 | - arguments provided to the `load_data` live throughout its lifetime. So if you need to change them, delete your database and create it again 125 | - All field/column names are converted to lower case to make them case insensitive. 126 | - Columns with names date,time,datetime,timestamp are automatically converted into dates 127 | - To rename columns before loading, use the columns argument. 128 | -------------------------------------------------------------------------------- /docs/rationale.md: -------------------------------------------------------------------------------- 1 | # Basic assumption 2 | 3 | **fastbt** is based on the following assumptions on entry and exit 4 | 5 | 1. You enter into a position at the start of the day 6 | 2. You exit your position either at the end of the day or by a stop loss 7 | 3. You take care of capital requirements 8 | 9 | So your entry price is the price at which you want to buy a security and the exit price is either the stop loss price or the close price. Ideally, your entry price should be a limit order so that you are guaranteed execution at the specific price. If you prefer a market order, they you can model it as slippage. 10 | 11 | # How prices are resolved? 12 | 13 | **fastbt** resolves prices based on open, high, low, close, buy and sell prices for a security for that day. 14 | So, you ideally place a **buy and sell order for the security with one of them being a stop loss.** 15 | 16 | The orders are considered executed at the given price if the minimum of the buy and sell price is greater than the low price and the maximum of the buy and sell price is less than the high price of the security for the given period. 17 | 18 | The order matching is done in the following way 19 | 20 | - If both the BUY and SELL price are within the low-high range then the given BUY and SELL prices are taken 21 | - If either BUY or SELL price is within the low-high range, then the other price is taken as the CLOSE price 22 | - If both of them are not within the low-high range, then the order is considered as not executed 23 | 24 | # Illustration 25 | 26 | Let's illustrate the example with a security. Let's say we placed an order to buy the security at 1025 and place a stop loss at 1000. 27 | 28 | ### Case 1 29 | 30 | --- 31 | 32 | | open | high | low | close | buy | sell | 33 | | ---- | ---- | --- | ----- | ---- | ---- | 34 | | 1022 | 1030 | 994 | 1011 | 1025 | 1000 | 35 | 36 | Here, the buy and sell price are within the low-high range. 37 | 38 | - Low High range = 994 to 1030 39 | - Sell Buy range = 1000 to 1025 40 | 41 | So, both the orders would be executed at the given price 42 | 43 | > BUY at 1025 44 | 45 | > SELL at 1000 46 | 47 | ### Case 2.1 48 | 49 | --- 50 | 51 | | open | high | low | close | buy | sell | 52 | | ---- | ---- | ---- | ----- | ---- | ---- | 53 | | 1022 | 1045 | 1016 | 1032 | 1025 | 1000 | 54 | 55 | - Low High range = 1016 to 1045 56 | - Sell buy range = 1000 to 1025 57 | 58 | Here the BUY order would be executed at the given price. 59 | Since the price of the SELL order is less than the lower price, the SELL price would be the close pirce 1032 60 | 61 | > BUY at 1025 62 | 63 | > SELL at 1032 64 | 65 | ### Case 2.2 66 | 67 | | open | high | low | close | buy | sell | 68 | | ---- | ---- | --- | ----- | ---- | ---- | 69 | | 1022 | 1022 | 950 | 1012 | 1025 | 1000 | 70 | 71 | - Low High range = 950 to 1022 72 | - Sell buy range = 1000 to 1025 73 | 74 | Same as the above with BUY being executed at close price 75 | 76 | > SELL at 1000 77 | 78 | > BUY at 1012 79 | 80 | ### Case 3 81 | 82 | --- 83 | 84 | | open | high | low | close | buy | sell | 85 | | ---- | ---- | ---- | ----- | ---- | ---- | 86 | | 1022 | 1023 | 1012 | 1022 | 1025 | 1000 | 87 | 88 | - Low High range = 1012 to 1023 89 | - Sell buy range = 1000 to 1025 90 | 91 | No orders would be executed since both the buy and sell price are not within the low high range 92 | 93 | ## Why this approach? 94 | 95 | This approach makes backtesting easier especially for short term trades. 96 | 97 | Let's take the following example of a security price 98 | 99 | | open | high | low | close | 100 | | ---- | ---- | --- | ----- | 101 | | 1000 | 1031 | 967 | 1018 | 102 | 103 | So, if a place a BUY order at 1012 and a corresponding SELL order at 1020, there is a possibility that the security might have touched the low price before rebounding back. So this would severly impact my capital if I am trading on leverage. I can place a corresponding stop loss order but the positions are not safely hedged since I would be having 2 sell orders for 1 buy order which I need to cancel if one of them gets hit. So I prefer this approach. 104 | 105 | > At present, this approach is tailored to day trading only but you can extend it to any frequency. 106 | 107 | ### Pros 108 | 109 | - Easy to understand and simple to test 110 | - No need for real time data. You can just use historical end of day data which is open source 111 | - Predictable order executions in backtest 112 | - Better replication of backtest versus real performance 113 | - Vectorized functions and parallel implementation 114 | 115 | ### Cons 116 | 117 | - Too much opinionated 118 | - Capital requirements not checked at each time frame 119 | - No way to model relationsip among securities explicitly 120 | - And a lot of goodies a full fledged backtesting framework provides 121 | 122 | Some of the above cons can be overcome by extending the framework such as relaxing the assumptions or modeling them in a different manner 123 | -------------------------------------------------------------------------------- /examples/Tradebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Introduction\n", 8 | "\n", 9 | "This is a quick introduction to the tradebook module. The tradebook is just a log of trades that shows your positions and values based on the trades you have done and provide you a few helper methods. This provides a flexible approach for simulating trades based on an event based system or a system where you iterate through each line as a separate observation.\n", 10 | "\n", 11 | "Caveat\n", 12 | "--------\n", 13 | "**This is not an orderbook.** All trades are assumed to be executed and its kept as simple as possible." 14 | ] 15 | }, 16 | { 17 | "cell_type": "markdown", 18 | "metadata": {}, 19 | "source": [ 20 | "## Initialize a tradebook" 21 | ] 22 | }, 23 | { 24 | "cell_type": "code", 25 | "execution_count": null, 26 | "metadata": {}, 27 | "outputs": [], 28 | "source": [ 29 | "import pandas as pd\n", 30 | "from fastbt.tradebook import TradeBook\n", 31 | "tb = TradeBook()" 32 | ] 33 | }, 34 | { 35 | "cell_type": "markdown", 36 | "metadata": {}, 37 | "source": [ 38 | "### Add some trades\n", 39 | "\n", 40 | "To add trades to a tradebook, you need 5 mandatory parameters\n", 41 | "\n", 42 | " * timestamp - could be a string or an id; but datetime or pandas timestamp preferred\n", 43 | " * symbol - the security symbol or asset code\n", 44 | " * price - float/number\n", 45 | " * qty - float/number\n", 46 | " * order - **B for BUY and S for SELL**\n", 47 | " \n", 48 | " \n", 49 | "Just use the `add_trade` method to add trades. Its the only method to add trades. You can include any other keyword arguments to add additional information to trades.\n", 50 | "\n", 51 | " " 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": null, 57 | "metadata": {}, 58 | "outputs": [], 59 | "source": [ 60 | "# Let's add a few trades\n", 61 | "tb.add_trade(pd.to_datetime('2019-02-01'), 'AAA', 100, 100, 'B')\n", 62 | "tb.add_trade(pd.to_datetime('2019-02-02'), 'AAA', 102, 100, 'B')\n", 63 | "tb.add_trade(pd.to_datetime('2019-02-02'), 'BBB', 1000, 15, 'S')" 64 | ] 65 | }, 66 | { 67 | "cell_type": "markdown", 68 | "metadata": {}, 69 | "source": [ 70 | "### Get some information\n", 71 | "\n", 72 | "Internally all data is represented as dictionaries\n", 73 | "\n", 74 | "Use\n", 75 | " * `tb.positions` to get the positions for all stocks\n", 76 | " * `tb.values` to get the values\n", 77 | " * `tb.trades` for trades\n", 78 | " \n", 79 | "To get these details for a single stock use, `tb.positions.get()`\n", 80 | " " 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "metadata": {}, 87 | "outputs": [], 88 | "source": [ 89 | "# Print tradebook summary\n", 90 | "tb # Shows that you have made 3 trades and 2 of the positions are still open" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": null, 96 | "metadata": {}, 97 | "outputs": [], 98 | "source": [ 99 | "# Get positions\n", 100 | "tb.positions # negative indicates short position" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": null, 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "# Get position for a particular stock\n", 110 | "print(tb.positions.get('AAA'))" 111 | ] 112 | }, 113 | { 114 | "cell_type": "code", 115 | "execution_count": null, 116 | "metadata": {}, 117 | "outputs": [], 118 | "source": [ 119 | "# Get the current value\n", 120 | "tb.values # Negative values indicate cash outflow" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [ 129 | "# Get the trades\n", 130 | "tb.trades.get('AAA')" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "# Get all your trades\n", 140 | "tb.all_trades" 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "# A few helper methods\n", 150 | "print(tb.o) # Number of open positions\n", 151 | "print(tb.l) # Number of long positions\n", 152 | "print(tb.s) # Number of short positions" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "### Something to look out for \n", 160 | "\n", 161 | " * A position of zero indicates that all trades are settled\n", 162 | " * A positive position indicates holdings\n", 163 | " * A negative position indicates short selling\n", 164 | " * Conversely, a positive value indicates money received from short selling and a negative value indicates cash outflow for buying holding\n", 165 | " * If all positions are zero, then the corresponding values indicate profit or loss\n", 166 | " * Trades are represented as a dictionary with keys being the symbol and values being the list of all trades. So to get the first trade, use `tb.trades[symbol][0]`\n", 167 | " \n", 168 | "Let's try out by closing all existing positions" 169 | ] 170 | }, 171 | { 172 | "cell_type": "code", 173 | "execution_count": null, 174 | "metadata": {}, 175 | "outputs": [], 176 | "source": [ 177 | "tb.positions, tb.values" 178 | ] 179 | }, 180 | { 181 | "cell_type": "code", 182 | "execution_count": null, 183 | "metadata": {}, 184 | "outputs": [], 185 | "source": [ 186 | "# Close existing positions\n", 187 | "tb.add_trade(pd.to_datetime('2019-03-05'), 'AAA', 105, 200, 'S', info='exit')\n", 188 | "tb.add_trade(pd.to_datetime('2019-03-05'), 'BBB', 1010, 15, 'B', info='exit')\n", 189 | "tb.positions, tb.values" 190 | ] 191 | }, 192 | { 193 | "cell_type": "markdown", 194 | "metadata": {}, 195 | "source": [ 196 | "> You could now see that both the positions are closed but you got a profit on AAA and a loss on BBB" 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": null, 202 | "metadata": {}, 203 | "outputs": [], 204 | "source": [ 205 | "# Summing up total profit\n", 206 | "print(tb)\n", 207 | "tb.values.values()" 208 | ] 209 | }, 210 | { 211 | "cell_type": "markdown", 212 | "metadata": {}, 213 | "source": [ 214 | "> And you could nicely load them up in a dataframe and see your additional info column added" 215 | ] 216 | }, 217 | { 218 | "cell_type": "code", 219 | "execution_count": null, 220 | "metadata": {}, 221 | "outputs": [], 222 | "source": [ 223 | "pd.DataFrame(tb.all_trades).sort_values(by='ts')" 224 | ] 225 | }, 226 | { 227 | "cell_type": "markdown", 228 | "metadata": {}, 229 | "source": [ 230 | "# Creating a strategy\n", 231 | "\n", 232 | "Let's create a simple strategy for bitcoin and let's see how it works. This is a long only strategy\n", 233 | "\n", 234 | "> **ENTER** when 7 day simple moving average (SMA) is greater than 30 day SMA and **EXIT** when 7 day SMA is less than 30 day SMA\n", 235 | "\n", 236 | "Other info\n", 237 | "-----------\n", 238 | "\n", 239 | "* Invest $10000 for each trade\n", 240 | "* Hold only one position at a single time (BUY only, no reversals)\n", 241 | "* If you are already holding a position, check for the exit rule\n", 242 | "* SMA is calculated on OPEN price and its assumed that you buy and sell at the open price\n", 243 | "\n", 244 | "The sample file already has the columns sma7 and sma30" 245 | ] 246 | }, 247 | { 248 | "cell_type": "code", 249 | "execution_count": null, 250 | "metadata": {}, 251 | "outputs": [], 252 | "source": [ 253 | "df = pd.read_csv('data/BTC.csv', parse_dates=['date'])" 254 | ] 255 | }, 256 | { 257 | "cell_type": "code", 258 | "execution_count": null, 259 | "metadata": {}, 260 | "outputs": [], 261 | "source": [ 262 | "# We would be using standard Python csv library\n", 263 | "\n", 264 | "import csv\n", 265 | "filename = 'data/BTC.csv' # File available in data directory\n", 266 | "btc = TradeBook()\n", 267 | "capital = 10000 # this is fixed\n", 268 | "with open(filename) as csvfile:\n", 269 | " reader = csv.DictReader(csvfile)\n", 270 | " for row in reader:\n", 271 | " # Convert to floats since by default csv reads everything as string\n", 272 | " sma7 = float(row['sma7']) + 0\n", 273 | " sma30 = float(row['sma30']) + 0\n", 274 | " price = float(row['open']) + 0\n", 275 | " # Check for entry rule and existing position\n", 276 | " # Enter only if you have no existing position\n", 277 | " if (sma7 > sma30) and (btc.l == 0): \n", 278 | " qty = int(capital/price)\n", 279 | " btc.add_trade(row['date'], 'BTC', price, qty, 'B')\n", 280 | " \n", 281 | " # Check for exit\n", 282 | " if btc.positions['BTC'] > 0:\n", 283 | " qty = btc.positions['BTC'] # Get the present position\n", 284 | " if sma7 < sma30:\n", 285 | " btc.add_trade(row['date'], 'BTC', price , qty, 'S')" 286 | ] 287 | }, 288 | { 289 | "cell_type": "code", 290 | "execution_count": null, 291 | "metadata": {}, 292 | "outputs": [], 293 | "source": [ 294 | "btc, btc.values" 295 | ] 296 | }, 297 | { 298 | "cell_type": "markdown", 299 | "metadata": {}, 300 | "source": [ 301 | "**Hurray!** You have made a profit and still hold a position. But its not surprising since bitcoin has increased twenty fold during this period. Let's do some analytics for fun.\n", 302 | "\n", 303 | "Beware, you are not taking commission and transaction costs into account" 304 | ] 305 | }, 306 | { 307 | "cell_type": "code", 308 | "execution_count": null, 309 | "metadata": {}, 310 | "outputs": [], 311 | "source": [ 312 | "trades = pd.DataFrame(btc.all_trades)\n", 313 | "trades['ts'] = pd.to_datetime(trades['ts'])\n", 314 | "trades['year'] = trades['ts'].dt.year\n", 315 | "trades['values'] = trades['qty'] * trades['price']\n", 316 | "trades.groupby(['year', 'order']).agg({'qty': sum, 'values': sum}).unstack()" 317 | ] 318 | }, 319 | { 320 | "cell_type": "markdown", 321 | "metadata": {}, 322 | "source": [ 323 | "Looks, 2013 and 2017 seemed to be really good years" 324 | ] 325 | } 326 | ], 327 | "metadata": { 328 | "kernelspec": { 329 | "display_name": "Python 3", 330 | "language": "python", 331 | "name": "python3" 332 | }, 333 | "language_info": { 334 | "codemirror_mode": { 335 | "name": "ipython", 336 | "version": 3 337 | }, 338 | "file_extension": ".py", 339 | "mimetype": "text/x-python", 340 | "name": "python", 341 | "nbconvert_exporter": "python", 342 | "pygments_lexer": "ipython3", 343 | "version": "3.6.8" 344 | } 345 | }, 346 | "nbformat": 4, 347 | "nbformat_minor": 2 348 | } 349 | -------------------------------------------------------------------------------- /examples/apps/code_generator.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | import pandas as pd 3 | import yaml 4 | from fastbt.experimental import CodeGenerator 5 | 6 | 7 | @st.cache 8 | def load_data(): 9 | pass 10 | 11 | 12 | st.title('Code Generator') 13 | 14 | cg = CodeGenerator(name='tradebook') 15 | 16 | file_input = st.file_uploader('Upload a blocks file') 17 | 18 | if file_input: 19 | dct = yaml.safe_load(file_input) 20 | for k, v in dct.items(): 21 | cg.add_code_block(k, v) 22 | 23 | blocks = st.multiselect(label='Available blocks', 24 | options=list(dct.keys())) 25 | st.write(blocks) 26 | cg.clear() 27 | for k, v in dct.items(): 28 | cg.add_code_block(k, v) 29 | cg.add_text('class NewClass:') 30 | for b in blocks: 31 | cg.add_block(b, indent=True) 32 | code = cg.generate_code() 33 | code = '```python' + '\n' + code + '\n' + '```' 34 | st.markdown(code) 35 | st.text(code) 36 | -------------------------------------------------------------------------------- /examples/apps/option_payoff.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | import pandas as pd 3 | from fastbt.options.order import OptionPayoff 4 | 5 | if 'options' not in st.session_state: 6 | st.session_state.options = [] 7 | 8 | def update(): 9 | kwargs = { 10 | 'strike': int(st.session_state.strike), 11 | 'opt_type': st.session_state.opt_type.upper()[0], 12 | 'position': st.session_state.order.upper()[0], 13 | 'premium': float(st.session_state.premium), 14 | 'qty': int(st.session_state.quantity)*lot_size 15 | } 16 | st.session_state.options.append(kwargs) 17 | 18 | cols = st.columns(2) 19 | spot = cols[0].number_input('spot_price', value=15000,min_value=0, max_value=30000, step=100) 20 | lot_size = cols[1].number_input('lot_size', min_value=1, value=1) 21 | 22 | with st.form(key='opt_form'): 23 | columns = st.columns(5) 24 | columns[0].radio('Option', options=('put','call'), key='opt_type') 25 | columns[1].radio('Order type', options=('buy','sell'), key='order') 26 | columns[2].text_input('strike', key='strike', value=10000) 27 | columns[3].text_input('premium', key='premium', value=100) 28 | columns[4].text_input('quantity', key='quantity', value=1) 29 | submit = st.form_submit_button(label='Update', on_click=update) 30 | 31 | payoff = OptionPayoff() 32 | payoff.spot = spot 33 | for opt in st.session_state.options: 34 | payoff.add(**opt) 35 | 36 | 37 | st.write(pd.DataFrame(st.session_state.options)) 38 | 39 | collect = {} 40 | for i in range(int(spot)-1000, int(spot)+1000): 41 | val = sum(payoff.calc(spot=i)) 42 | collect[i] = val 43 | 44 | s = pd.Series(collect) 45 | s.name = 'pnl' 46 | s.index.name = 'spot' 47 | st.line_chart(s) 48 | -------------------------------------------------------------------------------- /examples/apps/simple.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | import pandas as pd 3 | import pyfolio as pf 4 | import matplotlib.pyplot as plt 5 | from fastbt.rapid import backtest 6 | from fastbt.datasource import DataSource 7 | 8 | 9 | @st.cache 10 | def load_data(x, y): 11 | tmp = x[x.symbol.isin(y)] 12 | return tmp 13 | 14 | 15 | @st.cache 16 | def transform(data): 17 | """ 18 | Return transform data 19 | """ 20 | ds = DataSource(data) 21 | ds.add_pct_change(col_name='ret', lag=1) 22 | ds.add_formula('(open/prevclose)-1', col_name='pret') 23 | return ds.data 24 | 25 | 26 | @st.cache 27 | def backtesting(data, **kwargs): 28 | results = backtest(data=data, **kwargs) 29 | return results 30 | 31 | 32 | @st.cache 33 | def results_frame(data): 34 | byday = result.groupby('timestamp').net_profit.sum().reset_index() 35 | byday['cum_profit'] = byday.net_profit.cumsum() 36 | byday['max_profit'] = byday.cum_profit.expanding().max() 37 | byday['year'] = byday.timestamp.dt.year 38 | byday['month'] = byday.timestamp.dt.month 39 | return byday.set_index('timestamp') 40 | 41 | 42 | data_uploader = st.text_input(label='Enter the entire path of your file') 43 | universe_uploader = st.file_uploader(label='Load your universe Excel file') 44 | universes = [] 45 | symbols = None 46 | xls = None 47 | data = None 48 | 49 | 50 | if universe_uploader: 51 | xls = pd.ExcelFile(universe_uploader) 52 | universes = xls.sheet_names 53 | 54 | universe_select = st.selectbox(label='Select your universe', options=universes) 55 | 56 | if universe_select: 57 | st.write(universe_select) 58 | symbols = xls.parse(sheet_name=universe_select, header=None).values.ravel() 59 | symbols = list(symbols) 60 | if st.checkbox('Show symbols'): 61 | st.write(symbols) 62 | 63 | 64 | order = st.radio('BUY or SELL', options=['B', 'S']) 65 | price = st.text_input('Enter price formula', value='open') 66 | 67 | stop_loss = st.number_input(label='stop loss', min_value=0.5, max_value=5.0, value=2.0, step=.5) 68 | 69 | sort_by = st.selectbox('Select a metric to rank', ['pret', 'ret']) 70 | sort_mode = st.radio('This is to select the bottom or top stocks', [True, False]) 71 | 72 | if data_uploader: 73 | data = pd.read_hdf(data_uploader) 74 | df2 = load_data(data, symbols) 75 | df2 = transform(df2) 76 | if st.checkbox('Run Backtest'): 77 | result = backtesting(data=df2, order=order, price=price, stop_loss=stop_loss, sort_by=sort_by, sort_mode=sort_mode, 78 | commission=0.035, slippage=0.03) 79 | res = results_frame(result) 80 | st.line_chart(res[['cum_profit', 'max_profit']]) 81 | by_month = res.groupby(['year', 'month']).net_profit.sum() 82 | by_month.plot.bar(title='Net profit by month') 83 | st.pyplot() 84 | st.subheader('Statistics') 85 | st.write(pf.timeseries.perf_stats(res.net_profit/100000)) 86 | st.subheader('Drawdown table') 87 | st.table(pf.timeseries.gen_drawdown_table(res.net_profit/100000)) 88 | if st.checkbox('Export results to csv'): 89 | result.to_csv('output.csv') 90 | st.write('File saved') 91 | -------------------------------------------------------------------------------- /examples/backtest_template.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/examples/backtest_template.xls -------------------------------------------------------------------------------- /examples/data/bank.csv: -------------------------------------------------------------------------------- 1 | timestamp,symbol,series,open,high,low,close,last,prevclose,tottrdqty,tottrdval,totaltrades,isin 2 | 2018-09-03,AXISBANK,EQ,655.45,655.45,629.6,631.8,630.7,649.25,6494484,4148247645.2,88364,INE238A01034 3 | 2018-09-03,FEDERALBNK,EQ,81.5,81.85,80.4,80.75,80.55,81.1,7172556,582517741.9,18895,INE171A01029 4 | 2018-09-03,HDFCBANK,EQ,2069.4,2078.95,2063.5,2075.05,2073.95,2061.2,1915402,3973616594.8,65178,INE040A01026 5 | 2018-09-03,ICICIBANK,EQ,343.6,344.0,332.55,334.15,335.0,342.6,12859450,4339499870.6,88616,INE090A01021 6 | 2018-09-03,IDFCBANK,EQ,47.95,48.85,47.6,47.9,47.95,47.6,6484207,313096997.2,15826,INE092T01019 7 | 2018-09-03,YESBANK,EQ,347.95,348.0,337.2,339.05,338.3,343.5,26137760,8970860648.65,231131,INE528G01027 8 | 2018-09-03,SBIN,EQ,312.5,312.5,304.8,306.35,305.65,309.6,14708076,4560944743.85,94708,INE062A01020 9 | 2018-09-03,BANKBARODA,EQ,153.95,156.5,150.9,151.7,151.3,152.95,16081701,2484262709.35,72339,INE028A01039 10 | 2018-09-03,PNB,EQ,88.3,89.3,86.0,86.35,86.0,88.15,30610408,2695345823.75,76964,INE160A01022 11 | 2018-09-03,KOTAKBANK,EQ,1292.0,1295.0,1265.0,1269.1,1266.25,1287.25,2076747,2639495128.3,59594,INE237A01028 12 | 2018-09-03,INDUSINDBK,EQ,1906.0,1918.85,1884.7,1897.0,1895.0,1906.6,937981,1782402338.1,85683,INE095A01012 13 | 2018-09-03,RBLBANK,EQ,630.1,643.5,622.0,623.9,624.4,627.25,1532438,970892850.6,28755,INE976G01028 14 | 2018-09-04,ICICIBANK,EQ,334.8,336.45,327.25,328.5,329.0,334.15,15470215,5118689354.85,125732,INE090A01021 15 | 2018-09-04,IDFCBANK,EQ,48.15,48.15,44.65,44.95,45.0,47.9,13628779,625429495.35,25322,INE092T01019 16 | 2018-09-04,INDUSINDBK,EQ,1895.0,1898.8,1843.0,1855.6,1858.3,1897.0,1290572,2395967832.2,62292,INE095A01012 17 | 2018-09-04,KOTAKBANK,EQ,1270.0,1275.3,1251.1,1257.6,1259.0,1269.1,2001489,2516574198.8,49476,INE237A01028 18 | 2018-09-04,PNB,EQ,86.4,86.85,82.85,83.15,83.3,86.35,25579702,2167003555.55,60666,INE160A01022 19 | 2018-09-04,SBIN,EQ,306.8,307.45,295.45,296.4,296.25,306.35,42859084,12991766170.45,149148,INE062A01020 20 | 2018-09-04,YESBANK,EQ,340.75,343.4,332.55,334.05,333.5,339.05,22057643,7439453003.7,205971,INE528G01027 21 | 2018-09-04,HDFCBANK,EQ,2076.9,2077.95,2049.0,2051.8,2052.05,2075.05,1782634,3670291386.7,67279,INE040A01026 22 | 2018-09-04,RBLBANK,EQ,623.1,627.3,601.5,608.0,607.5,623.9,1608895,982139065.55,24934,INE976G01028 23 | 2018-09-04,BANKBARODA,EQ,151.3,152.3,144.5,145.55,145.55,151.7,13746105,2039698627.7,53444,INE028A01039 24 | 2018-09-04,AXISBANK,EQ,631.0,644.4,622.75,641.8,642.75,631.8,9305124,5905251786.15,109084,INE238A01034 25 | 2018-09-04,FEDERALBNK,EQ,81.0,81.15,76.2,77.0,77.4,80.75,16528254,1289517331.0,50199,INE171A01029 26 | 2018-09-05,AXISBANK,EQ,641.0,646.65,631.35,637.65,637.0,641.8,8837398,5642182256.7,113081,INE238A01034 27 | 2018-09-05,BANKBARODA,EQ,145.4,146.55,140.5,144.55,144.45,145.55,12672028,1825172677.35,50620,INE028A01039 28 | 2018-09-05,FEDERALBNK,EQ,77.4,78.15,75.65,77.75,77.8,77.0,10489491,806285810.85,36246,INE171A01029 29 | 2018-09-05,HDFCBANK,EQ,2053.8,2060.2,2035.55,2045.85,2044.35,2051.8,1410377,2883899267.05,73829,INE040A01026 30 | 2018-09-05,IDFCBANK,EQ,44.95,45.5,43.65,44.85,45.15,44.95,9884978,440583789.15,19418,INE092T01019 31 | 2018-09-05,ICICIBANK,EQ,327.0,331.35,324.6,329.65,329.0,328.5,14548867,4770315019.65,106589,INE090A01021 32 | 2018-09-05,INDUSINDBK,EQ,1855.8,1864.7,1825.0,1854.85,1860.0,1855.6,804386,1481157881.05,45554,INE095A01012 33 | 2018-09-05,KOTAKBANK,EQ,1257.0,1266.0,1227.6,1238.15,1237.0,1257.6,2262546,2806937975.05,74217,INE237A01028 34 | 2018-09-05,PNB,EQ,83.2,83.85,81.4,83.1,83.15,83.15,21704587,1794074680.9,54937,INE160A01022 35 | 2018-09-05,RBLBANK,EQ,605.1,610.95,586.2,593.55,594.0,608.0,1533562,912852741.8,27460,INE976G01028 36 | 2018-09-05,SBIN,EQ,296.5,298.85,290.4,296.55,296.9,296.4,22922686,6766403146.0,148306,INE062A01020 37 | 2018-09-05,YESBANK,EQ,332.9,344.9,332.25,343.8,344.05,334.05,24809578,8414837234.45,200611,INE528G01027 38 | 2018-09-06,INDUSINDBK,EQ,1870.0,1885.0,1851.0,1880.0,1879.0,1854.85,1003340,1879392028.45,54166,INE095A01012 39 | 2018-09-06,KOTAKBANK,EQ,1242.0,1264.6,1238.15,1260.9,1258.55,1238.15,2947633,3706721323.1,66281,INE237A01028 40 | 2018-09-06,RBLBANK,EQ,598.0,600.0,578.65,591.55,591.6,593.55,1766611,1039492166.65,45182,INE976G01028 41 | 2018-09-06,SBIN,EQ,298.0,299.85,294.5,296.45,294.9,296.55,18001336,5352603390.05,111206,INE062A01020 42 | 2018-09-06,PNB,EQ,84.0,84.7,82.85,83.25,83.2,83.1,25079939,2096853285.65,57788,INE160A01022 43 | 2018-09-06,AXISBANK,EQ,638.9,643.3,624.6,638.2,637.5,637.65,10026621,6344121074.15,122891,INE238A01034 44 | 2018-09-06,IDFCBANK,EQ,45.15,45.4,44.45,44.9,44.7,44.85,12803268,573560680.1,23485,INE092T01019 45 | 2018-09-06,ICICIBANK,EQ,330.0,331.25,325.5,328.65,327.95,329.65,11322771,3724325241.4,81857,INE090A01021 46 | 2018-09-06,HDFCBANK,EQ,2049.0,2059.0,2032.6,2052.2,2050.15,2045.85,2600603,5316362856.9,125530,INE040A01026 47 | 2018-09-06,FEDERALBNK,EQ,78.5,78.5,76.7,77.35,77.3,77.75,6731974,521808769.85,30898,INE171A01029 48 | 2018-09-06,BANKBARODA,EQ,145.9,147.1,144.15,146.1,145.7,144.55,10475666,1525305545.45,40096,INE028A01039 49 | 2018-09-06,YESBANK,EQ,346.55,347.8,337.9,339.2,339.1,343.8,17527530,6001339480.75,141838,INE528G01027 50 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Never enforce `E501` (line length violations). 2 | # E743 -> Checks for the use of the characters 'l', 'O', or 'I' as function names. 3 | ignore = ["E501", "E743"] 4 | exclude = ["tests"] 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | 5 | [flake8] 6 | max-line-length = 140 7 | exclude = */migrations/* 8 | 9 | [tool:pytest] 10 | testpaths = tests 11 | norecursedirs = 12 | migrations 13 | 14 | python_files = 15 | test_*.py 16 | *_test.py 17 | tests.py 18 | addopts = 19 | -ra 20 | --strict 21 | --doctest-modules 22 | --doctest-glob=\*.rst 23 | --tb=short 24 | 25 | [isort] 26 | force_single_line = True 27 | line_length = 120 28 | known_first_party = fastbt 29 | default_section = THIRDPARTY 30 | forced_separate = test_fastbt 31 | not_skip = __init__.py 32 | skip = migrations 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | 6 | import io 7 | import re 8 | from glob import glob 9 | from os.path import basename 10 | from os.path import dirname 11 | from os.path import join 12 | from os.path import splitext 13 | 14 | from setuptools import find_packages 15 | from setuptools import setup 16 | 17 | 18 | def read(*names, **kwargs): 19 | with io.open( 20 | join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8") 21 | ) as fh: 22 | return fh.read() 23 | 24 | 25 | EXTRAS_REQUIRE = dict( 26 | ta=["TA-Lib"], 27 | io=["tables", "zarr", "openpyxl", "xlwt"], 28 | compiled=["numba>0.55.0"], 29 | plotting=["bokeh>3.0.0"], 30 | apps=["streamlit>1.15.0"], 31 | test=["pytest", "pytest-watch", "ruff"], 32 | options=["pydantic"], 33 | ) 34 | 35 | 36 | setup( 37 | name="fastbt", 38 | version="0.6.0", 39 | license="MIT license", 40 | description="A simple framework for fast and dirty backtesting", 41 | long_description="%s\n%s" 42 | % ( 43 | re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub( 44 | "", read("README.md") 45 | ), 46 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 47 | ), 48 | long_description_content_type="text/markdown", 49 | author="UM", 50 | author_email="uberdeveloper001@gmail.com", 51 | url="https://github.com/uberdeveloper/fastbt", 52 | packages=find_packages("src"), 53 | package_dir={"": "src"}, 54 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 55 | include_package_data=True, 56 | zip_safe=False, 57 | classifiers=[ 58 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 59 | "Development Status :: 3 - Alpha", 60 | "Intended Audience :: Financial and Insurance Industry", 61 | "License :: OSI Approved :: MIT License", 62 | "Operating System :: OS Independent", 63 | "Programming Language :: Python", 64 | "Programming Language :: Python :: 3.7", 65 | "Programming Language :: Python :: 3.9", 66 | "Programming Language :: Python :: Implementation :: CPython", 67 | # uncomment if you test on these interpreters: 68 | # 'Programming Language :: Python :: Implementation :: IronPython', 69 | # 'Programming Language :: Python :: Implementation :: Jython', 70 | # 'Programming Language :: Python :: Implementation :: Stackless', 71 | "Topic :: Office/Business :: Financial :: Investment", 72 | ], 73 | keywords=[ 74 | # eg: 'keyword1', 'keyword2', 'keyword3', 75 | "fastbt", 76 | "backtesting", 77 | "algorithmic trading", 78 | "quantitative finance", 79 | "research", 80 | "finance", 81 | ], 82 | install_requires=[ 83 | # eg: 'aspectlib==1.1.1', 'six>=1.7', 84 | "pandas>=1.0.0", 85 | "sqlalchemy<=2.0.0", 86 | "pendulum>=2.0.0", 87 | ], 88 | extras_require=EXTRAS_REQUIRE, 89 | ) 90 | -------------------------------------------------------------------------------- /src/fastbt/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.0" 2 | __name__ = "fastbt" 3 | -------------------------------------------------------------------------------- /src/fastbt/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request 2 | from fastbt.rapid import backtest 3 | import pandas as pd 4 | 5 | 6 | class CustomFlask(Flask): 7 | jinja_options = Flask.jinja_options.copy() 8 | jinja_options.update( 9 | dict( 10 | block_start_string="<%", 11 | block_end_string="%>", 12 | variable_start_string="%%", 13 | variable_end_string="%%", 14 | comment_start_string="<#", 15 | comment_end_string="#>", 16 | ) 17 | ) 18 | 19 | 20 | app = CustomFlask(__name__) 21 | 22 | 23 | @app.route("/") 24 | def hello_world(): 25 | col_types = ["lag", "percent_change", "rolling", "formula", "indicator"] 26 | context = {"name": "Ram", "col_types": col_types} 27 | return render_template("backtest.html", **context) 28 | 29 | 30 | @app.route("/backtest", methods=["POST"]) 31 | def run_backtest(): 32 | if request.method == "POST": 33 | pass 34 | # return str(request.form) 35 | df = pd.read_csv( 36 | "/home/machine/Projects/finance/nifty50", parse_dates=["timestamp"] 37 | ) 38 | import json 39 | 40 | columns = json.loads(request.form.get("columns")) 41 | conditions = json.loads(request.form.get("conditions")) 42 | print(columns) 43 | result = backtest( 44 | data=df, order="S", stop_loss=3, columns=columns, conditions=conditions 45 | ) 46 | txt = str(result.columns) 47 | return str(result.net_profit.sum()) + "\n" + txt 48 | 49 | 50 | @app.route("/ds", methods=["GET", "POST"]) 51 | def ds(): 52 | controls = [ 53 | { 54 | "input": True, 55 | "type": "file", 56 | "name": "directory", 57 | "placeholder": "Select a directory with a file", 58 | }, 59 | { 60 | "input": True, 61 | "type": "text", 62 | "name": "engine", 63 | "placeholder": "sqlalchemy connection string or HDF5 filename", 64 | }, 65 | { 66 | "input": True, 67 | "type": "text", 68 | "name": "tablename", 69 | "placeholder": "tablename in SQL or HDF", 70 | }, 71 | {"select": True, "name": "mode", "choices": ["SQL", "HDF"]}, 72 | ] 73 | context = {"controls": controls} 74 | if request.method == "GET": 75 | return render_template("datastore.html", **context) 76 | elif request.method == "POST": 77 | print(request.form) 78 | return str(request.form) 79 | 80 | 81 | if __name__ == "__main__": 82 | app.run(debug=True) 83 | -------------------------------------------------------------------------------- /src/fastbt/brokers/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | DEPRECIATION_WARNING = """ 4 | Brokers support would be removed from version 0.7.0 5 | Use omspy for live order placement with brokers 6 | """ 7 | warnings.warn(DEPRECIATION_WARNING, DeprecationWarning, stacklevel=2) 8 | -------------------------------------------------------------------------------- /src/fastbt/brokers/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Trading API 4 | description: A generic trading API for stock markets. 5 | version: 0.1.0 6 | 7 | servers: 8 | - url: https://localhost 9 | 10 | paths: 11 | /profile: 12 | get: 13 | summary: User profile 14 | description: Get the user profile 15 | responses: 16 | '200': 17 | description: Returns a dictionary of user properties 18 | content: 19 | application/json: 20 | schema: 21 | $ref: '#/components/schemas/Profile' 22 | /orders: 23 | get: 24 | summary: List of orders 25 | description: Gets the list of all orders placed for the day 26 | responses: 27 | '200': 28 | description: Returns a list of dictionaries for each of the orders 29 | content: 30 | application/json: 31 | schema: 32 | type: array 33 | items: 34 | $ref: '#/components/schemas/Order' 35 | 36 | /order_cancel/{id}: 37 | post: 38 | summary: Cancel an order 39 | description: Given an order id, cancel an order 40 | parameters: 41 | - in: path 42 | name: id 43 | required: true 44 | schema: 45 | type: integer 46 | description: Given an order id, cancel an order 47 | responses: 48 | '200': 49 | description: Response from provider 50 | content: 51 | application/json: 52 | schema: 53 | $ref: '#/components/schemas/Message' 54 | 55 | 56 | /trades: 57 | get: 58 | summary: List of trades 59 | description: Gets the list of all trades 60 | responses: 61 | '200': 62 | description: Returns a list of all trades 63 | content: 64 | application/json: 65 | schema: 66 | type: array 67 | items: 68 | $ref: '#/components/schemas/Trade' 69 | 70 | /positions: 71 | get: 72 | summary: List of positions 73 | description: Gets the list of all positions 74 | responses: 75 | '200': 76 | description: Returns a list of all positions 77 | content: 78 | application/json: 79 | schema: 80 | type: array 81 | items: 82 | $ref: '#/components/schemas/Position' 83 | 84 | components: 85 | schemas: 86 | Profile: 87 | type: object 88 | properties: 89 | id: 90 | type: string 91 | name: 92 | type: string 93 | email: 94 | type: string 95 | 96 | Order: 97 | type: object 98 | additionalProperties: true 99 | properties: 100 | order_id: 101 | type: integer 102 | order_timestamp: 103 | type: string 104 | symbol: 105 | type: string 106 | price: 107 | type: number 108 | quantity: 109 | type: number 110 | order_type: 111 | type: string 112 | side: 113 | type: string 114 | filled_quantity: 115 | type: string 116 | status: 117 | type: string 118 | 119 | Trade: 120 | type: object 121 | additionalProperties: true 122 | properties: 123 | trade_id: 124 | type: integer 125 | order_id: 126 | type: integer 127 | timestamp: 128 | type: string 129 | symbol: 130 | type: string 131 | price: 132 | type: number 133 | quantity: 134 | type: integer 135 | side: 136 | type: string 137 | 138 | Position: 139 | type: object 140 | additionalProperties: true 141 | properties: 142 | symbol: 143 | type: string 144 | quantity: 145 | type: integer 146 | side: 147 | type: string 148 | average_price: 149 | type: number 150 | 151 | Quote: 152 | type: object 153 | properties: 154 | symbol: 155 | type: string 156 | open: 157 | type: number 158 | high: 159 | type: number 160 | low: 161 | type: number 162 | close: 163 | type: number 164 | ltp: 165 | type: number 166 | volume: 167 | type: number 168 | ltt: 169 | type: string 170 | description: last traded timestamp 171 | 172 | Message: 173 | type: object 174 | additionalProperties: true 175 | properties: 176 | code: 177 | type: integer 178 | description: A numeric code indicating the success or failure of the transaction 179 | id: 180 | type: integer 181 | description: The corresponding identifier usually an order id or a trade id 182 | message: 183 | type: string 184 | description: A detailed message of what really happened from the provider 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /src/fastbt/brokers/fivepaisa.py: -------------------------------------------------------------------------------- 1 | from fastbt.Meta import Broker, pre, post 2 | from py5paisa import FivePaisaClient 3 | from py5paisa.order import ( 4 | Order, 5 | Exchange, 6 | ExchangeSegment, 7 | OrderType, 8 | ) 9 | 10 | 11 | def get_instrument_token(contracts, exchange, symbol): 12 | """ 13 | Fetch the instrument token 14 | contracts 15 | the contracts master as a dictionary 16 | exchange 17 | exchange to look up for 18 | symbol 19 | symbol to look up for 20 | """ 21 | return contracts.get(f"{exchange}:{symbol}") 22 | 23 | 24 | class FivePaisa(Broker): 25 | """ 26 | Automated trading class for five paisa 27 | """ 28 | 29 | exchange = {"NSE": Exchange.NSE, "BSE": Exchange.BSE, "MCX": Exchange.MCX} 30 | 31 | exchange_segment = {"EQ": ExchangeSegment.CASH, "FO": ExchangeSegment.DERIVATIVE} 32 | 33 | side = {"BUY": OrderType.BUY, "SELL": OrderType.SELL} 34 | 35 | def __init__(self, email: str, password: str, dob: str): 36 | """ 37 | Initialize the broker 38 | """ 39 | self._email = email 40 | self._password = password 41 | self._dob = dob 42 | super(FivePaisa, self).__init__() 43 | self.contracts = {} 44 | print("Hi Five paisa") 45 | 46 | def _shortcuts(self): 47 | """ 48 | Provides shortcut functions to predefined methods 49 | This is a just a mapping of the generic class 50 | methods to the broker specific method 51 | For shortcuts to work, user should have been authenticated 52 | """ 53 | self.margins = self.fivepaisa.margin 54 | self.positions = self.fivepaisa.positions 55 | 56 | def authenticate(self): 57 | client = FivePaisaClient( 58 | email=self._email, passwd=self._password, dob=self._dob 59 | ) 60 | client.login() 61 | self.fivepaisa = client 62 | self._shortcuts() 63 | 64 | def _get_instrument_token(self, symbol, exchange="NSE", contracts=None): 65 | if not (contracts): 66 | contracts = self.contracts 67 | return get_instrument_token( 68 | contracts=contracts, exchange=exchange, symbol=symbol 69 | ) 70 | 71 | @post 72 | def orders(self): 73 | return self.fivepaisa.order_book() 74 | 75 | @pre 76 | def order_place(self, **kwargs): 77 | """ 78 | Place an order 79 | """ 80 | defaults = { 81 | "exchange": "N", 82 | "exchange_segment": "C", 83 | "is_intraday": True, 84 | "price": 0, 85 | } 86 | kwargs.update(defaults) 87 | symbol = kwargs.pop("symbol") 88 | exchange = kwargs.pop("exchange") 89 | kwargs.pop("side", "BUY") 90 | scrip_code = self._get_instrument_token(symbol=symbol, exchange=exchange) 91 | order = Order( 92 | order_type=self.side.get("BUY"), 93 | scrip_code=scrip_code, 94 | exchange=Exchange.NSE, 95 | **kwargs, 96 | ) 97 | # print(order.scrip_code, order.quantity, order.order_type) 98 | # print(order) 99 | self.fivepaisa.place_order(order) 100 | -------------------------------------------------------------------------------- /src/fastbt/brokers/fivepaisa.yaml: -------------------------------------------------------------------------------- 1 | orders: 2 | BrokerOrderId: order_id 3 | BrokerOrderTime: order_timestamp 4 | ScripName: symbol 5 | Rate: price 6 | Qty: quantity 7 | AtMarket: order_type 8 | BuySell: side 9 | TradedQty: filled_quantity 10 | OrderStatus: status 11 | orderplace: 12 | Qty: quantity 13 | OrderType: side 14 | -------------------------------------------------------------------------------- /src/fastbt/brokers/fyers.py: -------------------------------------------------------------------------------- 1 | from fastbt.Meta import Broker, pre, post 2 | from selenium import webdriver 3 | from selenium.webdriver.common.by import By 4 | from selenium.webdriver.support.ui import WebDriverWait 5 | from selenium.webdriver.support import expected_conditions as EC 6 | 7 | from fyers_api import accessToken, fyersModel 8 | from functools import partial 9 | 10 | 11 | class Fyers(Broker): 12 | """ 13 | Automated Trading class 14 | """ 15 | 16 | def __init__(self): 17 | """ 18 | To be implemented 19 | """ 20 | super(Fyers, self).__init__() 21 | 22 | def authenticate(self, **kwargs): 23 | """ 24 | Fyers authentication to be implemented 25 | """ 26 | try: 27 | with open("fyers-token.tok", "r") as f: 28 | self._token = f.read() 29 | self.fyers = fyersModel.FyersModel() 30 | self._shortcuts() 31 | code = self.fyers.get_profile(self._token)["code"] 32 | if code == 401: 33 | print("Authentication failure, logging in again") 34 | self._login(**kwargs) 35 | self.fyers = fyersModel.FyersModel() 36 | self._shortcuts() 37 | except Exception as E: 38 | print("Into Exception", E) 39 | self._login(**kwargs) 40 | self.fyers = fyersModel.FyersModel() 41 | self._shortcuts() 42 | 43 | @staticmethod 44 | def get_token(url, key="access_token"): 45 | """ 46 | Get the access token from the url 47 | url 48 | the url returned 49 | key 50 | the key to fetch 51 | Note 52 | ----- 53 | By default, it is expected that when the query string 54 | is parsed, it would have a parameter with the name 55 | **access_token** and using the parse function this would 56 | fetch a list with a single element 57 | """ 58 | import urllib.parse 59 | 60 | parsed = urllib.parse.urlparse(url) 61 | return urllib.parse.parse_qs(parsed.query)[key][0] 62 | 63 | def _login(self, **kwargs): 64 | import time 65 | 66 | app_id = kwargs.pop("app_id") 67 | app_secret = kwargs.pop("app_secret") 68 | username = kwargs.pop("username") 69 | password = kwargs.pop("password") 70 | dob = kwargs.pop("dob") 71 | app_session = accessToken.SessionModel(app_id, app_secret) 72 | response = app_session.auth() 73 | auth_code = response["data"]["authorization_code"] 74 | app_session.set_token(auth_code) 75 | url = app_session.generate_token() 76 | # Initiating the driver to log in 77 | driver = webdriver.Chrome() 78 | driver.get(url) 79 | # Auto login 80 | login_form = WebDriverWait(driver, 45).until( 81 | EC.presence_of_element_located((By.ID, "myForm")) 82 | ) 83 | login_form.find_elements_by_id("fyers_id")[0].send_keys(username) 84 | login_form.find_elements_by_id("password")[0].send_keys(password) 85 | login_form.find_elements_by_class_name("login-dob")[0].click() 86 | login_form.find_elements_by_id("dob")[0].send_keys(dob) 87 | driver.find_element_by_id("btn_id").click() 88 | time.sleep(2) 89 | WebDriverWait(driver, 15).until( 90 | EC.presence_of_element_located((By.NAME, "email")) 91 | ) 92 | url = driver.current_url 93 | token = self.get_token(url) 94 | self._token = token 95 | with open("fyers-token.tok", "w") as f: 96 | f.write(token) 97 | driver.close() 98 | 99 | def _shortcuts(self): 100 | self.holdings = partial(self.fyers.holdings, token=self._token) 101 | self.trades = partial(self.fyers.tradebook, token=self._token) 102 | self.positions = partial(self.fyers.positions, token=self._token) 103 | 104 | def _fetch(self, data): 105 | """ 106 | Fetch the necessary data from the request 107 | data 108 | the data dictionary returned from the request 109 | returns None in case of other status codes 110 | """ 111 | if data["code"] in [200, 201]: 112 | return data["data"] 113 | else: 114 | return None 115 | 116 | @post 117 | def profile(self): 118 | prof = self.fyers.get_profile(self._token) 119 | prof = self._fetch(prof) 120 | if prof: 121 | prof["result"] 122 | else: 123 | return {} 124 | 125 | @pre 126 | def order_place(self, **kwargs): 127 | """ 128 | Place an order 129 | """ 130 | return self.fyers.place_orders(token=self._token, data=kwargs) 131 | 132 | def order_cancel(self, order_id): 133 | return self.fyers.delete_orders(token=self._token, data={"id": order_id}) 134 | 135 | @post 136 | def orders(self): 137 | ords = self.fyers.orders(token=self._token) 138 | ords = self._fetch(ords) 139 | if ords: 140 | all_orders = ords["orderBook"] 141 | for o in all_orders: 142 | if o["side"] == 1: 143 | o["side"] = "BUY" 144 | elif o["side"] == -1: 145 | o["side"] = "SELL" 146 | # update status 147 | status_map = { 148 | 1: "CANCELED", 149 | 2: "COMPLETE", 150 | 4: "PENDING", 151 | 5: "REJECTED", 152 | 6: "PENDING", 153 | } 154 | for o in all_orders: 155 | o["status"] = status_map.get(o["status"], "PENDING") 156 | return all_orders 157 | else: 158 | return [] 159 | -------------------------------------------------------------------------------- /src/fastbt/brokers/fyers.yaml: -------------------------------------------------------------------------------- 1 | orders: 2 | qty: quantity 3 | id: order_id 4 | orderDateTime: timestamp 5 | limitPrice: price 6 | filledQty: filled_quantity 7 | type: order_type 8 | 9 | profile: 10 | user_id: user_id 11 | user_name: user_name 12 | email: email_id 13 | 14 | -------------------------------------------------------------------------------- /src/fastbt/brokers/master_trust.yaml: -------------------------------------------------------------------------------- 1 | orders: 2 | oms_order_id: order_id 3 | order_timestamp: order_entry_time 4 | trading_symbol: symbol 5 | order_side: side 6 | order_status: status 7 | completed_orders: 8 | oms_order_id: order_id 9 | order_timestamp: order_entry_time 10 | trading_symbol: symbol 11 | order_side: side 12 | order_status: status 13 | pending_orders: 14 | order_id: oms_order_id 15 | order_timestamp: order_entry_time 16 | trading_symbol: symbol 17 | order_side: side 18 | order_status: status 19 | positions: 20 | trading_symbol: symbol 21 | net_quantity: quantity 22 | average_price: average_price 23 | order_place: 24 | symbol: tradingsymbol 25 | side: order_side 26 | -------------------------------------------------------------------------------- /src/fastbt/brokers/spec.yaml: -------------------------------------------------------------------------------- 1 | profile: 2 | - id 3 | - name 4 | - email 5 | orders: 6 | - order_id 7 | - order_timestamp 8 | - symbol 9 | - price 10 | - quantity 11 | - order_type 12 | - side 13 | - filled_quantity 14 | - status 15 | trades: 16 | - trade_id 17 | - timestamp 18 | - order_id 19 | - symbol 20 | - price 21 | - quantity 22 | - side 23 | positions: 24 | - symbol 25 | - quantity 26 | - side 27 | - average_price 28 | order_place: 29 | - symbol 30 | - price 31 | - quantity 32 | - order_type 33 | - side 34 | order_modify: 35 | - order_id 36 | - quanity 37 | - price 38 | order_cancel: 39 | - order_id 40 | quote: 41 | - symbol 42 | - open 43 | - high 44 | - low 45 | - close 46 | - ltp 47 | - volume 48 | - ltt -------------------------------------------------------------------------------- /src/fastbt/brokers/zerodha.yaml: -------------------------------------------------------------------------------- 1 | orders: 2 | order_id: order_id 3 | order_timestamp: order_timestamp 4 | tradingsymbol: symbol 5 | price: price 6 | quantity: quantity 7 | order_type: order_type 8 | transaction_type: side 9 | filled_quantity: filled_quantity 10 | status: status 11 | positions: 12 | tradingsymbol: symbol 13 | quantity: quantity 14 | side: side 15 | average_price: average_price 16 | order_place: 17 | symbol: tradingsymbol 18 | side: transaction_type -------------------------------------------------------------------------------- /src/fastbt/features.py: -------------------------------------------------------------------------------- 1 | """ 2 | Features for machine learning and other analysis 3 | All features are optimized with numba for speed 4 | """ 5 | 6 | import numpy as np 7 | from numba import njit 8 | 9 | 10 | @njit 11 | def high_count(values): 12 | """ 13 | Given a list of values, return the number of 14 | times high is broken 15 | >>> arr = np.array([11,12,9,8,13]) 16 | >>> list(high_count(arr)) 17 | [0, 1, 1, 1, 2] 18 | """ 19 | length = len(values) 20 | arr = np.zeros(length, dtype=np.int16) 21 | count = 0 22 | max_val = values[0] 23 | for i in np.arange(1, length): 24 | if values[i] > max_val: 25 | max_val = values[i] 26 | count += 1 27 | arr[i] = count 28 | return arr 29 | 30 | 31 | @njit 32 | def low_count(values): 33 | """ 34 | Given a list of values, return the number of 35 | times low is broken 36 | >>> arr = np.array([13,14,12,11,9,10]) 37 | >>> list(low_count(arr)) 38 | [0, 0, 1, 2, 3, 3] 39 | """ 40 | length = len(values) 41 | arr = np.zeros(length, dtype=np.int16) 42 | count = 0 43 | min_val = values[0] 44 | for i in np.arange(1, length): 45 | if values[i] < min_val: 46 | min_val = values[i] 47 | count += 1 48 | arr[i] = count 49 | return arr 50 | 51 | 52 | @njit 53 | def last_high(values): 54 | """ 55 | Given a list of values, return an array with 56 | the index of the corresponding last highs 57 | Note 58 | ---- 59 | index starts at zero 60 | >>> arr = np.array([12,14,11,12,13,18]) 61 | >>> list(last_high(arr)) 62 | [0, 1, 1, 1, 1, 5] 63 | """ 64 | length = len(values) 65 | arr = np.zeros(length, dtype=np.int32) 66 | max_val = values[0] 67 | counter = 0 68 | for i in np.arange(1, length): 69 | if values[i] > max_val: 70 | max_val = values[i] 71 | counter = i 72 | arr[i] = counter 73 | return arr 74 | -------------------------------------------------------------------------------- /src/fastbt/files/IndexConstituents.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/src/fastbt/files/IndexConstituents.xlsx -------------------------------------------------------------------------------- /src/fastbt/files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/src/fastbt/files/__init__.py -------------------------------------------------------------------------------- /src/fastbt/metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains consolidated metrics 3 | """ 4 | 5 | import pandas as pd 6 | import numpy as np 7 | import os 8 | 9 | try: 10 | pass 11 | except ImportError: 12 | print("pyfolio not installed") 13 | 14 | from fastbt.utils import generate_weights, recursive_merge 15 | 16 | 17 | def spread_test(data, periods=["Y", "Q", "M"]): 18 | """ 19 | Test whether the returns are spread over the entire period 20 | or consolidated in a single period 21 | data 22 | returns/pnl as series with date as index 23 | periods 24 | periods to check as list. 25 | all valid pandas date offset strings accepted 26 | 27 | returns a dataframe with periods as index and 28 | profit/loss count and total payoff 29 | """ 30 | collect = [] 31 | for period in periods: 32 | rsp = data.resample(period).sum() 33 | gt = rsp[rsp >= 0] 34 | lt = rsp[rsp < 0] 35 | values = (len(gt), gt.sum(), len(lt), lt.sum()) 36 | collect.append(values) 37 | return pd.DataFrame( 38 | collect, index=periods, columns=["num_profit", "profit", "num_loss", "loss"] 39 | ) 40 | 41 | 42 | def shuffled_drawdown(data, capital=1000): 43 | """ 44 | Calculate the shuffled drawdown for the given data 45 | """ 46 | np.random.shuffle(data) 47 | cum_p = data.cumsum() + capital 48 | max_p = np.maximum.accumulate(cum_p) 49 | diff = (cum_p - max_p) / capital 50 | return diff.min() 51 | 52 | 53 | def lot_compounding(pnl, lot_size, initial_capital, capital_per_lot, max_lots=None): 54 | """ 55 | Calculate the compounded returns based on lot size 56 | pnl 57 | pandas series with daily pnl amount 58 | lot_size 59 | lot size; pnl would be multiplied by this lot size since 60 | pnl is assumed to be for a single quantity 61 | initial_capital 62 | initial investment 63 | capital_per_lot 64 | capital per lot. Capital at the start of the day 65 | is divided by this amount to calculate the number of lots 66 | max_lots 67 | maximum lots after which lot size would not be compounded 68 | returns a dataframe with daily capital and the number of lots 69 | """ 70 | length = len(pnl) 71 | capital_array = np.zeros(length) 72 | lots_array = np.zeros(length) 73 | capital = initial_capital 74 | lots = round(capital / capital_per_lot) 75 | capital_array[0] = initial_capital 76 | lots_array[0] = lots 77 | profit = pnl.values.ravel() 78 | for i in range(length - 1): 79 | daily_profit = profit[i] * lot_size * lots 80 | capital += daily_profit 81 | lots = round(capital / capital_per_lot) 82 | if max_lots: 83 | lots = min(lots, max_lots) 84 | capital_array[i + 1] = capital 85 | lots_array[i + 1] = lots 86 | return pd.DataFrame({"capital": capital_array, "lots": lots_array}, index=pnl.index) 87 | 88 | 89 | class MultiStrategy: 90 | """ 91 | A class to analyze multiple strategies 92 | """ 93 | 94 | def __init__(self): 95 | """ 96 | All initialization goes here 97 | """ 98 | self._sources = {} 99 | self.generate_weights = generate_weights 100 | 101 | def add_source(self, name, data): 102 | """ 103 | Add a data source as a pandas series 104 | name 105 | name of the data source 106 | data 107 | a pandas series with date as index 108 | and profit and loss as values 109 | """ 110 | self._sources[name] = data 111 | 112 | def corr(self, names=[], column="pnl"): 113 | """ 114 | Create a correlation matrix 115 | names 116 | names are the names of data sources to be merged 117 | by default, all data sources are used 118 | column 119 | column name to merge 120 | """ 121 | keys = self._sources.keys() 122 | if not (names): 123 | names = keys 124 | # Rename columns for better reporting 125 | collect = [] 126 | for name in names: 127 | src = self._sources.get(name) 128 | if src is not None: 129 | cols = ["date", column] 130 | tmp = src[cols].rename(columns={column: name}) 131 | collect.append(tmp) 132 | if len(collect) > 0: 133 | frame = recursive_merge(collect, on=["date"], how="outer").fillna(0) 134 | return frame.corr() 135 | else: 136 | return [] 137 | 138 | def from_directory(self, directory, func=None): 139 | """ 140 | Add data sources from a directory 141 | directory 142 | directory in which the results are stored 143 | func 144 | function to be applied after the file is read 145 | Note 146 | ---- 147 | This is a helper function to add all portfolio 148 | results in a directory. 149 | 1) All files are expected to be in csv format 150 | 2) All files should have date and pnl columns 151 | 3) Each file is added as a data source with 152 | the filename considered the name 153 | 4) Files are not considered case sensitive. 154 | So, if you have 2 files result.csv and RESULT.csv 155 | they are considered the same and the data is overwritten 156 | 5) Except csv files, all files in the directory are discarded 157 | """ 158 | for root, direc, files in os.walk(directory): 159 | for f in files: 160 | if f.endswith(".csv"): 161 | name = f.split(".")[0] 162 | path = os.path.join(root, f) 163 | tmp = pd.read_csv(path) 164 | if func is not None: 165 | tmp = func(tmp) 166 | self.add_source(name=name, data=tmp) 167 | 168 | def get_column(self, column="pnl", on="date", how="outer"): 169 | """ 170 | Get a single column from all the dataframes and merge 171 | them into a single dataframe 172 | """ 173 | names = self._sources.keys() 174 | # Rename columns for better reporting 175 | collect = [] 176 | for name in names: 177 | src = self._sources.get(name) 178 | if src is not None: 179 | cols = ["date", column] 180 | tmp = src[cols].rename(columns={column: name}) 181 | collect.append(tmp) 182 | if len(collect) > 0: 183 | frame = recursive_merge(collect, on=["date"], how="outer").fillna(0) 184 | return frame 185 | 186 | def apply(self, column="pnl", func=None): 187 | """ 188 | Apply a function to each column in the dataframe 189 | """ 190 | if func is None: 191 | return pd.DataFrame() 192 | names = self._sources.keys() 193 | collect = {} 194 | for name in names: 195 | collect[name] = func(self._sources[name][column] / 1000) 196 | frame = self.get_column(column=column) 197 | frame2 = frame.mean(axis=1) / 1000 198 | collect["all"] = func(frame2) 199 | return collect 200 | -------------------------------------------------------------------------------- /src/fastbt/models/breakout.py: -------------------------------------------------------------------------------- 1 | import random 2 | from collections import defaultdict 3 | from typing import Optional, List, Dict 4 | from fastbt.models.base import BaseSystem 5 | from fastbt.utils import tick 6 | from pydantic import BaseModel 7 | from copy import deepcopy 8 | 9 | 10 | class StockData(BaseModel): 11 | name: str 12 | token: Optional[int] 13 | can_trade: bool = True 14 | positions: int = 0 15 | ltp: float = 0 16 | day_high: float = -1 # Impossible value for initialization 17 | day_low: float = 1e10 # Almost impossible value for initialization 18 | order_id: Optional[str] 19 | stop_id: Optional[str] 20 | stop_loss: Optional[float] 21 | high: Optional[float] 22 | low: Optional[float] 23 | 24 | 25 | class HighLow(BaseModel): 26 | symbol: str 27 | high: float 28 | low: float 29 | 30 | 31 | class Breakout(BaseSystem): 32 | """ 33 | A simple breakout system 34 | Trades are taken when the given high or low is broke 35 | """ 36 | 37 | def __init__( 38 | self, symbols: List[str], instrument_map: Dict[str, int] = {}, **kwargs 39 | ) -> None: 40 | """ 41 | Initialize the strategy 42 | symbols 43 | list of symbols 44 | instrument_map 45 | dictionary mapping symbols to scrip code 46 | kwargs 47 | list of keyword arguments that could be passed to the 48 | strategy in addition to those inherited from base system 49 | """ 50 | super(Breakout, self).__init__(**kwargs) 51 | self._data = defaultdict(StockData) 52 | self._instrument_map = instrument_map 53 | self._rev_map = {v: k for k, v in instrument_map.items() if v is not None} 54 | for symbol in symbols: 55 | self._data[symbol] = StockData( 56 | name=symbol, token=instrument_map.get(symbol) 57 | ) 58 | 59 | def update_high_low(self, high_low: List[HighLow]) -> None: 60 | """ 61 | Update the high and low values for breakout 62 | These values are used for calculating breakouts 63 | """ 64 | for hl in high_low: 65 | if type(hl) == dict: 66 | hl = HighLow(**hl) 67 | print(hl, hl.symbol) 68 | d = self._data.get(hl.symbol) 69 | if d: 70 | d.high = hl.high 71 | d.low = hl.low 72 | 73 | def stop_loss( 74 | self, symbol: str, side: str, method: str = "auto", stop: float = 0 75 | ) -> float: 76 | """ 77 | Get the stop loss for the symbol 78 | symbol 79 | name of the symbol 80 | side 81 | BUY/SELL 82 | method 83 | stop loss calculation method 84 | Note 85 | ---- 86 | 1) returns 0 if the method if the symbol is not found 87 | 2) sl method reverts to auto in case of unknown string 88 | """ 89 | 90 | def sl(): 91 | # Internal function to calculate stop 92 | if side == "BUY": 93 | return float(self.data[symbol].low) 94 | elif side == "SELL": 95 | return float(self.data[symbol].high) 96 | else: 97 | return 0.0 98 | 99 | # TO DO: A better way to implement stop functions 100 | d = self.data.get(symbol) 101 | if d: 102 | ltp = d.ltp 103 | if method == "value": 104 | return self.stop_loss_by_value(price=ltp, side=side, stop=stop) 105 | elif method == "percent": 106 | return self.stop_loss_by_percentage(price=ltp, side=side, stop=stop) 107 | else: 108 | return sl() 109 | else: 110 | return 0.0 111 | 112 | def fetch(self, data: List[Dict]) -> None: 113 | """ 114 | Update data 115 | """ 116 | # Using get since we may me missing tokens 117 | for d in data: 118 | token = d.get("instrument_token") 119 | ltp = d.get("last_price") 120 | if token and ltp: 121 | symbol = self._rev_map.get(token) 122 | if symbol: 123 | self._data[symbol].ltp = ltp 124 | 125 | def order(self, symbol: str, side: str, **kwargs): 126 | order_id = stop_id = None 127 | v = self.data[symbol] 128 | price = tick(v.ltp) 129 | stop = tick(self.stop_loss(symbol=symbol, side=side, stop=3, method="percent")) 130 | quantity = self.get_quantity(price=price, stop=stop) 131 | v.can_trade = False 132 | if side == "SELL": 133 | v.positions = 0 - quantity 134 | else: 135 | v.positions = quantity 136 | side_map = {"BUY": "SELL", "SELL": "BUY"} 137 | # Defaults for live order 138 | defaults = deepcopy(self.ORDER_DEFAULT_KWARGS) 139 | defaults.update( 140 | dict( 141 | symbol=symbol, 142 | order_type="LIMIT", 143 | price=price, 144 | quantity=quantity, 145 | side=side, 146 | ) 147 | ) 148 | defaults.update(kwargs) 149 | if self.env == "live": 150 | order_id = self.broker.order_place(**defaults) 151 | side2 = side_map.get(side) 152 | stop_args = dict(order_type="SL-M", trigger_price=stop, side=side2) 153 | defaults.update(stop_args) 154 | stop_id = self.broker.order_place(**defaults) 155 | else: 156 | order_id = random.randint(100000, 999999) 157 | stop_id = random.randint(100000, 999999) 158 | v.order_id = order_id 159 | v.stop_id = stop_id 160 | return (order_id, stop_id) 161 | 162 | def entry(self): 163 | """ 164 | Positions entry 165 | An order is entered if the ltp is greater than high or low 166 | subject to the constraints and conditions 167 | Override this method for your own entry logic 168 | """ 169 | if self.open_positions >= self.MAX_POSITIONS: 170 | return 171 | for k, v in self.data.items(): 172 | # The instrument can be traded and should have no 173 | # open positions and ltp should be updated 174 | if (v.can_trade) and (v.ltp > 0): 175 | if v.positions == 0: 176 | if v.ltp > v.high: 177 | # Place a BUY order 178 | print("BUY", k, v.ltp, v.high, v.low) 179 | self.order(symbol=k, side="BUY") 180 | elif v.ltp < v.low: 181 | # Place a SELL order 182 | self.order(symbol=k, side="SELL") 183 | print("SELL", k, v.ltp, v.high, v.low) 184 | 185 | @property 186 | def open_positions(self): 187 | count = 0 188 | for k, v in self.data.items(): 189 | if (v.positions != 0) or not (v.can_trade): 190 | count += 1 191 | return count 192 | -------------------------------------------------------------------------------- /src/fastbt/option_chain.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from scipy.stats import norm 4 | import logging 5 | from typing import List, Dict 6 | 7 | # Set up logging 8 | logging.basicConfig( 9 | level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" 10 | ) 11 | 12 | 13 | def black_scholes_price( 14 | S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call" 15 | ) -> float: 16 | """Calculate the Black-Scholes option price.""" 17 | d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) 18 | d2 = d1 - sigma * np.sqrt(T) 19 | 20 | if option_type == "call": 21 | price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) 22 | elif option_type == "put": 23 | price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) 24 | 25 | return price 26 | 27 | 28 | def black_scholes_greeks( 29 | S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call" 30 | ) -> Dict[str, float]: 31 | """Calculate the Greeks for the Black-Scholes option price.""" 32 | d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) 33 | d2 = d1 - sigma * np.sqrt(T) 34 | 35 | delta = norm.cdf(d1) if option_type == "call" else norm.cdf(d1) - 1 36 | gamma = norm.pdf(d1) / (S * sigma * np.sqrt(T)) 37 | theta = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T)) - r * K * np.exp( 38 | -r * T 39 | ) * norm.cdf(d2 if option_type == "call" else -d2) 40 | vega = S * norm.pdf(d1) * np.sqrt(T) 41 | rho = K * T * np.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2) 42 | 43 | return {"delta": delta, "gamma": gamma, "theta": theta, "vega": vega, "rho": rho} 44 | 45 | 46 | def add_noise(price: float, noise_level: float = 0.02) -> float: 47 | """Add random noise to simulate real-time prices.""" 48 | noise = np.random.normal(0, noise_level, size=price.shape) 49 | return price * (1 + noise) 50 | 51 | 52 | def generate_option_chain( 53 | S: float, 54 | K_range: List[float], 55 | T: float, 56 | r: float, 57 | base_sigma: float, 58 | smile_params: Dict[str, float], 59 | noise_level: float = 0.02, 60 | generate_greeks: bool = True, 61 | ) -> pd.DataFrame: 62 | """Generate an option chain with simulated prices and optionally Greeks.""" 63 | K = np.array(K_range) 64 | 65 | # Adjust the volatility based on the strike price to create a volatility smile 66 | sigma = base_sigma * (1 + smile_params["a"] * np.abs(K - S) / S) 67 | 68 | d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) 69 | d2 = d1 - sigma * np.sqrt(T) 70 | 71 | # Calculate option prices 72 | call_prices = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) 73 | put_prices = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) 74 | 75 | # Add noise to the prices to simulate real-time prices 76 | call_prices = add_noise(call_prices, noise_level) 77 | put_prices = add_noise(put_prices, noise_level) 78 | 79 | option_chain = { 80 | "Strike": K, 81 | "Call Price": call_prices, 82 | "Put Price": put_prices, 83 | "Volatility": sigma, # Include the volatility for reference 84 | } 85 | 86 | if generate_greeks: 87 | # Calculate Greeks 88 | call_delta = norm.cdf(d1) 89 | put_delta = norm.cdf(d1) - 1 90 | gamma = norm.pdf(d1) / (S * sigma * np.sqrt(T)) 91 | call_theta = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T)) - r * K * np.exp( 92 | -r * T 93 | ) * norm.cdf(d2) 94 | put_theta = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T)) - r * K * np.exp( 95 | -r * T 96 | ) * norm.cdf(-d2) 97 | vega = S * norm.pdf(d1) * np.sqrt(T) 98 | call_rho = K * T * np.exp(-r * T) * norm.cdf(d2) 99 | put_rho = -K * T * np.exp(-r * T) * norm.cdf(-d2) 100 | 101 | option_chain.update( 102 | { 103 | "Call Delta": call_delta, 104 | "Put Delta": put_delta, 105 | "Gamma": gamma, 106 | "Call Theta": call_theta, 107 | "Put Theta": put_theta, 108 | "Vega": vega, 109 | "Call Rho": call_rho, 110 | "Put Rho": put_rho, 111 | } 112 | ) 113 | 114 | return pd.DataFrame(option_chain) 115 | 116 | 117 | def generate_option_chains_for_expiries( 118 | S: float, 119 | K_range: List[float], 120 | expiries: List[float], 121 | r: float, 122 | base_sigma: float, 123 | smile_params: Dict[str, float], 124 | noise_level: float = 0.02, 125 | generate_greeks: bool = True, 126 | ) -> Dict[str, pd.DataFrame]: 127 | """Generate option chains for multiple expiries.""" 128 | all_option_chains = {} 129 | 130 | for T in expiries: 131 | option_chain = generate_option_chain( 132 | S, K_range, T, r, base_sigma, smile_params, noise_level, generate_greeks 133 | ) 134 | all_option_chains[f"Expiry {T} years"] = option_chain 135 | 136 | return all_option_chains 137 | 138 | 139 | def validate_parameters( 140 | S: float, 141 | K_range: List[float], 142 | expiries: List[float], 143 | r: float, 144 | base_sigma: float, 145 | smile_params: Dict[str, float], 146 | noise_level: float, 147 | ) -> None: 148 | """Validate the input parameters.""" 149 | if not all(T > 0 for T in expiries): 150 | raise ValueError("All expiries must be positive.") 151 | if not (0 <= noise_level <= 1): 152 | raise ValueError("Noise level must be between 0 and 1.") 153 | if base_sigma <= 0: 154 | raise ValueError("Base volatility must be positive.") 155 | if r < 0: 156 | raise ValueError("Risk-free rate cannot be negative.") 157 | if not isinstance(smile_params, dict) or "a" not in smile_params: 158 | raise ValueError("Smile parameters must be a dictionary with key 'a'.") 159 | logging.info("Parameters validated successfully.") 160 | -------------------------------------------------------------------------------- /src/fastbt/options/backtest.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from typing import List, Tuple 3 | 4 | 5 | class OptionsBacktest: 6 | """ 7 | Backtesting a options strategy 8 | """ 9 | 10 | def __init__(self, data: pd.DataFrame, start="9:30", end="15:15", tradebook=None): 11 | """ 12 | options 13 | options data 14 | """ 15 | self.data = data.copy() 16 | self.data["timestamp"] = self.data.timestamp + pd.Timedelta(seconds=1) 17 | self.start = start 18 | self.end = end 19 | if tradebook: 20 | print(tradebook) 21 | self.tradebook = tradebook 22 | else: 23 | self.tradebook = lambda x: x 24 | 25 | def generate_options_table( 26 | self, contracts: List[Tuple[str, str, int]] 27 | ) -> pd.DataFrame: 28 | """ 29 | Given a list of contracts, generate the options table 30 | contracts 31 | list of 3-tuples in the form ('BUY', 'CALL', 0) 32 | """ 33 | prices = ( 34 | self.data.set_index("timestamp").between_time(self.start, self.start).spot 35 | ) 36 | option_strikes = [] 37 | for k, v in prices.items(): 38 | strike = (int(v / 100) * 100) + 100 39 | 40 | def opt_type(x): 41 | return "CE" if x == "CALL" else "PE" 42 | 43 | def sign(x): 44 | return 1 if x == "BUY" else -1 45 | 46 | for ctx in contracts: 47 | side, opt, strk = ctx 48 | tup = (k.date(), strike + (strk * 100), opt_type(opt), sign(side)) 49 | option_strikes.append(tup) 50 | columns = ["date", "strike", "opt", "side"] 51 | option_strikes = pd.DataFrame(option_strikes, columns=columns) 52 | option_strikes["date"] = pd.to_datetime(option_strikes.date.values) 53 | return option_strikes 54 | 55 | def get_result(self, data): 56 | res = pd.DataFrame.from_records( 57 | data, 58 | columns=["side", "entry_time", "entry_price", "exit_time", "exit_price"], 59 | index=data.index, 60 | ) 61 | res["hour"] = res.exit_time.dt.hour 62 | res["year"] = res.exit_time.dt.year 63 | res["wkday"] = res.exit_time.dt.weekday 64 | res["profit"] = res.eval("(exit_price-entry_price)*side") 65 | return res.reset_index() 66 | 67 | def run(self, contracts): 68 | """ 69 | Run the option with the given data 70 | """ 71 | 72 | def f(x, stop=100): 73 | tb = self.tradebook( 74 | x.open.values, 75 | high=x.high.values, 76 | low=x.low.values, 77 | close=x.close.values, 78 | timestamp=x.timestamp.values, 79 | order=x.side.values[0], 80 | stop=stop, 81 | ) 82 | return tb 83 | 84 | option_strikes = self.generate_options_table(contracts) 85 | run_data = ( 86 | self.data.set_index("timestamp") 87 | .between_time(self.start, self.end) 88 | .reset_index() 89 | ) 90 | run_data = run_data.merge(option_strikes, on=["date", "strike", "opt"]) 91 | result = run_data.groupby(["date", "ticker"]).apply(f, stop=25) 92 | result = self.get_result(result) 93 | return result 94 | -------------------------------------------------------------------------------- /src/fastbt/options/store.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | import pendulum 3 | 4 | 5 | def generic_parser(name: str) -> Tuple[str, float, pendulum.Date, str]: 6 | """ 7 | A simple generic options parser 8 | """ 9 | split = name.split("|") 10 | split[1] = float(split[1]) 11 | date = split[2] 12 | print(date[:4], date[5:7], date[8:]) 13 | split[2] = pendulum.date( 14 | year=int(date[:4]), month=int(date[5:7]), day=int(date[8:]) 15 | ) 16 | print(split[2]) 17 | return tuple(split) 18 | -------------------------------------------------------------------------------- /src/fastbt/options/utils.py: -------------------------------------------------------------------------------- 1 | import fastbt.utils as utils 2 | import pendulum 3 | from typing import List, Optional 4 | 5 | get_itm = utils.get_itm 6 | get_atm = utils.get_atm 7 | 8 | 9 | def get_expiry( 10 | expiries: List[pendulum.DateTime], n: int = 1, sort: bool = True 11 | ) -> pendulum.DateTime: 12 | """ 13 | get the nth expiry from the list of expiries 14 | **1 is the current expiry** 15 | expiries 16 | list of sorted expiries 17 | n 18 | this is just a simple python list index 19 | sorted 20 | if True, the list is sorted and then values are returned. 21 | If you already have a sorted list, you can pass False to save on time 22 | Note 23 | ---- 24 | 1) expiries start at 1 (not 0) 25 | """ 26 | n = n if n < 1 else n - 1 27 | print("int0") 28 | if sort: 29 | return sorted(expiries)[n] 30 | else: 31 | return expiries[n] 32 | 33 | 34 | def get_monthly_expiry( 35 | expiries: List[pendulum.DateTime], n: int = 1, sort: bool = True 36 | ) -> Optional[pendulum.DateTime]: 37 | """ 38 | get the nth monthly expiry from the list of expiries 39 | returns the last expiry in the month 40 | expiries start at 1 (not 0) 41 | expiries 42 | list of sorted expiries 43 | n 44 | this is just a simple python list index 45 | sorted 46 | if True, the list is sorted and then values are returned. 47 | Note 48 | ---- 49 | 1) returns None if the expiry cannot be found 50 | """ 51 | if len(expiries) == 1: 52 | return expiries[0] 53 | if sort: 54 | expiries = sorted(expiries) 55 | i = 1 56 | prev = expiries[0] 57 | for prev, date in zip(expiries[:-1], expiries[1:]): 58 | if prev.month != date.month: 59 | i += 1 60 | if i > n: 61 | return prev 62 | return date 63 | 64 | 65 | def get_yearly_expiry( 66 | expiries: List[pendulum.DateTime], n: int = 1, sort: bool = True 67 | ) -> Optional[pendulum.DateTime]: 68 | """ 69 | get the nth yearly expiry from the list of expiries 70 | returns the last expiry in the year 71 | expiries start at 1 (not 0) 72 | expiries 73 | list of sorted expiries 74 | n 75 | number of expiry to return 76 | sorted 77 | if True, the list is sorted and then values are returned. 78 | """ 79 | if len(expiries) == 1: 80 | return expiries[0] 81 | if sort: 82 | expiries = sorted(expiries) 83 | i = 1 84 | prev = expiries[0] 85 | for prev, date in zip(expiries[:-1], expiries[1:]): 86 | if prev.year != date.year: 87 | i += 1 88 | if i > n: 89 | return prev 90 | return date 91 | 92 | 93 | def get_expiry_by( 94 | expiries: List[pendulum.DateTime], 95 | year: int = 0, 96 | month: int = 0, 97 | n: int = 1, 98 | sort: bool = True, 99 | ) -> pendulum.DateTime: 100 | """ 101 | get the nth expiry by year and month 102 | **1 is the current expiry** 103 | expiries 104 | list of sorted expiries 105 | year 106 | year to filter, if 0 all years are taken 107 | month 108 | month to filter, if 0 all months are taken 109 | n 110 | number of expiry to return in the above filter 111 | sort 112 | if True, the list is sorted and then values are returned. 113 | 114 | Note 115 | ----- 116 | 1) If month and year are both zero, the nth expiry is returned 117 | """ 118 | if len(expiries) == 1: 119 | return expiries[0] 120 | if sort: 121 | expiries = sorted(expiries) 122 | 123 | if (month == 0) and (year == 0): 124 | return get_expiry(expiries, n=n) 125 | elif month == 0: 126 | filtered = [expiry for expiry in expiries if expiry.year == year] 127 | elif year == 0: 128 | filtered = [expiry for expiry in expiries if expiry.month == month] 129 | else: 130 | filtered = [ 131 | expiry 132 | for expiry in expiries 133 | if (expiry.year == year and expiry.month == month) 134 | ] 135 | n = n if n < 1 else n - 1 136 | return filtered[n] 137 | 138 | 139 | def get_expiry_by_days( 140 | expiries: List[pendulum.DateTime], days: int, sort: bool = True 141 | ) -> Optional[pendulum.DateTime]: 142 | """ 143 | Get the nearest expiry from current date till the given number of days 144 | expiries 145 | list of expiries 146 | days 147 | number of days to hold the option 148 | sort 149 | if True, the list is sorted and then expiry is calculated 150 | Note 151 | ---- 152 | 1) returns the nearest matching expiry exceeding the given number of days 153 | 2) if the last expiry is less than the given days, no value is returned 154 | """ 155 | if len(expiries) == 1: 156 | return expiries[0] 157 | if sort: 158 | expiries = sorted(expiries) 159 | today = pendulum.today(tz="local").date() 160 | target_date = today.add(days=days) 161 | if expiries[-1] < target_date: 162 | # return None if the target date is greater than last expiry 163 | return None 164 | for i, expiry in enumerate(expiries): 165 | if expiry >= target_date: 166 | return expiry 167 | return expiry 168 | -------------------------------------------------------------------------------- /src/fastbt/plotting.py: -------------------------------------------------------------------------------- 1 | """ 2 | A plotting module for easy plotting of financial charts 3 | """ 4 | from bokeh.plotting import figure 5 | from bokeh.models import ColumnDataSource 6 | import pandas as pd 7 | from math import pi 8 | 9 | 10 | def candlestick_plot(data, title="Candlestick", interval="5min"): 11 | """ 12 | return a bokeh candlestick plot 13 | data 14 | dataframe with open,high,low and close columns 15 | Note 16 | ----- 17 | Prototype copied from the below link 18 | https://bokeh.pydata.org/en/latest/docs/gallery/candlestick.html 19 | """ 20 | df = data.copy() 21 | df["date"] = pd.to_datetime(df["timestamp"]) 22 | df["date"] = df["date"] 23 | df["color"] = ["green" if x > y else "red" for (x, y) in zip(df.close, df.open)] 24 | source = ColumnDataSource() 25 | source.data = source.from_df(df) 26 | spacing = int(interval[:-3]) * 0.3 27 | w = spacing * 60 * 1000 # half day in ms 28 | TOOLS = "pan,wheel_zoom,box_zoom,reset,save" 29 | p = figure( 30 | x_axis_type="datetime", 31 | tools=TOOLS, 32 | title=title, 33 | plot_width=720, 34 | tooltips=[ 35 | ("date", "@date{%F %H:%M}"), 36 | ("open", "@open{0.00}"), 37 | ("high", "@high{0.00}"), 38 | ("low", "@low{0.00}"), 39 | ("close", "@close{0.00}"), 40 | ], 41 | ) 42 | p.hover.formatters = {"@date": "datetime"} 43 | p.xaxis.major_label_orientation = pi / 4 44 | p.grid.grid_line_alpha = 0.3 45 | p.segment("date", "high", "date", "low", color="black", source=source) 46 | p.vbar( 47 | "date", 48 | w, 49 | "open", 50 | "close", 51 | fill_color="color", 52 | line_color="black", 53 | source=source, 54 | ) 55 | return p 56 | -------------------------------------------------------------------------------- /src/fastbt/static/script.js: -------------------------------------------------------------------------------- 1 | console.log("Hello world"); 2 | /* 3 | Interface for python backtest 4 | Columns are converted into their respective counterparts 5 | in DataSource 6 | */ 7 | var app = new Vue({ 8 | el: "#app", 9 | data: { 10 | columns_list: ["open", "high", "low", "close", "volume"], 11 | function_list: [ 12 | "count", 13 | "sum", 14 | "mean", 15 | "max", 16 | "min", 17 | "var", 18 | "std", 19 | "zscore" 20 | ], 21 | operators: ["+", "-", "*", "/", "(", ")", ">", "<", "==", 22 | ">=", "<=", "<>" 23 | ], 24 | indicator_list: ["SMA", "EMA", 'WMA', 'DEMA', 'TEMA', 'TRIMA', 25 | 'MOM', 'ADX', 'ATR', 'CCI', 'WILLR', 'RSI', 'CCI', 26 | ], 27 | col_type: "lag", 28 | col_name: null, 29 | col_on: "close", 30 | columns: [], 31 | conditions: [], 32 | // column definitions 33 | period: 1, 34 | formula: null, 35 | lag: null, 36 | indicator: null, 37 | func: "mean", 38 | condition: '', 39 | // Status 40 | isLag: true, 41 | isPercentChange: false, 42 | isIndicator: false, 43 | isFormula: false, 44 | isRolling: false, 45 | mapper: { 46 | lag: "L", 47 | percent_change: "P", 48 | formula: "F", 49 | indicator: "I", 50 | rolling: "R" 51 | } 52 | }, 53 | methods: { 54 | setStatus(text) { 55 | /* 56 | Set the status of the given text to true 57 | and the others to false since only one 58 | of them could be active at a time 59 | */ 60 | this.isLag = false; 61 | this.isPercentChange = false; 62 | this.isFormula = false; 63 | this.isRolling = false; 64 | this.isIndicator = false; 65 | switch (text) { 66 | case "lag": 67 | this.isLag = true; 68 | break; 69 | case "percent_change": 70 | this.isPercentChange = true; 71 | break; 72 | case "formula": 73 | this.isFormula = true; 74 | break; 75 | case "rolling": 76 | this.isRolling = true; 77 | break; 78 | case "indicator": 79 | this.isIndicator = true; 80 | break; 81 | } 82 | }, 83 | clear() { 84 | // clear preset values from inputs 85 | this.formula = null; 86 | this.col_name = null; 87 | this.indicator = null; 88 | this.period = null; 89 | }, 90 | evalLag() { 91 | // evaluate Lag parameter 92 | if (this.col_name == null) { 93 | this.col_name = "auto"; 94 | } else { 95 | this.columns_list.push(this.col_name) 96 | } 97 | if (this.period == null) { 98 | return false; 99 | } 100 | return { 101 | L: { 102 | period: this.period, 103 | col_name: this.col_name, 104 | on: this.col_on 105 | } 106 | }; 107 | }, 108 | evalPercentChange() { 109 | // evaluate percentage change 110 | if (this.col_name == null) { 111 | this.col_name = "auto"; 112 | } else { 113 | this.columns_list.push(this.col_name) 114 | } 115 | if (this.period == null || this.lag == 0 || this.period == 0) { 116 | return false; 117 | } 118 | let P = { 119 | P: { 120 | on: this.col_on, 121 | period: this.period, 122 | col_name: this.col_name 123 | } 124 | }; 125 | // TO DO: Bug to fix for negative lag values 126 | if (this.lag == true) { 127 | P.P.lag = this.lag; 128 | } 129 | return P; 130 | }, 131 | evalRolling() { 132 | // evaluate Rolling Window function 133 | if (this.col_name == null) { 134 | this.col_name = "auto"; 135 | } else { 136 | this.columns_list.push(this.col_name) 137 | } 138 | if (this.period == null || this.lag == 0 || this.period == 0) { 139 | return false; 140 | } 141 | let R = { 142 | R: { 143 | on: this.col_on, 144 | window: this.period, 145 | col_name: this.col_name, 146 | function: this.func 147 | } 148 | }; 149 | // TO DO: Bug to fix for negative lag values 150 | if (this.lag == true) { 151 | R.R.lag = this.lag; 152 | } 153 | return R; 154 | }, 155 | evalFormula() { 156 | // evaluate formula 157 | if (this.col_name == null) { 158 | return false; 159 | } else { 160 | this.columns_list.push(this.col_name) 161 | } 162 | if (this.formula == null) { 163 | return false; 164 | } 165 | return { 166 | F: { 167 | formula: this.formula, 168 | col_name: this.col_name 169 | } 170 | }; 171 | }, 172 | evalIndicator() { 173 | // evaluate Indicator 174 | if (this.col_name == null) { 175 | this.col_name = 'auto' 176 | } else { 177 | this.columns_list.push(this.col_name) 178 | } 179 | if (this.period == null || this.lag == 0 || this.period == 0) { 180 | return false; 181 | } 182 | if (this.indicator == null) { 183 | return false; 184 | } 185 | let I = { 186 | I: { 187 | indicator: this.indicator, 188 | period: this.period, 189 | col_name: this.col_name 190 | } 191 | }; 192 | // TO DO: Bug to fix for negative lag values 193 | if (this.lag == true) { 194 | I.I.lag = this.lag; 195 | } 196 | return I; 197 | }, 198 | addColumn(text) { 199 | let col_type = this.mapper[text]; 200 | let val = null; 201 | if (col_type == "L") { 202 | val = this.evalLag(); 203 | } else if (col_type == "P") { 204 | val = this.evalPercentChange(); 205 | } else if (col_type == "F") { 206 | val = this.evalFormula(); 207 | } else if (col_type == "R") { 208 | val = this.evalRolling(); 209 | } else if (col_type == "I") { 210 | val = this.evalIndicator() 211 | } 212 | if (val) { 213 | console.log(val); 214 | this.columns.push(val); 215 | } 216 | this.clear(); 217 | }, 218 | extendCondition(text) { 219 | this.condition = this.condition + text 220 | }, 221 | addCondition() { 222 | this.conditions.push(this.condition) 223 | this.condition = '' 224 | } 225 | } 226 | }); 227 | -------------------------------------------------------------------------------- /src/fastbt/templates/backtest.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

Backtest parameters

14 |
15 |
16 |
17 |
18 |
19 | Settings 20 |
21 | 22 |
23 | Add columns 24 |
25 | You can add use the list of columns added in the 26 | side bar or type your own 27 |
28 | 33 | 36 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | 50 | 51 | 52 |
53 | 54 |
55 | Add conditions 56 | 57 | 58 | 59 | 62 | 63 | 64 |
65 | 66 |
67 | Sort 68 | 69 |
70 |
71 | The hidden output 72 | 75 | 76 |
77 | 78 | 79 | 80 |
81 |
82 |
83 |
84 | 85 |
86 |
87 |
89 |
90 | 91 |
92 |
93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/fastbt/templates/datastore.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Data Loader 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 |
21 | Data Loader class 22 | <% for ctrl in controls %> 23 | 24 | <% if ctrl.input %> 25 | 26 | <% endif %> 27 | <% if ctrl.select %> 28 | 33 | <% endif %> 34 | <% endfor %> 35 |
36 | 37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 |
47 |
48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/fastbt/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello [[name]] 8 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 | 23 |
24 |
25 | General 26 |
27 |
28 | 29 |
30 |
31 |
32 |

33 | 34 |

35 |
36 |
37 | 38 |
39 | 40 |
41 |
42 |
43 |

44 | 45 |

46 |
47 |
48 | 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 |
57 |

58 | 59 |

60 |
61 |
62 | 63 |
64 | 65 |
66 |
67 |
68 |

69 | 70 |

71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |

83 | 84 |

85 |
86 |
87 | 88 |
89 | 90 |
91 |
92 |
93 |

94 | 95 |

96 |
97 |
98 | 99 |
100 | 101 | 102 |
103 | 104 |
105 | Add columns 106 | 107 |
108 | 113 |
114 |
115 |
116 | 117 |
118 |
119 | 120 | 121 | 122 |
123 | 124 | 125 |
126 | 127 |
128 | 129 |
130 |
131 | 132 | {{item}} 133 |
134 | 135 |
136 |
137 |
138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/fastbt/tradebook.py: -------------------------------------------------------------------------------- 1 | from collections import Counter, defaultdict 2 | from typing import Dict, List 3 | 4 | 5 | class TradeBook: 6 | """ 7 | TO DO: 8 | Add open, long, short positions 9 | """ 10 | 11 | def __init__(self, name="tradebook"): 12 | self._name = name 13 | self._trades = defaultdict(list) 14 | self._values = Counter() 15 | self._positions = Counter() 16 | 17 | def __repr__(self): 18 | string = "{name} with {count} entries and {pos} positions" 19 | pos = sum([1 for x in self._positions.values() if x != 0]) 20 | string = string.format(name=self._name, count=len(self.all_trades), pos=pos) 21 | return string 22 | 23 | @property 24 | def name(self) -> str: 25 | return self._name 26 | 27 | @property 28 | def trades(self) -> Dict[str, List[Dict]]: 29 | return self._trades 30 | 31 | @property 32 | def all_trades(self) -> List[Dict]: 33 | """ 34 | return all trades as a single list 35 | """ 36 | lst = [] 37 | if self._trades: 38 | for k, v in self._trades.items(): 39 | lst.extend(v) 40 | return lst 41 | 42 | @property 43 | def positions(self) -> Dict[str, int]: 44 | """ 45 | return the positions of all symbols 46 | """ 47 | return self._positions 48 | 49 | @property 50 | def values(self) -> Dict[str, float]: 51 | """ 52 | return the values of all symbols 53 | """ 54 | return self._values 55 | 56 | @property 57 | def o(self) -> int: 58 | """ 59 | return the count of open positions in the tradebook 60 | """ 61 | return sum([1 for pos in self.positions.values() if pos != 0]) 62 | 63 | @property 64 | def l(self) -> int: 65 | """ 66 | return the count of long positions in the tradebook 67 | """ 68 | return sum([1 for pos in self.positions.values() if pos > 0]) 69 | 70 | @property 71 | def s(self) -> int: 72 | """ 73 | return the count of short positions in the tradebook 74 | """ 75 | return sum([1 for pos in self.positions.values() if pos < 0]) 76 | 77 | def add_trade( 78 | self, 79 | timestamp: str, 80 | symbol: str, 81 | price: float, 82 | qty: float, 83 | order: str, 84 | **kwargs, 85 | ) -> None: 86 | """ 87 | Add a trade to the tradebook 88 | timestamp 89 | python/pandas timestamp 90 | symbol 91 | an unique identifier for the security 92 | price 93 | price of the security 94 | qty 95 | quantity of the security 96 | order 97 | B/BUY for B and S/SELL for SELL 98 | kwargs 99 | any other arguments as a dictionary 100 | """ 101 | o = {"B": 1, "S": -1} 102 | order = order.upper()[0] 103 | q = qty * o[order] 104 | dct = { 105 | "ts": timestamp, 106 | "symbol": symbol, 107 | "price": price, 108 | "qty": q, 109 | "order": order, 110 | } 111 | dct.update(kwargs) 112 | self._trades[symbol].append(dct) 113 | self._positions.update({symbol: q}) 114 | value = q * price * -1 115 | self._values.update({symbol: value}) 116 | 117 | def clear(self) -> None: 118 | """ 119 | clear all existing entries 120 | """ 121 | self._trades = defaultdict(list) 122 | self._values = Counter() 123 | self._positions = Counter() 124 | self._trades = defaultdict(list) 125 | 126 | def remove_trade(self, symbol: str): 127 | """ 128 | Remove the last trade for the given symbol 129 | and adjust the positions and values 130 | """ 131 | trades = self._trades.get(symbol) 132 | if trades: 133 | if len(trades) > 0: 134 | trade = trades.pop() 135 | q = trade["qty"] * -1 136 | value = q * trade["price"] * -1 137 | self._positions.update({symbol: q}) 138 | self._values.update({symbol: value}) 139 | 140 | def mtm(self, prices: Dict[str, float]) -> Dict[str, float]: 141 | """ 142 | Calculate the mtm for the given positions given 143 | the current prices 144 | price 145 | current prices of the symbols 146 | """ 147 | values: Dict = Counter() 148 | for k, v in self.positions.items(): 149 | if abs(v) > 0: 150 | ltp = prices.get(k) 151 | if ltp is None: 152 | raise ValueError(f"{k} not given in prices") 153 | else: 154 | values[k] = v * ltp 155 | values.update(self.values) 156 | return values 157 | 158 | @property 159 | def open_positions(self) -> Dict[str, float]: 160 | """ 161 | return the list of open positions 162 | """ 163 | return {k: v for k, v in self.positions.items() if abs(v) > 0} 164 | 165 | @property 166 | def long_positions(self) -> Dict[str, float]: 167 | """ 168 | return the list of open positions 169 | """ 170 | return {k: v for k, v in self.positions.items() if v > 0} 171 | 172 | @property 173 | def short_positions(self) -> Dict[str, float]: 174 | """ 175 | return the list of open positions 176 | """ 177 | return {k: v for k, v in self.positions.items() if v < 0} 178 | -------------------------------------------------------------------------------- /src/fastbt/urlpatterns.py: -------------------------------------------------------------------------------- 1 | file_patterns = { 2 | "bhav": ( 3 | "https://archives.nseindia.com/content/historical/EQUITIES/{year}/{month}/cm{date}bhav.csv.zip", 4 | lambda x: { 5 | "year": x.year, 6 | "month": x.strftime("%b").upper(), 7 | "date": x.strftime("%d%b%Y").upper(), 8 | }, 9 | ), 10 | "sec_del": ( 11 | "https://archives.nseindia.com/archives/equities/mto/MTO_{date}.DAT", 12 | lambda x: {"date": x.strftime("%d%m%Y")}, 13 | ), 14 | "bhav_pr": ( 15 | "https://archives.nseindia.com/archives/equities/bhavcopy/pr/PR{date}.zip", 16 | lambda x: {"date": x.strftime("%d%m%y")}, 17 | ), 18 | "old_derivatives": ( 19 | "https://archives.nseindia.com/content/historical/DERIVATIVES/{year}/{month}/fo{date}bhav.csv.zip", 20 | lambda x: { 21 | "year": x.year, 22 | "month": x.strftime("%b").upper(), 23 | "date": x.strftime("%d%b%Y").upper(), 24 | }, 25 | ), 26 | "derivatives": ( 27 | "https://nsearchives.nseindia.com/content/fo/BhavCopy_NSE_FO_0_0_0_{date}_F_0000.csv.zip", 28 | lambda x: { 29 | "date": x.strftime("%Y%m%d"), 30 | }, 31 | ), 32 | "combineoi_deleq": ( 33 | "https://nsearchives.nseindia.com/archives/nsccl/mwpl/combineoi_deleq_{date}.csv", 34 | lambda x: { 35 | "date": x.strftime("%d%m%Y"), 36 | }, 37 | ), 38 | "bhav_sec": ( 39 | "https://archives.nseindia.com/products/content/sec_bhavdata_full_{date}.csv", 40 | lambda x: {"date": x.strftime("%d%m%Y")}, 41 | ), 42 | "indices": ( 43 | "https://archives.nseindia.com/content/indices/ind_close_all_{date}.csv", 44 | lambda x: {"date": x.strftime("%d%m%Y")}, 45 | ), 46 | "top10marketcap": ( 47 | "https://archives.nseindia.com/content/indices/top10nifty50_{date}.csv", 48 | lambda x: {"date": x.strftime("%d%m%y")}, 49 | ), 50 | "fii_stats": ( 51 | "https://archives.nseindia.com/content/fo/fii_stats_{date}.xls", 52 | lambda x: {"date": x.strftime("%d-%b-%Y")}, 53 | ), 54 | "fno_participant": ( 55 | "https://archives.nseindia.com/content/nsccl/fao_participant_vol_{date}.csv", 56 | lambda x: {"date": x.strftime("%d%m%Y")}, 57 | ), 58 | "fno_category": ( 59 | "https://archives.nseindia.com/archives/fo/cat/fo_cat_turnover_{date}.xls", 60 | lambda x: {"date": x.strftime("%d%m%y")}, 61 | ), 62 | "fno_oi_participant": ( 63 | "https://archives.nseindia.com/content/nsccl/fao_participant_oi_{date}.csv", 64 | lambda x: {"date": x.strftime("%d%m%Y")}, 65 | ), 66 | "equity_info": ( 67 | "https://www.nseindia.com/api/quote-equity?symbol={symbol}", 68 | lambda x: {"symbol": x.upper()}, 69 | ), 70 | "trade_info": ( 71 | "https://www.nseindia.com/api/quote-equity?symbol={symbol}§ion=trade_info", 72 | lambda x: {"symbol": x.upper()}, 73 | ), 74 | "ipo_eq": ( 75 | "https://www.nseindia.com/api/ipo-detail?symbol={symbol}&series=EQ", 76 | lambda x: {"symbol": x.upper()}, 77 | ), 78 | "hist_data": ( 79 | "https://www.nseindia.com/api/historical/cm/equity?symbol={symbol}", 80 | lambda x: {"symbol": x.upper()}, 81 | ), 82 | "nse_oi": ( 83 | "https://archives.nseindia.com/archives/nsccl/mwpl/nseoi_{date}.zip", 84 | lambda x: {"date": x.strftime("%d%m%Y")}, 85 | ), 86 | "oi_cli_limit": ( 87 | "https://archives.nseindia.com/content/nsccl/oi_cli_limit_{date}.lst", 88 | lambda x: {"date": x.strftime("%d-%b-%Y").upper()}, 89 | ), 90 | "fo": ( 91 | "https://archives.nseindia.com/archives/fo/mkt/fo{date}.zip", 92 | lambda x: {"date": x.strftime("%d%m%Y")}, 93 | ), 94 | "fo_volt": ( 95 | "https://archives.nseindia.com/archives/nsccl/volt/FOVOLT_{date}.csv", 96 | lambda x: {"date": x.strftime("%d%m%Y")}, 97 | ), 98 | } 99 | -------------------------------------------------------------------------------- /tests/Test.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "10000*(1+8/100)**" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 6, 15 | "metadata": {}, 16 | "outputs": [ 17 | { 18 | "data": { 19 | "text/plain": [ 20 | "21589.24997272788" 21 | ] 22 | }, 23 | "execution_count": 6, 24 | "metadata": {}, 25 | "output_type": "execute_result" 26 | } 27 | ], 28 | "source": [ 29 | "principal = 10000\n", 30 | "rate = 8\n", 31 | "period = 10\n", 32 | "\n", 33 | "principal * ((1 + (rate/100)) ** period)\n" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": 3, 39 | "metadata": {}, 40 | "outputs": [ 41 | { 42 | "data": { 43 | "text/plain": [ 44 | "18000.0" 45 | ] 46 | }, 47 | "execution_count": 3, 48 | "metadata": {}, 49 | "output_type": "execute_result" 50 | } 51 | ], 52 | "source": [ 53 | "10000 + (10000*8*10)/100" 54 | ] 55 | } 56 | ], 57 | "metadata": { 58 | "kernelspec": { 59 | "display_name": "Python 3", 60 | "language": "python", 61 | "name": "python3" 62 | }, 63 | "language_info": { 64 | "codemirror_mode": { 65 | "name": "ipython", 66 | "version": 3 67 | }, 68 | "file_extension": ".py", 69 | "mimetype": "text/x-python", 70 | "name": "python", 71 | "nbconvert_exporter": "python", 72 | "pygments_lexer": "ipython3", 73 | "version": "3.6.5" 74 | } 75 | }, 76 | "nbformat": 4, 77 | "nbformat_minor": 2 78 | } 79 | -------------------------------------------------------------------------------- /tests/brokers/keys.conf: -------------------------------------------------------------------------------- 1 | [KEYS] 2 | APP_NAME=5P123456 3 | APP_SOURCE=111111 4 | USER_ID=DUMMYID 5 | PASSWORD=DUMMYPASSWORD 6 | USER_KEY=THIS_IS_A_DUMMY_USERKEY 7 | ENCRYPTION_KEY=THIS_IS_A_DUMMY_ENCRYPTION_KEY 8 | -------------------------------------------------------------------------------- /tests/brokers/test_fivepaisa.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import Mock, patch, call 3 | 4 | from fastbt.brokers.fivepaisa import * 5 | from py5paisa import FivePaisaClient 6 | import requests 7 | import json 8 | 9 | contracts = {"NSE:SBIN": 3045, "NFO:SBT": 5316} 10 | 11 | 12 | def test_get_instrument_token(): 13 | token = get_instrument_token(contracts, "NSE", "SBIN") 14 | assert token == 3045 15 | token = get_instrument_token(contracts, "NFO", "SBT") 16 | assert token == 5316 17 | token = get_instrument_token(contracts, "NFO", "ABCD") 18 | assert token is None 19 | 20 | 21 | def test_broker_get_instrument_token(): 22 | broker = FivePaisa("a", "b", "c") 23 | broker.contracts = contracts 24 | assert broker._get_instrument_token(symbol="SBIN") == 3045 25 | 26 | 27 | def test_broker_get_instrument_token_override_contracts(): 28 | broker = FivePaisa("a", "b", "c") 29 | assert broker._get_instrument_token(symbol="SBIN", contracts=contracts) == 3045 30 | 31 | 32 | @patch("py5paisa.FivePaisaClient") 33 | def test_broker_order_place(fivepaisa): 34 | broker = FivePaisa("a", "b", "c") 35 | broker.fivepaisa = fivepaisa 36 | broker.order_place(symbol="sbin", quantity=10, side="buy") 37 | broker.order_place(symbol="sbin", quantity=10, side="buy") 38 | -------------------------------------------------------------------------------- /tests/brokers/test_init.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | warnings.simplefilter("always") 4 | 5 | 6 | def test_warning_broker(): 7 | with warnings.catch_warnings(record=True) as w: 8 | from fastbt.brokers.zerodha import Zerodha 9 | 10 | message = str(w[-1].message) 11 | assert len(w) == 1 12 | assert issubclass(w[-1].category, DeprecationWarning) 13 | assert "Brokers support would be removed from version 0.7.0" in message 14 | assert "omspy" in message 15 | -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | PATH = os.path.join(os.path.realpath(os.curdir), "src") 5 | sys.path.append(PATH) 6 | -------------------------------------------------------------------------------- /tests/data/BT.yaml: -------------------------------------------------------------------------------- 1 | s_0: 2 | price: open 3 | order: B 4 | columns: 5 | - F: 6 | formula: (open+close)/2 7 | col_name: avgPrice 8 | conditions: 9 | - open > prevclose 10 | s_1: 11 | price: open * 0.999 12 | order: S 13 | columns: 14 | - F: 15 | formula: (open+close/2) 16 | col_name: avgPrice 17 | conditions: 18 | - open < 100 -------------------------------------------------------------------------------- /tests/data/BTC.csv: -------------------------------------------------------------------------------- 1 | date,symbol,open,high,low,close,volume 2 | 2018-Jul-11 0:00:00,BTC,6302.49,6396.39,6292.58,6381.87,2481016 3 | 2018-Jul-12 0:00:00,BTC,6381.87,6381.87,6087.73,6245.63,2383771 4 | 2018-Jul-13 0:00:00,BTC,6245.63,6330.97,6140.38,6217.61,2733534 5 | 2018-Jul-14 0:00:00,BTC,6217.61,6305.58,6184.01,6247.23,1941105 6 | 2018-Jul-15 0:00:00,BTC,6247.23,6384.5,6233.5,6349.04,1913069 7 | 2018-Jul-16 0:00:00,BTC,6349.04,6745.95,6332.29,6726.4,2397652 8 | 2018-Jul-17 0:00:00,BTC,6726.4,7440.25,6663.03,7314.94,2226388 9 | 2018-Jul-18 0:00:00,BTC,7314.94,7574.9,7244.87,7378.76,1997300 10 | 2018-Jul-19 0:00:00,BTC,7378.76,7556.41,7285.92,7470.82,2210382 11 | 2018-Jul-20 0:00:00,BTC,7470.82,7657.22,7272.66,7330.54,2186208 12 | 2018-Jul-21 0:00:00,BTC,3665.27,3721.2,3608.47,3702.15,5094820 13 | 2018-Jul-22 0:00:00,BTC,3702.15,3781.73,3672.22,3698.15,5613652 14 | 2018-Jul-23 0:00:00,BTC,3698.15,3892.69,3687.04,3858.75,5593886 15 | 2018-Jul-24 0:00:00,BTC,3858.75,4236.78,3847.23,4198.81,4797746 16 | 2018-Jul-25 0:00:00,BTC,4198.81,4239.67,4030.87,4083.38,4268700 17 | 2018-Jul-26 0:00:00,BTC,4083.38,4151.43,3927.57,3964.81,5567688 18 | 2018-Jul-27 0:00:00,BTC,3964.81,4135.05,3899.35,4091.51,5187556 19 | 2018-Jul-28 0:00:00,BTC,4091.51,4114.98,4037.18,4114.98,5537654 20 | 2018-Jul-29 0:00:00,BTC,4114.98,4141.6,4061.79,4107.78,4507662 21 | 2018-Jul-30 0:00:00,BTC,4107.78,4135.73,3930.16,4084,5584344 22 | 2018-Jul-31 0:00:00,BTC,4084,4084,3824.36,3863.45,4859358 23 | 2018-Aug-01 0:00:00,BTC,15453.78,15500.16,14888.74,15207.5,5666376 24 | 2018-Aug-02 0:00:00,BTC,15207.5,15402.26,14935.92,15070.04,5847078 25 | 2018-Aug-03 0:00:00,BTC,15070.04,15070.04,14580.04,14831.12,6187262 26 | 2018-Aug-04 0:00:00,BTC,14831.12,14970.62,13866.18,14018.18,4534402 27 | 2018-Aug-05 0:00:00,BTC,14018.18,14164.24,13788.34,14053.98,5151168 28 | 2018-Aug-06 0:00:00,BTC,14053.98,14291.48,13689.42,13874.14,5751346 29 | 2018-Aug-07 0:00:00,BTC,13874.14,14297.04,13370.28,13434.42,6053494 30 | 2018-Aug-08 0:00:00,BTC,13434.42,13434.42,12266.16,12561.16,5170836 31 | 2018-Aug-09 0:00:00,BTC,12561.16,13243.42,12408.08,13075.8,4731580 32 | 2018-Aug-10 0:00:00,BTC,13075.8,13157.04,12043.22,12286.6,4913820 33 | -------------------------------------------------------------------------------- /tests/data/NASDAQ/adjustments/splits.csv: -------------------------------------------------------------------------------- 1 | symbol,date,from,to 2 | IGIB,8/8/2018,1,2 3 | IGSB,8/8/2018,1,2 4 | USIG,8/8/2018,1,2 5 | JMU,7/31/2018,10,1 6 | IDRA,7/30/2018,8,1 7 | -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180730.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180730.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180731.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180731.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180801.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180801.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180802.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180802.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180803.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180803.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180806.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180806.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180807.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180807.zip -------------------------------------------------------------------------------- /tests/data/NASDAQ/data/NASDAQ_20180808.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/NASDAQ/data/NASDAQ_20180808.zip -------------------------------------------------------------------------------- /tests/data/backtest.json: -------------------------------------------------------------------------------- 1 | {"start": "2018-01-01", "end": "2018-01-07", "capital": 100000, "leverage": 1, "commission": 0, "slippage": 0, "price": "open", "stop_loss": 3, "order": "B", "universe": "all", "limit": 5, "columns": [{"F": {"formula": "(open+close)/2", "col_name": "avgPrice"}}], "conditions": ["open > prevclose"], "sort_by": "price", "sort_mode": true} -------------------------------------------------------------------------------- /tests/data/backtest.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/backtest.xls -------------------------------------------------------------------------------- /tests/data/backtest.yaml: -------------------------------------------------------------------------------- 1 | start: 2018-01-01 2 | end: 2018-01-07 3 | capital: 100000 4 | leverage: 1 5 | commission: 0 6 | slippage: 0 7 | price: open 8 | stop_loss: 3 9 | order: B 10 | universe: all 11 | limit: 5 12 | columns: 13 | - F: 14 | formula: (open+close)/2 15 | col_name: avgPrice 16 | conditions: 17 | - open > prevclose 18 | sort_by: price 19 | sort_mode: True 20 | 21 | -------------------------------------------------------------------------------- /tests/data/data.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/data.sqlite3 -------------------------------------------------------------------------------- /tests/data/option_strategies.yaml: -------------------------------------------------------------------------------- 1 | - 2 | kwargs: 3 | name: short_straddle 4 | a: 0 5 | step: 100 6 | spot: 17234 7 | output: 8 | - [sell, call, 17200] 9 | - [sell, put, 17200] 10 | - 11 | kwargs: 12 | name: short_straddle 13 | a: 1 14 | step: 100 15 | spot: 17234 16 | output: 17 | - [sell, call, 17300] 18 | - [sell, put, 17100] 19 | - 20 | kwargs: 21 | name: short_strangle 22 | spot: 17234 23 | output: 24 | - [sell, put, 17100] 25 | - [sell, call, 17300] 26 | - 27 | kwargs: 28 | name: short_strangle 29 | spot: 17234 30 | a: 2 31 | b: 2 32 | output: 33 | - [sell, put, 17000] 34 | - [sell, call, 17400] 35 | - 36 | kwargs: 37 | name: long_straddle 38 | a: 0 39 | step: 100 40 | spot: 17234 41 | output: 42 | - [buy, call, 17200] 43 | - [buy, put, 17200] 44 | - 45 | kwargs: 46 | name: long_straddle 47 | a: 1 48 | step: 100 49 | spot: 17234 50 | output: 51 | - [buy, call, 17300] 52 | - [buy, put, 17100] 53 | - 54 | kwargs: 55 | name: long_strangle 56 | spot: 17234 57 | output: 58 | - [buy, put, 17100] 59 | - [buy, call, 17300] 60 | - 61 | kwargs: 62 | name: long_strangle 63 | spot: 17234 64 | a: 2 65 | b: 2 66 | output: 67 | - [buy, put, 17000] 68 | - [buy, call, 17400] 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /tests/data/options_payoff.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/options_payoff.xlsx -------------------------------------------------------------------------------- /tests/data/results.csv: -------------------------------------------------------------------------------- 1 | strategy,start,end,capital,lev,comm,slip,sl,order,limit,universe,sort_by,sort_mode,profit,commission,slippage,net_profit,high,low,drawdown,returns,sharpe 2 | s_0,1/1/2018,1/7/2018,100000,1,0,0,3,B,5,all,price,TRUE,-715.09,0,0,-715.09,1200.76,-2599.34,-0.0259934,-0.0071509,-0.065 3 | s_0,1/1/2018,1/4/2018,100000,1,0,0,3,B,5,all,price,TRUE,1200.76,0,0,1200.76,1200.76,-190.49,-0.0019049,0.0120076,0.35 4 | s_0,1/1/2018,1/7/2018,50000,2,0,0,3,B,5,all,price,TRUE,-715.09,0,0,-715.09,1200.76,-2599.34,-0.0519868,-0.0143018,-0.065 5 | s_0,1/1/2018,1/7/2018,10000,10,0,0,3,B,5,all,price,TRUE,-715.09,0,0,-715.09,1200.76,-2599.34,-0.259934,-0.071509,-0.065 6 | s_0,1/1/2018,1/7/2018,100000,1,0.1,0.1,3,B,5,all,price,TRUE,-715.09,1199.5,1199.5,-3114.09,257.57808,-4594.29232,-0.045942923,-0.0311409,-0.065 7 | s_0,1/1/2018,1/7/2018,50000,2,0.2,0,3,B,5,all,price,TRUE,-715.09,2399,0,-3114.09,257.57808,-4594.29232,-0.091885846,-0.0622818,-0.065 8 | s_0,1/1/2018,1/7/2018,100000,1,0.1,0.1,3,B,3,all,price,TRUE,3253.61,1203.50681,1203.50681,846.59638,1551.56898,-2257.96172,-0.022579617,0.008465964,0.220011261 9 | s_0,1/1/2018,1/7/2018,100000,1,0.1,0.1,3,B,3,all,price,FALSE,-1979.54,1197.64366,1197.64366,-4374.82732,257.57808,-7419.81022,-0.074198102,-0.043748273,-0.148721484 10 | s_0,1/1/2018,1/7/2018,25000,4,0,0,3,B,1,all,price,FALSE,-9877.2,0,0,-9877.2,-625.8,-9877.2,-0.395088,-0.395088,-1.045491728 11 | s_0,1/1/2018,1/7/2018,100000,1,0,0,1,B,5,all,price,TRUE,4907.56,0,0,4907.56,4907.56,394.46,0.0039446,0.0490756,0.539575764 12 | s_1,1/1/2018,1/7/2018,100000,3,0.1,0.1,3,S,5,all,price,TRUE,23169.89,3619.47635,3619.47635,15930.9373,15930.9373,-8874.93292,-0.088749329,0.159309373,0.335162812 13 | -------------------------------------------------------------------------------- /tests/data/sample.csv: -------------------------------------------------------------------------------- 1 | timestamp,symbol,open,high,low,close,volume,prevclose 2 | 1-Jan-18,one,10,10.22,9.98,10.12,392523,9.8 3 | 2-Jan-18,one,10.12,10.25,10.12,10.22,403480,10.12 4 | 3-Jan-18,one,10.25,11.2,10.25,11,163698,10.22 5 | 4-Jan-18,one,11.1,11.15,10.45,10.54,495028,11 6 | 5-Jan-18,one,10.6,10.62,10.6,10.6,610039,10.54 7 | 6-Jan-18,one,10.64,10.7,10.12,10.4,808660,10.6 8 | 1-Jan-18,two,1000,1001,999,1000,470737,1000 9 | 2-Jan-18,two,1000,1000.5,999.5,1000,600935,1000 10 | 3-Jan-18,two,1001,1001,1000,1000,426030,1000 11 | 4-Jan-18,two,1000,1001,999,1000,167272,1000 12 | 5-Jan-18,two,1000,1000,1000,1000,38997,1000 13 | 6-Jan-18,two,1000,1000,999,1000,174296,1000 14 | 1-Jan-18,three,100,102.2,95,101.2,340149,100.5 15 | 2-Jan-18,three,102,102.5,101.2,102.2,167084,101.2 16 | 3-Jan-18,three,112,112,109,110,433733,102.2 17 | 4-Jan-18,three,108,112.4,106.05,109,281131,110 18 | 5-Jan-18,three,107.5,107.5,99.4,101.2,659252,109 19 | 6-Jan-18,three,101.3,102.4,96.5,98,444321,101.2 20 | 1-Jan-18,four,150,153.3,142.5,151.8,803189,154 21 | 2-Jan-18,four,153,154,152,153.3,119604,151.8 22 | 3-Jan-18,four,168,174,163.5,165,393708,153.3 23 | 4-Jan-18,four,162,181,159,174,767938,165 24 | 5-Jan-18,four,174.5,178.85,165.6,173,983684,174 25 | 6-Jan-18,four,174,174,161.25,166.45,637858,173 26 | 1-Jan-18,five,25,26,25,25.4,41688,24.9 27 | 2-Jan-18,five,25.5,25.7,24.5,25.55,682644,25.4 28 | 3-Jan-18,five,25.65,27,25.65,26.5,971704,25.55 29 | 4-Jan-18,five,26.3,26.8,26,26.45,449791,26.5 30 | 5-Jan-18,five,26.5,28,26.45,27.4,850786,26.45 31 | 6-Jan-18,five,27.5,33,27.5,32,533203,27.4 32 | 1-Jan-18,six,73,73.5,71,72.4,818035,72 33 | 2-Jan-18,six,69,69,64,66.5,105778,72.4 34 | 3-Jan-18,six,66.5,67.2,65,65.4,145497,66.5 35 | 4-Jan-18,six,65,66.2,64.5,65,86014,65.4 36 | 5-Jan-18,six,65.4,70,63,64.5,195539,65 37 | 6-Jan-18,six,64.5,64.5,56,58,333149,64.5 38 | -------------------------------------------------------------------------------- /tests/data/sample.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberdeveloper/fastbt/c63ebc053fa49b3dd4c05c727714fe6ca6f51e2d/tests/data/sample.xlsx -------------------------------------------------------------------------------- /tests/options/test_option_utils.py: -------------------------------------------------------------------------------- 1 | from fastbt.options.utils import * 2 | import pytest 3 | import pendulum 4 | import random 5 | 6 | 7 | @pytest.fixture 8 | def expiry_dates(): 9 | date = pendulum.date(2021, 1, 31) 10 | dates = [] 11 | for i in range(18): 12 | dates.append(date.add(months=i)) 13 | return dates 14 | 15 | 16 | @pytest.fixture 17 | def expiry_dates2(): 18 | """ 19 | contains a lot of dates 20 | """ 21 | start = pendulum.date(2021, 1, 1) 22 | end = pendulum.date(2024, 8, 31) 23 | period = pendulum.period(start, end) 24 | dates = [] 25 | for p in period.range("weeks"): 26 | dates.append(p) 27 | return sorted(dates) 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "test_input, expected", 32 | [ 33 | (dict(spot=12344, opt="p"), 12300), 34 | (dict(spot=248, step=5, opt="put", n=3), 265), 35 | ], 36 | ) 37 | def test_get_atm(test_input, expected): 38 | assert get_atm(**test_input) == expected 39 | 40 | 41 | @pytest.mark.parametrize( 42 | "test_input, expected", 43 | [ 44 | (dict(spot=12344, opt="c"), 12300), 45 | (dict(spot=248, opt="call", step=5, n=3), 230), 46 | (dict(spot=13000, opt="p", n=2), 13200), 47 | ], 48 | ) 49 | def test_get_itm(test_input, expected): 50 | assert get_itm(**test_input) == expected 51 | 52 | 53 | def test_get_expiry(expiry_dates): 54 | assert get_expiry(expiry_dates, sort=False) == pendulum.date(2021, 1, 31) 55 | assert get_expiry(expiry_dates, 3, sort=False) == pendulum.date(2021, 3, 31) 56 | assert get_expiry(expiry_dates, -1, sort=False) == pendulum.date(2022, 6, 30) 57 | 58 | 59 | def test_get_expiry_unsorted(expiry_dates): 60 | dates = expiry_dates[:] 61 | random.shuffle(dates) 62 | # Make sure the first date is not the least expiry 63 | assert dates[0] != pendulum.date(2021, 1, 31) 64 | assert get_expiry(dates) == pendulum.date(2021, 1, 31) 65 | assert get_expiry(dates, 2) == pendulum.date(2021, 2, 28) 66 | assert get_expiry(dates, -1) == pendulum.date(2022, 6, 30) 67 | 68 | 69 | def test_get_monthly_expiry(expiry_dates2): 70 | dates = expiry_dates2 71 | assert get_monthly_expiry(dates) == pendulum.date(2021, 1, 29) 72 | assert get_monthly_expiry(dates, 5) == pendulum.date(2021, 5, 28) 73 | assert get_monthly_expiry(dates, 27) == pendulum.date(2023, 3, 31) 74 | 75 | 76 | def test_get_yearly_expiry(expiry_dates2): 77 | dates = expiry_dates2 78 | assert get_yearly_expiry(dates) == pendulum.date(2021, 12, 31) 79 | assert get_yearly_expiry(dates, 2) == pendulum.date(2022, 12, 30) 80 | assert get_yearly_expiry(dates, 4) == pendulum.date(2024, 8, 30) 81 | 82 | 83 | def test_get_all_single_expiry_date(): 84 | dates = [pendulum.today()] 85 | assert get_expiry(dates) == get_monthly_expiry(dates) == get_yearly_expiry(dates) 86 | 87 | 88 | def test_get_expiry_by_no_args(expiry_dates2): 89 | dates = expiry_dates2 90 | assert get_expiry_by(dates) == dates[0] 91 | assert get_expiry_by(dates, n=10) == dates[9] 92 | assert get_expiry_by(dates, n=101) == dates[100] 93 | assert get_expiry_by(dates, n=-1) == dates[-1] 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "test_input, expected", 98 | [ 99 | ((2021, 2, 2), (2021, 2, 12)), 100 | ((2023, 8, 0), (2023, 8, 4)), 101 | ((2023, 8, 1), (2023, 8, 4)), 102 | ((2024, 7, -1), (2024, 7, 26)), 103 | ((2024, 0, 10), (2024, 3, 8)), 104 | ((0, 0, -7), (2024, 7, 19)), 105 | ], 106 | ) 107 | def test_get_expiry_by(test_input, expected, expiry_dates2): 108 | dates = expiry_dates2 109 | assert get_expiry_by(dates, *test_input) == pendulum.date(*expected) 110 | 111 | 112 | @pytest.mark.parametrize( 113 | "test_input, expected", 114 | [ 115 | (7, (2021, 1, 8)), 116 | (100, (2021, 4, 16)), 117 | (1000, (2023, 9, 29)), 118 | ], 119 | ) 120 | def test_get_expiry_by_days(test_input, expected, expiry_dates2): 121 | dates = expiry_dates2 122 | known = pendulum.datetime(2021, 1, 1) 123 | with pendulum.test(known): 124 | assert get_expiry_by_days(dates, test_input) == pendulum.date(*expected) 125 | 126 | 127 | def test_get_expiry_by_dates_no_matching_date(expiry_dates2): 128 | assert get_expiry_by_days(expiry_dates2, 10000) is None 129 | -------------------------------------------------------------------------------- /tests/options/test_payoff.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from collections import Counter 3 | from fastbt.options.payoff import * 4 | from pydantic import ValidationError 5 | 6 | 7 | @pytest.fixture 8 | def simple(): 9 | return ExpiryPayoff() 10 | 11 | 12 | @pytest.fixture 13 | def contracts_list(): 14 | contracts = [ 15 | dict(strike=16000, option="c", side=1, premium=100, quantity=1), 16 | dict(strike=16000, option="p", side=1, premium=100, quantity=1), 17 | dict(strike=15900, option="p", side=-1, premium=85, quantity=1), 18 | dict(strike=15985, option="h", side=1, premium=0, quantity=1), 19 | dict(strike=16030, option="f", side=-1, premium=0, quantity=1), 20 | ] 21 | return [Contract(**kwargs) for kwargs in contracts] 22 | 23 | 24 | def test_option_contract_defaults(): 25 | contract = Contract(strike=18000, option="c", side=1, premium=150, quantity=1) 26 | assert contract.strike == 18000 27 | assert contract.option == Opt.CALL 28 | assert contract.side == Side.BUY 29 | assert contract.premium == 150 30 | assert contract.quantity == 1 31 | 32 | 33 | def test_option_contract_option_types(): 34 | contract = Contract(option="h", side=1, strike=15000) 35 | assert contract.premium == 0 36 | assert contract.option == Opt.HOLDING 37 | assert contract.strike == 15000 38 | assert contract.side == Side.BUY 39 | assert contract.quantity == 1 40 | 41 | 42 | def test_payoff_defaults(simple): 43 | p = simple 44 | assert p.spot == 0 45 | assert p._options == [] 46 | assert p.options == [] 47 | 48 | 49 | def test_payoff_add_contract(simple): 50 | p = simple 51 | p.add_contract(14000, "c", -1, 120, 50) 52 | p.add_contract(14200, Opt.PUT, Side.BUY, 150, 50) 53 | assert len(p.options) == 2 54 | assert p.options[0].side == Side.SELL 55 | assert p.options[0].option == Opt.CALL 56 | 57 | 58 | def test_payoff_add(simple): 59 | p = simple 60 | kwargs = dict(strike=12000, option="p", side=1, premium=100, quantity=50) 61 | kwargs2 = dict(strike=12400, option="c", side=-1, premium=100, quantity=50) 62 | kwargs3 = dict(option="f", side=-1, strike=12500, quantity=50) 63 | p.add(Contract(**kwargs)) 64 | p.add(Contract(**kwargs2)) 65 | p.add(Contract(**kwargs3)) 66 | assert len(p.options) == 3 67 | assert p.options[0].side.value == 1 68 | assert p.options[0].option == "p" 69 | assert p.options[2].option == Opt.FUTURE 70 | 71 | 72 | def test_payoff_clear(simple): 73 | p = simple 74 | kwargs = dict(strike=12000, option="p", side=1, premium=100, quantity=50) 75 | kwargs2 = dict(strike=12400, option="c", side=-1, premium=100, quantity=50) 76 | kwargs3 = dict(option="f", side=-1, strike=12500, quantity=50) 77 | p.add(Contract(**kwargs)) 78 | p.add(Contract(**kwargs2)) 79 | p.add(Contract(**kwargs3)) 80 | assert len(p.options) == 3 81 | p.clear() 82 | assert len(p.options) == 0 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "strike,option,spot,expected", 87 | [ 88 | (18200, "c", 18178, 0), 89 | (18200, "p", 18178, 22), 90 | (18200, "f", 18395, 195), 91 | (18200, "f", 18100, -100), 92 | (18200, "h", 18100, -100), 93 | ], 94 | ) 95 | def test_option_contract_value(strike, option, spot, expected): 96 | contract = Contract( 97 | strike=strike, option=option, side=Side.BUY, premium=150, quantity=1 98 | ) 99 | assert contract.value(spot=spot) == expected 100 | 101 | 102 | @pytest.mark.parametrize( 103 | "strike,option,side,premium,quantity,spot,expected", 104 | [ 105 | (18200, "c", Side.BUY, 120, 1, 18175, -120), 106 | (18200, "c", Side.SELL, 120, 1, 18175, 120), 107 | (18234, "f", -1, 120, 1, 18245, -11), 108 | (18234, "h", 1, 0, 1, 18245, 11), 109 | (18200, "c", 1, 120, 1, 18290, -30), 110 | (18200, "p", 1, 100, 1, 18290, -100), 111 | (18200, "p", Side.SELL, 100, 1, 18200, 100), 112 | (18200, "p", Side.BUY, 100, 10, 18200, -1000), 113 | ], 114 | ) 115 | def test_option_contract_net_value( 116 | strike, option, side, premium, quantity, spot, expected 117 | ): 118 | contract = Contract( 119 | strike=strike, option=option, side=side, premium=premium, quantity=quantity 120 | ) 121 | assert contract.net_value(spot=spot) == expected 122 | 123 | 124 | def test_payoff_payoff(contracts_list): 125 | c = contracts_list 126 | p = ExpiryPayoff(spot=16000) 127 | # No contract 128 | assert p.payoff() == 0 129 | assert p.payoff(17000) == 0 130 | # Simple holding 131 | p.add(c[3]) 132 | assert p.payoff() == 15 133 | assert p.payoff(16150) == 165 134 | # Add a future 135 | p.add(c[4]) 136 | assert p.payoff(16150) == 45 137 | # Add a call option 138 | p.add(c[0]) 139 | assert p.payoff(16150) == 95 140 | # Add 2 put options 141 | p.add(c[1]) 142 | p.add(c[2]) 143 | assert p.payoff(16150) == 80 144 | 145 | 146 | def test_option_contract_validate_premium(): 147 | # Raise error when no premium 148 | with pytest.raises(ValueError): 149 | contract = Contract(strike=16000, option="c", side=1) 150 | with pytest.raises(ValueError): 151 | contract = Contract(strike=16000, option="p", side=1) 152 | # Raise no error when futures or holdings 153 | contract = Contract(strike=16000, option="f", side=1) 154 | contract = Contract(strike=16000, option="h", side=1) 155 | 156 | 157 | def test_payoff_simulate(contracts_list): 158 | p = ExpiryPayoff() 159 | c = contracts_list 160 | p.add(c[0]) 161 | p.add(c[2]) 162 | assert p.simulate(range(15000, 17500, 500)) == [-915, -415, -15, 485, 985] 163 | 164 | 165 | def test_payoff_lot_size(contracts_list): 166 | c = contracts_list 167 | p = ExpiryPayoff(spot=16000, lot_size=100) 168 | # Simple holding 169 | p.add(c[3]) 170 | assert p.payoff() == 1500 171 | assert p.payoff(16150) == 16500 172 | # Add a future 173 | p.add(c[4]) 174 | assert p.payoff(16150) == 4500 175 | # Add a call option 176 | p.add(c[0]) 177 | # Add 2 put options 178 | p.add(c[1]) 179 | p.add(c[2]) 180 | # Change lot size 181 | p.lot_size = 50 182 | assert p.payoff(16150) == 4000 183 | 184 | 185 | def test_payoff_net_positions(contracts_list): 186 | c = contracts_list 187 | p = ExpiryPayoff() 188 | assert p.net_positions == Counter() 189 | p.add(c[3]) 190 | assert p.net_positions == Counter(h=1) 191 | for contract in contracts_list: 192 | p.add(contract) 193 | assert p.net_positions == Counter(c=1, p=0, h=2, f=-1) 194 | p.lot_size = 50 195 | p.options[-1].quantity = 2 196 | assert p.net_positions == Counter(c=50, p=0, h=100, f=-100) 197 | 198 | 199 | def test_payoff_has_naked_positions(contracts_list): 200 | c = contracts_list 201 | p = ExpiryPayoff() 202 | for contract in contracts_list: 203 | p.add(contract) 204 | assert p.has_naked_positions is False 205 | p.add(c[2]) 206 | assert p.has_naked_positions is True 207 | 208 | 209 | def test_payoff_is_zero(contracts_list): 210 | c = contracts_list 211 | p = ExpiryPayoff() 212 | # Holdings vs futures 213 | assert p.is_zero is True 214 | p.add(c[3]) 215 | assert p.is_zero is False 216 | p.add(c[4]) 217 | assert p.is_zero is True 218 | for contract in contracts_list: 219 | p.add(contract) 220 | assert p.is_zero is False 221 | # SELL 2 calls 222 | p.add_contract(16200, "c", -1, 200, 2) 223 | assert p.is_zero is False 224 | # BUY another call 225 | p.add_contract(16400, "c", 1, 200, 1) 226 | assert p.is_zero is True 227 | 228 | 229 | def test_payoff_parse_valid(): 230 | p = ExpiryPayoff() 231 | assert p._parse("16900c150b2") == Contract( 232 | strike=16900, option=Opt.CALL, premium=150, side=Side.BUY, quantity=2 233 | ) 234 | assert p._parse("16700p130.85s") == Contract( 235 | strike=16700, option=Opt.PUT, premium=130.85, side=Side.SELL, quantity=1 236 | ) 237 | assert p._parse("16000fs") == Contract( 238 | strike=16000, option=Opt.FUTURE, side=Side.SELL 239 | ) 240 | assert p._parse("16000h120s10") == Contract( 241 | strike=16000, option=Opt.HOLDING, premium=120, side=Side.SELL, quantity=10 242 | ) 243 | 244 | 245 | def test_payoff_parse_valid_upper_case(): 246 | p = ExpiryPayoff() 247 | assert p._parse("16900C150B2") == Contract( 248 | strike=16900, option=Opt.CALL, premium=150, side=Side.BUY, quantity=2 249 | ) 250 | assert p._parse("16700P130.85s") == Contract( 251 | strike=16700, option=Opt.PUT, premium=130.85, side=Side.SELL, quantity=1 252 | ) 253 | 254 | 255 | @pytest.mark.parametrize("test_input", ["16900k150b2", "16900c120x15", "c15200"]) 256 | def test_payoff_parse_invalid(test_input): 257 | p = ExpiryPayoff() 258 | assert p._parse(test_input) is None 259 | 260 | 261 | @pytest.mark.parametrize("test_input", ["14250cb", "h120s"]) 262 | def test_payoff_parse_error(test_input): 263 | p = ExpiryPayoff() 264 | with pytest.raises(ValidationError): 265 | p._parse(test_input) 266 | 267 | 268 | def test_payoff_a(contracts_list): 269 | p = ExpiryPayoff() 270 | p.a("16000c100b") 271 | p.a("16000p100b1") 272 | p.a("15900p85s1") 273 | p.a("15985hb") 274 | p.a("16030fs") 275 | print(p.options) 276 | p2 = ExpiryPayoff() 277 | for contract in contracts_list: 278 | p2.add(contract) 279 | assert p.options == p2.options 280 | assert p.payoff(17150) == p2.payoff(17150) 281 | assert p.net_positions == p2.net_positions 282 | 283 | 284 | def test_payoff_simulate_auto(): 285 | p = ExpiryPayoff(spot=100) 286 | p.a("102c3b") 287 | p.a("98p3b") 288 | sim = p.simulate() 289 | assert len(sim) == 10 290 | assert sim == p.simulate(range(95, 105)) 291 | p.sim_range = 10 292 | sim = p.simulate() 293 | assert len(sim) == 20 294 | assert sim == p.simulate(range(90, 110)) 295 | 296 | 297 | def test_payoff_simulate_auto(): 298 | p = ExpiryPayoff(spot=0.85) 299 | p.a("0.9c0.03b") 300 | p.a("0.9p0.02b") 301 | sim = p.simulate() 302 | assert sim is None 303 | sim = p.simulate([x * 0.01 for x in range(80, 120)]) 304 | assert len(sim) == 40 305 | 306 | 307 | def test_payoff_approx_margin(): 308 | p = ExpiryPayoff(spot=700, lot_size=10) 309 | p.a("750c25s5") 310 | assert p.margin_approx == 7000 311 | p.a("780p21s10") 312 | assert p.margin_approx == 21000 313 | p.a("780h0s100") 314 | assert p.margin_approx == 21000 315 | p.margin_percentage = 0.4 316 | assert p.margin_approx == 42000 317 | 318 | 319 | def test_payoff_pnl(): 320 | p = ExpiryPayoff(spot=1000, sim_range=10, lot_size=10) 321 | p.a("1000c12b") 322 | pnl = p.pnl() 323 | assert round(pnl.avg_return, 2) == 131.24 324 | assert round(pnl.avg_win, 2) == 440 325 | assert round(pnl.avg_loss, 2) == -114.11 326 | assert pnl.median == -120 327 | assert pnl.max_loss == -120 328 | assert pnl.max_profit == 880 329 | assert round(pnl.win_rate, 2) == 0.44 330 | assert round(pnl.loss_rate, 2) == 0.56 331 | -------------------------------------------------------------------------------- /tests/options/test_store.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastbt.options.store import * 3 | import pendulum 4 | 5 | 6 | def test_generic_parser(): 7 | name = "AAPL|120|2020-11-15|CE" 8 | res = generic_parser(name) 9 | assert res == ("AAPL", 120, pendulum.date(2020, 11, 15), "CE") 10 | -------------------------------------------------------------------------------- /tests/test_breakout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pendulum 3 | import random 4 | from fastbt.models.breakout import Breakout, StockData, HighLow 5 | from fastbt.brokers.zerodha import Zerodha 6 | from pydantic import ValidationError 7 | from unittest.mock import Mock, patch, call 8 | 9 | 10 | @pytest.fixture 11 | def base_breakout(): 12 | return Breakout( 13 | symbols=["GOOG", "AAPL"], instrument_map={"GOOG": 1010, "AAPL": 2100} 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def sl_breakout(): 19 | ts = Breakout( 20 | symbols=["GOOG", "AAPL", "INTL"], 21 | instrument_map={"GOOG": 1010, "AAPL": 2100, "INTL": 3000}, 22 | ) 23 | ts.update_high_low( 24 | [ 25 | HighLow(symbol="AAPL", high=101, low=98), 26 | HighLow(symbol="GOOG", high=104, low=100), 27 | HighLow(symbol="INTL", high=302, low=295), 28 | ] 29 | ) 30 | ts._data["AAPL"].ltp = 100 31 | ts._data["GOOG"].ltp = 104 32 | ts._data["INTL"].ltp = 300 33 | return ts 34 | 35 | 36 | @pytest.fixture 37 | def live_order(): 38 | with patch("fastbt.brokers.zerodha.Zerodha") as broker: 39 | ts = Breakout( 40 | symbols=["GOOG", "AAPL", "INTL"], 41 | instrument_map={"GOOG": 1010, "AAPL": 2100, "INTL": 3000}, 42 | broker=broker, 43 | env="live", 44 | ) 45 | ts.update_high_low( 46 | [ 47 | HighLow(symbol="AAPL", high=101, low=98), 48 | HighLow(symbol="GOOG", high=104, low=100), 49 | HighLow(symbol="INTL", high=302, low=295), 50 | ] 51 | ) 52 | ts._data["AAPL"].ltp = 100 53 | ts._data["INTL"].ltp = 300 54 | return ts 55 | 56 | 57 | def test_breakout_parent_defaults(base_breakout): 58 | ts = base_breakout 59 | assert ts.SYSTEM_START_TIME == pendulum.today(tz="Asia/Kolkata").add( 60 | hours=9, minutes=15 61 | ) 62 | assert ts.SYSTEM_END_TIME == pendulum.today(tz="Asia/Kolkata").add( 63 | hours=15, minutes=15 64 | ) 65 | assert ts.env == "paper" 66 | assert ts.done is False 67 | 68 | 69 | def test_stock_data(base_breakout): 70 | ts = base_breakout 71 | assert len(ts.data) == 2 72 | my_data = { 73 | "GOOG": StockData(name="GOOG", token=1010), 74 | "AAPL": StockData(name="AAPL", token=2100), 75 | } 76 | assert ts.data == my_data 77 | assert ts.data["GOOG"].high is None 78 | assert ts.data["AAPL"].low is None 79 | 80 | 81 | def test_rev_map(base_breakout): 82 | ts = base_breakout 83 | assert ts._rev_map == {1010: "GOOG", 2100: "AAPL"} 84 | 85 | 86 | def test_high_low(base_breakout): 87 | ts = base_breakout 88 | ts.update_high_low( 89 | [ 90 | HighLow(symbol="AAPL", high=150, low=120), 91 | HighLow(symbol="GOOG", high=150, low=120), 92 | ] 93 | ) 94 | assert ts.data["AAPL"].high == 150 95 | assert ts.data["GOOG"].low == 120 96 | 97 | 98 | def test_high_low_dict(base_breakout): 99 | ts = base_breakout 100 | ts.update_high_low([{"symbol": "AAPL", "high": 150, "low": 120}]) 101 | assert ts.data["AAPL"].high == 150 102 | 103 | 104 | def test_high_low_dict_extra_values(base_breakout): 105 | ts = base_breakout 106 | ts.update_high_low([{"symbol": "AAPL", "high": 150, "low": 120, "open": 160}]) 107 | assert ts.data["AAPL"].high == 150 108 | 109 | 110 | def test_high_low_dict_no_symbols(base_breakout): 111 | ts = base_breakout 112 | ts.update_high_low([{"symbol": "DOW", "high": 150, "low": 120, "open": 160}]) 113 | assert ts.data["AAPL"].high is None 114 | assert ts.data["GOOG"].low is None 115 | 116 | 117 | def test_high_low_no_data_raise_error(base_breakout): 118 | ts = base_breakout 119 | with pytest.raises(ValidationError): 120 | ts.update_high_low([{"symbol": "AAPL", "high": 15}]) 121 | 122 | 123 | def test_stop_loss_default(sl_breakout): 124 | ts = sl_breakout 125 | sl = ts.stop_loss("AAPL", "BUY") 126 | assert sl == 98 127 | sl = ts.stop_loss("INTL", "SELL") 128 | assert sl == 302 129 | 130 | 131 | def test_stop_loss_value(sl_breakout): 132 | ts = sl_breakout 133 | sl = ts.stop_loss("AAPL", "BUY") 134 | assert sl == 98 135 | sl = ts.stop_loss("AAPL", "BUY", method="value") 136 | assert sl == 100 137 | sl = ts.stop_loss("AAPL", "BUY", method="value", stop=1) 138 | assert sl == 99 139 | sl = ts.stop_loss("GOOG", "SELL", method="value", stop=1) 140 | assert sl == 105 141 | 142 | 143 | def test_stop_loss_percentage(sl_breakout): 144 | ts = sl_breakout 145 | sl = ts.stop_loss("AAPL", "BUY") 146 | assert sl == 98 147 | sl = ts.stop_loss("AAPL", "BUY", method="percent") 148 | assert sl == 100 149 | sl = ts.stop_loss("AAPL", "BUY", method="percent", stop=1.5) 150 | assert sl == 98.5 151 | sl = ts.stop_loss("INTL", "SELL", method="percent", stop=3) 152 | assert sl == 309 153 | 154 | 155 | def test_stop_loss_no_symbol(sl_breakout): 156 | ts = sl_breakout 157 | sl = ts.stop_loss("SOME", "BUY") 158 | assert sl == 0 159 | 160 | 161 | def test_stop_loss_unknown_method(sl_breakout): 162 | ts = sl_breakout 163 | sl = ts.stop_loss("AAPL", "BUY", method="unknown") 164 | assert sl == 98 165 | 166 | 167 | def test_fetch(base_breakout): 168 | ts = base_breakout 169 | ts.fetch( 170 | [ 171 | {"instrument_token": 1010, "last_price": 118.4}, 172 | {"instrument_token": 2100, "last_price": 218.4}, 173 | ] 174 | ) 175 | assert ts.data["AAPL"].ltp == 218.4 176 | 177 | 178 | def test_fetch_no_symbol(base_breakout): 179 | ts = base_breakout 180 | ts.fetch( 181 | [ 182 | {"instrument_token": 1011, "last_price": 118.4}, 183 | {"instrument_token": 2100, "last_price": 218.4}, 184 | ] 185 | ) 186 | assert ts.data["AAPL"].ltp == 218.4 187 | assert ts.data["GOOG"].ltp == 0 188 | 189 | 190 | def test_entry_buy(sl_breakout): 191 | ts = sl_breakout 192 | ts._data["AAPL"].ltp = 101.5 193 | ts.run() 194 | assert ts.data["AAPL"].can_trade is False 195 | assert ts.data["AAPL"].positions == 985 196 | 197 | 198 | def test_entry_sell(sl_breakout): 199 | ts = sl_breakout 200 | ts._data["AAPL"].ltp = 97.9 201 | ts.run() 202 | assert ts.data["AAPL"].can_trade is False 203 | assert ts.data["AAPL"].positions == -1021 204 | 205 | 206 | def test_dont_trade_when_can_trade_false(sl_breakout): 207 | ts = sl_breakout 208 | ts._data["AAPL"].ltp = 101.5 209 | ts._data["AAPL"].can_trade = False 210 | ts.run() 211 | assert ts.data["AAPL"].positions == 0 212 | 213 | 214 | def test_dont_trade_when_positions_not_zero(sl_breakout): 215 | ts = sl_breakout 216 | ts._data["AAPL"].ltp = 101.5 217 | ts._data["AAPL"].positions = 36 218 | ts.run() 219 | assert ts.data["AAPL"].positions == 36 220 | assert ts.data["AAPL"].can_trade is True 221 | 222 | 223 | def test_entry_multiple_symbols(sl_breakout): 224 | ts = sl_breakout 225 | ts._data["AAPL"].ltp = 101.5 226 | ts._data["GOOG"].ltp = 99.9 227 | ts._data["INTL"].ltp = 302 228 | ts.run() 229 | assert ts.data["AAPL"].positions == 985 230 | assert ts.data["GOOG"].positions == -1001 231 | # No trades since prices are equal 232 | assert ts.data["INTL"].positions == 0 233 | 234 | ts._data["INTL"].ltp = 302.0005 235 | ts.run() 236 | assert ts.data["INTL"].positions == 331 237 | 238 | 239 | def test_open_positions(sl_breakout): 240 | ts = sl_breakout 241 | ts._data["AAPL"].ltp = 101.5 242 | ts._data["GOOG"].ltp = 99.9 243 | ts._data["INTL"].ltp = 302 244 | ts.run() 245 | assert ts.open_positions == 2 246 | 247 | 248 | def test_open_positions_can_trade(sl_breakout): 249 | ts = sl_breakout 250 | ts._data["AAPL"].can_trade = False 251 | ts._data["AAPL"].positions = 0 252 | ts.run() 253 | assert ts.open_positions == 1 254 | 255 | 256 | def test_order_live(live_order): 257 | ts = live_order 258 | ts._data["AAPL"].ltp = 101.5 259 | ts.run() 260 | assert ts.broker.order_place.call_count == 2 261 | assert ts.data.get("AAPL").positions == 985 262 | assert ts.data.get("AAPL").can_trade is False 263 | 264 | 265 | def test_order_live_multiple_runs(live_order): 266 | ts = live_order 267 | ts._data["AAPL"].ltp = 101.5 268 | ts.run() 269 | for i in range(10): 270 | ts.run() 271 | assert ts.broker.order_place.call_count == 2 272 | 273 | 274 | def test_order_live_args(live_order): 275 | ts = live_order 276 | ts._data["AAPL"].ltp = 101.5 277 | ts.run() 278 | kwargs = dict( 279 | symbol="AAPL", order_type="LIMIT", side="BUY", price=101.5, quantity=985 280 | ) 281 | assert ts.broker.order_place.call_args_list[0] == call(**kwargs) 282 | kwargs = dict( 283 | symbol="AAPL", 284 | order_type="SL-M", 285 | side="SELL", 286 | trigger_price=98.45, 287 | price=101.5, 288 | quantity=985, 289 | ) 290 | assert ts.broker.order_place.call_args_list[-1] == call(**kwargs) 291 | for i in range(10): 292 | ts.run() 293 | assert ts.broker.order_place.call_count == 2 294 | 295 | 296 | def test_order_live_update_order_id(live_order): 297 | ts = live_order 298 | ts.broker.order_place.return_value = 111111 299 | ts._data["AAPL"].ltp = 101.5 300 | ts.run() 301 | assert ts.data["AAPL"].order_id == 111111 302 | assert ts.data["AAPL"].stop_id == 111111 303 | 304 | 305 | def test_order_live_kwargs(live_order): 306 | ts = live_order 307 | ts._data["AAPL"].ltp = 101.5 308 | ts.ORDER_DEFAULT_KWARGS = {"exchange": "NYSE", "validity": "DAY", "product": "MIS"} 309 | ts.run() 310 | kwargs = dict( 311 | symbol="AAPL", 312 | order_type="LIMIT", 313 | side="BUY", 314 | price=101.5, 315 | quantity=985, 316 | exchange="NYSE", 317 | validity="DAY", 318 | product="MIS", 319 | ) 320 | assert ts.broker.order_place.call_args_list[0] == call(**kwargs) 321 | kwargs = dict( 322 | symbol="AAPL", 323 | order_type="SL-M", 324 | side="SELL", 325 | trigger_price=98.45, 326 | price=101.5, 327 | quantity=985, 328 | exchange="NYSE", 329 | validity="DAY", 330 | product="MIS", 331 | ) 332 | assert ts.broker.order_place.call_args_list[-1] == call(**kwargs) 333 | 334 | 335 | def test_max_positions(sl_breakout): 336 | ts = sl_breakout 337 | ts.MAX_POSITIONS = 1 338 | ts._data["AAPL"].ltp = 101.5 339 | ts.run() 340 | ts._data["GOOG"].ltp = 99.9 341 | ts._data["INTL"].ltp = 303 342 | ts.run() 343 | assert ts.open_positions == 1 344 | 345 | ts.MAX_POSITIONS = 2 346 | ts.run() 347 | assert ts.open_positions == 3 348 | -------------------------------------------------------------------------------- /tests/test_datasource.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pandas as pd 3 | import numpy as np 4 | import context 5 | 6 | from fastbt.datasource import DataSource 7 | import talib 8 | 9 | 10 | class TestDataSource(unittest.TestCase): 11 | def setUp(self): 12 | df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]) 13 | self.ds = DataSource(data=df) 14 | 15 | def test_data(self): 16 | self.assertEqual(self.ds.data.iloc[20, 1], "five") 17 | self.assertEqual(self.ds.data.iloc[14, 3], 112) 18 | self.assertEqual(self.ds.data.iloc[24, 7], 10.54) 19 | 20 | def test_data_without_sort(self): 21 | df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]) 22 | self.ds = DataSource(data=df, sort=False) 23 | self.assertEqual(self.ds.data.iloc[9, 4], 999) 24 | self.assertEqual(self.ds.data.iloc[24, 6], 41688) 25 | self.assertEqual(self.ds.data.at[4, "close"], 10.6) 26 | 27 | def test_initialize_case(self): 28 | df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]) 29 | df.columns = [x.upper() for x in df.columns] 30 | self.assertEqual(df.columns[0], "TIMESTAMP") 31 | self.ds = DataSource(data=df) 32 | self.assertEqual(self.ds.data.columns[0], "timestamp") 33 | 34 | def test_initialize_column_rename(self): 35 | df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]) 36 | df.columns = [ 37 | "TS", 38 | "TRADINGSYMBOL", 39 | "OPEN", 40 | "HIGH", 41 | "LOW", 42 | "CLOSE", 43 | "VOLUME", 44 | "PREVCLOSE", 45 | ] 46 | self.ds = DataSource(data=df, timestamp="TS", symbol="TRADINGSYMBOL") 47 | self.assertEqual(self.ds.data.columns[0], "timestamp") 48 | self.assertEqual(self.ds.data.columns[1], "symbol") 49 | 50 | def test_add_lag(self): 51 | length = len(self.ds.data) 52 | idx = pd.IndexSlice 53 | self.ds.add_lag(on="close") 54 | self.ds.add_lag(on="volume", period=2) 55 | d = self.ds.data.set_index(["timestamp", "symbol"]) 56 | self.assertEqual(d.at[idx["2018-01-04", "one"], "lag_close_1"], 11) 57 | self.assertEqual(d.at[idx["2018-01-06", "six"], "lag_volume_2"], 86014) 58 | self.assertEqual(len(self.ds.data.columns), 10) 59 | self.assertEqual(len(self.ds.data), length) 60 | 61 | def test_add_lag_column_rename(self): 62 | idx = pd.IndexSlice 63 | self.ds.add_lag(on="close") 64 | self.ds.add_lag(on="close", col_name="some_col") 65 | d = self.ds.data.set_index(["timestamp", "symbol"]) 66 | self.assertEqual(d.at[idx["2018-01-04", "one"], "lag_close_1"], 11) 67 | self.assertEqual(d.at[idx["2018-01-04", "one"], "some_col"], 11) 68 | self.assertEqual(d.at[idx["2018-01-05", "three"], "some_col"], 109) 69 | 70 | def test_add_pct_change(self): 71 | idx = pd.IndexSlice 72 | self.ds.add_pct_change(on="close") 73 | self.ds.add_pct_change(on="close", period=2) 74 | self.ds.add_pct_change(on="close", period=2, col_name="new_col") 75 | d = self.ds.data.set_index(["timestamp", "symbol"]) 76 | R = lambda x: round(x, 2) 77 | self.assertEqual(R(d.at[idx["2018-01-05", "three"], "chg_close_1"]), -0.07) 78 | self.assertEqual(R(d.at[idx["2018-01-06", "five"], "chg_close_1"]), 0.17) 79 | self.assertEqual(R(d.at[idx["2018-01-05", "four"], "chg_close_2"]), 0.05) 80 | self.assertEqual(R(d.at[idx["2018-01-05", "four"], "new_col"]), 0.05) 81 | self.assertEqual(R(d.at[idx["2018-01-03", "six"], "new_col"]), -0.1) 82 | self.assertEqual(pd.isna(d.at[idx["2018-01-02", "one"], "new_col"]), True) 83 | self.assertEqual(len(self.ds.data.columns), 11) 84 | 85 | def test_add_pct_change_lag(self): 86 | idx = pd.IndexSlice 87 | self.ds.add_pct_change(on="close", period=2, lag=1) 88 | self.ds.add_pct_change(on="close", period=1, lag=2) 89 | d = self.ds.data.set_index(["timestamp", "symbol"]) 90 | R = lambda x: round(x, 2) 91 | self.assertEqual(R(d.at[idx["2018-01-04", "four"], "chg_close_2"]), 0.09) 92 | self.assertEqual(R(d.at[idx["2018-01-04", "four"], "chg_close_1"]), 0.01) 93 | self.assertEqual(R(d.at[idx["2018-01-06", "three"], "chg_close_1"]), -0.01) 94 | 95 | def test_add_pct_change_lag_col_name(self): 96 | idx = pd.IndexSlice 97 | self.ds.add_pct_change(on="high", period=2, lag=1) 98 | self.ds.add_pct_change(on="close", period=1, lag=2, col_name="lagged_2") 99 | d = self.ds.data.set_index(["timestamp", "symbol"]) 100 | R = lambda x: round(x, 2) 101 | self.assertEqual(R(d.at[idx["2018-01-05", "six"], "chg_high_2"]), -0.04) 102 | self.assertEqual(R(d.at[idx["2018-01-04", "four"], "lagged_2"]), 0.01) 103 | 104 | def test_formula_add_col_name(self): 105 | idx = pd.IndexSlice 106 | self.ds.add_formula("open+close", "new_col") 107 | self.ds.add_formula("volume/close", "new_col_2") 108 | d = self.ds.data.set_index(["timestamp", "symbol"]) 109 | R = lambda x: round(x, 2) 110 | self.assertEqual(R(d.at[idx["2018-01-04", "four"], "new_col"]), 336) 111 | self.assertEqual(R(d.at[idx["2018-01-06", "one"], "new_col_2"]), 77755.77) 112 | 113 | def test_formula_case_insensitive(self): 114 | idx = pd.IndexSlice 115 | self.ds.add_formula("OPEN+CLOSE", "new_col") 116 | self.ds.add_formula("volume/close", "NEW_COL_2") 117 | d = self.ds.data.set_index(["timestamp", "symbol"]) 118 | R = lambda x: round(x, 2) 119 | self.assertEqual(R(d.at[idx["2018-01-04", "four"], "new_col"]), 336) 120 | self.assertEqual(R(d.at[idx["2018-01-06", "one"], "new_col_2"]), 77755.77) 121 | 122 | def test_formula_calculated_column(self): 123 | idx = pd.IndexSlice 124 | self.ds.add_formula("(open+close)*100", "new_col_1") 125 | self.ds.add_formula("volume/100", "new_col_2") 126 | self.ds.add_formula("new_col_1+new_col_2", "new_col_3") 127 | d = self.ds.data.set_index(["timestamp", "symbol"]) 128 | R = lambda x: round(x, 2) 129 | self.assertEqual(R(d.at[idx["2018-01-06", "one"], "new_col_3"]), 10190.6) 130 | self.assertEqual(R(d.at[idx["2018-01-05", "two"], "new_col_3"]), 200389.97) 131 | 132 | def test_rolling_simple(self): 133 | from pandas import isna 134 | 135 | q = 'symbol == "one"' 136 | df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]).query(q) 137 | df["r2"] = df["close"].rolling(2).mean() 138 | self.ds.add_rolling(2, col_name="r2") 139 | df2 = self.ds.data.query(q) 140 | print("RESULT", df["r2"], df2["r2"]) 141 | for a, b in zip(df["r2"], df2["r2"]): 142 | if not (isna(a)): 143 | assert a == b 144 | 145 | def test_rolling_values(self): 146 | idx = pd.IndexSlice 147 | self.ds.add_rolling(4, on="volume", function="max") 148 | d = self.ds.data.set_index(["timestamp", "symbol"]) 149 | R = lambda x: round(x, 2) 150 | self.assertEqual(d.at[idx["2018-01-05", "five"], "rol_max_volume_4"], 971704) 151 | self.assertEqual(d.at[idx["2018-01-05", "six"], "rol_max_volume_4"], 195539) 152 | self.assertEqual(d.at[idx["2018-01-04", "three"], "rol_max_volume_4"], 433733) 153 | # Adding lag and testing 154 | self.ds.add_rolling(4, on="volume", function="max", lag=1) 155 | d = self.ds.data.set_index(["timestamp", "symbol"]) 156 | self.assertEqual(d.at[idx["2018-01-06", "five"], "rol_max_volume_4"], 971704) 157 | self.assertEqual(d.at[idx["2018-01-06", "six"], "rol_max_volume_4"], 195539) 158 | self.assertEqual(d.at[idx["2018-01-05", "three"], "rol_max_volume_4"], 433733) 159 | # Testing for 2 lags and column name 160 | self.ds.add_rolling(4, on="volume", function="max", lag=2, col_name="check") 161 | d = self.ds.data.set_index(["timestamp", "symbol"]) 162 | self.assertEqual(d.at[idx["2018-01-06", "three"], "check"], 433733) 163 | 164 | def test_batch(self): 165 | length = len(self.ds.data) 166 | batch = [ 167 | {"P": {"on": "close", "period": 1, "lag": 1}}, 168 | {"L": {"on": "volume", "period": 1}}, 169 | {"F": {"formula": "(open+close)/2", "col_name": "AvgPrice"}}, 170 | {"I": {"indicator": "SMA", "period": 3, "lag": 1, "col_name": "SMA3"}}, 171 | {"F": {"formula": "avgprice + sma3", "col_name": "final"}}, 172 | {"R": {"window": 3, "function": "mean"}}, 173 | ] 174 | d = self.ds.batch_process(batch).set_index(["timestamp", "symbol"]) 175 | self.assertEqual(len(d.columns), 12) 176 | self.assertEqual(len(self.ds.data.columns), 14) 177 | self.assertEqual(len(self.ds.data), length) 178 | 179 | def test_raise_error_if_not_dataframe(self): 180 | pass 181 | 182 | 183 | def test_rolling_zscore(): 184 | np.random.seed(100) 185 | df = pd.DataFrame(np.random.randn(100, 4), columns=["open", "high", "low", "close"]) 186 | df["symbol"] = list("ABCD") * 25 187 | dates = list(pd.date_range(end="2018-04-25", periods=25)) * 4 188 | df["timestamp"] = dates 189 | from fastbt.datasource import DataSource 190 | 191 | ds = DataSource(df) 192 | ds.add_rolling(on="close", window=5, function="zscore") 193 | assert ds.data.query('symbol=="A"').iloc[8]["rol_zscore_close_5"].round(2) == 0.12 194 | assert ds.data.query('symbol=="B"').iloc[-7]["rol_zscore_close_5"].round(2) == 0.17 195 | assert ds.data.query('symbol=="C"').iloc[-6]["rol_zscore_close_5"].round(2) == -0.48 196 | 197 | 198 | class TestDataSourceReindex(unittest.TestCase): 199 | def setUp(self): 200 | df = pd.DataFrame( 201 | np.arange(24).reshape(6, 4), columns=["open", "high", "low", "close"] 202 | ) 203 | df["symbol"] = list("ABCABA") 204 | df["timestamp"] = [1, 1, 1, 2, 3, 3] 205 | self.df = df 206 | 207 | def test_reindex(self): 208 | ds = DataSource(self.df) 209 | ds.reindex([1, 2, 3]) 210 | assert len(ds.data) == 9 211 | # Check values 212 | assert ds.data.set_index(["symbol", "timestamp"]).at[("A", 1), "open"] == 0 213 | assert ds.data.set_index(["symbol", "timestamp"]).at[("B", 2), "close"] == 7 214 | assert ds.data.set_index(["symbol", "timestamp"]).at[("C", 3), "high"] == 9 215 | ds.reindex([1, 2, 3, 4]) 216 | assert len(ds.data) == 12 217 | 218 | def test_reindex_different_fills(self): 219 | ds = DataSource(self.df) 220 | ds.reindex([1, 2, 3], method=None) 221 | print(ds.data) 222 | assert pd.isnull( 223 | ds.data.set_index(["symbol", "timestamp"]).at[("C", 3), "high"] 224 | ) 225 | ds = DataSource(self.df) 226 | ds.reindex([1, 2, 3, 4], method="bfill") 227 | assert ds.data.set_index(["symbol", "timestamp"]).at[("B", 2), "close"] == 19 228 | 229 | 230 | class TestDataSourceTALIB(unittest.TestCase): 231 | 232 | """ 233 | Test TALIB indicators 234 | """ 235 | 236 | def setUp(self): 237 | self.df = pd.read_csv("tests/data/sample.csv", parse_dates=["timestamp"]) 238 | 239 | def test_single_symbol(self): 240 | df = self.df.query('symbol=="one"') 241 | ds = DataSource(df) 242 | ds.add_indicator("SMA", period=3, col_name="sma") 243 | assert len(ds.data) == 6 244 | 245 | sma = talib.SMA(df.close.values, timeperiod=3) 246 | # If both are equal, there should be no differences 247 | assert (ds.data.sma - sma).sum() == 0 248 | -------------------------------------------------------------------------------- /tests/test_features.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from fastbt.features import * 4 | 5 | 6 | def test_high_count(): 7 | arr = np.arange(6) 8 | assert list(high_count(arr)) == [0, 1, 2, 3, 4, 5] 9 | 10 | 11 | def test_high_count_reversed(): 12 | arr = np.array([5, 4, 3, 2, 1, 0]) 13 | assert np.array_equal(high_count(arr), np.zeros(6, dtype=int)) 14 | 15 | 16 | def test_low_count(): 17 | arr = np.array([5, 4, 3, 2, 1, 0]) 18 | assert list(low_count(arr)) == [0, 1, 2, 3, 4, 5] 19 | 20 | 21 | def test_low_count_reversed(): 22 | arr = np.arange(6) 23 | assert np.array_equal(low_count(arr), np.zeros(6, dtype=int)) 24 | 25 | 26 | def test_high_and_low_count(): 27 | arr = np.array([101, 102, 97.4, 91, 96, 102, 106]) 28 | result = np.array([0, 1, 1, 1, 1, 1, 2]) 29 | assert np.array_equal(high_count(arr), result) 30 | result = np.array([0, 0, 1, 2, 2, 2, 2]) 31 | assert np.array_equal(low_count(arr), result) 32 | 33 | 34 | def test_last_high(): 35 | arr = np.array([101, 102, 100, 100, 103, 102]) 36 | result = np.array([0, 1, 1, 1, 4, 4]) 37 | assert np.array_equal(last_high(arr), result) 38 | -------------------------------------------------------------------------------- /tests/test_metrics.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pandas as pd 3 | 4 | from fastbt.metrics import * 5 | 6 | 7 | class TestSpread(unittest.TestCase): 8 | def setUp(self): 9 | dates = pd.date_range("2016-01-01", "2019-12-31") 10 | s = pd.Series(index=dates) 11 | s.loc[:] = 1 12 | self.s = s 13 | 14 | def test_default(self): 15 | s = self.s.copy() 16 | df = spread_test(s) 17 | answer = pd.DataFrame( 18 | { 19 | "num_profit": [4, 16, 48], 20 | "profit": [1461.0, 1461.0, 1461.0], 21 | "num_loss": [0, 0, 0], 22 | "loss": [0.0, 0.0, 0.0], 23 | }, 24 | index=["Y", "Q", "M"], 25 | ) 26 | assert answer.equals(df) 27 | -------------------------------------------------------------------------------- /tests/test_simulation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from fastbt.simulation import * 4 | from pandas.testing import assert_frame_equal 5 | 6 | test_data = ( 7 | pd.read_csv("tests/data/index.csv", parse_dates=["date"]) 8 | .set_index("date") 9 | .sort_index() 10 | ) 11 | 12 | 13 | def test_walk_forward_simple(): 14 | expected = pd.read_csv("tests/data/is_pret.csv", parse_dates=["date"]) 15 | result = walk_forward(test_data, "Y", ["is_pret"], "ret", sum) 16 | del result["_period"] 17 | assert len(result) == len(expected) 18 | assert_frame_equal(expected, result) 19 | -------------------------------------------------------------------------------- /tests/test_tradebook.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import context 3 | import pytest 4 | 5 | from fastbt.tradebook import TradeBook 6 | from collections import Counter 7 | 8 | 9 | @pytest.fixture 10 | def simple(): 11 | tb = TradeBook(name="tb") 12 | tb.add_trade("2023-01-01", "aapl", 120, 10, "B") 13 | tb.add_trade("2023-01-01", "goog", 100, 10, "B") 14 | tb.add_trade("2023-01-05", "aapl", 120, 20, "S") 15 | tb.add_trade("2023-01-07", "goog", 100, 10, "B") 16 | return tb 17 | 18 | 19 | def test_name(): 20 | tb = TradeBook() 21 | assert tb.name == "tradebook" 22 | tb = TradeBook("myTradeBook") 23 | assert tb.name == "myTradeBook" 24 | 25 | 26 | def test_repr(): 27 | tb = TradeBook("MyTradeBook") 28 | for i in range(10): 29 | tb.add_trade("2018-01-01", "AAA", 100, 100, "B") 30 | assert tb.__repr__() == "MyTradeBook with 10 entries and 1 positions" 31 | tb.add_trade("2018-01-01", "AAA", 110, 1000, "S") 32 | assert tb.__repr__() == "MyTradeBook with 11 entries and 0 positions" 33 | 34 | 35 | def test_trades(): 36 | tb = TradeBook() 37 | for i in range(100): 38 | tb.add_trade("2018-01-01", "AAA", 100, 100, "B") 39 | assert len(tb.all_trades) == 100 40 | tb.add_trade("2018-01-01", "AAA", 100, 1, "S") 41 | assert len(tb.all_trades) == 101 42 | counter = 101 43 | import random 44 | 45 | for i in range(5): 46 | r = random.randint(0, 50) 47 | counter += r 48 | for j in range(r): 49 | tb.add_trade("2018-01-01", "MX", 100, r, "S") 50 | assert len(tb.all_trades) == counter 51 | 52 | 53 | def test_trades_multiple_symbols(): 54 | tb = TradeBook() 55 | symbols = list("ABCD") 56 | trades = [10, 20, 30, 40] 57 | for i, j in zip(symbols, trades): 58 | for p in range(j): 59 | if p % 5 == 0: 60 | tb.add_trade("2019-01-01", i, 100, 10, "B", tag="mod") 61 | else: 62 | tb.add_trade("2019-01-01", i, 100, 10, "B") 63 | assert len(tb.trades["A"]) == 10 64 | assert len(tb.trades["B"]) == 20 65 | assert len(tb.trades["C"]) == 30 66 | assert len(tb.trades["D"]) == 40 67 | assert len(tb.all_trades) == 100 68 | assert sum([1 for d in tb.all_trades if d.get("tag")]) == 20 69 | 70 | 71 | def test_trades_keyword_arguments(): 72 | tb = TradeBook() 73 | dct = { 74 | "timestamp": "2019-01-01", 75 | "symbol": "AAAA", 76 | "price": 100, 77 | "qty": 10, 78 | "order": "B", 79 | } 80 | tb.add_trade(**dct) 81 | tb.add_trade(**dct, id=7) 82 | tb.add_trade(**dct, tag="x") 83 | tb.add_trade(**dct, tag="y") 84 | assert tb.trades["AAAA"][0]["price"] == 100 85 | assert tb.trades["AAAA"][1]["id"] == 7 86 | assert sum([1 for d in tb.all_trades if d.get("tag")]) == 2 87 | 88 | 89 | def test_positions(): 90 | tb = TradeBook() 91 | for i in range(10): 92 | tb.add_trade("2018-01-01", "SNP", 18000, 1, "B") 93 | assert len(tb.positions) == 1 94 | assert tb.positions["SNP"] == 10 95 | for i in range(5): 96 | tb.add_trade("2018-01-02", "SNP", 19000, 1, "S") 97 | assert tb.positions["SNP"] == 5 98 | tb.add_trade("2018-01-05", "QQQ", 4300, 3, "S") 99 | assert len(tb.positions) == 2 100 | assert tb.positions["QQQ"] == -3 101 | 102 | 103 | class TestPositions(unittest.TestCase): 104 | def setUp(self): 105 | tb = TradeBook() 106 | tb.add_trade(1, "ABC", 75, 100, "B") 107 | tb.add_trade(1, "AAA", 76, 100, "B") 108 | tb.add_trade(1, "XYZ", 77, 100, "S") 109 | tb.add_trade(1, "XXX", 78, 100, "S") 110 | self.tb = tb 111 | 112 | def test_open_positions(self): 113 | assert self.tb.o == 4 114 | self.tb.add_trade(2, "ABC", 75, 100, "S") 115 | assert self.tb.o == 3 116 | 117 | def test_long_positions(self): 118 | assert self.tb.l == 2 119 | self.tb.add_trade(2, "MAB", 10, 10, "B") 120 | assert self.tb.l == 3 121 | self.tb.add_trade(2, "MAB", 10, 10, "S") 122 | self.tb.add_trade(1, "ABC", 75, 100, "S") 123 | self.tb.add_trade(1, "AAA", 76, 100, "S") 124 | assert self.tb.l == 0 125 | self.tb.add_trade(1, "AAA", 76, 100, "S") 126 | assert self.tb.l == 0 127 | 128 | def test_short_positions(self): 129 | assert self.tb.s == 2 130 | self.tb.add_trade(2, "XYZ", 77, 100, "B") 131 | self.tb.add_trade(2, "XXX", 78, 100, "B") 132 | assert self.tb.s == 0 133 | for i in range(10): 134 | self.tb.add_trade(i + 3, "MMM", 85, 10, "S") 135 | assert self.tb.s == 1 136 | 137 | def test_long_positions_two(self): 138 | assert self.tb.l == 2 139 | for i in range(10): 140 | self.tb.add_trade(i + 2, "AAA", 82, 10, "B") 141 | assert self.tb.l == 2 142 | for i in range(10): 143 | self.tb.add_trade(i + 12, "AAA" + str(i), 75, 10, "B") 144 | assert self.tb.l == i + 3 145 | 146 | 147 | class TestValues(unittest.TestCase): 148 | def setUp(self): 149 | tb = TradeBook() 150 | tb.add_trade(1, "ABC", 75, 100, "B") 151 | tb.add_trade(1, "AAA", 76, 100, "B") 152 | tb.add_trade(1, "XYZ", 77, 100, "S") 153 | tb.add_trade(1, "XXX", 78, 100, "S") 154 | self.tb = tb 155 | 156 | def test_values(self): 157 | c = Counter(ABC=-7500, AAA=-7600, XYZ=7700, XXX=7800) 158 | assert self.tb.values == c 159 | 160 | def test_values_pnl(self): 161 | self.tb.add_trade(2, "ABC", 80, 100, "S") 162 | c = Counter(ABC=500, AAA=-7600, XYZ=7700, XXX=7800) 163 | assert self.tb.values == c 164 | 165 | def test_values_pnl_two(self): 166 | self.tb.add_trade(3, "XXX", 80, 100, "B") 167 | assert self.tb.values["XXX"] == -200 168 | 169 | def test_clear(self): 170 | assert len(self.tb.positions) > 0 171 | self.tb.clear() 172 | assert len(self.tb.positions) == 0 173 | assert self.tb.positions == Counter() 174 | assert self.tb.values == Counter() 175 | assert self.tb.trades == dict() 176 | 177 | 178 | def test_remove_trade_buy(): 179 | tb = TradeBook() 180 | tb.add_trade("2023-01-01", "aapl", 100, 10, "B") 181 | tb.add_trade("2023-01-02", "aapl", 100, 20, "B") 182 | tb.add_trade("2023-01-03", "aapl", 100, 30, "B") 183 | assert tb.positions == dict(aapl=60) 184 | assert tb.values == dict(aapl=-6000) 185 | tb.remove_trade("aapl") 186 | assert tb.positions == dict(aapl=30) 187 | assert tb.values == dict(aapl=-3000) 188 | tb.add_trade("2023-01-02", "aapl", 100, 20, "B") 189 | assert tb.positions == dict(aapl=50) 190 | assert tb.values == dict(aapl=-5000) 191 | for i in range(10): 192 | tb.remove_trade("aapl") 193 | assert tb.positions == tb.values == dict(aapl=0) 194 | 195 | 196 | def test_remove_trade_sell(): 197 | tb = TradeBook() 198 | tb.add_trade("2023-01-01", "aapl", 100, 10, "S") 199 | tb.add_trade("2023-01-02", "aapl", 100, 20, "S") 200 | tb.add_trade("2023-01-03", "aapl", 100, 30, "S") 201 | assert tb.positions == dict(aapl=-60) 202 | assert tb.values == dict(aapl=6000) 203 | tb.remove_trade("aapl") 204 | assert tb.positions == dict(aapl=-30) 205 | assert tb.values == dict(aapl=3000) 206 | for i in range(10): 207 | tb.remove_trade("aapl") 208 | assert tb.positions == tb.values == dict(aapl=0) 209 | 210 | 211 | def test_remove_trade_multiple_symbols(): 212 | tb = TradeBook() 213 | tb.add_trade("2023-01-01", "aapl", 100, 10, "S") 214 | tb.add_trade("2023-01-01", "goog", 100, 10, "B") 215 | assert tb.positions == dict(aapl=-10, goog=10) 216 | assert tb.values == dict(aapl=1000, goog=-1000) 217 | tb.remove_trade("goog") 218 | assert tb.positions == dict(aapl=-10, goog=0) 219 | assert tb.values == dict(aapl=1000, goog=0) 220 | tb.remove_trade("xom") 221 | assert tb.positions == dict(aapl=-10, goog=0) 222 | assert tb.values == dict(aapl=1000, goog=0) 223 | 224 | 225 | def test_mtm_no_positions(): 226 | tb = TradeBook() 227 | assert tb.mtm(prices=dict()) == dict() 228 | tb.add_trade("2023-01-01", "goog", 100, 10, "B") 229 | tb.add_trade("2023-01-01", "goog", 110, 10, "S") 230 | assert tb.mtm(prices=dict()) == dict(goog=100) 231 | assert tb.mtm(prices=dict(goog=125)) == dict(goog=100) 232 | 233 | 234 | def test_mtm_long_positions(): 235 | tb = TradeBook() 236 | tb.add_trade("2023-01-01", "goog", 100, 10, "B") 237 | assert tb.mtm(dict(goog=120)) == dict(goog=200) 238 | assert tb.mtm(dict(goog=90)) == dict(goog=-100) 239 | tb.remove_trade("goog") 240 | assert tb.mtm(dict(goog=120)) == dict(goog=0) 241 | tb.clear() 242 | assert tb.mtm(dict(goog=120)) == dict() 243 | 244 | 245 | def test_mtm_short_positions(): 246 | tb = TradeBook() 247 | tb.add_trade("2023-01-01", "goog", 100, 10, "S") 248 | assert tb.mtm(dict(goog=120)) == dict(goog=-200) 249 | assert tb.mtm(dict(goog=90)) == dict(goog=100) 250 | 251 | 252 | def test_mtm_multiple_positions(): 253 | tb = TradeBook() 254 | tb.add_trade("2023-01-01", "aapl", 180, 5, "B") 255 | tb.add_trade("2023-01-01", "goog", 100, 10, "S") 256 | assert tb.mtm(dict(aapl=180, goog=100)) == dict(aapl=0, goog=0) 257 | assert tb.mtm(dict(aapl=200, goog=130)) == dict(aapl=100, goog=-300) 258 | 259 | 260 | def test_mtm_raise_error(): 261 | tb = TradeBook() 262 | tb.add_trade("2023-01-01", "aapl", 180, 5, "B") 263 | tb.add_trade("2023-01-01", "goog", 100, 10, "S") 264 | with pytest.raises(ValueError): 265 | assert tb.mtm(dict(apl=180, goog=100)) == dict(aapl=0, goog=0) 266 | 267 | 268 | def test_add_trade_buy_sell(): 269 | tb = TradeBook() 270 | tb.add_trade("2023-01-01", "aapl", 120, 10, "BUY") 271 | tb.add_trade("2023-01-01", "goog", 100, 10, "buy") 272 | tb.add_trade("2023-01-05", "aapl", 120, 20, "sell") 273 | tb.add_trade("2023-01-07", "goog", 100, 10, "SELL") 274 | assert tb.positions == dict(aapl=-10, goog=0) 275 | 276 | 277 | def test_open_positions(simple): 278 | assert simple.open_positions == dict(aapl=-10, goog=20) 279 | simple.add_trade("2023-01-05", "aapl", 120, 10, "buy") 280 | assert simple.open_positions == dict(goog=20) 281 | 282 | 283 | def test_long_positions(simple): 284 | assert simple.long_positions == dict(goog=20) 285 | simple.remove_trade("goog") 286 | assert simple.long_positions == dict(goog=10) 287 | simple.remove_trade("goog") 288 | assert simple.long_positions == dict() 289 | 290 | 291 | def test_short_positions(simple): 292 | assert simple.short_positions == dict(aapl=-10) 293 | simple.clear() 294 | assert simple.short_positions == dict() 295 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | ; a generative tox configuration, see: https://tox.readthedocs.io/en/latest/config.html#generative-envlist 2 | 3 | [tox] 4 | envlist = 5 | clean, 6 | check, 7 | {py35,py36,py37}, 8 | report 9 | 10 | [testenv] 11 | basepython = 12 | py35: {env:TOXPYTHON:python3.5} 13 | py36: {env:TOXPYTHON:python3.6} 14 | py37: {env:TOXPYTHON:python3.7} 15 | {bootstrap,clean,check,report,codecov,coveralls}: {env:TOXPYTHON:python3} 16 | setenv = 17 | PYTHONPATH={toxinidir}/tests 18 | PYTHONUNBUFFERED=yes 19 | passenv = 20 | * 21 | usedevelop = false 22 | deps = 23 | pytest 24 | pytest-travis-fold 25 | pytest-cov 26 | numpy 27 | pandas 28 | PyYAML 29 | sqlalchemy 30 | tables 31 | xlrd 32 | TA-Lib 33 | commands = 34 | {posargs:pytest --cov --cov-report=term-missing -vv tests} 35 | 36 | [testenv:bootstrap] 37 | deps = 38 | jinja2 39 | matrix 40 | skip_install = true 41 | commands = 42 | python ci/bootstrap.py 43 | 44 | [testenv:check] 45 | deps = 46 | docutils 47 | check-manifest 48 | flake8 49 | readme-renderer 50 | pygments 51 | isort 52 | skip_install = true 53 | commands = 54 | python setup.py check --strict --metadata --restructuredtext 55 | check-manifest {toxinidir} 56 | flake8 src tests setup.py 57 | isort --verbose --check-only --diff --recursive src tests setup.py 58 | 59 | [testenv:coveralls] 60 | deps = 61 | coveralls 62 | skip_install = true 63 | commands = 64 | coveralls [] 65 | 66 | [testenv:codecov] 67 | deps = 68 | codecov 69 | skip_install = true 70 | commands = 71 | coverage xml --ignore-errors 72 | codecov [] 73 | 74 | [testenv:report] 75 | deps = coverage 76 | skip_install = true 77 | commands = 78 | coverage report 79 | coverage html 80 | 81 | [testenv:clean] 82 | commands = coverage erase 83 | skip_install = true 84 | deps = coverage 85 | 86 | --------------------------------------------------------------------------------