├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── dockerfiles ├── bionic │ └── Dockerfile ├── centos8 │ └── Dockerfile ├── fedora33 │ └── Dockerfile └── focal │ └── Dockerfile ├── examples ├── buy_and_hold.py ├── long_short.py ├── momentum_taa.py ├── sixty_forty.py └── sixty_forty_fees.py ├── pyproject.toml ├── qstrader ├── __init__.py ├── alpha_model │ ├── __init__.py │ ├── alpha_model.py │ ├── fixed_signals.py │ └── single_signal.py ├── asset │ ├── __init__.py │ ├── asset.py │ ├── cash.py │ ├── equity.py │ └── universe │ │ ├── __init__.py │ │ ├── dynamic.py │ │ ├── static.py │ │ └── universe.py ├── broker │ ├── __init__.py │ ├── broker.py │ ├── fee_model │ │ ├── __init__.py │ │ ├── fee_model.py │ │ ├── percent_fee_model.py │ │ └── zero_fee_model.py │ ├── portfolio │ │ ├── __init__.py │ │ ├── portfolio.py │ │ ├── portfolio_event.py │ │ ├── position.py │ │ └── position_handler.py │ ├── simulated_broker.py │ └── transaction │ │ ├── __init__.py │ │ └── transaction.py ├── data │ ├── __init__.py │ ├── backtest_data_handler.py │ └── daily_bar_csv.py ├── exchange │ ├── __init__.py │ ├── exchange.py │ └── simulated_exchange.py ├── execution │ ├── __init__.py │ ├── execution_algo │ │ ├── __init__.py │ │ ├── execution_algo.py │ │ └── market_order.py │ ├── execution_handler.py │ └── order.py ├── portcon │ ├── __init__.py │ ├── optimiser │ │ ├── __init__.py │ │ ├── equal_weight.py │ │ ├── fixed_weight.py │ │ └── optimiser.py │ ├── order_sizer │ │ ├── __init__.py │ │ ├── dollar_weighted.py │ │ ├── long_short.py │ │ └── order_sizer.py │ └── pcm.py ├── risk_model │ ├── __init__.py │ └── risk_model.py ├── settings.py ├── signals │ ├── __init__.py │ ├── buffer.py │ ├── momentum.py │ ├── signal.py │ ├── signals_collection.py │ ├── sma.py │ └── vol.py ├── simulation │ ├── __init__.py │ ├── daily_bday.py │ ├── event.py │ └── sim_engine.py ├── statistics │ ├── __init__.py │ ├── json_statistics.py │ ├── performance.py │ ├── statistics.py │ └── tearsheet.py ├── system │ ├── __init__.py │ ├── qts.py │ └── rebalance │ │ ├── __init__.py │ │ ├── buy_and_hold.py │ │ ├── daily.py │ │ ├── end_of_month.py │ │ ├── rebalance.py │ │ └── weekly.py ├── trading │ ├── __init__.py │ ├── backtest.py │ └── trading_session.py └── utils │ ├── __init__.py │ └── console.py ├── requirements ├── base.txt └── tests.txt ├── scripts ├── __init__.py └── static_backtest.py └── tests ├── conftest.py ├── integration ├── portcon │ └── test_pcm_e2e.py └── trading │ ├── conftest.py │ ├── fixtures │ ├── ABC.csv │ ├── DEF.csv │ ├── GHI.csv │ ├── long_short_history.dat │ └── sixty_forty_history.dat │ └── test_backtest_e2e.py └── unit ├── alpha_model ├── test_fixed_signals.py └── test_single_signal.py ├── asset ├── test_cash.py └── universe │ ├── test_dynamic_universe.py │ └── test_static_universe.py ├── broker ├── fee_model │ ├── test_percent_fee_model.py │ └── test_zero_fee_model.py ├── portfolio │ ├── test_portfolio.py │ ├── test_position.py │ └── test_position_handler.py ├── test_simulated_broker.py └── transaction │ └── test_transaction.py ├── portcon ├── optimiser │ ├── test_equal_weight.py │ └── test_fixed_weight.py ├── order_sizer │ ├── test_dollar_weighted.py │ └── test_long_short.py └── test_pcm.py ├── signals ├── test_momentum.py └── test_sma.py ├── simulation ├── test_daily_bday.py └── test_event.py ├── system └── rebalance │ ├── test_buy_and_hold_rebalance.py │ ├── test_daily_rebalance.py │ ├── test_end_of_month_rebalance.py │ └── test_weekly_rebalance.py └── utils └── test_console.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | # Have to re-enable the standard pragma 4 | pragma: no cover 5 | 6 | # Don't complain if tests don't hit defensive assertion code: 7 | raise NotImplementedError 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.py[co] 3 | .DS_Store 4 | __pycache__ 5 | out 6 | cover 7 | 8 | # environment 9 | env/ 10 | venv/ 11 | 12 | # Packages 13 | *.egg 14 | *.egg-info 15 | dist 16 | build 17 | eggs 18 | parts 19 | private_files 20 | bin 21 | var 22 | sdist 23 | develop-eggs 24 | .installed.cfg 25 | hatch-1.9.3.pkg 26 | 27 | # Installer logs 28 | pip-log.txt 29 | 30 | # Unit test / coverage reports 31 | .coverage 32 | .tox 33 | 34 | #Translations 35 | *.mo 36 | 37 | #Mr Developer 38 | .mr.developer.cfg 39 | 40 | # PyTest 41 | .cache 42 | 43 | # Textmate (a Mac editor) meta files 44 | ._* 45 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | - "3.9" 8 | 9 | before_install: 10 | - export PYTHONPATH=$PYTHONPATH:$(pwd) 11 | 12 | install: 13 | - pip install -r requirements/base.txt 14 | - pip install -r requirements/tests.txt 15 | 16 | script: 17 | - pytest --cov=qstrader/ 18 | - flake8 --ignore E501,F501,W504 tests qstrader 19 | 20 | after_success: 21 | - coveralls 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 2 | 3 | * Updates dependencies to use numpy v2.0.0. 4 | * Updates simulated_broker.py to change np.NaN to np.nan 5 | * Updates backtest_data_handler.py to change np.NaN to np.nan 6 | * Updates daily_bar_csv.py to change np.NaN to np.nan 7 | * Updates tests 8 | 9 | # 0.2.9 10 | 11 | * Updates requirements file to use numpy v1.26.4 or lower. This is the last version of QSTrader that supports numpy<2.0.0. 12 | 13 | # 0.2.8 14 | 15 | * Updates BacktestTradingSession.get_target_allocations() to use burn_in_dt.date() instead of burn_in_dt Timestamp. Previous method compared a Timestamp to a datetime.date. 16 | * Adds an integration test to check that target allocations match the expected output, including a date index. 17 | 18 | # 0.2.7 19 | 20 | * Updates the execution handler to update final orders ensuring an execution order is created in the event of a single submission without a further rebalance. 21 | * Updates rebalance_buy_and_hold to check if the start_dt is a business day 22 | If start_dt is a business day rebalance_dates = [start_dt] 23 | If start_dt is a weekend rebalance_dates = [next business day] 24 | * Adds a unit test to check that the buisness day calculation is correct 25 | * Adds an integration test to check that a backtest using buy_and_hold_rebalance generates execution orders on the correct dates 26 | 27 | 28 | # 0.2.6 29 | 30 | * Removed get_portfolio_total_non_cash_equity and get_account_total_non_cash_equity from broker/broker.py abstract base class. These methods are not implemented. 31 | * Added save option to TearsheetStatistics class in statistics/tearsheet.py. The tearsheet output can now be saved to a given filename by passing the optional filename parameter as a string when calling the plot_results function. 32 | 33 | 34 | # 0.2.5 35 | 36 | * Moved build-backend system to Hatchling from setuptools 37 | * Updated the python package requirements to work with click 8.1 38 | * Updated ReadMe and ChangeLog. 39 | 40 | # 0.2.4 41 | 42 | * Fixed bug involving NaN at Timestamp in sixty_forty example. 43 | * Removed support for python 3.7 and 3.8 44 | * Updated the python package requirements to work with matplotlib 3.8, numpy 1.26 and pandas 2.2.0 45 | 46 | # 0.2.3 47 | 48 | * Updated the python package requirements to work with matplotlib 3.4, numpy 1.21 and pandas 1.3 49 | * Removed support for python 3.6 50 | * Added a Tactical Asset Allocation monthly momentum strategy to the examples 51 | 52 | # 0.2.2 53 | 54 | * Added link to full documentation at [https://www.quantstart.com/qstrader/](https://www.quantstart.com/qstrader/) 55 | * Fixed bug where burn-in period was still allowing portfolio rebalances and trade executions 56 | * Added QSTrader Dockerfiles for various Linux distributions 57 | * Removed support for Python 3.5 and added support for Python 3.9 58 | * Increased minimum supported Pandas version to 1.1.5 from 0.25.1 59 | * Modified end-to-end backtest integration test to check for approximate equality of results to fix differences across Pandas versions 60 | * Disallowed Matplotlib 3.3.3 temporarily to avoid deprecated functionality from causing errors 61 | * Event print messages during backtests can now be disabled through a boolean setting 62 | 63 | # 0.2.1 64 | 65 | * Added VolatilitySignal class to calculate rolling annualised volatility of returns for an asset 66 | * Removed errors for orders that exceed cash account balance in SimulatedBroker and Portfolio. Replaced with console warnings. 67 | 68 | # 0.2.0 69 | 70 | * Significant overhaul of Position, PositionHandler, Portfolio, Transaction and SimulatedBroker classes to correctly account for short selling of assets 71 | * Addition of LongShortLeveragedOrderSizer to allow long/short leveraged portfolios 72 | * Added a new long/short leveraged portfolio example backtest 73 | * Added some unit and integration tests to improve test coverage slightly 74 | 75 | # 0.1.4 76 | 77 | * Added ValueError with more verbose description for NaN pricing data when backtest start date too early 78 | * Removed usage of 'inspect' library for updating attributes of Position within PositionHandler 79 | * Added unit tests for Cash asset, StaticUniverse, DynamicUniverse and string colour utility function 80 | * Added two more statistics to the JSON statistics calculation 81 | 82 | # 0.1.3 83 | 84 | * Fixed bug involving DynamicUniverse not adding assets to momentum and signal calculation if not present at start of backtest 85 | * Modified MomentumSignal and SMASignal to allow calculation if available prices less than lookbacks 86 | * Added daily rebalancing capability 87 | * Added some unit tests to improve test coverage slightly 88 | 89 | # 0.1.2 90 | 91 | * Added RiskModel class hierarchy 92 | * Modified API for MomentumSignal and SMASignal to utilise inherited Signal object 93 | * Added SignalsCollection entity to update data for derived Signal classes 94 | * Removed unnecessary BufferAlphaModel 95 | * Added some unit tests to improve test coverage slightly 96 | 97 | # 0.1.1 98 | 99 | * Removed the need to specify a CSV data directory as an environment variable by adding a default of the current working directory of the executed script 100 | * Addes CI support for Python 3.5, 3.6 and 3.8 in addition to 3.7 101 | * Added some unit tests to improve test coverage slightly 102 | 103 | # 0.1.0 104 | 105 | * Initial relase of QSTrader to PyPI 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2024 QuantStart.com, QuarkGluon Ltd 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 | include README.md LICENSE CHANGELOG.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QSTrader 2 | 3 | | Development | Details | 4 | | ------------- | ------------- | 5 | | Test Status | [![Build Status](https://img.shields.io/travis/mhallsmoore/qstrader?label=TravisCI&style=flat-square)](https://travis-ci.org/mhallsmoore/qstrader) [![Coverage Status](https://img.shields.io/coveralls/github/mhallsmoore/qstrader?style=flat-square&label=Coverage)](https://coveralls.io/github/mhallsmoore/qstrader?branch=master) | 6 | | Version Info | [![PyPI](https://img.shields.io/pypi/v/qstrader?style=flat-square&label=PyPI&color=blue)](https://pypi.org/project/qstrader) [![PyPI Downloads](https://img.shields.io/pypi/dm/qstrader?style=flat-square&label=PyPI%20Downloads)](https://pypi.org/project/qstrader) | 7 | | Compatibility | [![Python Version](https://img.shields.io/pypi/pyversions/qstrader?style=flat-square&label=Python%20Versions)](https://pypi.org/project/qstrader) | 8 | | License | ![GitHub](https://img.shields.io/github/license/mhallsmoore/qstrader?style=flat-square&label=License) | 9 | 10 | QSTrader is a free Python-based open-source modular schedule-driven backtesting framework for long-short equities and ETF based systematic trading strategies. 11 | 12 | QSTrader can be best described as a loosely-coupled collection of modules for carrying out end-to-end backtests with realistic trading mechanics. 13 | 14 | The default modules provide useful functionality for certain types of systematic trading strategies and can be utilised without modification. However the intent of QSTrader is for the users to extend, inherit or fully replace each module in order to provide custom functionality for their own use case. 15 | 16 | The software is currently under active development and is provided under a permissive "MIT" license. 17 | 18 | # Previous Version and Advanced Algorithmic Trading 19 | 20 | Please note that the previous version of QSTrader, which is utilised through the **Advanced Algorithmic Trading** ebook, can be found along with the appropriate installation instructions [here](https://github.com/mhallsmoore/qstrader/tree/advanced-algorithmic-trading). 21 | 22 | It has recently been updated to support Python 3.9, 3.10, 3.11 and 3.12 with up to date package dependencies. 23 | 24 | # Installation 25 | 26 | Installation requires a Python3 environment. The simplest approach is to download a self-contained scientific Python distribution such as the [Anaconda Individual Edition](https://www.anaconda.com/products/individual#Downloads). You can then install QSTrader into an isolated [virtual environment](https://docs.python.org/3/tutorial/venv.html#virtual-environments-and-packages) using pip as shown below. 27 | 28 | Any issues with installation should be reported to the development team as issues [here](https://github.com/mhallsmoore/qstrader/issues). 29 | 30 | ## conda 31 | 32 | [conda](https://docs.conda.io/projects/conda/en/latest/) is a command-line tool that comes with the Anaconda distribution. It allows you to manage virtual environments as well as packages _using the same tool_. 33 | 34 | The following command will create a brand new environment called `backtest`. 35 | 36 | ``` 37 | conda create -n backtest python 38 | ``` 39 | This will use the conda default Python version. At time of writing this was Python 3.12. QSTrader currently supports Python 3.9, 3.10, 3.11 and 3.12. Optionally you can specify a python version by substituting python==3.9 into the command as follows: 40 | 41 | ``` 42 | conda create -n backtest python==3.9 43 | ``` 44 | 45 | In order to start using QSTrader, you need to activate this new environment and install QSTrader using pip. 46 | 47 | ``` 48 | conda activate backtest 49 | pip3 install qstrader 50 | ``` 51 | 52 | ## pip 53 | 54 | Alternatively, you can use [venv](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) to handle the environment creation and [pip](https://docs.python.org/3/tutorial/venv.html#managing-packages-with-pip) to handle the package installation. 55 | 56 | ``` 57 | python -m venv backtest 58 | source backtest/bin/activate # Need to activate environment before installing package 59 | pip3 install qstrader 60 | ``` 61 | 62 | # Full Documentation 63 | 64 | Comprehensive documentation and beginner tutorials for QSTrader can be found on QuantStart.com at [https://www.quantstart.com/qstrader/](https://www.quantstart.com/qstrader/). 65 | 66 | # Quickstart 67 | 68 | The QSTrader repository provides some simple example strategies at [/examples](https://github.com/mhallsmoore/qstrader/tree/master/examples). 69 | 70 | Within this quickstart section a classic 60/40 equities/bonds portfolio will be backtested with monthly rebalancing on the last day of the calendar month. 71 | 72 | To get started download the [sixty_forty.py](https://github.com/mhallsmoore/qstrader/blob/master/examples/sixty_forty.py) file and place into the directory of your choice. 73 | 74 | The 60/40 script makes use of OHLC 'daily bar' data from Yahoo Finance. In particular it requires the [SPY](https://finance.yahoo.com/quote/SPY/history?p=SPY) and [AGG](https://finance.yahoo.com/quote/AGG/history?p=AGG) ETFs data. Download the full history for each and save as CSV files in same directory as ``sixty_forty.py``. 75 | 76 | Assuming that an appropriate Python environment exists and QSTrader has been installed (see **Installation** above), make sure to activate the virtual environment, navigate to the directory with ``sixty_forty.py`` and type: 77 | 78 | ``` 79 | python sixty_forty.py 80 | ``` 81 | 82 | You will then see some console output as the backtest simulation engine runs through each day and carries out the rebalancing logic once per month. Once the backtest is complete a tearsheet will appear: 83 | 84 | ![Image of 60/40 Backtest](https://quantstartmedia.s3.amazonaws.com/images/qstrader_sixty_forty_backtest.png) 85 | 86 | You can examine the commented ``sixty_forty.py`` file to see the current QSTrader backtesting API. 87 | 88 | If you have any questions about the installation or example usage then please feel free to email [support@quantstart.com](mailto:support@quantstart.com) or raise an issue [here](https://github.com/mhallsmoore/qstrader/issues). 89 | 90 | # Current Features 91 | 92 | * **Backtesting Engine** - QSTrader employs a schedule-based portfolio construction approach to systematic trading. Signal generation is decoupled from portfolio construction, risk management, execution and simulated brokerage accounting in a modular, object-oriented fashion. 93 | 94 | * **Performance Statistics** - QSTrader provides typical 'tearsheet' performance assessment of strategies. It also supports statistics export via JSON to allow external software to consume metrics from backtests. 95 | 96 | * **Free Open-Source Software** - QSTrader has been released under a permissive open-source MIT License. This allows full usage in both research and commercial applications, without restriction, but with no warranty of any kind whatsoever (see **License** below). QSTrader is completely free and costs nothing to download or use. 97 | 98 | * **Software Development** - QSTrader is written in the Python programming language for straightforward cross-platform support. QSTrader contains a suite of unit and integration tests for the majority of its modules. Tests are continually added for new features. 99 | 100 | # License Terms 101 | 102 | Copyright (c) 2015-2024 QuantStart.com, QuarkGluon Ltd 103 | 104 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 105 | 106 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 107 | 108 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 109 | 110 | # Trading Disclaimer 111 | 112 | Trading equities on margin carries a high level of risk, and may not be suitable for all investors. Past performance is not indicative of future results. The high degree of leverage can work against you as well as for you. Before deciding to invest in equities you should carefully consider your investment objectives, level of experience, and risk appetite. The possibility exists that you could sustain a loss of some or all of your initial investment and therefore you should not invest money that you cannot afford to lose. You should be aware of all the risks associated with equities trading, and seek advice from an independent financial advisor if you have any doubts. 113 | -------------------------------------------------------------------------------- /dockerfiles/bionic/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD: docker build -t qstrader-bionic . 2 | # RUN: docker run -it -v "$PWD":/qstrader-data qstrader-bionic 3 | 4 | FROM ubuntu:bionic 5 | 6 | RUN apt-get update && apt-get install -y git python3-pip 7 | RUN git clone https://github.com/mhallsmoore/qstrader.git 8 | RUN pip3 install -r qstrader/requirements/base.txt 9 | RUN pip3 install -r qstrader/requirements/tests.txt 10 | WORKDIR /qstrader 11 | ENV PYTHONPATH /qstrader 12 | RUN pytest 13 | -------------------------------------------------------------------------------- /dockerfiles/centos8/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD: docker build -t qstrader-centos8 . 2 | # RUN: docker run -it -v "$PWD":/qstrader-data qstrader-centos8 3 | 4 | FROM centos:8 5 | 6 | RUN dnf install -y make gcc gcc-c++ git python3-pip python3-devel 7 | RUN git clone https://github.com/mhallsmoore/qstrader.git 8 | RUN pip3 install -r qstrader/requirements/base.txt 9 | RUN pip3 install -r qstrader/requirements/tests.txt 10 | WORKDIR /qstrader 11 | ENV PYTHONPATH /qstrader 12 | RUN pytest 13 | -------------------------------------------------------------------------------- /dockerfiles/fedora33/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD: docker build -t qstrader-f33 . 2 | # RUN: docker run -it -v "$PWD":/qstrader-data qstrader-f33 3 | 4 | FROM fedora:33 5 | 6 | RUN dnf install -y make gcc gcc-c++ git python3-pip python3-devel 7 | RUN git clone https://github.com/mhallsmoore/qstrader.git 8 | RUN pip3 install -r qstrader/requirements/base.txt 9 | RUN pip3 install -r qstrader/requirements/tests.txt 10 | WORKDIR /qstrader 11 | ENV PYTHONPATH /qstrader 12 | RUN pytest 13 | -------------------------------------------------------------------------------- /dockerfiles/focal/Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD: docker build -t qstrader-focal . 2 | # RUN: docker run -it -v "$PWD":/qstrader-data qstrader-focal 3 | 4 | FROM ubuntu:focal 5 | 6 | RUN apt-get update && apt-get install -y git python3-pip 7 | RUN git clone https://github.com/mhallsmoore/qstrader.git 8 | RUN pip3 install -r qstrader/requirements/base.txt 9 | RUN pip3 install -r qstrader/requirements/tests.txt 10 | WORKDIR /qstrader 11 | ENV PYTHONPATH /qstrader 12 | RUN pytest 13 | -------------------------------------------------------------------------------- /examples/buy_and_hold.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | import pytz 5 | 6 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 7 | from qstrader.asset.equity import Equity 8 | from qstrader.asset.universe.static import StaticUniverse 9 | from qstrader.data.backtest_data_handler import BacktestDataHandler 10 | from qstrader.data.daily_bar_csv import CSVDailyBarDataSource 11 | from qstrader.statistics.tearsheet import TearsheetStatistics 12 | from qstrader.trading.backtest import BacktestTradingSession 13 | 14 | 15 | if __name__ == "__main__": 16 | start_dt = pd.Timestamp('2004-11-19 14:30:00', tz=pytz.UTC) 17 | end_dt = pd.Timestamp('2019-12-31 23:59:00', tz=pytz.UTC) 18 | 19 | # Construct the symbol and asset necessary for the backtest 20 | strategy_symbols = ['GLD'] 21 | strategy_assets = ['EQ:GLD'] 22 | strategy_universe = StaticUniverse(strategy_assets) 23 | 24 | # To avoid loading all CSV files in the directory, set the 25 | # data source to load only those provided symbols 26 | csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.') 27 | data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols) 28 | data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source]) 29 | 30 | # Construct an Alpha Model that simply provides a fixed 31 | # signal for the single GLD ETF at 100% allocation 32 | # with a backtest that does not rebalance 33 | strategy_alpha_model = FixedSignalsAlphaModel({'EQ:GLD': 1.0}) 34 | strategy_backtest = BacktestTradingSession( 35 | start_dt, 36 | end_dt, 37 | strategy_universe, 38 | strategy_alpha_model, 39 | rebalance='buy_and_hold', 40 | long_only=True, 41 | cash_buffer_percentage=0.01, 42 | data_handler=data_handler 43 | ) 44 | strategy_backtest.run() 45 | 46 | # Performance Output 47 | tearsheet = TearsheetStatistics( 48 | strategy_equity=strategy_backtest.get_equity_curve(), 49 | title='Buy & Hold GLD ETF' 50 | ) 51 | tearsheet.plot_results() 52 | -------------------------------------------------------------------------------- /examples/long_short.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | import pytz 5 | 6 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 7 | from qstrader.asset.equity import Equity 8 | from qstrader.asset.universe.static import StaticUniverse 9 | from qstrader.data.backtest_data_handler import BacktestDataHandler 10 | from qstrader.data.daily_bar_csv import CSVDailyBarDataSource 11 | from qstrader.statistics.tearsheet import TearsheetStatistics 12 | from qstrader.trading.backtest import BacktestTradingSession 13 | 14 | 15 | if __name__ == "__main__": 16 | start_dt = pd.Timestamp('2007-01-31 14:30:00', tz=pytz.UTC) 17 | end_dt = pd.Timestamp('2020-05-31 23:59:00', tz=pytz.UTC) 18 | 19 | # Construct the symbols and assets necessary for the backtest 20 | strategy_symbols = ['TLT', 'IEI'] 21 | strategy_assets = ['EQ:%s' % symbol for symbol in strategy_symbols] 22 | strategy_universe = StaticUniverse(strategy_assets) 23 | 24 | # To avoid loading all CSV files in the directory, set the 25 | # data source to load only those provided symbols 26 | csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.') 27 | data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols) 28 | data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source]) 29 | 30 | # Construct an Alpha Model that simply provides 31 | # static allocations to a universe of assets 32 | # In this case 100% TLT ETF, -70% IEI ETF, 33 | # rebalanced at the end of each month, leveraged 5x 34 | strategy_alpha_model = FixedSignalsAlphaModel( 35 | {'EQ:TLT': 1.0, 'EQ:IEI': -0.7} 36 | ) 37 | strategy_backtest = BacktestTradingSession( 38 | start_dt, 39 | end_dt, 40 | strategy_universe, 41 | strategy_alpha_model, 42 | rebalance='end_of_month', 43 | long_only=False, 44 | gross_leverage=5.0, 45 | data_handler=data_handler 46 | ) 47 | strategy_backtest.run() 48 | 49 | # Construct benchmark assets (buy & hold SPY) 50 | benchmark_symbols = ['SPY'] 51 | benchmark_assets = ['EQ:SPY'] 52 | benchmark_universe = StaticUniverse(benchmark_assets) 53 | benchmark_data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=benchmark_symbols) 54 | benchmark_data_handler = BacktestDataHandler(benchmark_universe, data_sources=[benchmark_data_source]) 55 | 56 | # Construct a benchmark Alpha Model that provides 57 | # 100% static allocation to the SPY ETF, with no rebalance 58 | benchmark_alpha_model = FixedSignalsAlphaModel({'EQ:SPY': 1.0}) 59 | benchmark_backtest = BacktestTradingSession( 60 | start_dt, 61 | end_dt, 62 | benchmark_universe, 63 | benchmark_alpha_model, 64 | rebalance='buy_and_hold', 65 | long_only=True, 66 | cash_buffer_percentage=0.01, 67 | data_handler=benchmark_data_handler 68 | ) 69 | benchmark_backtest.run() 70 | 71 | # Performance Output 72 | tearsheet = TearsheetStatistics( 73 | strategy_equity=strategy_backtest.get_equity_curve(), 74 | benchmark_equity=benchmark_backtest.get_equity_curve(), 75 | title='Long/Short Leveraged Treasury Bond ETFs' 76 | ) 77 | tearsheet.plot_results() 78 | -------------------------------------------------------------------------------- /examples/sixty_forty.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | import pytz 5 | 6 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 7 | from qstrader.asset.equity import Equity 8 | from qstrader.asset.universe.static import StaticUniverse 9 | from qstrader.data.backtest_data_handler import BacktestDataHandler 10 | from qstrader.data.daily_bar_csv import CSVDailyBarDataSource 11 | from qstrader.statistics.tearsheet import TearsheetStatistics 12 | from qstrader.trading.backtest import BacktestTradingSession 13 | 14 | 15 | if __name__ == "__main__": 16 | start_dt = pd.Timestamp('2003-09-30 14:30:00', tz=pytz.UTC) 17 | end_dt = pd.Timestamp('2019-12-31 23:59:00', tz=pytz.UTC) 18 | 19 | # Construct the symbols and assets necessary for the backtest 20 | strategy_symbols = ['SPY', 'AGG'] 21 | strategy_assets = ['EQ:%s' % symbol for symbol in strategy_symbols] 22 | strategy_universe = StaticUniverse(strategy_assets) 23 | 24 | # To avoid loading all CSV files in the directory, set the 25 | # data source to load only those provided symbols 26 | csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.') 27 | data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols) 28 | data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source]) 29 | 30 | # Construct an Alpha Model that simply provides 31 | # static allocations to a universe of assets 32 | # In this case 60% SPY ETF, 40% AGG ETF, 33 | # rebalanced at the end of each month 34 | strategy_alpha_model = FixedSignalsAlphaModel({'EQ:SPY': 0.6, 'EQ:AGG': 0.4}) 35 | strategy_backtest = BacktestTradingSession( 36 | start_dt, 37 | end_dt, 38 | strategy_universe, 39 | strategy_alpha_model, 40 | rebalance='end_of_month', 41 | long_only=True, 42 | cash_buffer_percentage=0.01, 43 | data_handler=data_handler 44 | ) 45 | strategy_backtest.run() 46 | 47 | # Construct benchmark assets (buy & hold SPY) 48 | benchmark_assets = ['EQ:SPY'] 49 | benchmark_universe = StaticUniverse(benchmark_assets) 50 | 51 | # Construct a benchmark Alpha Model that provides 52 | # 100% static allocation to the SPY ETF, with no rebalance 53 | benchmark_alpha_model = FixedSignalsAlphaModel({'EQ:SPY': 1.0}) 54 | benchmark_backtest = BacktestTradingSession( 55 | start_dt, 56 | end_dt, 57 | benchmark_universe, 58 | benchmark_alpha_model, 59 | rebalance='buy_and_hold', 60 | long_only=True, 61 | cash_buffer_percentage=0.01, 62 | data_handler=data_handler 63 | ) 64 | benchmark_backtest.run() 65 | 66 | # Performance Output 67 | tearsheet = TearsheetStatistics( 68 | strategy_equity=strategy_backtest.get_equity_curve(), 69 | benchmark_equity=benchmark_backtest.get_equity_curve(), 70 | title='60/40 US Equities/Bonds' 71 | ) 72 | tearsheet.plot_results() 73 | -------------------------------------------------------------------------------- /examples/sixty_forty_fees.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | import pytz 5 | 6 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 7 | from qstrader.asset.equity import Equity 8 | from qstrader.asset.universe.static import StaticUniverse 9 | from qstrader.broker.fee_model.percent_fee_model import PercentFeeModel 10 | from qstrader.broker.fee_model.zero_fee_model import ZeroFeeModel 11 | from qstrader.data.backtest_data_handler import BacktestDataHandler 12 | from qstrader.data.daily_bar_csv import CSVDailyBarDataSource 13 | from qstrader.statistics.tearsheet import TearsheetStatistics 14 | from qstrader.trading.backtest import BacktestTradingSession 15 | 16 | 17 | if __name__ == "__main__": 18 | start_dt = pd.Timestamp('2003-09-30 14:30:00', tz=pytz.UTC) 19 | end_dt = pd.Timestamp('2019-12-31 23:59:00', tz=pytz.UTC) 20 | 21 | # Construct the symbols and assets necessary for the backtest 22 | strategy_symbols = ['SPY', 'AGG'] 23 | strategy_assets = ['EQ:%s' % symbol for symbol in strategy_symbols] 24 | strategy_universe = StaticUniverse(strategy_assets) 25 | 26 | # To avoid loading all CSV files in the directory, set the 27 | # data source to load only those provided symbols 28 | csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.') 29 | data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols) 30 | data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source]) 31 | 32 | # Construct the transaction cost modelling - fees/slippage 33 | fee_model = PercentFeeModel(commission_pct=0.1 / 100.0, tax_pct=0.5 / 100.0) 34 | 35 | # Construct an Alpha Model that simply provides 36 | # static allocations to a universe of assets 37 | # In this case 60% SPY ETF, 40% AGG ETF, 38 | # rebalanced at the end of each month 39 | strategy_alpha_model = FixedSignalsAlphaModel({'EQ:SPY': 0.6, 'EQ:AGG': 0.4}) 40 | strategy_backtest = BacktestTradingSession( 41 | start_dt, 42 | end_dt, 43 | strategy_universe, 44 | strategy_alpha_model, 45 | rebalance='end_of_month', 46 | long_only=True, 47 | cash_buffer_percentage=0.01, 48 | data_handler=data_handler, 49 | fee_model=fee_model 50 | ) 51 | strategy_backtest.run() 52 | 53 | # Construct benchmark assets (60/40 without fees) 54 | benchmark_backtest = BacktestTradingSession( 55 | start_dt, 56 | end_dt, 57 | strategy_universe, 58 | strategy_alpha_model, 59 | rebalance='end_of_month', 60 | long_only=True, 61 | cash_buffer_percentage=0.01, 62 | data_handler=data_handler, 63 | fee_model=ZeroFeeModel() 64 | ) 65 | benchmark_backtest.run() 66 | 67 | # Performance Output 68 | tearsheet = TearsheetStatistics( 69 | strategy_equity=strategy_backtest.get_equity_curve(), 70 | benchmark_equity=benchmark_backtest.get_equity_curve(), 71 | title='60/40 US Equities/Bonds (With/Without Fees)' 72 | ) 73 | tearsheet.plot_results() 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "qstrader" 7 | version = "0.3.0" 8 | dependencies = [ 9 | "click>=8.1", 10 | "matplotlib>=3.8", 11 | "numpy>=2.0.0", 12 | "pandas>=2.2", 13 | "seaborn>=0.13", 14 | ] 15 | authors = [ 16 | {name="Michael Halls-Moore", email="support@quantstart.com"} 17 | ] 18 | maintainers = [ 19 | {name="Juliette James", email="juliette.james@quantstart.com"} 20 | ] 21 | description = "QSTrader backtesting simulation engine" 22 | readme = "README.md" 23 | requires-python = ">= 3.9" 24 | keywords = ["backtesting", "systematic trading", "algorithmic trading"] 25 | classifiers = [ 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | "Development Status :: 5 - Production/Stable", 34 | ] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/mhallsmoore/qstrader" 38 | Issues = "https://github.com/mhallsmoore/qstrader/issues" 39 | Documentation = "https://www.quantstart.com/qstrader/" 40 | Website = "https://www.quantstart.com" 41 | 42 | 43 | -------------------------------------------------------------------------------- /qstrader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/__init__.py -------------------------------------------------------------------------------- /qstrader/alpha_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/alpha_model/__init__.py -------------------------------------------------------------------------------- /qstrader/alpha_model/alpha_model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AlphaModel(object): 5 | """ 6 | Abstract interface for an AlphaModel callable. 7 | 8 | A derived-class instance of AlphaModel takes in an Asset 9 | Universe and an optional DataHandler instance in order 10 | to generate forecast signals on Assets. 11 | 12 | These signals are used by the PortfolioConstructionModel 13 | to generate target weights for the portfolio. 14 | 15 | Implementing __call__ produces a dictionary keyed by 16 | Asset and with a scalar value as the signal. 17 | """ 18 | 19 | __metaclass__ = ABCMeta 20 | 21 | @abstractmethod 22 | def __call__(self, dt): 23 | raise NotImplementedError( 24 | "Should implement __call__()" 25 | ) 26 | -------------------------------------------------------------------------------- /qstrader/alpha_model/fixed_signals.py: -------------------------------------------------------------------------------- 1 | from qstrader.alpha_model.alpha_model import AlphaModel 2 | 3 | 4 | class FixedSignalsAlphaModel(AlphaModel): 5 | """ 6 | A simple AlphaModel that provides a single scalar forecast 7 | value for each Asset in the Universe. 8 | 9 | Parameters 10 | ---------- 11 | signal_weights : `dict{str: float}` 12 | The signal weights per asset symbol. 13 | universe : `Universe`, optional 14 | The Assets to make signal forecasts for. 15 | data_handler : `DataHandler`, optional 16 | An optional DataHandler used to preserve interface across AlphaModels. 17 | """ 18 | 19 | def __init__( 20 | self, 21 | signal_weights, 22 | universe=None, 23 | data_handler=None 24 | ): 25 | self.signal_weights = signal_weights 26 | self.universe = universe 27 | self.data_handler = data_handler 28 | 29 | def __call__(self, dt): 30 | """ 31 | Produce the dictionary of fixed scalar signals for 32 | each of the Asset instances within the Universe. 33 | 34 | Parameters 35 | ---------- 36 | dt : `pd.Timestamp` 37 | The time 'now' used to obtain appropriate data and universe 38 | for the the signals. 39 | 40 | Returns 41 | ------- 42 | `dict{str: float}` 43 | The Asset symbol keyed scalar-valued signals. 44 | """ 45 | return self.signal_weights 46 | -------------------------------------------------------------------------------- /qstrader/alpha_model/single_signal.py: -------------------------------------------------------------------------------- 1 | from qstrader.alpha_model.alpha_model import AlphaModel 2 | 3 | 4 | class SingleSignalAlphaModel(AlphaModel): 5 | """ 6 | A simple AlphaModel that provides a single scalar forecast 7 | value for each Asset in the Universe. 8 | 9 | Parameters 10 | ---------- 11 | universe : `Universe` 12 | The Assets to make signal forecasts for. 13 | signal : `float`, optional 14 | The single fixed floating point scalar value for the signals. 15 | data_handler : `DataHandler`, optional 16 | An optional DataHandler used to preserve interface across AlphaModels. 17 | """ 18 | 19 | def __init__( 20 | self, 21 | universe, 22 | signal=1.0, 23 | data_handler=None 24 | ): 25 | self.universe = universe 26 | self.signal = signal 27 | self.data_handler = data_handler 28 | 29 | def __call__(self, dt): 30 | """ 31 | Produce the dictionary of single fixed scalar signals for 32 | each of the Asset instances within the Universe. 33 | 34 | Parameters 35 | ---------- 36 | dt : `pd.Timestamp` 37 | The time 'now' used to obtain appropriate data and universe 38 | for the the signals. 39 | 40 | Returns 41 | ------- 42 | `dict{str: float}` 43 | The Asset symbol keyed scalar-valued signals. 44 | """ 45 | assets = self.universe.get_assets(dt) 46 | return {asset: self.signal for asset in assets} 47 | -------------------------------------------------------------------------------- /qstrader/asset/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/asset/__init__.py -------------------------------------------------------------------------------- /qstrader/asset/asset.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | 3 | 4 | class Asset(object): 5 | """ 6 | Generic asset class that stores meta data about a trading asset. 7 | """ 8 | 9 | __metaclass__ = ABCMeta 10 | -------------------------------------------------------------------------------- /qstrader/asset/cash.py: -------------------------------------------------------------------------------- 1 | from qstrader.asset.asset import Asset 2 | 3 | 4 | class Cash(Asset): 5 | """ 6 | Stores meta data about a cash asset. 7 | 8 | Parameters 9 | ---------- 10 | currency : str, optional 11 | The currency of the Cash Asset. Defaults to USD. 12 | """ 13 | 14 | def __init__( 15 | self, 16 | currency='USD' 17 | ): 18 | self.cash_like = True 19 | self.currency = currency 20 | -------------------------------------------------------------------------------- /qstrader/asset/equity.py: -------------------------------------------------------------------------------- 1 | from qstrader.asset.asset import Asset 2 | 3 | 4 | class Equity(Asset): 5 | """ 6 | Stores meta data about an equity common stock or ETF. 7 | 8 | Parameters 9 | ---------- 10 | name : `str` 11 | The asset's name (e.g. the company name and/or 12 | share class). 13 | symbol : `str` 14 | The asset's original ticker symbol. 15 | TODO: This will require modification to handle proper 16 | ticker mapping. 17 | tax_exempt: `boolean`, optional 18 | Is the share exempt from government taxation? 19 | Necessary for taxation on share transactions, such 20 | as UK stamp duty. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | name, 26 | symbol, 27 | tax_exempt=True 28 | ): 29 | self.cash_like = False 30 | self.name = name 31 | self.symbol = symbol 32 | self.tax_exempt = tax_exempt 33 | 34 | def __repr__(self): 35 | """ 36 | String representation of the Equity Asset. 37 | """ 38 | return ( 39 | "Equity(name='%s', symbol='%s', tax_exempt=%s)" % ( 40 | self.name, self.symbol, self.tax_exempt 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /qstrader/asset/universe/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/asset/universe/__init__.py -------------------------------------------------------------------------------- /qstrader/asset/universe/dynamic.py: -------------------------------------------------------------------------------- 1 | from qstrader.asset.universe.universe import Universe 2 | 3 | 4 | class DynamicUniverse(Universe): 5 | """ 6 | An Asset Universe that allows additions of assets 7 | beyond a certain datetime. 8 | 9 | TODO: This does not currently support removal of assets 10 | or sequences of additions/removals. 11 | 12 | Parameters 13 | ---------- 14 | asset_dates : `dict{str: pd.Timestamp}` 15 | Map of assets and their entry date. 16 | """ 17 | 18 | def __init__(self, asset_dates): 19 | self.asset_dates = asset_dates 20 | 21 | def get_assets(self, dt): 22 | """ 23 | Obtain the list of assets in the Universe at a particular 24 | point in time. This will always return a static list 25 | independent of the timestamp provided. 26 | 27 | If no date is provided do not include the asset. Only 28 | return those assets where the current datetime exceeds the 29 | provided datetime. 30 | 31 | Parameters 32 | ---------- 33 | dt : `pd.Timestamp` 34 | The timestamp at which to retrieve the Asset list. 35 | 36 | Returns 37 | ------- 38 | `list[str]` 39 | The list of Asset symbols in the static Universe. 40 | """ 41 | return [ 42 | asset for asset, asset_date in self.asset_dates.items() 43 | if asset_date is not None and dt >= asset_date 44 | ] 45 | -------------------------------------------------------------------------------- /qstrader/asset/universe/static.py: -------------------------------------------------------------------------------- 1 | from qstrader.asset.universe.universe import Universe 2 | 3 | 4 | class StaticUniverse(Universe): 5 | """ 6 | An Asset Universe that does not change its composition 7 | through time. 8 | 9 | Parameters 10 | ---------- 11 | asset_list : `list[str]` 12 | The list of Asset symbols that form the StaticUniverse. 13 | """ 14 | 15 | def __init__(self, asset_list): 16 | self.asset_list = asset_list 17 | 18 | def get_assets(self, dt): 19 | """ 20 | Obtain the list of assets in the Universe at a particular 21 | point in time. This will always return a static list 22 | independent of the timestamp provided. 23 | 24 | Parameters 25 | ---------- 26 | dt : `pd.Timestamp` 27 | The timestamp at which to retrieve the Asset list. 28 | 29 | Returns 30 | ------- 31 | `list[str]` 32 | The list of Asset symbols in the static Universe. 33 | """ 34 | return self.asset_list 35 | -------------------------------------------------------------------------------- /qstrader/asset/universe/universe.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Universe(object): 5 | """ 6 | Interface specification for an Asset Universe. 7 | """ 8 | 9 | __metaclass__ = ABCMeta 10 | 11 | @abstractmethod 12 | def get_assets(self, dt): 13 | raise NotImplementedError( 14 | "Should implement get_assets()" 15 | ) 16 | -------------------------------------------------------------------------------- /qstrader/broker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/broker/__init__.py -------------------------------------------------------------------------------- /qstrader/broker/broker.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Broker(object): 5 | """ 6 | This abstract class provides an interface to a 7 | generic broker entity. Both simulated and live brokers 8 | will be derived from this ABC. This ensures that trading 9 | algorithm specific logic is completely identical for both 10 | simulated and live environments. 11 | 12 | The Broker has an associated master denominated currency 13 | through which all subscriptions and withdrawals will occur. 14 | 15 | The Broker entity can support multiple sub-portfolios, each 16 | with their own separate handling of PnL. The individual PnLs 17 | from each sub-portfolio can be aggregated to generate an 18 | account-wide PnL. 19 | 20 | The Broker can execute orders. It contains a queue of 21 | open orders, needed for handling closed market situations. 22 | 23 | The Broker also supports individual history events for each 24 | sub-portfolio, which can be aggregated, along with the 25 | account history, to produce a full trading history for the 26 | account. 27 | """ 28 | 29 | __metaclass__ = ABCMeta 30 | 31 | @abstractmethod 32 | def subscribe_funds_to_account(self, amount): 33 | raise NotImplementedError( 34 | "Should implement subscribe_funds_to_account()" 35 | ) 36 | 37 | @abstractmethod 38 | def withdraw_funds_from_account(self, amount): 39 | raise NotImplementedError( 40 | "Should implement withdraw_funds_from_account()" 41 | ) 42 | 43 | @abstractmethod 44 | def get_account_cash_balance(self, currency=None): 45 | raise NotImplementedError( 46 | "Should implement get_account_cash_balance()" 47 | ) 48 | 49 | @abstractmethod 50 | def get_account_total_equity(self): 51 | raise NotImplementedError( 52 | "Should implement get_account_total_equity()" 53 | ) 54 | 55 | @abstractmethod 56 | def create_portfolio(self, portfolio_id, name): 57 | raise NotImplementedError( 58 | "Should implement create_portfolio()" 59 | ) 60 | 61 | @abstractmethod 62 | def list_all_portfolios(self): 63 | raise NotImplementedError( 64 | "Should implement list_all_portfolios()" 65 | ) 66 | 67 | @abstractmethod 68 | def subscribe_funds_to_portfolio(self, portfolio_id, amount): 69 | raise NotImplementedError( 70 | "Should implement subscribe_funds_to_portfolio()" 71 | ) 72 | 73 | @abstractmethod 74 | def withdraw_funds_from_portfolio(self, portfolio_id, amount): 75 | raise NotImplementedError( 76 | "Should implement withdraw_funds_from_portfolio()" 77 | ) 78 | 79 | @abstractmethod 80 | def get_portfolio_cash_balance(self, portfolio_id): 81 | raise NotImplementedError( 82 | "Should implement get_portfolio_cash_balance()" 83 | ) 84 | 85 | @abstractmethod 86 | def get_portfolio_total_equity(self, portfolio_id): 87 | raise NotImplementedError( 88 | "Should implement get_portfolio_total_equity()" 89 | ) 90 | 91 | @abstractmethod 92 | def get_portfolio_as_dict(self, portfolio_id): 93 | raise NotImplementedError( 94 | "Should implement get_portfolio_as_dict()" 95 | ) 96 | 97 | @abstractmethod 98 | def submit_order(self, portfolio_id, order): 99 | raise NotImplementedError( 100 | "Should implement submit_order()" 101 | ) 102 | -------------------------------------------------------------------------------- /qstrader/broker/fee_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/broker/fee_model/__init__.py -------------------------------------------------------------------------------- /qstrader/broker/fee_model/fee_model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class FeeModel(object): 5 | """ 6 | Abstract class to handle the calculation of brokerage 7 | commission, fees and taxes. 8 | """ 9 | 10 | __metaclass__ = ABCMeta 11 | 12 | @abstractmethod 13 | def _calc_commission(self, asset, quantity, consideration, broker=None): 14 | raise NotImplementedError( 15 | "Should implement _calc_commission()" 16 | ) 17 | 18 | @abstractmethod 19 | def _calc_tax(self, asset, quantity, consideration, broker=None): 20 | raise NotImplementedError( 21 | "Should implement _calc_tax()" 22 | ) 23 | 24 | @abstractmethod 25 | def calc_total_cost(self, asset, quantity, consideration, broker=None): 26 | raise NotImplementedError( 27 | "Should implement calc_total_cost()" 28 | ) 29 | -------------------------------------------------------------------------------- /qstrader/broker/fee_model/percent_fee_model.py: -------------------------------------------------------------------------------- 1 | from qstrader.broker.fee_model.fee_model import FeeModel 2 | 3 | 4 | class PercentFeeModel(FeeModel): 5 | """ 6 | A FeeModel subclass that produces a percentage cost 7 | for tax and commission. 8 | 9 | Parameters 10 | ---------- 11 | commission_pct : `float`, optional 12 | The percentage commission applied to the consideration. 13 | 0-100% is in the range [0.0, 1.0]. Hence, e.g. 0.1% is 0.001 14 | tax_pct : `float`, optional 15 | The percentage tax applied to the consideration. 16 | 0-100% is in the range [0.0, 1.0]. Hence, e.g. 0.1% is 0.001 17 | """ 18 | 19 | def __init__(self, commission_pct=0.0, tax_pct=0.0): 20 | super().__init__() 21 | self.commission_pct = commission_pct 22 | self.tax_pct = tax_pct 23 | 24 | def _calc_commission(self, asset, quantity, consideration, broker=None): 25 | """ 26 | Returns the percentage commission from the consideration. 27 | 28 | Parameters 29 | ---------- 30 | asset : `str` 31 | The asset symbol string. 32 | quantity : `int` 33 | The quantity of assets (needed for InteractiveBrokers 34 | style calculations). 35 | consideration : `float` 36 | Price times quantity of the order. 37 | broker : `Broker`, optional 38 | An optional Broker reference. 39 | 40 | Returns 41 | ------- 42 | `float` 43 | The percentage commission. 44 | """ 45 | return self.commission_pct * abs(consideration) 46 | 47 | def _calc_tax(self, asset, quantity, consideration, broker=None): 48 | """ 49 | Returns the percentage tax from the consideration. 50 | 51 | Parameters 52 | ---------- 53 | asset : `str` 54 | The asset symbol string. 55 | quantity : `int` 56 | The quantity of assets (needed for InteractiveBrokers 57 | style calculations). 58 | consideration : `float` 59 | Price times quantity of the order. 60 | broker : `Broker`, optional 61 | An optional Broker reference. 62 | 63 | Returns 64 | ------- 65 | `float` 66 | The percentage tax. 67 | """ 68 | return self.tax_pct * abs(consideration) 69 | 70 | def calc_total_cost(self, asset, quantity, consideration, broker=None): 71 | """ 72 | Calculate the total of any commission and/or tax 73 | for the trade of size 'consideration'. 74 | 75 | Parameters 76 | ---------- 77 | asset : `str` 78 | The asset symbol string. 79 | quantity : `int` 80 | The quantity of assets (needed for InteractiveBrokers 81 | style calculations). 82 | consideration : `float` 83 | Price times quantity of the order. 84 | broker : `Broker`, optional 85 | An optional Broker reference. 86 | 87 | Returns 88 | ------- 89 | `float` 90 | The total commission and tax. 91 | """ 92 | commission = self._calc_commission(asset, quantity, consideration, broker) 93 | tax = self._calc_tax(asset, quantity, consideration, broker) 94 | return commission + tax 95 | -------------------------------------------------------------------------------- /qstrader/broker/fee_model/zero_fee_model.py: -------------------------------------------------------------------------------- 1 | from qstrader.broker.fee_model.fee_model import FeeModel 2 | 3 | 4 | class ZeroFeeModel(FeeModel): 5 | """ 6 | A FeeModel subclass that produces no commission, fees 7 | or taxes. This is the default fee model for simulated 8 | brokerages within QSTrader. 9 | """ 10 | 11 | def _calc_commission(self, asset, quantity, consideration, broker=None): 12 | """ 13 | Returns zero commission. 14 | 15 | Parameters 16 | ---------- 17 | asset : `str` 18 | The asset symbol string. 19 | quantity : `int` 20 | The quantity of assets (needed for InteractiveBrokers 21 | style calculations). 22 | consideration : `float` 23 | Price times quantity of the order. 24 | broker : `Broker`, optional 25 | An optional Broker reference. 26 | 27 | Returns 28 | ------- 29 | `float` 30 | The zero-cost commission. 31 | """ 32 | return 0.0 33 | 34 | def _calc_tax(self, asset, quantity, consideration, broker=None): 35 | """ 36 | Returns zero tax. 37 | 38 | Parameters 39 | ---------- 40 | asset : `str` 41 | The asset symbol string. 42 | quantity : `int` 43 | The quantity of assets (needed for InteractiveBrokers 44 | style calculations). 45 | consideration : `float` 46 | Price times quantity of the order. 47 | broker : `Broker`, optional 48 | An optional Broker reference. 49 | 50 | Returns 51 | ------- 52 | `float` 53 | The zero-cost tax. 54 | """ 55 | return 0.0 56 | 57 | def calc_total_cost(self, asset, quantity, consideration, broker=None): 58 | """ 59 | Calculate the total of any commission and/or tax 60 | for the trade of size 'consideration'. 61 | 62 | Parameters 63 | ---------- 64 | asset : `str` 65 | The asset symbol string. 66 | quantity : `int` 67 | The quantity of assets (needed for InteractiveBrokers 68 | style calculations). 69 | consideration : `float` 70 | Price times quantity of the order. 71 | broker : `Broker`, optional 72 | An optional Broker reference. 73 | 74 | Returns 75 | ------- 76 | `float` 77 | The zero-cost total commission and tax. 78 | """ 79 | commission = self._calc_commission(asset, quantity, consideration, broker) 80 | tax = self._calc_tax(asset, quantity, consideration, broker) 81 | return commission + tax 82 | -------------------------------------------------------------------------------- /qstrader/broker/portfolio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/broker/portfolio/__init__.py -------------------------------------------------------------------------------- /qstrader/broker/portfolio/portfolio_event.py: -------------------------------------------------------------------------------- 1 | class PortfolioEvent(object): 2 | """ 3 | Stores an individual instance of a portfolio event used to create 4 | an event trail to track all changes to a portfolio through time. 5 | 6 | Parameters 7 | ---------- 8 | dt : `datetime` 9 | Datetime of the event. 10 | type : `str` 11 | The type of portfolio event, e.g. 'subscription', 'withdrawal'. 12 | description ; `str` 13 | Human-readable portfolio event type. 14 | debit : `float` 15 | A debit to the cash balance of the portfolio. 16 | credit : `float` 17 | A credit to the cash balance of the portfolio. 18 | balance : `float` 19 | The current cash balance of the portfolio. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | dt, 25 | type, 26 | description, 27 | debit, 28 | credit, 29 | balance 30 | ): 31 | self.dt = dt 32 | self.type = type 33 | self.description = description 34 | self.debit = debit 35 | self.credit = credit 36 | self.balance = balance 37 | 38 | def __eq__(self, other): 39 | if self.dt != other.dt: 40 | return False 41 | if self.type != other.type: 42 | return False 43 | if self.description != other.description: 44 | return False 45 | if self.debit != other.debit: 46 | return False 47 | if self.credit != other.credit: 48 | return False 49 | if self.balance != other.balance: 50 | return False 51 | return True 52 | 53 | def __repr__(self): 54 | return ( 55 | "PortfolioEvent(dt=%s, type=%s, description=%s, " 56 | "debit=%s, credit=%s, balance=%s)" % ( 57 | self.dt, self.type, self.description, 58 | self.debit, self.credit, self.balance 59 | ) 60 | ) 61 | 62 | @classmethod 63 | def create_subscription(cls, dt, credit, balance): 64 | return cls( 65 | dt, type='subscription', description='SUBSCRIPTION', 66 | debit=0.0, credit=round(credit, 2), balance=round(balance, 2) 67 | ) 68 | 69 | @classmethod 70 | def create_withdrawal(cls, dt, debit, balance): 71 | return cls( 72 | dt, type='withdrawal', description='WITHDRAWAL', 73 | debit=round(debit, 2), credit=0.0, balance=round(balance, 2) 74 | ) 75 | 76 | def to_dict(self): 77 | return { 78 | 'dt': self.dt, 79 | 'type': self.type, 80 | 'description': self.description, 81 | 'debit': self.debit, 82 | 'credit': self.credit, 83 | 'balance': self.balance 84 | } 85 | -------------------------------------------------------------------------------- /qstrader/broker/portfolio/position_handler.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from qstrader.broker.portfolio.position import Position 4 | 5 | 6 | class PositionHandler(object): 7 | """ 8 | A class that keeps track of, and updates, the current 9 | list of Position instances stored in a Portfolio entity. 10 | """ 11 | 12 | def __init__(self): 13 | """ 14 | Initialise the PositionHandler object to generate 15 | an ordered dictionary containing the current positions. 16 | """ 17 | self.positions = OrderedDict() 18 | 19 | def transact_position(self, transaction): 20 | """ 21 | Execute the transaction and update the appropriate 22 | position for the transaction's asset accordingly. 23 | """ 24 | asset = transaction.asset 25 | if asset in self.positions: 26 | self.positions[asset].transact(transaction) 27 | else: 28 | position = Position.open_from_transaction(transaction) 29 | self.positions[asset] = position 30 | 31 | # If the position has zero quantity remove it 32 | if self.positions[asset].net_quantity == 0: 33 | del self.positions[asset] 34 | 35 | def total_market_value(self): 36 | """ 37 | Calculate the sum of all the positions' market values. 38 | """ 39 | return sum( 40 | pos.market_value 41 | for asset, pos in self.positions.items() 42 | ) 43 | 44 | def total_unrealised_pnl(self): 45 | """ 46 | Calculate the sum of all the positions' unrealised P&Ls. 47 | """ 48 | return sum( 49 | pos.unrealised_pnl 50 | for asset, pos in self.positions.items() 51 | ) 52 | 53 | def total_realised_pnl(self): 54 | """ 55 | Calculate the sum of all the positions' realised P&Ls. 56 | """ 57 | return sum( 58 | pos.realised_pnl 59 | for asset, pos in self.positions.items() 60 | ) 61 | 62 | def total_pnl(self): 63 | """ 64 | Calculate the sum of all the positions' P&Ls. 65 | """ 66 | return sum( 67 | pos.total_pnl 68 | for asset, pos in self.positions.items() 69 | ) 70 | -------------------------------------------------------------------------------- /qstrader/broker/transaction/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/broker/transaction/__init__.py -------------------------------------------------------------------------------- /qstrader/broker/transaction/transaction.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Transaction(object): 5 | """ 6 | Handles the transaction of an asset, as used in the 7 | Position class. 8 | 9 | Parameters 10 | ---------- 11 | asset : `str` 12 | The asset symbol of the transaction 13 | quantity : `int` 14 | Whole number quantity of shares in the transaction 15 | dt : `pd.Timestamp` 16 | The date/time of the transaction 17 | price : `float` 18 | The transaction price carried out 19 | order_id : `int` 20 | The unique order identifier 21 | commission : `float`, optional 22 | The trading commission 23 | """ 24 | 25 | def __init__( 26 | self, 27 | asset, 28 | quantity, 29 | dt, 30 | price, 31 | order_id, 32 | commission=0.0 33 | ): 34 | self.asset = asset 35 | self.quantity = quantity 36 | self.direction = np.copysign(1, self.quantity) 37 | self.dt = dt 38 | self.price = price 39 | self.order_id = order_id 40 | self.commission = commission 41 | 42 | def __repr__(self): 43 | """ 44 | Provides a representation of the Transaction 45 | to allow full recreation of the object. 46 | 47 | Returns 48 | ------- 49 | `str` 50 | The string representation of the Transaction. 51 | """ 52 | return "%s(asset=%s, quantity=%s, dt=%s, " \ 53 | "price=%s, order_id=%s)" % ( 54 | type(self).__name__, self.asset, 55 | self.quantity, self.dt, 56 | self.price, self.order_id 57 | ) 58 | 59 | @property 60 | def cost_without_commission(self): 61 | """ 62 | Calculate the cost of the transaction without including 63 | any commission costs. 64 | 65 | Returns 66 | ------- 67 | `float` 68 | The transaction cost without commission. 69 | """ 70 | return self.quantity * self.price 71 | 72 | @property 73 | def cost_with_commission(self): 74 | """ 75 | Calculate the cost of the transaction including 76 | any commission costs. 77 | 78 | Returns 79 | ------- 80 | `float` 81 | The transaction cost with commission. 82 | """ 83 | if self.commission == 0.0: 84 | return self.cost_without_commission 85 | else: 86 | return self.cost_without_commission + self.commission 87 | -------------------------------------------------------------------------------- /qstrader/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/data/__init__.py -------------------------------------------------------------------------------- /qstrader/data/backtest_data_handler.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class BacktestDataHandler(object): 5 | """ 6 | """ 7 | 8 | def __init__( 9 | self, 10 | universe, 11 | data_sources=None 12 | ): 13 | self.universe = universe 14 | self.data_sources = data_sources 15 | 16 | def get_asset_latest_bid_price(self, dt, asset_symbol): 17 | """ 18 | """ 19 | # TODO: Check for asset in Universe 20 | bid = np.nan 21 | for ds in self.data_sources: 22 | try: 23 | bid = ds.get_bid(dt, asset_symbol) 24 | if not np.isnan(bid): 25 | return bid 26 | except Exception: 27 | bid = np.nan 28 | return bid 29 | 30 | def get_asset_latest_ask_price(self, dt, asset_symbol): 31 | """ 32 | """ 33 | # TODO: Check for asset in Universe 34 | ask = np.nan 35 | for ds in self.data_sources: 36 | try: 37 | ask = ds.get_ask(dt, asset_symbol) 38 | if not np.isnan(ask): 39 | return ask 40 | except Exception: 41 | ask = np.nan 42 | return ask 43 | 44 | def get_asset_latest_bid_ask_price(self, dt, asset_symbol): 45 | """ 46 | """ 47 | # TODO: For the moment this is sufficient for OHLCV 48 | # data, which only usually provides mid prices 49 | # This will need to be revisited when handling intraday 50 | # bid/ask time series. 51 | # It has been added as an optimisation mechanism for 52 | # interday backtests. 53 | bid = self.get_asset_latest_bid_price(dt, asset_symbol) 54 | return (bid, bid) 55 | 56 | def get_asset_latest_mid_price(self, dt, asset_symbol): 57 | """ 58 | """ 59 | bid_ask = self.get_asset_latest_bid_ask_price(dt, asset_symbol) 60 | try: 61 | mid = (bid_ask[0] + bid_ask[1]) / 2.0 62 | except Exception: 63 | # TODO: Log this 64 | mid = np.nan 65 | return mid 66 | 67 | def get_assets_historical_range_close_price( 68 | self, start_dt, end_dt, asset_symbols, adjusted=False 69 | ): 70 | """ 71 | """ 72 | prices_df = None 73 | for ds in self.data_sources: 74 | try: 75 | prices_df = ds.get_assets_historical_closes( 76 | start_dt, end_dt, asset_symbols, adjusted=adjusted 77 | ) 78 | if prices_df is not None: 79 | return prices_df 80 | except Exception: 81 | raise 82 | return prices_df 83 | -------------------------------------------------------------------------------- /qstrader/exchange/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/exchange/__init__.py -------------------------------------------------------------------------------- /qstrader/exchange/exchange.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Exchange(object): 5 | """ 6 | Interface to a trading exchange such as the NYSE or LSE. 7 | This class family is only required for simulations, rather than 8 | live or paper trading. 9 | 10 | It exposes methods for obtaining calendar capability 11 | for trading opening times and market events. 12 | """ 13 | 14 | __metaclass__ = ABCMeta 15 | 16 | @abstractmethod 17 | def is_open_at_datetime(self, dt): 18 | raise NotImplementedError( 19 | "Should implement is_open_at_datetime()" 20 | ) 21 | -------------------------------------------------------------------------------- /qstrader/exchange/simulated_exchange.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from qstrader.exchange.exchange import Exchange 4 | 5 | 6 | class SimulatedExchange(Exchange): 7 | """ 8 | The SimulatedExchange class is used to model a live 9 | trading venue. 10 | 11 | It exposes methods to inform a client class intance of 12 | when the exchange is open to determine when orders can 13 | be executed. 14 | 15 | Parameters 16 | ---------- 17 | start_dt : `pd.Timestamp` 18 | The starting time of the simulated exchange. 19 | """ 20 | 21 | def __init__(self, start_dt): 22 | self.start_dt = start_dt 23 | 24 | # TODO: Eliminate hardcoding of NYSE 25 | # TODO: Make these timezone-aware 26 | self.open_dt = datetime.time(14, 30) 27 | self.close_dt = datetime.time(21, 00) 28 | 29 | def is_open_at_datetime(self, dt): 30 | """ 31 | Check if the SimulatedExchange is open at a particular 32 | provided pandas Timestamp. 33 | 34 | This logic is simplistic in that it only checks whether 35 | the provided time is between market hours on a weekday. 36 | 37 | There is no historical calendar handling or concept of 38 | exchange holidays. 39 | 40 | Parameters 41 | ---------- 42 | dt : `pd.Timestamp` 43 | The timestamp to check for open market hours. 44 | 45 | Returns 46 | ------- 47 | `Boolean` 48 | Whether the exchange is open at this timestamp. 49 | """ 50 | if dt.weekday() > 4: 51 | return False 52 | return self.open_dt <= dt.time() and dt.time() < self.close_dt 53 | -------------------------------------------------------------------------------- /qstrader/execution/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/execution/__init__.py -------------------------------------------------------------------------------- /qstrader/execution/execution_algo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/execution/execution_algo/__init__.py -------------------------------------------------------------------------------- /qstrader/execution/execution_algo/execution_algo.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class ExecutionAlgorithm(object): 5 | """ 6 | Callable which takes in a list of desired rebalance Orders 7 | and outputs a new Order list with a particular execution 8 | algorithm strategy. 9 | """ 10 | 11 | __metaclass__ = ABCMeta 12 | 13 | @abstractmethod 14 | def __call__(self, dt, initial_orders): 15 | raise NotImplementedError( 16 | "Should implement __call__()" 17 | ) 18 | -------------------------------------------------------------------------------- /qstrader/execution/execution_algo/market_order.py: -------------------------------------------------------------------------------- 1 | from qstrader.execution.execution_algo.execution_algo import ExecutionAlgorithm 2 | 3 | 4 | class MarketOrderExecutionAlgorithm(ExecutionAlgorithm): 5 | """ 6 | Simple execution algorithm that creates an unmodified list 7 | of market Orders from the rebalance Orders. 8 | """ 9 | 10 | def __call__(self, dt, initial_orders): 11 | """ 12 | Simply returns the initial orders list in a 'pass through' manner. 13 | 14 | Parameters 15 | ---------- 16 | dt : `pd.Timestamp` 17 | The current time used to populate the Order instances. 18 | rebalance_orders : `list[Order]` 19 | The list of rebalance orders to execute. 20 | 21 | Returns 22 | ------- 23 | `list[Order]` 24 | The final list of orders to send to the Broker to be executed. 25 | """ 26 | return initial_orders 27 | -------------------------------------------------------------------------------- /qstrader/execution/execution_handler.py: -------------------------------------------------------------------------------- 1 | class ExecutionHandler(object): 2 | """ 3 | Handles the execution of a list of Orders output by the 4 | PortfolioConstructionModel via the Broker. 5 | 6 | Parameters 7 | ---------- 8 | broker : `Broker` 9 | The derived Broker instance to execute orders against. 10 | broker_portfolio_id : `str` 11 | The specific portfolio at the Broker to execute against. 12 | universe : `Universe` 13 | The derived Universe instance to obtain the Asset list from. 14 | submit_orders : `Boolean`, optional 15 | Whether to actually submit orders to the Broker or silently 16 | discard them. Defaults to False -> Do not send orders. 17 | execution_algo : `ExecutionAlgorithm`, optional 18 | The derived ExecutionAlgorithm instance to use for the 19 | execution strategy. 20 | data_handler : `DataHandler`, optional 21 | The derived DataHandler instances used to (optionally) obtain any 22 | necessary data for the execution strategy. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | broker, 28 | broker_portfolio_id, 29 | universe, 30 | submit_orders=False, 31 | execution_algo=None, 32 | data_handler=None 33 | ): 34 | self.broker = broker 35 | self.broker_portfolio_id = broker_portfolio_id 36 | self.universe = universe 37 | self.submit_orders = submit_orders 38 | self.execution_algo = execution_algo 39 | self.data_handler = data_handler 40 | 41 | def _apply_execution_algo_to_rebalances(self, dt, rebalance_orders): 42 | """ 43 | Generates a new list of Orders based on the appropriate 44 | execution strategy. 45 | 46 | Parameters 47 | ---------- 48 | dt : `pd.Timestamp` 49 | The current time used to populate the Order instances. 50 | rebalance_orders : `list[Order]` 51 | The list of rebalance orders to execute. 52 | 53 | Returns 54 | ------- 55 | `list[Order]` 56 | The final list of orders to send to the Broker to be executed. 57 | """ 58 | return self.execution_algo(dt, rebalance_orders) 59 | 60 | def __call__(self, dt, rebalance_orders): 61 | """ 62 | Take the list of rebalanced Orders generated from the 63 | portfolio construction process and execute them at the 64 | Broker, via the appropriate execution algorithm. 65 | 66 | Parameters 67 | ---------- 68 | dt : `pd.Timestamp` 69 | The current time used to populate the Order instances. 70 | rebalance_orders : `list[Order]` 71 | The list of rebalance orders to execute. 72 | 73 | Returns 74 | ------- 75 | `None` 76 | """ 77 | final_orders = self._apply_execution_algo_to_rebalances( 78 | dt, rebalance_orders 79 | ) 80 | 81 | # If order submission is specified then send the 82 | # individual order items to the Broker instance 83 | if self.submit_orders: 84 | for order in final_orders: 85 | self.broker.submit_order(self.broker_portfolio_id, order) 86 | self.broker.update(dt) 87 | -------------------------------------------------------------------------------- /qstrader/execution/order.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import numpy as np 4 | 5 | 6 | class Order(object): 7 | """ 8 | Represents sending an order from a trading algo entity 9 | to a brokerage to execute. 10 | 11 | A commission can be added here to override the commission 12 | model, if known. An order_id can be added if required, 13 | otherwise it will be randomly assigned. 14 | 15 | Parameters 16 | ---------- 17 | dt : `pd.Timestamp` 18 | The date-time that the order was created. 19 | asset : `Asset` 20 | The asset to transact with the order. 21 | quantity : `int` 22 | The quantity of the asset to transact. 23 | A negative quantity means a short. 24 | commission : `float`, optional 25 | If commission is known it can be added. 26 | order_id : `str`, optional 27 | The order ID of the order, if known. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | dt, 33 | asset, 34 | quantity, 35 | commission=0.0, 36 | order_id=None 37 | ): 38 | self.created_dt = dt 39 | self.cur_dt = dt 40 | self.asset = asset 41 | self.quantity = quantity 42 | self.commission = commission 43 | self.direction = np.copysign(1, self.quantity) 44 | self.order_id = self._set_or_generate_order_id(order_id) 45 | 46 | def _order_attribs_equal(self, other): 47 | """ 48 | Asserts whether all attributes of the Order are equal 49 | with the exception of the order ID. 50 | 51 | Used primarily for testing that orders are generated correctly. 52 | 53 | Parameters 54 | ---------- 55 | other : `Order` 56 | The order to compare attribute equality to. 57 | 58 | Returns 59 | ------- 60 | `Boolean` 61 | Whether the non-order ID attributes are equal. 62 | """ 63 | if self.created_dt != other.created_dt: 64 | return False 65 | if self.cur_dt != other.cur_dt: 66 | return False 67 | if self.asset != other.asset: 68 | return False 69 | if self.quantity != other.quantity: 70 | return False 71 | if self.commission != other.commission: 72 | return False 73 | if self.direction != other.direction: 74 | return False 75 | return True 76 | 77 | def __repr__(self): 78 | """ 79 | Output a string representation of the object 80 | 81 | Returns 82 | ------- 83 | `str` 84 | String representation of the Order instance. 85 | """ 86 | return ( 87 | "Order(dt='%s', asset='%s', quantity=%s, " 88 | "commission=%s, direction=%s, order_id=%s)" % ( 89 | self.created_dt, self.asset, self.quantity, 90 | self.commission, self.direction, self.order_id 91 | ) 92 | ) 93 | 94 | def _set_or_generate_order_id(self, order_id=None): 95 | """ 96 | Sets or generates a unique order ID for the order, using a UUID. 97 | 98 | Parameters 99 | ---------- 100 | order_id : `str`, optional 101 | An optional order ID override. 102 | 103 | Returns 104 | ------- 105 | `str` 106 | The order ID string for the Order. 107 | """ 108 | if order_id is None: 109 | return uuid.uuid4().hex 110 | else: 111 | return order_id 112 | -------------------------------------------------------------------------------- /qstrader/portcon/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/portcon/__init__.py -------------------------------------------------------------------------------- /qstrader/portcon/optimiser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/portcon/optimiser/__init__.py -------------------------------------------------------------------------------- /qstrader/portcon/optimiser/equal_weight.py: -------------------------------------------------------------------------------- 1 | from qstrader.portcon.optimiser.optimiser import PortfolioOptimiser 2 | 3 | 4 | class EqualWeightPortfolioOptimiser(PortfolioOptimiser): 5 | """ 6 | Produces a dictionary keyed by Asset with (optionally) scaled 7 | equal weights. Without scaling this is normalised to ensure vector 8 | sums to unity. This overrides the weights provided in the initial_weights 9 | dictionary. 10 | 11 | Parameters 12 | ---------- 13 | scale : `float`, optional 14 | An optional scale factor to adjust the weights by. Otherwise vector 15 | is set to sum to unity. 16 | data_handler : `DataHandler`, optional 17 | An optional DataHandler used to preserve interface across 18 | PortfolioOptimisers. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | scale=1.0, 24 | data_handler=None 25 | ): 26 | self.scale = scale 27 | self.data_handler = data_handler 28 | 29 | def __call__(self, dt, initial_weights): 30 | """ 31 | Produce the dictionary of single fixed scalar target weight 32 | values for each of the Asset instances provided. 33 | 34 | Parameters 35 | ---------- 36 | dt : `pd.Timestamp` 37 | The time 'now' used to obtain appropriate data for the 38 | target weights. 39 | initial_weights : `dict{str: float}` 40 | The initial weights prior to optimisation. 41 | 42 | Returns 43 | ------- 44 | `dict{str: float}` 45 | The Asset symbol keyed scalar-valued target weights. 46 | """ 47 | assets = initial_weights.keys() 48 | num_assets = len(assets) 49 | equal_weight = 1.0 / float(num_assets) 50 | scaled_equal_weight = self.scale * equal_weight 51 | return {asset: scaled_equal_weight for asset in assets} 52 | -------------------------------------------------------------------------------- /qstrader/portcon/optimiser/fixed_weight.py: -------------------------------------------------------------------------------- 1 | from qstrader.portcon.optimiser.optimiser import PortfolioOptimiser 2 | 3 | 4 | class FixedWeightPortfolioOptimiser(PortfolioOptimiser): 5 | """ 6 | Produces a dictionary keyed by Asset with that utilises the weights 7 | provided directly. This simply 'passes through' the provided weights 8 | without modification. 9 | 10 | Parameters 11 | ---------- 12 | data_handler : `DataHandler`, optional 13 | An optional DataHandler used to preserve interface across 14 | TargetWeightGenerators. 15 | """ 16 | 17 | def __init__( 18 | self, 19 | data_handler=None 20 | ): 21 | self.data_handler = data_handler 22 | 23 | def __call__(self, dt, initial_weights): 24 | """ 25 | Produce the dictionary of target weight 26 | values for each of the Asset instances provided. 27 | 28 | Parameters 29 | ---------- 30 | dt : `pd.Timestamp` 31 | The time 'now' used to obtain appropriate data for the 32 | target weights. 33 | initial_weights : `dict{str: float}` 34 | The initial weights prior to optimisation. 35 | 36 | Returns 37 | ------- 38 | `dict{str: float}` 39 | The Asset symbol keyed scalar-valued target weights. 40 | """ 41 | return initial_weights 42 | -------------------------------------------------------------------------------- /qstrader/portcon/optimiser/optimiser.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class PortfolioOptimiser(object): 5 | """ 6 | Abstract interface for a PortfolioOptimiser callable. 7 | 8 | A derived-class instance of PortfolioOptimisertakes in 9 | a list of Assets (not an Asset Universe) and an optional 10 | DataHandler instance in order to generate target weights 11 | for Assets. 12 | 13 | These are then potentially modified by the PortfolioConstructionModel, 14 | which generates a list of rebalance Orders. 15 | 16 | Implementing __call__ produces a dictionary keyed by 17 | Asset and with a scalar value as the weight. 18 | """ 19 | 20 | __metaclass__ = ABCMeta 21 | 22 | @abstractmethod 23 | def __call__(self, dt): 24 | raise NotImplementedError( 25 | "Should implement __call__()" 26 | ) 27 | -------------------------------------------------------------------------------- /qstrader/portcon/order_sizer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/portcon/order_sizer/__init__.py -------------------------------------------------------------------------------- /qstrader/portcon/order_sizer/dollar_weighted.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from qstrader.portcon.order_sizer.order_sizer import OrderSizer 4 | 5 | 6 | class DollarWeightedCashBufferedOrderSizer(OrderSizer): 7 | """ 8 | Creates a target portfolio of quantities for each Asset 9 | using its provided weight and total equity available in the 10 | Broker portfolio. 11 | 12 | Includes an optional cash buffer due to the non-fractional amount 13 | of share/unit sizes. The cash buffer defaults to 5% of the total 14 | equity, but can be modified. 15 | 16 | Parameters 17 | ---------- 18 | broker : `Broker` 19 | The derived Broker instance to obtain portfolio equity from. 20 | broker_portfolio_id : `str` 21 | The specific portfolio at the Broker to obtain equity from. 22 | data_handler : `DataHandler` 23 | To obtain latest asset prices from. 24 | cash_buffer_percentage : `float`, optional 25 | The percentage of the portfolio equity to retain in 26 | cash to avoid generating Orders that exceed account 27 | equity (assuming no margin available). 28 | """ 29 | 30 | def __init__( 31 | self, 32 | broker, 33 | broker_portfolio_id, 34 | data_handler, 35 | cash_buffer_percentage=0.05 36 | ): 37 | self.broker = broker 38 | self.broker_portfolio_id = broker_portfolio_id 39 | self.data_handler = data_handler 40 | self.cash_buffer_percentage = self._check_set_cash_buffer( 41 | cash_buffer_percentage 42 | ) 43 | 44 | def _check_set_cash_buffer(self, cash_buffer_percentage): 45 | """ 46 | Checks and sets the cash buffer percentage value. 47 | 48 | Parameters 49 | ---------- 50 | cash_buffer_percentage : `float` 51 | The percentage of the portfolio equity to retain in 52 | cash to avoid generating Orders that exceed account 53 | equity (assuming no margin available). 54 | 55 | Returns 56 | ------- 57 | `float` 58 | The cash buffer percentage value. 59 | """ 60 | if ( 61 | cash_buffer_percentage < 0.0 or cash_buffer_percentage > 1.0 62 | ): 63 | raise ValueError( 64 | 'Cash buffer percentage "%s" provided to dollar-weighted ' 65 | 'execution algorithm is negative or ' 66 | 'exceeds 100%.' % cash_buffer_percentage 67 | ) 68 | else: 69 | return cash_buffer_percentage 70 | 71 | def _obtain_broker_portfolio_total_equity(self): 72 | """ 73 | Obtain the Broker portfolio total equity. 74 | 75 | Returns 76 | ------- 77 | `float` 78 | The Broker portfolio total equity. 79 | """ 80 | return self.broker.get_portfolio_total_equity(self.broker_portfolio_id) 81 | 82 | def _normalise_weights(self, weights): 83 | """ 84 | Rescale provided weight values to ensure 85 | weight vector sums to unity. 86 | 87 | Parameters 88 | ---------- 89 | weights : `dict{Asset: float}` 90 | The un-normalised weight vector. 91 | 92 | Returns 93 | ------- 94 | `dict{Asset: float}` 95 | The unit sum weight vector. 96 | """ 97 | if any([weight < 0.0 for weight in weights.values()]): 98 | raise ValueError( 99 | 'Dollar-weighted cash-buffered order sizing does not support ' 100 | 'negative weights. All positions must be long-only.' 101 | ) 102 | 103 | weight_sum = sum(weight for weight in weights.values()) 104 | 105 | # If the weights are very close or equal to zero then rescaling 106 | # is not possible, so simply return weights unscaled 107 | if np.isclose(weight_sum, 0.0): 108 | return weights 109 | 110 | return { 111 | asset: (weight / weight_sum) 112 | for asset, weight in weights.items() 113 | } 114 | 115 | def __call__(self, dt, weights): 116 | """ 117 | Creates a dollar-weighted cash-buffered target portfolio from the 118 | provided target weights at a particular timestamp. 119 | 120 | Parameters 121 | ---------- 122 | dt : `pd.Timestamp` 123 | The current date-time timestamp. 124 | weights : `dict{Asset: float}` 125 | The (potentially unnormalised) target weights. 126 | 127 | Returns 128 | ------- 129 | `dict{Asset: dict}` 130 | The cash-buffered target portfolio dictionary with quantities. 131 | """ 132 | total_equity = self._obtain_broker_portfolio_total_equity() 133 | cash_buffered_total_equity = total_equity * ( 134 | 1.0 - self.cash_buffer_percentage 135 | ) 136 | 137 | # Pre-cost dollar weight 138 | N = len(weights) 139 | if N == 0: 140 | # No forecasts so portfolio remains in cash 141 | # or is fully liquidated 142 | return {} 143 | 144 | # Ensure weight vector sums to unity 145 | normalised_weights = self._normalise_weights(weights) 146 | 147 | target_portfolio = {} 148 | for asset, weight in sorted(normalised_weights.items()): 149 | pre_cost_dollar_weight = cash_buffered_total_equity * weight 150 | 151 | # Estimate broker fees for this asset 152 | est_quantity = 0 # TODO: Needs to be added for IB 153 | est_costs = self.broker.fee_model.calc_total_cost( 154 | asset, est_quantity, pre_cost_dollar_weight, broker=self.broker 155 | ) 156 | 157 | # Calculate integral target asset quantity assuming broker costs 158 | after_cost_dollar_weight = pre_cost_dollar_weight - est_costs 159 | asset_price = self.data_handler.get_asset_latest_ask_price( 160 | dt, asset 161 | ) 162 | 163 | if np.isnan(asset_price): 164 | raise ValueError( 165 | 'Asset price for "%s" at timestamp "%s" is Not-a-Number (NaN). ' 166 | 'This can occur if the chosen backtest start date is earlier ' 167 | 'than the first available price for a particular asset. Try ' 168 | 'modifying the backtest start date and re-running.' % (asset, dt) 169 | ) 170 | 171 | # TODO: Long only for the time being. 172 | asset_quantity = int( 173 | np.floor(after_cost_dollar_weight / asset_price) 174 | ) 175 | 176 | # Add to the target portfolio 177 | target_portfolio[asset] = {"quantity": asset_quantity} 178 | 179 | return target_portfolio 180 | -------------------------------------------------------------------------------- /qstrader/portcon/order_sizer/long_short.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from qstrader.portcon.order_sizer.order_sizer import OrderSizer 4 | 5 | 6 | class LongShortLeveragedOrderSizer(OrderSizer): 7 | """ 8 | Creates a target portfolio of quantities for each Asset 9 | using its provided weight and total equity available in the 10 | Broker portfolio, leveraging up if necessary via the supplied 11 | gross leverage. 12 | 13 | Parameters 14 | ---------- 15 | broker : `Broker` 16 | The derived Broker instance to obtain portfolio equity from. 17 | broker_portfolio_id : `str` 18 | The specific portfolio at the Broker to obtain equity from. 19 | data_handler : `DataHandler` 20 | To obtain latest asset prices from. 21 | gross_leverage : `float`, optional 22 | The amount of percentage leverage to use when sizing orders. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | broker, 28 | broker_portfolio_id, 29 | data_handler, 30 | gross_leverage=1.0 31 | ): 32 | self.broker = broker 33 | self.broker_portfolio_id = broker_portfolio_id 34 | self.data_handler = data_handler 35 | self.gross_leverage = self._check_set_gross_leverage( 36 | gross_leverage 37 | ) 38 | 39 | def _check_set_gross_leverage(self, gross_leverage): 40 | """ 41 | Checks and sets the gross leverage percentage value. 42 | 43 | Parameters 44 | ---------- 45 | gross_leverage : `float` 46 | The amount of percentage leverage to use when sizing orders. 47 | This assumes no restriction on margin. 48 | 49 | Returns 50 | ------- 51 | `float` 52 | The gross leverage percentage value. 53 | """ 54 | if ( 55 | gross_leverage <= 0.0 56 | ): 57 | raise ValueError( 58 | 'Gross leverage "%s" provided to long-short levered ' 59 | 'order sizer is non positive.' % gross_leverage 60 | ) 61 | else: 62 | return gross_leverage 63 | 64 | def _obtain_broker_portfolio_total_equity(self): 65 | """ 66 | Obtain the Broker portfolio total equity. 67 | 68 | Returns 69 | ------- 70 | `float` 71 | The Broker portfolio total equity. 72 | """ 73 | return self.broker.get_portfolio_total_equity(self.broker_portfolio_id) 74 | 75 | def _normalise_weights(self, weights): 76 | """ 77 | Rescale provided weight values to ensure the 78 | weights are scaled to gross exposure divided by 79 | gross leverage. 80 | 81 | Parameters 82 | ---------- 83 | weights : `dict{Asset: float}` 84 | The un-normalised weight vector. 85 | 86 | Returns 87 | ------- 88 | `dict{Asset: float}` 89 | The scaled weight vector. 90 | """ 91 | gross_exposure = sum(np.abs(weight) for weight in weights.values()) 92 | 93 | # If the weights are very close or equal to zero then rescaling 94 | # is not possible, so simply return weights unscaled 95 | if np.isclose(gross_exposure, 0.0): 96 | return weights 97 | 98 | gross_ratio = self.gross_leverage / gross_exposure 99 | 100 | return { 101 | asset: (weight * gross_ratio) 102 | for asset, weight in weights.items() 103 | } 104 | 105 | def __call__(self, dt, weights): 106 | """ 107 | Creates a long short leveraged target portfolio from the 108 | provided target weights at a particular timestamp. 109 | 110 | Parameters 111 | ---------- 112 | dt : `pd.Timestamp` 113 | The current date-time timestamp. 114 | weights : `dict{Asset: float}` 115 | The (potentially unnormalised) target weights. 116 | 117 | Returns 118 | ------- 119 | `dict{Asset: dict}` 120 | The long short target portfolio dictionary with quantities. 121 | """ 122 | total_equity = self._obtain_broker_portfolio_total_equity() 123 | 124 | # Pre-cost dollar weight 125 | N = len(weights) 126 | if N == 0: 127 | # No forecasts so portfolio remains in cash 128 | # or is fully liquidated 129 | return {} 130 | 131 | # Scale weights to take into account gross exposure and leverage 132 | normalised_weights = self._normalise_weights(weights) 133 | 134 | target_portfolio = {} 135 | for asset, weight in sorted(normalised_weights.items()): 136 | pre_cost_dollar_weight = total_equity * weight 137 | 138 | # Estimate broker fees for this asset 139 | est_quantity = 0 # TODO: Needs to be added for IB 140 | est_costs = self.broker.fee_model.calc_total_cost( 141 | asset, est_quantity, pre_cost_dollar_weight, broker=self.broker 142 | ) 143 | 144 | # Calculate integral target asset quantity assuming broker costs 145 | after_cost_dollar_weight = pre_cost_dollar_weight - est_costs 146 | asset_price = self.data_handler.get_asset_latest_ask_price( 147 | dt, asset 148 | ) 149 | 150 | if np.isnan(asset_price): 151 | raise ValueError( 152 | 'Asset price for "%s" at timestamp "%s" is Not-a-Number (NaN). ' 153 | 'This can occur if the chosen backtest start date is earlier ' 154 | 'than the first available price for a particular asset. Try ' 155 | 'modifying the backtest start date and re-running.' % (asset, dt) 156 | ) 157 | 158 | # Truncate the after cost dollar weight 159 | # to nearest integer 160 | truncated_after_cost_dollar_weight = ( 161 | np.floor(after_cost_dollar_weight) 162 | if after_cost_dollar_weight >= 0.0 163 | else np.ceil(after_cost_dollar_weight) 164 | ) 165 | asset_quantity = int( 166 | truncated_after_cost_dollar_weight / asset_price 167 | ) 168 | 169 | # Add to the target portfolio 170 | target_portfolio[asset] = {"quantity": asset_quantity} 171 | 172 | return target_portfolio 173 | -------------------------------------------------------------------------------- /qstrader/portcon/order_sizer/order_sizer.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class OrderSizer(object): 5 | """ 6 | Creates a target portfolio of quantities for each Asset 7 | using its provided weight and total equity available in the Broker portfolio. 8 | """ 9 | 10 | __metaclass__ = ABCMeta 11 | 12 | @abstractmethod 13 | def __call__(self, dt, weights): 14 | raise NotImplementedError( 15 | "Should implement call()" 16 | ) 17 | -------------------------------------------------------------------------------- /qstrader/risk_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/risk_model/__init__.py -------------------------------------------------------------------------------- /qstrader/risk_model/risk_model.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class RiskModel(object): 5 | """ 6 | Abstract interface for an RiskModel callable. 7 | 8 | A derived-class instance of RiskModel takes in an Asset 9 | Universe and an optional DataHandler instance in order 10 | to modify weights on Assets generated by an AlphaModel. 11 | 12 | These adjusted weights are used within the PortfolioConstructionModel 13 | to generate new target weights for the portfolio. 14 | 15 | Implementing __call__ produces a dictionary keyed by 16 | Asset and with a scalar value as the signal. 17 | """ 18 | 19 | __metaclass__ = ABCMeta 20 | 21 | @abstractmethod 22 | def __call__(self, dt, weights): 23 | raise NotImplementedError( 24 | "Should implement __call__()" 25 | ) 26 | -------------------------------------------------------------------------------- /qstrader/settings.py: -------------------------------------------------------------------------------- 1 | SUPPORTED = { 2 | 'CURRENCIES': [ 3 | 'USD', 'GBP', 'EUR' 4 | ], 5 | 'FEE_MODEL': { 6 | 'ZeroFeeModel': 'qstrader.broker.fee_model.zero_fee_model' 7 | } 8 | } 9 | 10 | LOGGING = { 11 | 'DATE_FORMAT': '%Y-%m-%d %H:%M:%S' 12 | } 13 | 14 | PRINT_EVENTS = True 15 | 16 | 17 | def set_print_events(print_events=True): 18 | global PRINT_EVENTS 19 | PRINT_EVENTS = print_events 20 | -------------------------------------------------------------------------------- /qstrader/signals/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/signals/__init__.py -------------------------------------------------------------------------------- /qstrader/signals/buffer.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | 3 | 4 | class AssetPriceBuffers(object): 5 | """ 6 | Utility class to store double-ended queue ("deque") 7 | based price buffers for usage in lookback-based 8 | indicator calculations. 9 | 10 | Parameters 11 | ---------- 12 | assets : `list[str]` 13 | The list of assets to create price buffers for. 14 | lookbacks : `list[int]`, optional 15 | The number of lookback periods to store prices for. 16 | """ 17 | 18 | def __init__(self, assets, lookbacks=[12]): 19 | self.assets = assets 20 | self.lookbacks = lookbacks 21 | self.prices = self._create_all_assets_prices_buffer_dict() 22 | 23 | @staticmethod 24 | def _asset_lookback_key(asset, lookback): 25 | """ 26 | Create the buffer dictionary lookup key based 27 | on asset name and lookback period. 28 | 29 | Parameters 30 | ---------- 31 | asset : `str` 32 | The asset symbol name. 33 | lookback : `int` 34 | The lookback period. 35 | 36 | Returns 37 | ------- 38 | `str` 39 | The lookup key. 40 | """ 41 | return '%s_%s' % (asset, lookback) 42 | 43 | def _create_single_asset_prices_buffer_dict(self, asset): 44 | """ 45 | Creates a dictionary of asset-lookback pair 46 | price buffers for a single asset. 47 | 48 | Returns 49 | ------- 50 | `dict{str: deque[float]}` 51 | The price buffer dictionary. 52 | """ 53 | return { 54 | AssetPriceBuffers._asset_lookback_key( 55 | asset, lookback 56 | ): deque(maxlen=lookback) 57 | for lookback in self.lookbacks 58 | } 59 | 60 | def _create_all_assets_prices_buffer_dict(self): 61 | """ 62 | Creates a dictionary of asset-lookback pair 63 | price buffers for all assets. 64 | 65 | Returns 66 | ------- 67 | `dict{str: deque[float]}` 68 | The price buffer dictionary. 69 | """ 70 | prices = {} 71 | for asset in self.assets: 72 | prices.update(self._create_single_asset_prices_buffer_dict(asset)) 73 | return prices 74 | 75 | def add_asset(self, asset): 76 | """ 77 | Add an asset to the list of current assets. This is necessary if 78 | the asset is part of a DynamicUniverse and isn't present at 79 | the beginning of a backtest. 80 | 81 | Parameters 82 | ---------- 83 | asset : `str` 84 | The asset symbol name. 85 | """ 86 | if asset in self.assets: 87 | raise ValueError( 88 | 'Unable to add asset "%s" since it already ' 89 | 'exists in this price buffer.' % asset 90 | ) 91 | else: 92 | self.prices.update(self._create_single_asset_prices_buffer_dict(asset)) 93 | 94 | def append(self, asset, price): 95 | """ 96 | Append a new price onto the price deque for 97 | the specific asset provided. 98 | 99 | Parameters 100 | ---------- 101 | asset : `str` 102 | The asset symbol name. 103 | price : `float` 104 | The new price of the asset. 105 | """ 106 | if price <= 0.0: 107 | raise ValueError( 108 | 'Unable to append non-positive price of "%0.2f" ' 109 | 'to metrics buffer for Asset "%s".' % (price, asset) 110 | ) 111 | 112 | # The asset may have been added to the universe subsequent 113 | # to the beginning of the backtest and as such needs a 114 | # newly created pricing buffer 115 | asset_lookback_key = AssetPriceBuffers._asset_lookback_key(asset, self.lookbacks[0]) 116 | if asset_lookback_key not in self.prices: 117 | self.prices.update(self._create_single_asset_prices_buffer_dict(asset)) 118 | 119 | for lookback in self.lookbacks: 120 | self.prices[ 121 | AssetPriceBuffers._asset_lookback_key( 122 | asset, lookback 123 | ) 124 | ].append(price) 125 | -------------------------------------------------------------------------------- /qstrader/signals/momentum.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from qstrader.signals.signal import Signal 5 | 6 | 7 | class MomentumSignal(Signal): 8 | """ 9 | Indicator class to calculate lookback-period momentum 10 | (based on cumulative return of last N periods) for 11 | a set of prices. 12 | 13 | If the number of available returns is less than the 14 | lookback parameter the momentum is calculated on 15 | this subset. 16 | 17 | Parameters 18 | ---------- 19 | start_dt : `pd.Timestamp` 20 | The starting datetime (UTC) of the signal. 21 | universe : `Universe` 22 | The universe of assets to calculate the signals for. 23 | lookbacks : `list[int]` 24 | The number of lookback periods to store prices for. 25 | """ 26 | 27 | def __init__(self, start_dt, universe, lookbacks): 28 | bumped_lookbacks = [lookback + 1 for lookback in lookbacks] 29 | super().__init__(start_dt, universe, bumped_lookbacks) 30 | 31 | @staticmethod 32 | def _asset_lookback_key(asset, lookback): 33 | """ 34 | Create the buffer dictionary lookup key based 35 | on asset name and lookback period. 36 | 37 | Parameters 38 | ---------- 39 | asset : `str` 40 | The asset symbol name. 41 | lookback : `int` 42 | The lookback period. 43 | 44 | Returns 45 | ------- 46 | `str` 47 | The lookup key. 48 | """ 49 | return '%s_%s' % (asset, lookback + 1) 50 | 51 | def _cumulative_return(self, asset, lookback): 52 | """ 53 | Calculate the cumulative returns for the provided 54 | lookback period ('momentum') based on the price 55 | buffers for a particular asset. 56 | 57 | Parameters 58 | ---------- 59 | asset : `str` 60 | The asset symbol name. 61 | lookback : `int` 62 | The lookback period. 63 | 64 | Returns 65 | ------- 66 | `float` 67 | The cumulative return ('momentum') for the period. 68 | """ 69 | series = pd.Series( 70 | self.buffers.prices[MomentumSignal._asset_lookback_key(asset, lookback)] 71 | ) 72 | returns = series.pct_change().dropna().to_numpy() 73 | 74 | if len(returns) < 1: 75 | return 0.0 76 | else: 77 | return (np.cumprod(1.0 + np.array(returns)) - 1.0)[-1] 78 | 79 | def __call__(self, asset, lookback): 80 | """ 81 | Calculate the lookback-period momentum 82 | for the asset. 83 | 84 | Parameters 85 | ---------- 86 | asset : `str` 87 | The asset symbol name. 88 | lookback : `int` 89 | The lookback period. 90 | 91 | Returns 92 | ------- 93 | `float` 94 | The momentum for the period. 95 | """ 96 | return self._cumulative_return(asset, lookback) 97 | -------------------------------------------------------------------------------- /qstrader/signals/signal.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from qstrader.signals.buffer import AssetPriceBuffers 4 | 5 | 6 | class Signal(object): 7 | """ 8 | Abstract class to provide historical price range-based 9 | rolling signals utilising deque-based 'buffers'. 10 | 11 | Parameters 12 | ---------- 13 | start_dt : `pd.Timestamp` 14 | The starting datetime (UTC) of the signal. 15 | universe : `Universe` 16 | The universe of assets to calculate the signals for. 17 | lookbacks : `list[int]` 18 | The number of lookback periods to store prices for. 19 | """ 20 | 21 | __metaclass__ = ABCMeta 22 | 23 | def __init__(self, start_dt, universe, lookbacks): 24 | self.start_dt = start_dt 25 | self.universe = universe 26 | self.lookbacks = lookbacks 27 | self.assets = self.universe.get_assets(start_dt) 28 | self.buffers = self._create_asset_price_buffers() 29 | 30 | def _create_asset_price_buffers(self): 31 | """ 32 | Create an AssetPriceBuffers instance. 33 | 34 | Returns 35 | ------- 36 | `AssetPriceBuffers` 37 | Stores the asset price buffers for the signal. 38 | """ 39 | return AssetPriceBuffers( 40 | self.assets, lookbacks=self.lookbacks 41 | ) 42 | 43 | def append(self, asset, price): 44 | """ 45 | Append a new price onto the price buffer for 46 | the specific asset provided. 47 | 48 | Parameters 49 | ---------- 50 | asset : `str` 51 | The asset symbol name. 52 | price : `float` 53 | The new price of the asset. 54 | """ 55 | self.buffers.append(asset, price) 56 | 57 | def update_assets(self, dt): 58 | """ 59 | Ensure that any new additions to the universe also receive 60 | a price buffer at the point at which they enter. 61 | 62 | Parameters 63 | ---------- 64 | dt : `pd.Timestamp` 65 | The update timestamp for the signal. 66 | """ 67 | universe_assets = self.universe.get_assets(dt) 68 | 69 | # TODO: Assume universe never decreases for now 70 | extra_assets = list(set(universe_assets) - set((self.assets))) 71 | for extra_asset in extra_assets: 72 | self.assets.append(extra_asset) 73 | 74 | @abstractmethod 75 | def __call__(self, asset, lookback): 76 | raise NotImplementedError( 77 | "Should implement __call__()" 78 | ) 79 | -------------------------------------------------------------------------------- /qstrader/signals/signals_collection.py: -------------------------------------------------------------------------------- 1 | class SignalsCollection(object): 2 | """ 3 | Provides a mechanism for aggregating all signals 4 | used by AlphaModels or RiskModels. 5 | 6 | Keeps track of updating the asset universe for each signal 7 | if a DynamicUniverse is utilised. 8 | 9 | Ensures each signal receives a new data point at the 10 | appropriate simulation iteration rate. 11 | 12 | Parameters 13 | ---------- 14 | signals : `dict{str: Signal}` 15 | Map of signal name to derived instance of Signal 16 | data_handler : `DataHandler` 17 | The data handler used to obtain pricing. 18 | """ 19 | 20 | def __init__(self, signals, data_handler): 21 | self.signals = signals 22 | self.data_handler = data_handler 23 | self.warmup = 0 # Used for 'burn in' 24 | 25 | def __getitem__(self, signal): 26 | """ 27 | Allow Signal to be returned via dictionary-like syntax. 28 | 29 | Parameters 30 | ---------- 31 | signal : `str` 32 | The signal string. 33 | 34 | Returns 35 | ------- 36 | `Signal` 37 | The signal instance. 38 | """ 39 | return self.signals[signal] 40 | 41 | def update(self, dt): 42 | """ 43 | Updates the universe (if dynamic) for each signal as well 44 | as the pricing information for this timestamp. 45 | 46 | Parameters 47 | ---------- 48 | dt : `pd.Timestamp` 49 | The time at which the signals are to be updated for. 50 | """ 51 | # Ensure any new assets in a DynamicUniverse 52 | # are added to the signal 53 | for name, signal in self.signals.items(): 54 | self.signals[name].update_assets(dt) 55 | 56 | # Update all of the signals with new prices 57 | for name, signal in self.signals.items(): 58 | assets = signal.assets 59 | for asset in assets: 60 | price = self.data_handler.get_asset_latest_mid_price(dt, asset) 61 | self.signals[name].append(asset, price) 62 | self.warmup += 1 63 | -------------------------------------------------------------------------------- /qstrader/signals/sma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from qstrader.signals.signal import Signal 4 | 5 | 6 | class SMASignal(Signal): 7 | """ 8 | Indicator class to calculate simple moving average 9 | of last N periods for a set of prices. 10 | 11 | Parameters 12 | ---------- 13 | start_dt : `pd.Timestamp` 14 | The starting datetime (UTC) of the signal. 15 | universe : `Universe` 16 | The universe of assets to calculate the signals for. 17 | lookbacks : `list[int]` 18 | The number of lookback periods to store prices for. 19 | """ 20 | 21 | def __init__(self, start_dt, universe, lookbacks): 22 | super().__init__(start_dt, universe, lookbacks) 23 | 24 | def _simple_moving_average(self, asset, lookback): 25 | """ 26 | Calculate the 'trend' for the provided lookback 27 | period based on the simple moving average of the 28 | price buffers for a particular asset. 29 | 30 | Parameters 31 | ---------- 32 | asset : `str` 33 | The asset symbol name. 34 | lookback : `int` 35 | The lookback period. 36 | 37 | Returns 38 | ------- 39 | `float` 40 | The SMA value ('trend') for the period. 41 | """ 42 | return np.mean(self.buffers.prices['%s_%s' % (asset, lookback)]) 43 | 44 | def __call__(self, asset, lookback): 45 | """ 46 | Calculate the lookback-period trend 47 | for the asset. 48 | 49 | Parameters 50 | ---------- 51 | asset : `str` 52 | The asset symbol name. 53 | lookback : `int` 54 | The lookback period. 55 | 56 | Returns 57 | ------- 58 | `float` 59 | The trend (SMA) for the period. 60 | """ 61 | return self._simple_moving_average(asset, lookback) 62 | -------------------------------------------------------------------------------- /qstrader/signals/vol.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from qstrader.signals.signal import Signal 5 | 6 | 7 | class VolatilitySignal(Signal): 8 | """ 9 | Indicator class to calculate lookback-period daily 10 | volatility of returns, which is then annualised. 11 | 12 | If the number of available returns is less than the 13 | lookback parameter the volatility is calculated on 14 | this subset. 15 | 16 | Parameters 17 | ---------- 18 | start_dt : `pd.Timestamp` 19 | The starting datetime (UTC) of the signal. 20 | universe : `Universe` 21 | The universe of assets to calculate the signals for. 22 | lookbacks : `list[int]` 23 | The number of lookback periods to store prices for. 24 | """ 25 | 26 | def __init__(self, start_dt, universe, lookbacks): 27 | bumped_lookbacks = [lookback + 1 for lookback in lookbacks] 28 | super().__init__(start_dt, universe, bumped_lookbacks) 29 | 30 | @staticmethod 31 | def _asset_lookback_key(asset, lookback): 32 | """ 33 | Create the buffer dictionary lookup key based 34 | on asset name and lookback period. 35 | 36 | Parameters 37 | ---------- 38 | asset : `str` 39 | The asset symbol name. 40 | lookback : `int` 41 | The lookback period. 42 | 43 | Returns 44 | ------- 45 | `str` 46 | The lookup key. 47 | """ 48 | return '%s_%s' % (asset, lookback + 1) 49 | 50 | def _annualised_vol(self, asset, lookback): 51 | """ 52 | Calculate the annualised volatility for the provided 53 | lookback period based on the price buffers for a 54 | particular asset. 55 | 56 | Parameters 57 | ---------- 58 | asset : `str` 59 | The asset symbol name. 60 | lookback : `int` 61 | The lookback period. 62 | 63 | Returns 64 | ------- 65 | `float` 66 | The annualised volatility of returns. 67 | """ 68 | series = pd.Series( 69 | self.buffers.prices[ 70 | VolatilitySignal._asset_lookback_key( 71 | asset, lookback 72 | ) 73 | ] 74 | ) 75 | returns = series.pct_change().dropna().to_numpy() 76 | 77 | if len(returns) < 1: 78 | return 0.0 79 | else: 80 | return np.std(returns) * np.sqrt(252) 81 | 82 | def __call__(self, asset, lookback): 83 | """ 84 | Calculate the annualised volatility of 85 | returns for the asset. 86 | 87 | Parameters 88 | ---------- 89 | asset : `str` 90 | The asset symbol name. 91 | lookback : `int` 92 | The lookback period. 93 | 94 | Returns 95 | ------- 96 | `float` 97 | The annualised volatility of returns. 98 | """ 99 | return self._annualised_vol(asset, lookback) 100 | -------------------------------------------------------------------------------- /qstrader/simulation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/simulation/__init__.py -------------------------------------------------------------------------------- /qstrader/simulation/daily_bday.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pandas as pd 4 | from pandas.tseries.offsets import BDay 5 | import pytz 6 | 7 | from qstrader.simulation.sim_engine import SimulationEngine 8 | from qstrader.simulation.event import SimulationEvent 9 | 10 | 11 | class DailyBusinessDaySimulationEngine(SimulationEngine): 12 | """ 13 | A SimulationEngine subclass that generates events on a daily 14 | frequency defaulting to typical business days, that is 15 | Monday-Friday. 16 | 17 | In particular it does not take into account any specific 18 | regional holidays, such as Federal Holidays in the USA or 19 | Bank Holidays in the UK. 20 | 21 | It produces a pre-market event, a market open event, 22 | a market closing event and a post-market event for every day 23 | between the starting and ending dates. 24 | 25 | Parameters 26 | ---------- 27 | starting_day : `pd.Timestamp` 28 | The starting day of the simulation. 29 | ending_day : `pd.Timestamp` 30 | The ending day of the simulation. 31 | pre_market : `Boolean`, optional 32 | Whether to include a pre-market event 33 | post_market : `Boolean`, optional 34 | Whether to include a post-market event 35 | """ 36 | 37 | def __init__(self, starting_day, ending_day, pre_market=True, post_market=True): 38 | if ending_day < starting_day: 39 | raise ValueError( 40 | "Ending date time %s is earlier than starting date time %s. " 41 | "Cannot create DailyBusinessDaySimulationEngine " 42 | "instance." % (ending_day, starting_day) 43 | ) 44 | 45 | self.starting_day = starting_day 46 | self.ending_day = ending_day 47 | self.pre_market = pre_market 48 | self.post_market = post_market 49 | self.business_days = self._generate_business_days() 50 | 51 | def _generate_business_days(self): 52 | """ 53 | Generate the list of business days using midnight UTC as 54 | the timestamp. 55 | 56 | Returns 57 | ------- 58 | `list[pd.Timestamp]` 59 | The business day range list. 60 | """ 61 | days = pd.date_range( 62 | self.starting_day, self.ending_day, freq=BDay() 63 | ) 64 | return days 65 | 66 | def __iter__(self): 67 | """ 68 | Generate the daily timestamps and event information 69 | for pre-market, market open, market close and post-market. 70 | 71 | Yields 72 | ------ 73 | `SimulationEvent` 74 | Market time simulation event to yield 75 | """ 76 | for index, bday in enumerate(self.business_days): 77 | year = bday.year 78 | month = bday.month 79 | day = bday.day 80 | 81 | if self.pre_market: 82 | yield SimulationEvent( 83 | pd.Timestamp( 84 | datetime.datetime(year, month, day), tz='UTC' 85 | ), event_type="pre_market" 86 | ) 87 | 88 | yield SimulationEvent( 89 | pd.Timestamp( 90 | datetime.datetime(year, month, day, 14, 30), 91 | tz=pytz.utc 92 | ), event_type="market_open" 93 | ) 94 | 95 | yield SimulationEvent( 96 | pd.Timestamp( 97 | datetime.datetime(year, month, day, 21, 00), 98 | tz=pytz.utc 99 | ), event_type="market_close" 100 | ) 101 | 102 | if self.post_market: 103 | yield SimulationEvent( 104 | pd.Timestamp( 105 | datetime.datetime(year, month, day, 23, 59), tz='UTC' 106 | ), event_type="post_market" 107 | ) 108 | -------------------------------------------------------------------------------- /qstrader/simulation/event.py: -------------------------------------------------------------------------------- 1 | class SimulationEvent(object): 2 | """ 3 | Stores a timestamp and event type string associated with 4 | a simulation event. 5 | 6 | Parameters 7 | ---------- 8 | ts : `pd.Timestamp` 9 | The timestamp of the simulation event. 10 | event_type : `str` 11 | The event type string. 12 | """ 13 | 14 | def __init__(self, ts, event_type): 15 | self.ts = ts 16 | self.event_type = event_type 17 | 18 | def __eq__(self, rhs): 19 | """ 20 | Two SimulationEvent entities are equal if they share 21 | the same timestamp and event type. 22 | 23 | Parameters 24 | ---------- 25 | rhs : `SimulationEvent` 26 | The comparison SimulationEvent. 27 | 28 | Returns 29 | ------- 30 | `boolean` 31 | Whether the two SimulationEvents are equal. 32 | """ 33 | if self.ts != rhs.ts: 34 | return False 35 | if self.event_type != rhs.event_type: 36 | return False 37 | return True 38 | -------------------------------------------------------------------------------- /qstrader/simulation/sim_engine.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class SimulationEngine(object): 5 | """ 6 | Interface to a tradinh event simulation engine. 7 | 8 | Subclasses are designed to take starting and ending 9 | timestamps to generate events at a specific frequency. 10 | 11 | This is achieved by overriding __iter__ and yielding Event 12 | entities. These entities would include signalling an exchange 13 | opening, an exchange closing, as well as pre- and post-opening 14 | events to allow handling of cash-flows and corporate actions. 15 | 16 | In this way the necessary events can be carried out for 17 | the entities in the system, such as dividend handling, 18 | capital changes, performance calculations and trading 19 | orders. 20 | """ 21 | 22 | __metaclass__ = ABCMeta 23 | 24 | @abstractmethod 25 | def __iter__(self): 26 | raise NotImplementedError( 27 | "Should implement __iter__()" 28 | ) 29 | -------------------------------------------------------------------------------- /qstrader/statistics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/statistics/__init__.py -------------------------------------------------------------------------------- /qstrader/statistics/performance.py: -------------------------------------------------------------------------------- 1 | from itertools import groupby 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | 7 | def aggregate_returns(returns, convert_to): 8 | """ 9 | Aggregates returns by day, week, month, or year. 10 | """ 11 | def cumulate_returns(x): 12 | return np.exp(np.log(1 + x).cumsum()).iloc[-1] - 1 13 | 14 | if convert_to == 'weekly': 15 | return returns.groupby( 16 | [lambda x: x.year, 17 | lambda x: x.month, 18 | lambda x: x.isocalendar()[1]]).apply(cumulate_returns) 19 | elif convert_to == 'monthly': 20 | return returns.groupby( 21 | [lambda x: x.year, lambda x: x.month]).apply(cumulate_returns) 22 | elif convert_to == 'yearly': 23 | return returns.groupby( 24 | [lambda x: x.year]).apply(cumulate_returns) 25 | else: 26 | ValueError('convert_to must be weekly, monthly or yearly') 27 | 28 | 29 | def create_cagr(equity, periods=252): 30 | """ 31 | Calculates the Compound Annual Growth Rate (CAGR) 32 | for the portfolio, by determining the number of years 33 | and then creating a compound annualised rate based 34 | on the total return. 35 | 36 | Parameters: 37 | equity - A pandas Series representing the equity curve. 38 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 39 | """ 40 | years = len(equity) / float(periods) 41 | return (equity.iloc[-1] ** (1.0 / years)) - 1.0 42 | 43 | 44 | def create_sharpe_ratio(returns, periods=252): 45 | """ 46 | Create the Sharpe ratio for the strategy, based on a 47 | benchmark of zero (i.e. no risk-free rate information). 48 | 49 | Parameters: 50 | returns - A pandas Series representing period percentage returns. 51 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 52 | """ 53 | return np.sqrt(periods) * (np.mean(returns)) / np.std(returns) 54 | 55 | 56 | def create_sortino_ratio(returns, periods=252): 57 | """ 58 | Create the Sortino ratio for the strategy, based on a 59 | benchmark of zero (i.e. no risk-free rate information). 60 | 61 | Parameters: 62 | returns - A pandas Series representing period percentage returns. 63 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 64 | """ 65 | return np.sqrt(periods) * (np.mean(returns)) / np.std(returns[returns < 0]) 66 | 67 | 68 | def create_drawdowns(returns): 69 | """ 70 | Calculate the largest peak-to-trough drawdown of the equity curve 71 | as well as the duration of the drawdown. Requires that the 72 | pnl_returns is a pandas Series. 73 | 74 | Parameters: 75 | equity - A pandas Series representing period percentage returns. 76 | 77 | Returns: 78 | drawdown, drawdown_max, duration 79 | """ 80 | # Calculate the cumulative returns curve 81 | # and set up the High Water Mark 82 | idx = returns.index 83 | hwm = np.zeros(len(idx)) 84 | 85 | # Create the high water mark 86 | for t in range(1, len(idx)): 87 | hwm[t] = max(hwm[t - 1], returns.iloc[t]) 88 | 89 | # Calculate the drawdown and duration statistics 90 | perf = pd.DataFrame(index=idx) 91 | perf["Drawdown"] = (hwm - returns) / hwm 92 | perf.loc[perf.index[0], 'Drawdown'] = 0.0 93 | perf["DurationCheck"] = np.where(perf["Drawdown"] == 0, 0, 1) 94 | duration = max( 95 | sum(1 for i in g if i == 1) 96 | for k, g in groupby(perf["DurationCheck"]) 97 | ) 98 | return perf["Drawdown"], np.max(perf["Drawdown"]), duration 99 | -------------------------------------------------------------------------------- /qstrader/statistics/statistics.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Statistics(object): 5 | """ 6 | Statistics is an abstract class providing an interface for 7 | all inherited statistic classes (live, historic, custom, etc). 8 | 9 | The goal of a Statistics object is to keep a record of useful 10 | information about one or many trading strategies as the strategy 11 | is running. This is done by hooking into the main event loop and 12 | essentially updating the object according to portfolio performance 13 | over time. 14 | 15 | Ideally, Statistics should be subclassed according to the strategies 16 | and timeframes-traded by the user. Different trading strategies 17 | may require different metrics or frequencies-of-metrics to be updated, 18 | however the example given is suitable for longer timeframes. 19 | """ 20 | 21 | __metaclass__ = ABCMeta 22 | 23 | @abstractmethod 24 | def update(self, dt): 25 | """ 26 | Update all the statistics according to values of the portfolio 27 | and open positions. This should be called from within the 28 | event loop. 29 | """ 30 | raise NotImplementedError("Should implement update()") 31 | 32 | @abstractmethod 33 | def get_results(self): 34 | """ 35 | Return a dict containing all statistics. 36 | """ 37 | raise NotImplementedError("Should implement get_results()") 38 | 39 | @abstractmethod 40 | def plot_results(self): 41 | """ 42 | Plot all statistics collected up until 'now' 43 | """ 44 | raise NotImplementedError("Should implement plot_results()") 45 | 46 | @abstractmethod 47 | def save(self, filename): 48 | """ 49 | Save statistics results to filename 50 | """ 51 | raise NotImplementedError("Should implement save()") 52 | -------------------------------------------------------------------------------- /qstrader/system/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/system/__init__.py -------------------------------------------------------------------------------- /qstrader/system/qts.py: -------------------------------------------------------------------------------- 1 | from qstrader.execution.execution_handler import ( 2 | ExecutionHandler 3 | ) 4 | from qstrader.execution.execution_algo.market_order import ( 5 | MarketOrderExecutionAlgorithm 6 | ) 7 | from qstrader.portcon.pcm import ( 8 | PortfolioConstructionModel 9 | ) 10 | from qstrader.portcon.optimiser.fixed_weight import ( 11 | FixedWeightPortfolioOptimiser 12 | ) 13 | from qstrader.portcon.order_sizer.dollar_weighted import ( 14 | DollarWeightedCashBufferedOrderSizer 15 | ) 16 | from qstrader.portcon.order_sizer.long_short import ( 17 | LongShortLeveragedOrderSizer 18 | ) 19 | 20 | 21 | class QuantTradingSystem(object): 22 | """ 23 | Encapsulates all components associated with the quantitative 24 | trading system. This includes the alpha model(s), the risk 25 | model, the transaction cost model along with portfolio construction 26 | and execution mechanism. 27 | 28 | Parameters 29 | ---------- 30 | universe : `Universe` 31 | The Asset Universe. 32 | broker : `Broker` 33 | The Broker to execute orders against. 34 | broker_portfolio_id : `str` 35 | The specific broker portfolio to send orders to. 36 | data_handler : `DataHandler` 37 | The data handler instance used for all market/fundamental data. 38 | alpha_model : `AlphaModel` 39 | The alpha model used within the portfolio construction. 40 | risk_model : `AlphaModel`, optional 41 | An optional risk model used within the portfolio construction. 42 | long_only : `Boolean`, optional 43 | Whether to invoke the long only order sizer or allow 44 | long/short leveraged portfolios. Defaults to long/short leveraged. 45 | submit_orders : `Boolean`, optional 46 | Whether to actually submit generated orders. Defaults to no submission. 47 | """ 48 | 49 | def __init__( 50 | self, 51 | universe, 52 | broker, 53 | broker_portfolio_id, 54 | data_handler, 55 | alpha_model, 56 | *args, 57 | risk_model=None, 58 | long_only=False, 59 | submit_orders=False, 60 | **kwargs 61 | ): 62 | self.universe = universe 63 | self.broker = broker 64 | self.broker_portfolio_id = broker_portfolio_id 65 | self.data_handler = data_handler 66 | self.alpha_model = alpha_model 67 | self.risk_model = risk_model 68 | self.long_only = long_only 69 | self.submit_orders = submit_orders 70 | self._initialise_models(**kwargs) 71 | 72 | def _create_order_sizer(self, **kwargs): 73 | """ 74 | Depending upon whether the quant trading system has been 75 | set to be long only, determine the appropriate order sizing 76 | mechanism. 77 | 78 | Returns 79 | ------- 80 | `OrderSizer` 81 | The order sizing mechanism for the portfolio construction. 82 | """ 83 | if self.long_only: 84 | if 'cash_buffer_percentage' not in kwargs: 85 | raise ValueError( 86 | 'Long only portfolio specified for Quant Trading System ' 87 | 'but no cash buffer percentage supplied.' 88 | ) 89 | cash_buffer_percentage = kwargs['cash_buffer_percentage'] 90 | 91 | order_sizer = DollarWeightedCashBufferedOrderSizer( 92 | self.broker, 93 | self.broker_portfolio_id, 94 | self.data_handler, 95 | cash_buffer_percentage=cash_buffer_percentage 96 | ) 97 | else: 98 | if 'gross_leverage' not in kwargs: 99 | raise ValueError( 100 | 'Long/short leveraged portfolio specified for Quant ' 101 | 'Trading System but no gross leverage percentage supplied.' 102 | ) 103 | gross_leverage = kwargs['gross_leverage'] 104 | 105 | order_sizer = LongShortLeveragedOrderSizer( 106 | self.broker, 107 | self.broker_portfolio_id, 108 | self.data_handler, 109 | gross_leverage=gross_leverage 110 | ) 111 | 112 | return order_sizer 113 | 114 | def _initialise_models(self, **kwargs): 115 | """ 116 | Initialise the various models for the quantitative 117 | trading strategy. This includes the portfolio 118 | construction and the execution. 119 | 120 | TODO: Add TransactionCostModel 121 | TODO: Ensure this is dynamically generated from config. 122 | """ 123 | # Determine the appropriate order sizing mechanism 124 | order_sizer = self._create_order_sizer(**kwargs) 125 | 126 | # TODO: Allow optimiser to be generated from config 127 | optimiser = FixedWeightPortfolioOptimiser( 128 | data_handler=self.data_handler 129 | ) 130 | 131 | # Generate the portfolio construction 132 | self.portfolio_construction_model = PortfolioConstructionModel( 133 | self.broker, 134 | self.broker_portfolio_id, 135 | self.universe, 136 | order_sizer, 137 | optimiser, 138 | alpha_model=self.alpha_model, 139 | risk_model=self.risk_model, 140 | data_handler=self.data_handler 141 | ) 142 | 143 | # Execution 144 | execution_algo = MarketOrderExecutionAlgorithm() 145 | self.execution_handler = ExecutionHandler( 146 | self.broker, 147 | self.broker_portfolio_id, 148 | self.universe, 149 | submit_orders=self.submit_orders, 150 | execution_algo=execution_algo, 151 | data_handler=self.data_handler 152 | ) 153 | 154 | def __call__(self, dt, stats=None): 155 | """ 156 | Construct the portfolio and (optionally) execute the orders 157 | with the broker. 158 | 159 | Parameters 160 | ---------- 161 | dt : `pd.Timestamp` 162 | The current time. 163 | stats : `dict`, optional 164 | An optional statistics dictionary to append values to 165 | throughout the simulation lifetime. 166 | 167 | Returns 168 | ------- 169 | `None` 170 | """ 171 | # Construct the target portfolio 172 | rebalance_orders = self.portfolio_construction_model(dt, stats=stats) 173 | 174 | # Execute the orders 175 | self.execution_handler(dt, rebalance_orders) 176 | -------------------------------------------------------------------------------- /qstrader/system/rebalance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/system/rebalance/__init__.py -------------------------------------------------------------------------------- /qstrader/system/rebalance/buy_and_hold.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from pandas.tseries.offsets import BusinessDay 3 | from qstrader.system.rebalance.rebalance import Rebalance 4 | 5 | 6 | class BuyAndHoldRebalance(Rebalance): 7 | """ 8 | Generates a single rebalance timestamp at the first business day 9 | after the start date. Creates a single set of orders at the beginning of 10 | a backtest, with no further rebalances carried out. 11 | 12 | Parameters 13 | ---------- 14 | start_dt : `pd.Timestamp` 15 | The starting datetime of the buy and hold rebalance. 16 | """ 17 | 18 | def __init__(self, start_dt): 19 | self.start_dt = start_dt 20 | self.rebalances = self._generate_rebalances() 21 | 22 | def _is_business_day(self): 23 | """ 24 | Checks if the start_dt is a business day. 25 | 26 | Returns 27 | ------- 28 | `boolean` 29 | """ 30 | return bool(len(pd.bdate_range(self.start_dt, self.start_dt))) 31 | 32 | def _generate_rebalances(self): 33 | """ 34 | Outputs the rebalance timestamp offset to the next 35 | business day. 36 | 37 | Does not include holidays. 38 | 39 | Returns 40 | ------- 41 | `list[pd.Timestamp]` 42 | The rebalance timestamp list. 43 | """ 44 | if not self._is_business_day(): 45 | rebalance_date = self.start_dt + BusinessDay() 46 | else: 47 | rebalance_date = self.start_dt 48 | return [rebalance_date] 49 | -------------------------------------------------------------------------------- /qstrader/system/rebalance/daily.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytz 3 | 4 | from qstrader.system.rebalance.rebalance import Rebalance 5 | 6 | 7 | class DailyRebalance(Rebalance): 8 | """ 9 | Generates a list of rebalance timestamps for pre- or post-market, 10 | for all business days (Monday-Friday) between two dates. 11 | 12 | Does not take into account holiday calendars. 13 | 14 | All timestamps produced are set to UTC. 15 | 16 | Parameters 17 | ---------- 18 | start_date : `pd.Timestamp` 19 | The starting timestamp of the rebalance range. 20 | end_date : `pd.Timestamp` 21 | The ending timestamp of the rebalance range. 22 | pre_market : `Boolean`, optional 23 | Whether to carry out the rebalance at market open/close. 24 | """ 25 | 26 | def __init__( 27 | self, 28 | start_date, 29 | end_date, 30 | pre_market=False 31 | ): 32 | self.start_date = start_date 33 | self.end_date = end_date 34 | self.market_time = self._set_market_time(pre_market) 35 | self.rebalances = self._generate_rebalances() 36 | 37 | def _set_market_time(self, pre_market): 38 | """ 39 | Determines whether to use market open or market close 40 | as the rebalance time. 41 | 42 | Parameters 43 | ---------- 44 | pre_market : `Boolean` 45 | Whether to use market open or market close 46 | as the rebalance time. 47 | 48 | Returns 49 | ------- 50 | `str` 51 | The string representation of the market time. 52 | """ 53 | return "14:30:00" if pre_market else "21:00:00" 54 | 55 | def _generate_rebalances(self): 56 | """ 57 | Output the rebalance timestamp list. 58 | 59 | Returns 60 | ------- 61 | `list[pd.Timestamp]` 62 | The list of rebalance timestamps. 63 | """ 64 | rebalance_dates = pd.bdate_range( 65 | start=self.start_date, end=self.end_date, 66 | ) 67 | 68 | rebalance_times = [ 69 | pd.Timestamp( 70 | "%s %s" % (date, self.market_time), tz=pytz.utc 71 | ) 72 | for date in rebalance_dates 73 | ] 74 | 75 | return rebalance_times 76 | -------------------------------------------------------------------------------- /qstrader/system/rebalance/end_of_month.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytz 3 | 4 | from qstrader.system.rebalance.rebalance import Rebalance 5 | 6 | 7 | class EndOfMonthRebalance(Rebalance): 8 | """ 9 | Generates a list of rebalance timestamps for pre- or post-market, 10 | for the final calendar day of the month between the starting and 11 | ending dates provided. 12 | 13 | All timestamps produced are set to UTC. 14 | 15 | Parameters 16 | ---------- 17 | start_dt : `pd.Timestamp` 18 | The starting datetime of the rebalance range. 19 | end_dt : `pd.Timestamp` 20 | The ending datetime of the rebalance range. 21 | pre_market : `Boolean`, optional 22 | Whether to carry out the rebalance at market open/close on 23 | the final day of the month. Defaults to False, i.e at 24 | market close. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | start_dt, 30 | end_dt, 31 | pre_market=False 32 | ): 33 | self.start_dt = start_dt 34 | self.end_dt = end_dt 35 | self.market_time = self._set_market_time(pre_market) 36 | self.rebalances = self._generate_rebalances() 37 | 38 | def _set_market_time(self, pre_market): 39 | """ 40 | Determines whether to use market open or market close 41 | as the rebalance time. 42 | 43 | Parameters 44 | ---------- 45 | pre_market : `Boolean` 46 | Whether the rebalance is carried out at market open/close. 47 | 48 | Returns 49 | ------- 50 | `str` 51 | The time string used for Pandas timestamp construction. 52 | """ 53 | return "14:30:00" if pre_market else "21:00:00" 54 | 55 | def _generate_rebalances(self): 56 | """ 57 | Utilise the Pandas date_range method to create the appropriate 58 | list of rebalance timestamps. 59 | 60 | Returns 61 | ------- 62 | `List[pd.Timestamp]` 63 | The list of rebalance timestamps. 64 | """ 65 | rebalance_dates = pd.date_range( 66 | start=self.start_dt, 67 | end=self.end_dt, 68 | freq='BME' 69 | ) 70 | 71 | rebalance_times = [ 72 | pd.Timestamp( 73 | "%s %s" % (date, self.market_time), tz=pytz.utc 74 | ) 75 | for date in rebalance_dates 76 | ] 77 | return rebalance_times 78 | -------------------------------------------------------------------------------- /qstrader/system/rebalance/rebalance.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Rebalance(object): 5 | """ 6 | Interface to a generic list of system logic and 7 | trade order rebalance timestamps. 8 | """ 9 | 10 | __metaclass__ = ABCMeta 11 | 12 | @abstractmethod 13 | def output_rebalances(self): 14 | raise NotImplementedError( 15 | "Should implement output_rebalances()" 16 | ) 17 | -------------------------------------------------------------------------------- /qstrader/system/rebalance/weekly.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytz 3 | 4 | from qstrader.system.rebalance.rebalance import Rebalance 5 | 6 | 7 | class WeeklyRebalance(Rebalance): 8 | """ 9 | Generates a list of rebalance timestamps for pre- or post-market, 10 | for a particular trading day of the week between the starting and 11 | ending dates provided. 12 | 13 | All timestamps produced are set to UTC. 14 | 15 | Parameters 16 | ---------- 17 | start_date : `pd.Timestamp` 18 | The starting timestamp of the rebalance range. 19 | end_date : `pd.Timestamp` 20 | The ending timestamp of the rebalance range. 21 | weekday : `str` 22 | The three-letter string representation of the weekday 23 | to rebalance on once per week. 24 | pre_market : `Boolean`, optional 25 | Whether to carry out the rebalance at market open/close. 26 | """ 27 | 28 | def __init__( 29 | self, 30 | start_date, 31 | end_date, 32 | weekday, 33 | pre_market=False 34 | ): 35 | self.weekday = self._set_weekday(weekday) 36 | self.start_date = start_date 37 | self.end_date = end_date 38 | self.pre_market_time = self._set_market_time(pre_market) 39 | self.rebalances = self._generate_rebalances() 40 | 41 | def _set_weekday(self, weekday): 42 | """ 43 | Checks that the weekday string corresponds to 44 | a business weekday. 45 | 46 | Parameters 47 | ---------- 48 | weekday : `str` 49 | The three-letter string representation of the weekday 50 | to rebalance on once per week. 51 | 52 | Returns 53 | ------- 54 | `str` 55 | The uppercase three-letter string representation of the 56 | weekday to rebalance on once per week. 57 | """ 58 | weekdays = ("MON", "TUE", "WED", "THU", "FRI") 59 | if weekday.upper() not in weekdays: 60 | raise ValueError( 61 | "Provided weekday keyword '%s' is not recognised " 62 | "or not a valid weekday." % weekday 63 | ) 64 | else: 65 | return weekday.upper() 66 | 67 | def _set_market_time(self, pre_market): 68 | """ 69 | Determines whether to use market open or market close 70 | as the rebalance time. 71 | 72 | Parameters 73 | ---------- 74 | pre_market : `Boolean` 75 | Whether to use market open or market close 76 | as the rebalance time. 77 | 78 | Returns 79 | ------- 80 | `str` 81 | The string representation of the market time. 82 | """ 83 | return "14:30:00" if pre_market else "21:00:00" 84 | 85 | def _generate_rebalances(self): 86 | """ 87 | Output the rebalance timestamp list. 88 | 89 | Returns 90 | ------- 91 | `list[pd.Timestamp]` 92 | The list of rebalance timestamps. 93 | """ 94 | rebalance_dates = pd.date_range( 95 | start=self.start_date, 96 | end=self.end_date, 97 | freq='W-%s' % self.weekday 98 | ) 99 | 100 | rebalance_times = [ 101 | pd.Timestamp( 102 | "%s %s" % (date, self.pre_market_time), tz=pytz.utc 103 | ) 104 | for date in rebalance_dates 105 | ] 106 | 107 | return rebalance_times 108 | -------------------------------------------------------------------------------- /qstrader/trading/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/trading/__init__.py -------------------------------------------------------------------------------- /qstrader/trading/trading_session.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class TradingSession(object): 5 | """ 6 | Interface to a live or backtested trading session. 7 | """ 8 | 9 | __metaclass__ = ABCMeta 10 | 11 | @abstractmethod 12 | def run(self): 13 | raise NotImplementedError( 14 | "Should implement run()" 15 | ) 16 | -------------------------------------------------------------------------------- /qstrader/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/qstrader/utils/__init__.py -------------------------------------------------------------------------------- /qstrader/utils/console.py: -------------------------------------------------------------------------------- 1 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 2 | 3 | 4 | def string_colour(text, colour=WHITE): 5 | """ 6 | Create string text in a particular colour to the terminal. 7 | """ 8 | seq = "\x1b[1;%dm" % (30 + colour) + text + "\x1b[0m" 9 | return seq 10 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | click>=8.0 2 | matplotlib>=3.8 3 | numpy>=2.0.0 4 | pandas>=2.2 5 | seaborn>=0.13 6 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | pytest>=5.2.2 2 | pytest-cov>=2.8.1 3 | coveralls>=1.8.2 4 | flake8>=3.7.9 5 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhallsmoore/qstrader/4c59e1584e83fcc2be644b820f827c0dd1b45c02/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/static_backtest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import os 3 | import sys 4 | 5 | import click 6 | import pandas as pd 7 | import pytz 8 | 9 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 10 | from qstrader.asset.equity import Equity 11 | from qstrader.asset.universe.static import StaticUniverse 12 | from qstrader.data.backtest_data_handler import BacktestDataHandler 13 | from qstrader.data.daily_bar_csv import CSVDailyBarDataSource 14 | from qstrader.statistics.json_statistics import JSONStatistics 15 | from qstrader.statistics.tearsheet import TearsheetStatistics 16 | from qstrader.trading.backtest import BacktestTradingSession 17 | 18 | 19 | def obtain_allocations(allocations): 20 | """ 21 | Converts the provided command-line allocations string 22 | into a dictionary used for QSTrader. 23 | 24 | Parameters 25 | ---------- 26 | allocations : `str` 27 | The asset allocations string. 28 | 29 | Returns 30 | ------- 31 | `dict` 32 | The asset allocation dictionary 33 | """ 34 | allocs_dict = {} 35 | try: 36 | allocs = allocations.split(',') 37 | for alloc in allocs: 38 | alloc_asset, alloc_value = alloc.split(':') 39 | allocs_dict['EQ:%s' % alloc_asset] = float(alloc_value) 40 | except Exception: 41 | print( 42 | "Could not determine the allocations from the provided " 43 | "allocations string. Terminating." 44 | ) 45 | sys.exit() 46 | else: 47 | return allocs_dict 48 | 49 | 50 | @click.command() 51 | @click.option('--start-date', 'start_date', help='Backtest starting date') 52 | @click.option('--end-date', 'end_date', help='Backtest ending date') 53 | @click.option('--allocations', 'allocations', help='Allocations key-values, i.e. "SPY:0.6,AGG:0.4"') 54 | @click.option('--title', 'strat_title', help='Backtest strategy title') 55 | @click.option('--id', 'strat_id', help='Backtest strategy ID string') 56 | @click.option('--tearsheet', 'tearsheet', is_flag=True, default=False, help='Whether to display the (blocking) tearsheet plot') 57 | def cli(start_date, end_date, allocations, strat_title, strat_id, tearsheet): 58 | csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.') 59 | 60 | start_dt = pd.Timestamp('%s 00:00:00' % start_date, tz=pytz.UTC) 61 | 62 | if end_date is None: 63 | # Use yesterday's date 64 | yesterday = (datetime.now() - timedelta(1)).strftime('%Y-%m-%d') 65 | end_dt = pd.Timestamp('%s 23:59:00' % yesterday, tz=pytz.UTC) 66 | else: 67 | end_dt = pd.Timestamp('%s 23:59:00' % end_date, tz=pytz.UTC) 68 | 69 | alloc_dict = obtain_allocations(allocations) 70 | 71 | # Assets and Data Handling 72 | strategy_assets = list(alloc_dict.keys()) 73 | strategy_symbols = [symbol.replace('EQ:', '') for symbol in strategy_assets] 74 | strategy_universe = StaticUniverse(strategy_assets) 75 | strategy_data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols) 76 | 77 | strategy_data_handler = BacktestDataHandler( 78 | strategy_universe, data_sources=[strategy_data_source] 79 | ) 80 | 81 | strategy_assets = alloc_dict.keys() 82 | strategy_alpha_model = FixedSignalsAlphaModel(alloc_dict) 83 | strategy_backtest = BacktestTradingSession( 84 | start_dt, 85 | end_dt, 86 | strategy_universe, 87 | strategy_alpha_model, 88 | rebalance='end_of_month', 89 | account_name=strat_title, 90 | portfolio_id='STATIC001', 91 | portfolio_name=strat_title, 92 | long_only=True, 93 | cash_buffer_percentage=0.01, 94 | data_handler=strategy_data_handler 95 | ) 96 | strategy_backtest.run() 97 | 98 | # Benchmark: 60/40 US Equities/Bonds 99 | benchmark_symbols = ['SPY', 'AGG'] 100 | benchmark_assets = ['EQ:SPY', 'EQ:AGG'] 101 | benchmark_universe = StaticUniverse(benchmark_assets) 102 | 103 | benchmark_data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=benchmark_symbols) 104 | benchmark_data_handler = BacktestDataHandler( 105 | benchmark_universe, data_sources=[benchmark_data_source] 106 | ) 107 | 108 | benchmark_signal_weights = {'EQ:SPY': 0.6, 'EQ:AGG': 0.4} 109 | benchmark_title = '60/40 US Equities/Bonds' 110 | benchmark_alpha_model = FixedSignalsAlphaModel(benchmark_signal_weights) 111 | benchmark_backtest = BacktestTradingSession( 112 | start_dt, 113 | end_dt, 114 | benchmark_universe, 115 | benchmark_alpha_model, 116 | rebalance='end_of_month', 117 | account_name='60/40 US Equities/Bonds', 118 | portfolio_id='6040EQBD', 119 | portfolio_name=benchmark_title, 120 | long_only=True, 121 | cash_buffer_percentage=0.01, 122 | data_handler=benchmark_data_handler 123 | ) 124 | benchmark_backtest.run() 125 | 126 | output_filename = ('%s_monthly.json' % strat_id).replace('-', '_') 127 | stats = JSONStatistics( 128 | equity_curve=strategy_backtest.get_equity_curve(), 129 | target_allocations=strategy_backtest.get_target_allocations(), 130 | strategy_id=strat_id, 131 | strategy_name=strat_title, 132 | benchmark_curve=benchmark_backtest.get_equity_curve(), 133 | benchmark_id='6040-us-equitiesbonds', 134 | benchmark_name=benchmark_title, 135 | output_filename=output_filename 136 | ) 137 | stats.to_file() 138 | 139 | if tearsheet: 140 | tearsheet = TearsheetStatistics( 141 | strategy_equity=strategy_backtest.get_equity_curve(), 142 | benchmark_equity=benchmark_backtest.get_equity_curve(), 143 | title=strat_title 144 | ) 145 | tearsheet.plot_results() 146 | 147 | 148 | if __name__ == "__main__": 149 | cli() 150 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class Helpers: 5 | @staticmethod 6 | def assert_order_lists_equal(orders_1, orders_2): 7 | """ 8 | Carries out Order-wise comparison on all Order attributes 9 | with exception of the generated ID, in order to determine 10 | if two order lists are equal. 11 | 12 | Parameters 13 | ---------- 14 | orders_1 : `List[Order]` 15 | The first order list. 16 | orders_2 : `List[Order]` 17 | The second order list. 18 | """ 19 | for order_1, order_2 in zip(orders_1, orders_2): 20 | assert order_1._order_attribs_equal(order_2) 21 | 22 | 23 | @pytest.fixture 24 | def helpers(): 25 | return Helpers 26 | -------------------------------------------------------------------------------- /tests/integration/portcon/test_pcm_e2e.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytz 5 | 6 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 7 | from qstrader.asset.universe.static import StaticUniverse 8 | from qstrader.broker.simulated_broker import SimulatedBroker 9 | from qstrader.exchange.simulated_exchange import SimulatedExchange 10 | from qstrader.execution.order import Order 11 | from qstrader.portcon.pcm import PortfolioConstructionModel 12 | from qstrader.portcon.optimiser.fixed_weight import ( 13 | FixedWeightPortfolioOptimiser 14 | ) 15 | from qstrader.portcon.order_sizer.dollar_weighted import ( 16 | DollarWeightedCashBufferedOrderSizer 17 | ) 18 | 19 | 20 | def test_pcm_fixed_weight_optimiser_fixed_alpha_weights_call_end_to_end( 21 | helpers 22 | ): 23 | """ 24 | Tests the full portfolio base class logic for carrying out 25 | rebalancing. 26 | 27 | TODO: DataHandler is mocked. A non-disk based data source 28 | should be utilised instead. 29 | """ 30 | first_dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 31 | asset_list = ['EQ:SPY', 'EQ:AGG', 'EQ:TLT', 'EQ:GLD'] 32 | initial_funds = 1e6 33 | account_id = '1234' 34 | port_id = '1234' 35 | cash_buffer_perc = 0.05 36 | 37 | exchange = SimulatedExchange(first_dt) 38 | universe = StaticUniverse(asset_list) 39 | 40 | mock_asset_prices_first = { 41 | 'EQ:SPY': 56.87, 42 | 'EQ:AGG': 219.45, 43 | 'EQ:TLT': 178.33, 44 | 'EQ:GLD': 534.21 45 | } 46 | data_handler = Mock() 47 | data_handler.get_asset_latest_ask_price.side_effect = \ 48 | lambda self, x: mock_asset_prices_first[x] 49 | 50 | broker = SimulatedBroker( 51 | first_dt, exchange, data_handler, account_id, 52 | initial_funds=initial_funds 53 | ) 54 | broker.create_portfolio(port_id, 'Portfolio') 55 | broker.subscribe_funds_to_portfolio(port_id, initial_funds) 56 | 57 | order_sizer = DollarWeightedCashBufferedOrderSizer( 58 | broker, port_id, data_handler, cash_buffer_perc 59 | ) 60 | optimiser = FixedWeightPortfolioOptimiser(data_handler) 61 | 62 | alpha_weights = { 63 | 'EQ:SPY': 0.345, 64 | 'EQ:AGG': 0.611, 65 | 'EQ:TLT': 0.870, 66 | 'EQ:GLD': 0.0765 67 | } 68 | alpha_model = FixedSignalsAlphaModel(alpha_weights) 69 | 70 | pcm = PortfolioConstructionModel( 71 | broker, port_id, universe, order_sizer, optimiser, alpha_model 72 | ) 73 | 74 | result_first = pcm(first_dt) 75 | expected_first = [ 76 | Order(first_dt, 'EQ:AGG', 1390), 77 | Order(first_dt, 'EQ:GLD', 71), 78 | Order(first_dt, 'EQ:SPY', 3029), 79 | Order(first_dt, 'EQ:TLT', 2436) 80 | ] 81 | helpers.assert_order_lists_equal(result_first, expected_first) 82 | -------------------------------------------------------------------------------- /tests/integration/trading/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def etf_filepath(): 8 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'fixtures') 9 | -------------------------------------------------------------------------------- /tests/integration/trading/fixtures/ABC.csv: -------------------------------------------------------------------------------- 1 | Date,Open,Close,Adj Close 2 | 2019-01-01,125.39488969413745,125.33363233868549,125.33363233868549 3 | 2019-01-02,125.89917786834457,127.1721912191578,127.1721912191578 4 | 2019-01-03,127.03269234354691,126.89335971731924,126.89335971731924 5 | 2019-01-04,128.2221622649511,128.898717689565,128.898717689565 6 | 2019-01-05,128.56506784434282,129.05901039753346,129.05901039753346 7 | 2019-01-06,128.72989650195026,128.39973675354403,128.39973675354403 8 | 2019-01-07,128.6472480185127,127.14321774077997,127.14321774077997 9 | 2019-01-08,125.80715956180252,125.40757960383644,125.40757960383644 10 | 2019-01-09,124.65213403014167,124.94976128818058,124.94976128818058 11 | 2019-01-10,124.27975739478052,123.21814723382533,123.21814723382533 12 | 2019-01-11,124.41870894923085,124.28884175220395,124.28884175220395 13 | 2019-01-12,124.3905701266007,123.31826808213218,123.31826808213218 14 | 2019-01-13,122.94057017855258,123.07510425632478,123.07510425632478 15 | 2019-01-14,122.22642906451051,122.56608201304746,122.56608201304746 16 | 2019-01-15,122.14704603397041,121.96848618508807,121.96848618508807 17 | 2019-01-16,121.55066902505783,123.0366806223872,123.0366806223872 18 | 2019-01-17,123.074044758589,122.29779796065557,122.29779796065557 19 | 2019-01-18,122.98612535041893,122.08390158985783,122.08390158985783 20 | 2019-01-19,122.29353209546302,120.82818553641833,120.82818553641833 21 | 2019-01-20,119.86006492214325,120.05672639455736,120.05672639455736 22 | 2019-01-21,120.66799878776659,120.84642163141329,120.84642163141329 23 | 2019-01-22,120.80473402060616,120.62092871794115,120.62092871794115 24 | 2019-01-23,119.54029698162026,119.04146249233915,119.04146249233915 25 | 2019-01-24,118.7399896732252,119.5862919308,119.5862919308 26 | 2019-01-25,119.89418253725772,118.60558418524995,118.60558418524995 27 | 2019-01-26,118.8962006679204,118.65200942931968,118.65200942931968 28 | 2019-01-27,118.18909050165004,118.69525147026332,118.69525147026332 29 | 2019-01-28,119.5214070867766,120.27712713948456,120.27712713948456 30 | 2019-01-29,119.6844601374134,119.4962063012826,119.4962063012826 31 | 2019-01-30,119.79446631527424,120.58580275586421,120.58580275586421 32 | 2019-01-31,120.26626267876608,120.17133895023754,120.17133895023754 33 | -------------------------------------------------------------------------------- /tests/integration/trading/fixtures/DEF.csv: -------------------------------------------------------------------------------- 1 | Date,Open,Close,Adj Close 2 | 2019-01-01,247.37936936736824,244.7660246428395,244.7660246428395 3 | 2019-01-02,246.85858308644677,250.26175730213362,250.26175730213362 4 | 2019-01-03,250.28369833170782,252.88309169825106,252.88309169825106 5 | 2019-01-04,253.9520439736528,252.59167186214754,252.59167186214754 6 | 2019-01-05,253.65881140916363,257.60141323702385,257.60141323702385 7 | 2019-01-06,257.7128041259375,261.78475332104443,261.78475332104443 8 | 2019-01-07,255.53083374870585,257.7384412686895,257.7384412686895 9 | 2019-01-08,258.15184721322674,257.61687087003514,257.61687087003514 10 | 2019-01-09,258.0416671434598,253.39841810004148,253.39841810004148 11 | 2019-01-10,253.0644536818734,254.1232232121341,254.1232232121341 12 | 2019-01-11,257.92524646745613,256.85374501816807,256.85374501816807 13 | 2019-01-12,255.08057960118802,254.06085339825603,254.06085339825603 14 | 2019-01-13,256.4840804329767,257.48758906092576,257.48758906092576 15 | 2019-01-14,256.38984436660866,257.84575530142945,257.84575530142945 16 | 2019-01-15,258.28400876096674,260.8797848994226,260.8797848994226 17 | 2019-01-16,259.3416255503608,258.7335559395058,258.7335559395058 18 | 2019-01-17,257.9685097696293,254.59424812784954,254.59424812784954 19 | 2019-01-18,255.51093524007868,256.34529073997334,256.34529073997334 20 | 2019-01-19,256.55616279980853,256.18163889572105,256.18163889572105 21 | 2019-01-20,252.94671795889766,252.1302402679711,252.1302402679711 22 | 2019-01-21,251.50301737992626,249.7815794913505,249.7815794913505 23 | 2019-01-22,249.5911481138543,250.74746707932877,250.74746707932877 24 | 2019-01-23,255.49065398540205,256.11389568676054,256.11389568676054 25 | 2019-01-24,256.9416422270286,256.9582071506607,256.9582071506607 26 | 2019-01-25,252.49986272511953,252.63145464771154,252.63145464771154 27 | 2019-01-26,252.97204400218808,259.177966418831,259.177966418831 28 | 2019-01-27,258.90372651929687,259.84936226235743,259.84936226235743 29 | 2019-01-28,259.96448429902085,257.28564297854126,257.28564297854126 30 | 2019-01-29,260.30288172848395,262.3767945115066,262.3767945115066 31 | 2019-01-30,264.56574136855073,262.4869617875856,262.4869617875856 32 | 2019-01-31,266.22361304141003,262.89573981277454,262.89573981277454 33 | -------------------------------------------------------------------------------- /tests/integration/trading/fixtures/GHI.csv: -------------------------------------------------------------------------------- 1 | Date,Open,Close,Adj Close 2 | 2015-11-06,209.740005,210.039993,180.776825 3 | 2015-11-09,209.309998,208.080002,179.089874 4 | 2015-11-10,207.509995,208.559998,179.503006 5 | 2015-11-11,208.880005,207.740005,178.797272 6 | 2015-11-12,206.5,204.839996,176.301285 7 | 2015-11-13,204.350006,202.539993,174.321686 8 | 2015-11-16,202.320007,205.619995,176.97261 9 | 2015-11-17,205.990005,205.470001,176.843521 10 | 2015-11-18,206.039993,208.729996,179.649323 11 | 2015-11-19,208.589996,208.550003,179.494431 12 | 2015-11-20,209.449997,209.309998,180.148499 13 | 2015-11-23,209.380005,209.070007,179.941971 14 | 2015-11-24,207.869995,209.350006,180.182999 15 | 2015-11-25,209.5,209.320007,180.15715 16 | 2015-11-27,209.429993,209.559998,180.363708 17 | 2015-11-30,209.75,208.690002,179.614899 18 | -------------------------------------------------------------------------------- /tests/integration/trading/fixtures/long_short_history.dat: -------------------------------------------------------------------------------- 1 | date,type,description,debit,credit,balance 2 | ,subscription,SUBSCRIPTION,0.0,1000000.0,1000000.0 3 | ,asset_transaction,SHORT -3364 EQ:DEF 246.86 02/01/2019,0.0,830432.27,1830432.27 4 | ,asset_transaction,LONG 9386 EQ:ABC 125.90 02/01/2019,1181689.68,0.0,648742.59 5 | ,asset_transaction,SHORT -131 EQ:ABC 127.03 03/01/2019,0.0,16641.28,665383.87 6 | ,asset_transaction,LONG 72.0 EQ:DEF 250.28 03/01/2019,18020.43,0.0,647363.45 7 | ,asset_transaction,SHORT -84.0 EQ:ABC 128.22 04/01/2019,0.0,10770.66,658134.11 8 | ,asset_transaction,LONG 71.0 EQ:DEF 253.95 04/01/2019,18030.6,0.0,640103.51 9 | ,asset_transaction,SHORT -67.0 EQ:DEF 255.53 07/01/2019,0.0,17120.57,657224.08 10 | ,asset_transaction,LONG 34.0 EQ:ABC 128.65 07/01/2019,4374.01,0.0,652850.07 11 | ,asset_transaction,SHORT -177.0 EQ:ABC 125.81 08/01/2019,0.0,22267.87,675117.94 12 | ,asset_transaction,LONG 171.0 EQ:DEF 258.15 08/01/2019,44143.97,0.0,630973.97 13 | ,asset_transaction,SHORT -21.0 EQ:ABC 124.65 09/01/2019,0.0,2617.69,633591.67 14 | ,asset_transaction,LONG 48.0 EQ:DEF 258.04 09/01/2019,12386.0,0.0,621205.67 15 | ,asset_transaction,SHORT -80.0 EQ:DEF 253.06 10/01/2019,0.0,20245.16,641450.82 16 | ,asset_transaction,LONG 116.0 EQ:ABC 124.28 10/01/2019,14416.45,0.0,627034.37 17 | ,asset_transaction,SHORT -44.0 EQ:ABC 124.42 11/01/2019,0.0,5474.42,632508.8 18 | ,asset_transaction,LONG 68.0 EQ:DEF 257.93 11/01/2019,17538.92,0.0,614969.88 19 | ,asset_transaction,SHORT -68.0 EQ:ABC 122.23 14/01/2019,0.0,8311.4,623281.28 20 | ,asset_transaction,LONG 29.0 EQ:DEF 256.39 14/01/2019,7435.31,0.0,615845.97 21 | ,asset_transaction,SHORT -53.0 EQ:ABC 122.15 15/01/2019,0.0,6473.79,622319.76 22 | ,asset_transaction,LONG 72.0 EQ:DEF 258.28 15/01/2019,18596.45,0.0,603723.32 23 | ,asset_transaction,SHORT -95.0 EQ:ABC 121.55 16/01/2019,0.0,11547.31,615270.63 24 | ,asset_transaction,LONG 80.0 EQ:DEF 259.34 16/01/2019,20747.33,0.0,594523.3 25 | ,asset_transaction,SHORT -74.0 EQ:DEF 257.97 17/01/2019,0.0,19089.67,613612.97 26 | ,asset_transaction,LONG 74.0 EQ:ABC 123.07 17/01/2019,9107.48,0.0,604505.49 27 | ,asset_transaction,SHORT -67.0 EQ:DEF 255.51 18/01/2019,0.0,17119.23,621624.72 28 | ,asset_transaction,LONG 108.0 EQ:ABC 122.99 18/01/2019,13282.5,0.0,608342.22 29 | ,asset_transaction,SHORT -54.0 EQ:ABC 120.67 21/01/2019,0.0,6516.07,614858.29 30 | ,asset_transaction,LONG 44.0 EQ:DEF 251.50 21/01/2019,11066.13,0.0,603792.16 31 | ,asset_transaction,SHORT -107.0 EQ:DEF 249.59 22/01/2019,0.0,26706.25,630498.41 32 | ,asset_transaction,LONG 176.0 EQ:ABC 120.80 22/01/2019,21261.63,0.0,609236.78 33 | ,asset_transaction,SHORT -32.0 EQ:ABC 119.54 23/01/2019,0.0,3825.29,613062.07 34 | ,asset_transaction,LONG 28.0 EQ:DEF 255.49 23/01/2019,7153.74,0.0,605908.33 35 | ,asset_transaction,SHORT -186.0 EQ:ABC 118.74 24/01/2019,0.0,22085.64,627993.97 36 | ,asset_transaction,LONG 165.0 EQ:DEF 256.94 24/01/2019,42395.37,0.0,585598.6 37 | ,asset_transaction,SHORT -19.0 EQ:ABC 119.89 25/01/2019,0.0,2277.99,587876.59 38 | ,asset_transaction,LONG 2.0 EQ:DEF 252.50 25/01/2019,505.0,0.0,587371.59 39 | ,asset_transaction,SHORT -62.0 EQ:DEF 259.96 28/01/2019,0.0,16117.8,603489.39 40 | ,asset_transaction,LONG 112.0 EQ:ABC 119.52 28/01/2019,13386.4,0.0,590102.99 41 | ,asset_transaction,SHORT -110.0 EQ:ABC 119.68 29/01/2019,0.0,13165.29,603268.28 42 | ,asset_transaction,LONG 48.0 EQ:DEF 260.30 29/01/2019,12494.54,0.0,590773.74 43 | ,asset_transaction,SHORT -159.0 EQ:ABC 119.79 30/01/2019,0.0,19047.32,609821.06 44 | ,asset_transaction,LONG 126.0 EQ:DEF 264.57 30/01/2019,33335.28,0.0,576485.78 45 | ,asset_transaction,SHORT -27.0 EQ:DEF 266.22 31/01/2019,0.0,7188.04,583673.81 46 | ,asset_transaction,LONG 9.0 EQ:ABC 120.27 31/01/2019,1082.4,0.0,582591.42 47 | -------------------------------------------------------------------------------- /tests/integration/trading/fixtures/sixty_forty_history.dat: -------------------------------------------------------------------------------- 1 | date,type,description,debit,credit,balance 2 | ,subscription,SUBSCRIPTION,0.00,1000000.0,1000000.0 3 | ,asset_transaction,LONG 4482 EQ:ABC 127.03 03/01/2019,569360.53,0.0,430639.47 4 | ,asset_transaction,LONG 1518 EQ:DEF 250.28 03/01/2019,379930.65,0.0,50708.82 5 | ,asset_transaction,SHORT -26 EQ:DEF 253.06 10/01/2019,0.0,6579.68,57288.49 6 | ,asset_transaction,LONG 58 EQ:ABC 124.28 10/01/2019,7208.23,0.0,50080.27 7 | ,asset_transaction,SHORT -32.0 EQ:DEF 257.97 17/01/2019,0.0,8254.99,58335.26 8 | ,asset_transaction,LONG 68 EQ:ABC 123.07 17/01/2019,8369.04,0.0,49966.23 9 | ,asset_transaction,SHORT -18.0 EQ:DEF 256.94 24/01/2019,0.0,4624.95,54591.18 10 | ,asset_transaction,LONG 48 EQ:ABC 118.74 24/01/2019,5699.52,0.0,48891.66 11 | ,asset_transaction,SHORT -11.0 EQ:DEF 266.22 31/01/2019,0.0,2928.46,51820.12 12 | ,asset_transaction,LONG 18 EQ:ABC 120.27 31/01/2019,2164.79,0.0,49655.32 -------------------------------------------------------------------------------- /tests/integration/trading/test_backtest_e2e.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | import pytz 5 | import pytest 6 | 7 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 8 | from qstrader.asset.universe.static import StaticUniverse 9 | from qstrader.trading.backtest import BacktestTradingSession 10 | 11 | from qstrader import settings 12 | 13 | 14 | def test_backtest_sixty_forty(etf_filepath): 15 | """ 16 | Ensures that a full end-to-end weekly rebalanced backtested 17 | trading session with fixed proportion weights produces the 18 | correct rebalance orders as well as correctly calculated 19 | market values after a single month's worth of daily 20 | backtesting. 21 | """ 22 | os.environ['QSTRADER_CSV_DATA_DIR'] = etf_filepath 23 | 24 | assets = ['EQ:ABC', 'EQ:DEF'] 25 | universe = StaticUniverse(assets) 26 | signal_weights = {'EQ:ABC': 0.6, 'EQ:DEF': 0.4} 27 | alpha_model = FixedSignalsAlphaModel(signal_weights) 28 | 29 | start_dt = pd.Timestamp('2019-01-01 00:00:00', tz=pytz.UTC) 30 | end_dt = pd.Timestamp('2019-01-31 23:59:00', tz=pytz.UTC) 31 | 32 | backtest = BacktestTradingSession( 33 | start_dt, 34 | end_dt, 35 | universe, 36 | alpha_model, 37 | portfolio_id='000001', 38 | rebalance='weekly', 39 | rebalance_weekday='WED', 40 | long_only=True, 41 | cash_buffer_percentage=0.05 42 | ) 43 | backtest.run(results=False) 44 | 45 | portfolio = backtest.broker.portfolios['000001'] 46 | 47 | portfolio_dict = portfolio.portfolio_to_dict() 48 | expected_dict = { 49 | 'EQ:ABC': { 50 | 'unrealised_pnl': -31121.26203538094, 51 | 'realised_pnl': 0.0, 52 | 'total_pnl': -31121.26203538094, 53 | 'market_value': 561680.8382534103, 54 | 'quantity': 4674 55 | }, 56 | 'EQ:DEF': { 57 | 'unrealised_pnl': 18047.831359406424, 58 | 'realised_pnl': 613.3956570402925, 59 | 'total_pnl': 18661.227016446715, 60 | 'market_value': 376203.80367208034, 61 | 'quantity': 1431.0 62 | } 63 | } 64 | 65 | history_df = portfolio.history_to_df().reset_index() 66 | expected_df = pd.read_csv(os.path.join(etf_filepath, 'sixty_forty_history.dat')) 67 | 68 | pd.testing.assert_frame_equal(history_df, expected_df) 69 | 70 | # Necessary as test fixtures differ between 71 | # Pandas 1.1.5 and 1.2.0 very slightly 72 | for symbol in expected_dict.keys(): 73 | for metric in expected_dict[symbol].keys(): 74 | assert portfolio_dict[symbol][metric] == pytest.approx(expected_dict[symbol][metric]) 75 | 76 | 77 | def test_backtest_long_short_leveraged(etf_filepath): 78 | """ 79 | Ensures that a full end-to-end daily rebalanced backtested 80 | trading session of a leveraged long short portfolio with 81 | fixed proportion weights produces the correct rebalance 82 | orders as well as correctly calculated market values after 83 | a single month's worth of daily backtesting. 84 | """ 85 | os.environ['QSTRADER_CSV_DATA_DIR'] = etf_filepath 86 | 87 | assets = ['EQ:ABC', 'EQ:DEF'] 88 | universe = StaticUniverse(assets) 89 | signal_weights = {'EQ:ABC': 1.0, 'EQ:DEF': -0.7} 90 | alpha_model = FixedSignalsAlphaModel(signal_weights) 91 | 92 | start_dt = pd.Timestamp('2019-01-01 00:00:00', tz=pytz.UTC) 93 | end_dt = pd.Timestamp('2019-01-31 23:59:00', tz=pytz.UTC) 94 | 95 | backtest = BacktestTradingSession( 96 | start_dt, 97 | end_dt, 98 | universe, 99 | alpha_model, 100 | portfolio_id='000001', 101 | rebalance='daily', 102 | long_only=False, 103 | gross_leverage=2.0 104 | ) 105 | backtest.run(results=False) 106 | 107 | portfolio = backtest.broker.portfolios['000001'] 108 | 109 | portfolio_dict = portfolio.portfolio_to_dict() 110 | expected_dict = { 111 | 'EQ:ABC': { 112 | 'unrealised_pnl': -48302.832839363175, 113 | 'realised_pnl': -3930.9847615026706, 114 | 'total_pnl': -52233.81760086585, 115 | 'market_value': 1055344.698660986, 116 | 'quantity': 8782.0 117 | }, 118 | 'EQ:DEF': { 119 | 'unrealised_pnl': -42274.737165376326, 120 | 'realised_pnl': -9972.897320721153, 121 | 'total_pnl': -52247.63448609748, 122 | 'market_value': -742417.5692312752, 123 | 'quantity': -2824.0 124 | } 125 | } 126 | 127 | history_df = portfolio.history_to_df().reset_index() 128 | expected_df = pd.read_csv(os.path.join(etf_filepath, 'long_short_history.dat')) 129 | 130 | pd.testing.assert_frame_equal(history_df, expected_df) 131 | assert portfolio_dict == expected_dict 132 | 133 | 134 | def test_backtest_buy_and_hold(etf_filepath, capsys): 135 | """ 136 | Ensures a backtest with a buy and hold rebalance calculates 137 | the correct dates for execution orders when the start date is not 138 | a business day. 139 | """ 140 | settings.print_events=True 141 | os.environ['QSTRADER_CSV_DATA_DIR'] = etf_filepath 142 | assets = ['EQ:GHI'] 143 | universe = StaticUniverse(assets) 144 | alpha_model = FixedSignalsAlphaModel({'EQ:GHI':1.0}) 145 | 146 | start_dt = pd.Timestamp('2015-11-07 14:30:00', tz=pytz.UTC) 147 | end_dt = pd.Timestamp('2015-11-10 14:30:00', tz=pytz.UTC) 148 | 149 | backtest = BacktestTradingSession( 150 | start_dt, 151 | end_dt, 152 | universe, 153 | alpha_model, 154 | rebalance='buy_and_hold', 155 | long_only=True, 156 | cash_buffer_percentage=0.01, 157 | ) 158 | backtest.run(results=False) 159 | 160 | expected_execution_text = "(2015-11-09 14:30:00+00:00) - executed order:" 161 | captured = capsys.readouterr() 162 | assert expected_execution_text in captured.out 163 | 164 | 165 | def test_backtest_target_allocations(etf_filepath,): 166 | """ 167 | """ 168 | settings.print_events=True 169 | os.environ['QSTRADER_CSV_DATA_DIR'] = etf_filepath 170 | 171 | assets = ['EQ:ABC', 'EQ:DEF'] 172 | universe = StaticUniverse(assets) 173 | signal_weights = {'EQ:ABC': 0.6, 'EQ:DEF': 0.4} 174 | alpha_model = FixedSignalsAlphaModel(signal_weights) 175 | 176 | start_dt = pd.Timestamp('2019-01-01 00:00:00', tz=pytz.UTC) 177 | end_dt = pd.Timestamp('2019-01-31 23:59:00', tz=pytz.UTC) 178 | burn_in_dt = pd.Timestamp('2019-01-07 14:30:00', tz=pytz.UTC) 179 | 180 | backtest = BacktestTradingSession( 181 | start_dt, 182 | end_dt, 183 | universe, 184 | alpha_model, 185 | portfolio_id='000001', 186 | rebalance='weekly', 187 | rebalance_weekday='WED', 188 | long_only=True, 189 | cash_buffer_percentage=0.05, 190 | burn_in_dt = burn_in_dt 191 | ) 192 | backtest.run(results=False) 193 | 194 | target_allocations = backtest.get_target_allocations() 195 | expected_ta = pd.DataFrame(data={'EQ:ABC': 0.6, 'EQ:DEF': 0.4}, index=pd.date_range("20190125", periods=5, freq='B')) 196 | actual_ta = target_allocations.tail() 197 | assert expected_ta.equals(actual_ta) 198 | -------------------------------------------------------------------------------- /tests/unit/alpha_model/test_fixed_signals.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytest 5 | import pytz 6 | 7 | from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'signals', 12 | [ 13 | ({'EQ:SPY': 0.75, 'EQ:AGG': 0.75, 'EQ:GLD': 0.75}), 14 | ({'EQ:SPY': -0.25, 'EQ:AGG': -0.25, 'EQ:GLD': -0.25}) 15 | ] 16 | ) 17 | def test_fixed_signals_alpha_model(signals): 18 | """ 19 | Checks that the fixed signals alpha model correctly produces 20 | the same signals for each asset in the universe. 21 | """ 22 | universe = Mock() 23 | universe.get_assets.return_value = ['EQ:SPY', 'EQ:AGG', 'EQ:GLD'] 24 | 25 | alpha = FixedSignalsAlphaModel(universe=universe, signal_weights=signals) 26 | dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 27 | 28 | assert alpha(dt) == signals 29 | -------------------------------------------------------------------------------- /tests/unit/alpha_model/test_single_signal.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytest 5 | import pytz 6 | 7 | from qstrader.alpha_model.single_signal import SingleSignalAlphaModel 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'signal,expected_signals', 12 | [ 13 | (0.75, {'EQ:SPY': 0.75, 'EQ:AGG': 0.75, 'EQ:GLD': 0.75}), 14 | (-0.25, {'EQ:SPY': -0.25, 'EQ:AGG': -0.25, 'EQ:GLD': -0.25}) 15 | ] 16 | ) 17 | def test_single_signal_alpha_model(signal, expected_signals): 18 | """ 19 | Checks that the single signal alpha model correctly produces 20 | the same signal for each asset in the universe. 21 | """ 22 | universe = Mock() 23 | universe.get_assets.return_value = ['EQ:SPY', 'EQ:AGG', 'EQ:GLD'] 24 | 25 | alpha = SingleSignalAlphaModel(universe=universe, signal=signal) 26 | dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 27 | 28 | assert alpha(dt) == expected_signals 29 | -------------------------------------------------------------------------------- /tests/unit/asset/test_cash.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from qstrader.asset.cash import Cash 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'currency,expected', 8 | [ 9 | ('USD', 'USD'), 10 | ('GBP', 'GBP'), 11 | ('EUR', 'EUR') 12 | ] 13 | ) 14 | def test_cash(currency, expected): 15 | """ 16 | Tests that the Cash asset is correctly instantiated. 17 | """ 18 | cash = Cash(currency) 19 | 20 | assert cash.cash_like 21 | assert cash.currency == expected 22 | -------------------------------------------------------------------------------- /tests/unit/asset/universe/test_dynamic_universe.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.asset.universe.dynamic import DynamicUniverse 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'asset_dates,dt,expected', 10 | [ 11 | ( 12 | { 13 | 'EQ:SPY': pd.Timestamp('1993-01-01 14:30:00', tz=pytz.utc), 14 | 'EQ:AGG': pd.Timestamp('2003-01-01 14:30:00', tz=pytz.utc), 15 | 'EQ:TLT': pd.Timestamp('2012-01-01 14:30:00', tz=pytz.utc) 16 | }, 17 | pd.Timestamp('1990-01-01 14:30:00', tz=pytz.utc), 18 | [] 19 | ), 20 | ( 21 | { 22 | 'EQ:SPY': pd.Timestamp('1993-01-01 14:30:00', tz=pytz.utc), 23 | 'EQ:AGG': pd.Timestamp('2003-01-01 14:30:00', tz=pytz.utc), 24 | 'EQ:TLT': pd.Timestamp('2012-01-01 14:30:00', tz=pytz.utc) 25 | }, 26 | pd.Timestamp('1995-01-01 14:30:00', tz=pytz.utc), 27 | ['EQ:SPY'] 28 | ), 29 | ( 30 | { 31 | 'EQ:SPY': pd.Timestamp('1993-01-01 14:30:00', tz=pytz.utc), 32 | 'EQ:AGG': pd.Timestamp('2003-01-01 14:30:00', tz=pytz.utc), 33 | 'EQ:TLT': pd.Timestamp('2012-01-01 14:30:00', tz=pytz.utc) 34 | }, 35 | pd.Timestamp('2005-01-01 14:30:00', tz=pytz.utc), 36 | ['EQ:SPY', 'EQ:AGG'] 37 | ), 38 | ( 39 | { 40 | 'EQ:SPY': pd.Timestamp('1993-01-01 14:30:00', tz=pytz.utc), 41 | 'EQ:AGG': pd.Timestamp('2003-01-01 14:30:00', tz=pytz.utc), 42 | 'EQ:TLT': pd.Timestamp('2012-01-01 14:30:00', tz=pytz.utc) 43 | }, 44 | pd.Timestamp('2015-01-01 14:30:00', tz=pytz.utc), 45 | ['EQ:SPY', 'EQ:AGG', 'EQ:TLT'] 46 | ) 47 | ] 48 | ) 49 | def test_dynamic_universe(asset_dates, dt, expected): 50 | """ 51 | Checks that the DynamicUniverse correctly returns the 52 | list of assets for a particular datetime. 53 | """ 54 | universe = DynamicUniverse(asset_dates) 55 | assert set(universe.get_assets(dt)) == set(expected) 56 | -------------------------------------------------------------------------------- /tests/unit/asset/universe/test_static_universe.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.asset.universe.static import StaticUniverse 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'assets,dt,expected', 10 | [ 11 | ( 12 | ['EQ:SPY', 'EQ:AGG'], 13 | pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc), 14 | ['EQ:SPY', 'EQ:AGG'] 15 | ), 16 | ( 17 | ['EQ:GLD', 'EQ:GSG', 'EQ:TLT'], 18 | pd.Timestamp('2020-05-01 15:00:00', tz=pytz.utc), 19 | ['EQ:GLD', 'EQ:GSG', 'EQ:TLT'] 20 | ) 21 | ] 22 | ) 23 | def test_static_universe(assets, dt, expected): 24 | """ 25 | Checks that the StaticUniverse correctly returns the 26 | list of assets for a particular datetime. 27 | """ 28 | universe = StaticUniverse(assets) 29 | assert universe.get_assets(dt) == expected 30 | -------------------------------------------------------------------------------- /tests/unit/broker/fee_model/test_percent_fee_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from qstrader.broker.fee_model.percent_fee_model import PercentFeeModel 4 | 5 | 6 | class AssetMock(object): 7 | def __init__(self): 8 | pass 9 | 10 | 11 | class BrokerMock(object): 12 | def __init__(self): 13 | pass 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "commission_pct,tax_pct,quantity,consideration," 18 | "expected_commission,expected_tax,expected_total", [ 19 | (0.0, 0.0, 100, 1000.0, 0.0, 0.0, 0.0), 20 | (0.002, 0.0, 100, 1000.0, 2.0, 0.0, 2.0), 21 | (0.0, 0.005, 100, 1000.0, 0.0, 5.0, 5.0), 22 | (0.001, 0.005, 100, 1000.0, 1.0, 5.0, 6.0), 23 | (0.001, 0.005, -100, -1000.0, 1.0, 5.0, 6.0), 24 | (0.002, 0.0025, -50, -8542.0, 17.084, 21.355, 38.439), 25 | ] 26 | ) 27 | def test_percent_commission( 28 | commission_pct, tax_pct, quantity, consideration, 29 | expected_commission, expected_tax, expected_total 30 | ): 31 | """ 32 | Tests that each method returns the appropriate 33 | percentage tax/commision. 34 | """ 35 | pfm = PercentFeeModel(commission_pct=commission_pct, tax_pct=tax_pct) 36 | asset = AssetMock() 37 | broker = BrokerMock() 38 | 39 | assert pfm._calc_commission(asset, quantity, consideration, broker=broker) == expected_commission 40 | assert pfm._calc_tax(asset, quantity, consideration, broker=broker) == expected_tax 41 | assert pfm.calc_total_cost(asset, quantity, consideration, broker=broker) == expected_total 42 | -------------------------------------------------------------------------------- /tests/unit/broker/fee_model/test_zero_fee_model.py: -------------------------------------------------------------------------------- 1 | from qstrader.broker.fee_model.zero_fee_model import ZeroFeeModel 2 | 3 | 4 | class AssetMock(object): 5 | def __init__(self): 6 | pass 7 | 8 | 9 | class BrokerMock(object): 10 | def __init__(self): 11 | pass 12 | 13 | 14 | def test_commission_is_zero_uniformly(): 15 | """ 16 | Tests that each method returns zero commission, 17 | irrespective of asset, consideration or broker. 18 | """ 19 | zbc = ZeroFeeModel() 20 | asset = AssetMock() 21 | quantity = 100 22 | consideration = 1000.0 23 | broker = BrokerMock() 24 | 25 | assert zbc._calc_commission(asset, quantity, consideration, broker=broker) == 0.0 26 | assert zbc._calc_tax(asset, quantity, consideration, broker=broker) == 0.0 27 | assert zbc.calc_total_cost(asset, quantity, consideration, broker=broker) == 0.0 28 | -------------------------------------------------------------------------------- /tests/unit/broker/portfolio/test_position_handler.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import pytz 6 | 7 | from qstrader.broker.portfolio.position_handler import PositionHandler 8 | from qstrader.broker.transaction.transaction import Transaction 9 | 10 | 11 | def test_transact_position_new_position(): 12 | """ 13 | Tests the 'transact_position' method for a transaction 14 | with a brand new asset and checks that all objects are 15 | set correctly. 16 | """ 17 | # Create the PositionHandler, Transaction and 18 | # carry out a transaction 19 | ph = PositionHandler() 20 | asset = 'EQ:AMZN' 21 | 22 | transaction = Transaction( 23 | asset, 24 | quantity=100, 25 | dt=pd.Timestamp('2015-05-06 15:00:00', tz=pytz.UTC), 26 | price=960.0, 27 | order_id=123, 28 | commission=26.83 29 | ) 30 | ph.transact_position(transaction) 31 | 32 | # Check that the position object is set correctly 33 | pos = ph.positions[asset] 34 | 35 | assert pos.buy_quantity == 100 36 | assert pos.sell_quantity == 0 37 | assert pos.net_quantity == 100 38 | assert pos.direction == 1 39 | assert pos.avg_price == 960.2683000000001 40 | 41 | 42 | def test_transact_position_current_position(): 43 | """ 44 | Tests the 'transact_position' method for a transaction 45 | with a current asset and checks that all objects are 46 | set correctly. 47 | """ 48 | # Create the PositionHandler, Transaction and 49 | # carry out a transaction 50 | ph = PositionHandler() 51 | asset = 'EQ:AMZN' 52 | dt = pd.Timestamp('2015-05-06 15:00:00', tz=pytz.UTC) 53 | new_dt = pd.Timestamp('2015-05-06 16:00:00', tz=pytz.UTC) 54 | 55 | transaction_long = Transaction( 56 | asset, 57 | quantity=100, 58 | dt=dt, 59 | price=960.0, 60 | order_id=123, 61 | commission=26.83 62 | ) 63 | ph.transact_position(transaction_long) 64 | 65 | transaction_long_again = Transaction( 66 | asset, 67 | quantity=200, 68 | dt=new_dt, 69 | price=990.0, 70 | order_id=234, 71 | commission=18.53 72 | ) 73 | ph.transact_position(transaction_long_again) 74 | 75 | # Check that the position object is set correctly 76 | pos = ph.positions[asset] 77 | 78 | assert pos.buy_quantity == 300 79 | assert pos.sell_quantity == 0 80 | assert pos.net_quantity == 300 81 | assert pos.direction == 1 82 | assert np.isclose(pos.avg_price, 980.1512) 83 | 84 | 85 | def test_transact_position_quantity_zero(): 86 | """ 87 | Tests the 'transact_position' method for a transaction 88 | with net zero quantity after the transaction to ensure 89 | deletion of the position. 90 | """ 91 | # Create the PositionHandler, Transaction and 92 | # carry out a transaction 93 | ph = PositionHandler() 94 | asset = 'EQ:AMZN' 95 | dt = pd.Timestamp('2015-05-06 15:00:00', tz=pytz.UTC) 96 | new_dt = pd.Timestamp('2015-05-06 16:00:00', tz=pytz.UTC) 97 | 98 | transaction_long = Transaction( 99 | asset, 100 | quantity=100, 101 | dt=dt, 102 | price=960.0, 103 | order_id=123, commission=26.83 104 | ) 105 | ph.transact_position(transaction_long) 106 | 107 | transaction_close = Transaction( 108 | asset, 109 | quantity=-100, 110 | dt=new_dt, 111 | price=980.0, 112 | order_id=234, 113 | commission=18.53 114 | ) 115 | ph.transact_position(transaction_close) 116 | 117 | # Go long and then close, then check that the 118 | # positions OrderedDict is empty 119 | assert ph.positions == OrderedDict() 120 | 121 | 122 | def test_total_values_for_no_transactions(): 123 | """ 124 | Tests 'total_market_value', 'total_unrealised_pnl', 125 | 'total_realised_pnl' and 'total_pnl' for the case 126 | of no transactions being carried out. 127 | """ 128 | ph = PositionHandler() 129 | assert ph.total_market_value() == 0.0 130 | assert ph.total_unrealised_pnl() == 0.0 131 | assert ph.total_realised_pnl() == 0.0 132 | assert ph.total_pnl() == 0.0 133 | 134 | 135 | def test_total_values_for_two_separate_transactions(): 136 | """ 137 | Tests 'total_market_value', 'total_unrealised_pnl', 138 | 'total_realised_pnl' and 'total_pnl' for single 139 | transactions in two separate assets. 140 | """ 141 | ph = PositionHandler() 142 | 143 | # Asset 1 144 | asset1 = 'EQ:AMZN' 145 | dt1 = pd.Timestamp('2015-05-06 15:00:00', tz=pytz.UTC) 146 | trans_pos_1 = Transaction( 147 | asset1, 148 | quantity=75, 149 | dt=dt1, 150 | price=483.45, 151 | order_id=1, 152 | commission=15.97 153 | ) 154 | ph.transact_position(trans_pos_1) 155 | 156 | # Asset 2 157 | asset2 = 'EQ:MSFT' 158 | dt2 = pd.Timestamp('2015-05-07 15:00:00', tz=pytz.UTC) 159 | trans_pos_2 = Transaction( 160 | asset2, 161 | quantity=250, 162 | dt=dt2, 163 | price=142.58, 164 | order_id=2, 165 | commission=8.35 166 | ) 167 | ph.transact_position(trans_pos_2) 168 | 169 | # Check all total values 170 | assert ph.total_market_value() == 71903.75 171 | assert np.isclose(ph.total_unrealised_pnl(), -24.31999999999971) 172 | assert ph.total_realised_pnl() == 0.0 173 | assert np.isclose(ph.total_pnl(), -24.31999999999971) 174 | -------------------------------------------------------------------------------- /tests/unit/broker/transaction/test_transaction.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from qstrader.asset.equity import Equity 4 | from qstrader.broker.transaction.transaction import Transaction 5 | 6 | 7 | def test_transaction_representation(): 8 | """ 9 | Tests that the Transaction representation 10 | correctly recreates the object. 11 | """ 12 | dt = pd.Timestamp('2015-05-06') 13 | asset = Equity('Apple, Inc.', 'AAPL') 14 | transaction = Transaction( 15 | asset, quantity=168, dt=dt, price=56.18, order_id=153 16 | ) 17 | exp_repr = ( 18 | "Transaction(asset=Equity(name='Apple, Inc.', symbol='AAPL', tax_exempt=True), " 19 | "quantity=168, dt=2015-05-06 00:00:00, price=56.18, order_id=153)" 20 | ) 21 | assert repr(transaction) == exp_repr 22 | -------------------------------------------------------------------------------- /tests/unit/portcon/optimiser/test_equal_weight.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.portcon.optimiser.equal_weight import ( 6 | EqualWeightPortfolioOptimiser 7 | ) 8 | 9 | 10 | class DataHandlerMock(object): 11 | pass 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "scale,initial_weights,expected_weights", 16 | [ 17 | ( 18 | 1.0, 19 | { 20 | 'EQ:ABCD': 0.25, 21 | 'EQ:DEFG': 0.75 22 | }, 23 | { 24 | 'EQ:ABCD': 0.5, 25 | 'EQ:DEFG': 0.5 26 | }, 27 | ), 28 | ( 29 | 2.0, 30 | { 31 | 'EQ:HIJK': 0.15, 32 | 'EQ:LMNO': 0.45, 33 | 'EQ:PQRS': 0.40 34 | }, 35 | { 36 | 'EQ:HIJK': 2 / 3.0, 37 | 'EQ:LMNO': 2 / 3.0, 38 | 'EQ:PQRS': 2 / 3.0 39 | } 40 | ) 41 | ] 42 | ) 43 | def test_fixed_weight_optimiser(scale, initial_weights, expected_weights): 44 | """ 45 | Tests initialisation and 'pass through' capability of 46 | FixedWeightPortfolioOptimiser. 47 | """ 48 | dt = pd.Timestamp('2019-01-01 00:00:00', tz=pytz.UTC) 49 | data_handler = DataHandlerMock() 50 | fwo = EqualWeightPortfolioOptimiser(scale=scale, data_handler=data_handler) 51 | assert fwo(dt, initial_weights) == expected_weights 52 | -------------------------------------------------------------------------------- /tests/unit/portcon/optimiser/test_fixed_weight.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.portcon.optimiser.fixed_weight import ( 6 | FixedWeightPortfolioOptimiser 7 | ) 8 | 9 | 10 | class DataHandlerMock(object): 11 | pass 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "initial_weights,expected_weights", 16 | [ 17 | ( 18 | { 19 | 'EQ:ABCD': 0.25, 20 | 'EQ:DEFG': 0.75 21 | }, 22 | { 23 | 'EQ:ABCD': 0.25, 24 | 'EQ:DEFG': 0.75 25 | }, 26 | ), 27 | ( 28 | { 29 | 'EQ:HIJK': 0.15, 30 | 'EQ:LMNO': 0.45, 31 | 'EQ:PQRS': 0.40 32 | }, 33 | { 34 | 'EQ:HIJK': 0.15, 35 | 'EQ:LMNO': 0.45, 36 | 'EQ:PQRS': 0.40 37 | } 38 | ) 39 | ] 40 | ) 41 | def test_fixed_weight_optimiser(initial_weights, expected_weights): 42 | """ 43 | Tests initialisation and 'pass through' capability of 44 | FixedWeightPortfolioOptimiser. 45 | """ 46 | dt = pd.Timestamp('2019-01-01 00:00:00', tz=pytz.UTC) 47 | data_handler = DataHandlerMock() 48 | fwo = FixedWeightPortfolioOptimiser(data_handler=data_handler) 49 | assert fwo(dt, initial_weights) == expected_weights 50 | -------------------------------------------------------------------------------- /tests/unit/portcon/order_sizer/test_dollar_weighted.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytest 5 | import pytz 6 | 7 | 8 | from qstrader.portcon.order_sizer.dollar_weighted import ( 9 | DollarWeightedCashBufferedOrderSizer 10 | ) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "cash_buffer_perc,expected", 15 | [ 16 | (-1.0, None), 17 | (0.0, 0.0), 18 | (0.5, 0.5), 19 | (0.99, 0.99), 20 | (1.0, 1.0), 21 | (1.5, None) 22 | ] 23 | ) 24 | def test_check_set_cash_buffer(cash_buffer_perc, expected): 25 | """ 26 | Checks that the cash buffer falls into the appropriate 27 | range and raises otherwise. 28 | """ 29 | broker = Mock() 30 | broker_portfolio_id = "1234" 31 | data_handler = Mock() 32 | 33 | if expected is None: 34 | with pytest.raises(ValueError): 35 | order_sizer = DollarWeightedCashBufferedOrderSizer( 36 | broker, broker_portfolio_id, data_handler, cash_buffer_perc 37 | ) 38 | else: 39 | order_sizer = DollarWeightedCashBufferedOrderSizer( 40 | broker, broker_portfolio_id, data_handler, cash_buffer_perc 41 | ) 42 | assert order_sizer.cash_buffer_percentage == cash_buffer_perc 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "weights,expected", 47 | [ 48 | ( 49 | {'EQ:ABC': 0.2, 'EQ:DEF': 0.6}, 50 | {'EQ:ABC': 0.25, 'EQ:DEF': 0.75} 51 | ), 52 | ( 53 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5}, 54 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5} 55 | ), 56 | ( 57 | {'EQ:ABC': 0.01, 'EQ:DEF': 0.01}, 58 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5} 59 | ), 60 | ( 61 | {'EQ:ABC': 0.1, 'EQ:DEF': 0.3, 'EQ:GHI': 0.02, 'EQ:JKL': 0.8}, 62 | {'EQ:ABC': 0.1 / 1.22, 'EQ:DEF': 0.3 / 1.22, 'EQ:GHI': 0.02 / 1.22, 'EQ:JKL': 0.8 / 1.22}, 63 | ), 64 | ( 65 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0}, 66 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0} 67 | ), 68 | ( 69 | {'EQ:ABC': -0.2, 'EQ:DEF': 0.6}, 70 | None 71 | ), 72 | ] 73 | ) 74 | def test_normalise_weights(weights, expected): 75 | """ 76 | Checks that the _normalise_weights method rescales the weights 77 | to ensure that they sum to unity. 78 | """ 79 | broker = Mock() 80 | broker_portfolio_id = "1234" 81 | data_handler = Mock() 82 | cash_buffer_perc = 0.05 83 | 84 | order_sizer = DollarWeightedCashBufferedOrderSizer( 85 | broker, broker_portfolio_id, data_handler, cash_buffer_perc 86 | ) 87 | if expected is None: 88 | with pytest.raises(ValueError): 89 | result = order_sizer._normalise_weights(weights) 90 | else: 91 | result = order_sizer._normalise_weights(weights) 92 | assert result == pytest.approx(expected) 93 | 94 | 95 | @pytest.mark.parametrize( 96 | "total_equity,cash_buffer_perc,weights,asset_prices,expected", 97 | [ 98 | ( 99 | 1e6, 100 | 0.05, 101 | {'EQ:SPY': 0.5, 'EQ:AGG': 0.5}, 102 | {'EQ:SPY': 250.0, 'EQ:AGG': 150.0}, 103 | {'EQ:SPY': {'quantity': 1900}, 'EQ:AGG': {'quantity': 3166}} 104 | ), 105 | ( 106 | 325000.0, 107 | 0.15, 108 | {'EQ:SPY': 0.6, 'EQ:AGG': 0.4}, 109 | {'EQ:SPY': 352.0, 'EQ:AGG': 178.0}, 110 | {'EQ:SPY': {'quantity': 470}, 'EQ:AGG': {'quantity': 620}} 111 | ), 112 | ( 113 | 687523.0, 114 | 0.025, 115 | {'EQ:SPY': 0.05, 'EQ:AGG': 0.328, 'EQ:TLT': 0.842, 'EQ:GLD': 0.9113}, 116 | {'EQ:SPY': 1036.23, 'EQ:AGG': 456.55, 'EQ:TLT': 987.63, 'EQ:GLD': 14.76}, 117 | { 118 | 'EQ:SPY': {'quantity': 15}, 119 | 'EQ:AGG': {'quantity': 225}, 120 | 'EQ:TLT': {'quantity': 268}, 121 | 'EQ:GLD': {'quantity': 19418}, 122 | } 123 | ) 124 | ] 125 | ) 126 | def test_call(total_equity, cash_buffer_perc, weights, asset_prices, expected): 127 | """ 128 | Checks that the __call__ method correctly outputs the target 129 | portfolio from a given set of weights and a timestamp. 130 | """ 131 | dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 132 | broker_portfolio_id = "1234" 133 | 134 | broker = Mock() 135 | broker.get_portfolio_total_equity.return_value = total_equity 136 | broker.fee_model.calc_total_cost.return_value = 0.0 137 | 138 | data_handler = Mock() 139 | data_handler.get_asset_latest_ask_price.side_effect = lambda self, x: asset_prices[x] 140 | 141 | order_sizer = DollarWeightedCashBufferedOrderSizer( 142 | broker, broker_portfolio_id, data_handler, cash_buffer_perc 143 | ) 144 | 145 | result = order_sizer(dt, weights) 146 | assert result == expected 147 | -------------------------------------------------------------------------------- /tests/unit/portcon/order_sizer/test_long_short.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytest 5 | import pytz 6 | 7 | 8 | from qstrader.portcon.order_sizer.long_short import ( 9 | LongShortLeveragedOrderSizer 10 | ) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "gross_leverage,expected", 15 | [ 16 | (-1.0, None), 17 | (0.0, None), 18 | (0.01, 0.01), 19 | (0.99, 0.99), 20 | (1.0, 1.0), 21 | (2.0, 2.0), 22 | (5.0, 5.0), 23 | ] 24 | ) 25 | def test_check_set_gross_leverage(gross_leverage, expected): 26 | """ 27 | Checks that the gross leverage falls into the appropriate 28 | range and raises otherwise. 29 | """ 30 | broker = Mock() 31 | broker_portfolio_id = "1234" 32 | data_handler = Mock() 33 | 34 | if expected is None: 35 | with pytest.raises(ValueError): 36 | order_sizer = LongShortLeveragedOrderSizer( 37 | broker, broker_portfolio_id, data_handler, gross_leverage 38 | ) 39 | else: 40 | order_sizer = LongShortLeveragedOrderSizer( 41 | broker, broker_portfolio_id, data_handler, gross_leverage 42 | ) 43 | assert order_sizer.gross_leverage == expected 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "weights,gross_leverage,expected", 48 | [ 49 | ( 50 | {'EQ:ABC': 0.2, 'EQ:DEF': 0.6}, 51 | 1.0, 52 | {'EQ:ABC': 0.25, 'EQ:DEF': 0.75} 53 | ), 54 | ( 55 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5}, 56 | 1.0, 57 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5} 58 | ), 59 | ( 60 | {'EQ:ABC': 0.01, 'EQ:DEF': 0.01}, 61 | 1.0, 62 | {'EQ:ABC': 0.5, 'EQ:DEF': 0.5} 63 | ), 64 | ( 65 | {'EQ:ABC': 0.2, 'EQ:DEF': 0.6}, 66 | 2.0, 67 | {'EQ:ABC': 0.5, 'EQ:DEF': 1.5} 68 | ), 69 | ( 70 | {'EQ:ABC': 0.2, 'EQ:DEF': 0.6}, 71 | 0.5, 72 | {'EQ:ABC': 0.125, 'EQ:DEF': 0.375} 73 | ), 74 | ( 75 | {'EQ:ABC': 0.1, 'EQ:DEF': 0.3, 'EQ:GHI': 0.02, 'EQ:JKL': 0.8}, 76 | 1.0, 77 | {'EQ:ABC': 0.1 / 1.22, 'EQ:DEF': 0.3 / 1.22, 'EQ:GHI': 0.02 / 1.22, 'EQ:JKL': 0.8 / 1.22} 78 | ), 79 | ( 80 | {'EQ:ABC': 0.1, 'EQ:DEF': 0.3, 'EQ:GHI': 0.02, 'EQ:JKL': 0.8}, 81 | 3.0, 82 | {'EQ:ABC': 0.3 / 1.22, 'EQ:DEF': 0.9 / 1.22, 'EQ:GHI': 0.06 / 1.22, 'EQ:JKL': 2.4 / 1.22} 83 | ), 84 | ( 85 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0}, 86 | 1.0, 87 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0} 88 | ), 89 | ( 90 | {'EQ:ABC': -0.2, 'EQ:DEF': 0.6}, 91 | 1.0, 92 | {'EQ:ABC': -0.25, 'EQ:DEF': 0.75} 93 | ), 94 | ( 95 | {'EQ:ABC': -0.2, 'EQ:DEF': 0.6}, 96 | 2.0, 97 | {'EQ:ABC': -0.5, 'EQ:DEF': 1.5} 98 | ), 99 | ( 100 | {'EQ:ABC': -0.1, 'EQ:DEF': 0.3, 'EQ:GHI': 0.02, 'EQ:JKL': -0.8}, 101 | 3.0, 102 | {'EQ:ABC': -0.3 / 1.22, 'EQ:DEF': 0.9 / 1.22, 'EQ:GHI': 0.06 / 1.22, 'EQ:JKL': -2.4 / 1.22} 103 | ) 104 | ] 105 | ) 106 | def test_normalise_weights(weights, gross_leverage, expected): 107 | """ 108 | Checks that the _normalise_weights method rescales the weights 109 | for the correct gross exposure and leverage. 110 | """ 111 | broker = Mock() 112 | broker_portfolio_id = "1234" 113 | data_handler = Mock() 114 | 115 | order_sizer = LongShortLeveragedOrderSizer( 116 | broker, broker_portfolio_id, data_handler, gross_leverage 117 | ) 118 | if expected is None: 119 | with pytest.raises(ValueError): 120 | result = order_sizer._normalise_weights(weights) 121 | else: 122 | result = order_sizer._normalise_weights(weights) 123 | assert result == pytest.approx(expected) 124 | 125 | 126 | @pytest.mark.parametrize( 127 | "total_equity,gross_leverage,weights,asset_prices,expected", 128 | [ 129 | ( 130 | 1e6, 131 | 1.0, 132 | {'EQ:SPY': 0.5, 'EQ:AGG': 0.5}, 133 | {'EQ:SPY': 250.0, 'EQ:AGG': 150.0}, 134 | {'EQ:SPY': {'quantity': 2000}, 'EQ:AGG': {'quantity': 3333}} 135 | ), 136 | ( 137 | 325000.0, 138 | 1.5, 139 | {'EQ:SPY': 0.6, 'EQ:AGG': 0.4}, 140 | {'EQ:SPY': 352.0, 'EQ:AGG': 178.0}, 141 | {'EQ:SPY': {'quantity': 830}, 'EQ:AGG': {'quantity': 1095}} 142 | ), 143 | ( 144 | 687523.0, 145 | 2.0, 146 | {'EQ:SPY': 0.05, 'EQ:AGG': 0.328, 'EQ:TLT': 0.842, 'EQ:GLD': 0.9113}, 147 | {'EQ:SPY': 1036.23, 'EQ:AGG': 456.55, 'EQ:TLT': 987.63, 'EQ:GLD': 14.76}, 148 | { 149 | 'EQ:SPY': {'quantity': 31}, 150 | 'EQ:AGG': {'quantity': 463}, 151 | 'EQ:TLT': {'quantity': 550}, 152 | 'EQ:GLD': {'quantity': 39833}, 153 | } 154 | ), 155 | ( 156 | 687523.0, 157 | 2.0, 158 | {'EQ:SPY': 0.05, 'EQ:AGG': -0.328, 'EQ:TLT': -0.842, 'EQ:GLD': 0.9113}, 159 | {'EQ:SPY': 1036.23, 'EQ:AGG': 456.55, 'EQ:TLT': 987.63, 'EQ:GLD': 14.76}, 160 | { 161 | 'EQ:SPY': {'quantity': 31}, 162 | 'EQ:AGG': {'quantity': -463}, 163 | 'EQ:TLT': {'quantity': -550}, 164 | 'EQ:GLD': {'quantity': 39833}, 165 | } 166 | ) 167 | ] 168 | ) 169 | def test_call(total_equity, gross_leverage, weights, asset_prices, expected): 170 | """ 171 | Checks that the __call__ method correctly outputs the target 172 | portfolio from a given set of weights and a timestamp. 173 | """ 174 | dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 175 | broker_portfolio_id = "1234" 176 | 177 | broker = Mock() 178 | broker.get_portfolio_total_equity.return_value = total_equity 179 | broker.fee_model.calc_total_cost.return_value = 0.0 180 | 181 | data_handler = Mock() 182 | data_handler.get_asset_latest_ask_price.side_effect = lambda self, x: asset_prices[x] 183 | 184 | order_sizer = LongShortLeveragedOrderSizer( 185 | broker, broker_portfolio_id, data_handler, gross_leverage 186 | ) 187 | 188 | result = order_sizer(dt, weights) 189 | assert result == expected 190 | -------------------------------------------------------------------------------- /tests/unit/portcon/test_pcm.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pandas as pd 4 | import pytest 5 | import pytz 6 | 7 | from qstrader.execution.order import Order 8 | from qstrader.portcon.pcm import PortfolioConstructionModel 9 | 10 | 11 | SENTINEL_DT = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | 'description,port_dict,uni_assets,expected', 16 | [ 17 | ( 18 | 'empty on both sides', 19 | {}, [], [] 20 | ), 21 | ( 22 | 'partially intersecting set of assets', 23 | { 24 | 'EQ:ABC': 100, 25 | 'EQ:DEF': 250, 26 | 'EQ:GHI': 38 27 | }, 28 | ['EQ:123', 'EQ:GHI', 'EQ:ABC', 'EQ:567'], 29 | ['EQ:123', 'EQ:567', 'EQ:ABC', 'EQ:DEF', 'EQ:GHI'] 30 | ), 31 | ( 32 | 'non-intersecting set of assets', 33 | {'EQ:ABC': 450, 'EQ:DEF': 210}, 34 | ['EQ:567', 'EQ:123'], 35 | ['EQ:123', 'EQ:567', 'EQ:ABC', 'EQ:DEF'] 36 | ) 37 | ] 38 | ) 39 | def test_obtain_full_asset_list(description, port_dict, uni_assets, expected): 40 | """ 41 | Tests the _obtain_full_asset_list method of the 42 | PortfolioConstructionModel base class. 43 | """ 44 | port_id = '1234' 45 | 46 | broker = Mock() 47 | broker.get_portfolio_as_dict.return_value = port_dict 48 | 49 | universe = Mock() 50 | universe.get_assets.return_value = uni_assets 51 | 52 | order_sizer = Mock() 53 | optimiser = Mock() 54 | 55 | pcm = PortfolioConstructionModel( 56 | broker, port_id, universe, order_sizer, optimiser 57 | ) 58 | 59 | result = pcm._obtain_full_asset_list(SENTINEL_DT) 60 | assert result == expected 61 | 62 | 63 | @pytest.mark.parametrize( 64 | 'description,full_assets,expected', 65 | [ 66 | ( 67 | 'empty assets', 68 | [], 69 | {} 70 | ), 71 | ( 72 | 'non-empty assets', 73 | ['EQ:ABC', 'EQ:123', 'EQ:A1B2'], 74 | {'EQ:ABC': 0.0, 'EQ:123': 0.0, 'EQ:A1B2': 0.0} 75 | ) 76 | ] 77 | ) 78 | def test_create_zero_target_weight_vector(description, full_assets, expected): 79 | """ 80 | Tests the _create_zero_target_weight_vector method of the 81 | PortfolioConstructionModel base class. 82 | """ 83 | port_id = '1234' 84 | 85 | broker = Mock() 86 | universe = Mock() 87 | order_sizer = Mock() 88 | optimiser = Mock() 89 | 90 | pcm = PortfolioConstructionModel( 91 | broker, port_id, universe, order_sizer, optimiser 92 | ) 93 | 94 | result = pcm._create_zero_target_weight_vector(full_assets) 95 | assert result == expected 96 | 97 | 98 | @pytest.mark.parametrize( 99 | 'description,zero_weights,optimised_weights,expected', 100 | [ 101 | ( 102 | 'empty weights on both sides', 103 | {}, 104 | {}, 105 | {} 106 | ), 107 | ( 108 | 'non-intersecting weights', 109 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0}, 110 | {'EQ:123': 0.5, 'EQ:567': 0.5}, 111 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0, 'EQ:123': 0.5, 'EQ:567': 0.5}, 112 | ), 113 | ( 114 | 'partially-intersecting weights', 115 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0, 'EQ:123': 0.0}, 116 | {'EQ:123': 0.25, 'EQ:567': 0.25, 'EQ:890': 0.5}, 117 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0, 'EQ:123': 0.25, 'EQ:567': 0.25, 'EQ:890': 0.5}, 118 | ), 119 | ( 120 | 'fully-intersecting weights', 121 | {'EQ:ABC': 0.0, 'EQ:DEF': 0.0, 'EQ:123': 0.0}, 122 | {'EQ:ABC': 0.25, 'EQ:DEF': 0.25, 'EQ:123': 0.5}, 123 | {'EQ:ABC': 0.25, 'EQ:DEF': 0.25, 'EQ:123': 0.5}, 124 | ) 125 | ] 126 | ) 127 | def test_create_full_asset_weight_vector( 128 | description, zero_weights, optimised_weights, expected 129 | ): 130 | """ 131 | Tests the _create_full_asset_weight_vector method of the 132 | PortfolioConstructionModel base class. 133 | """ 134 | port_id = '1234' 135 | 136 | broker = Mock() 137 | universe = Mock() 138 | order_sizer = Mock() 139 | optimiser = Mock() 140 | 141 | pcm = PortfolioConstructionModel( 142 | broker, port_id, universe, order_sizer, optimiser 143 | ) 144 | 145 | result = pcm._create_full_asset_weight_vector(zero_weights, optimised_weights) 146 | assert result == expected 147 | 148 | 149 | @pytest.mark.parametrize( 150 | 'description,target_portfolio,current_portfolio,expected', 151 | [ 152 | ( 153 | 'empty portfolios on both sides', 154 | {}, 155 | {}, 156 | [] 157 | ), 158 | ( 159 | 'non-empty equal portfolios on both sides - no orders', 160 | {'EQ:ABC': {'quantity': 100}, 'EQ:DEF': {'quantity': 250}}, 161 | {'EQ:ABC': {'quantity': 100}, 'EQ:DEF': {'quantity': 250}}, 162 | [] 163 | ), 164 | ( 165 | 'non-empty target portfolio with empty current portfolio', 166 | {'EQ:ABC': {'quantity': 100}, 'EQ:DEF': {'quantity': 250}}, 167 | {}, 168 | [ 169 | Order(SENTINEL_DT, 'EQ:ABC', 100), 170 | Order(SENTINEL_DT, 'EQ:DEF', 250) 171 | ] 172 | ), 173 | ( 174 | 'empty target portfolio with non-empty current portfolio', 175 | {}, 176 | {'EQ:ABC': {'quantity': 345}, 'EQ:DEF': {'quantity': 223}}, 177 | [ 178 | Order(SENTINEL_DT, 'EQ:ABC', -345), 179 | Order(SENTINEL_DT, 'EQ:DEF', -250) 180 | ] 181 | ), 182 | ( 183 | 'non-empty portfolios, non-intersecting symbols', 184 | {'EQ:ABC': {'quantity': 123}, 'EQ:DEF': {'quantity': 456}}, 185 | {'EQ:GHI': {'quantity': 217}, 'EQ:JKL': {'quantity': 48}}, 186 | [ 187 | Order(SENTINEL_DT, 'EQ:ABC', 123), 188 | Order(SENTINEL_DT, 'EQ:DEF', 456), 189 | Order(SENTINEL_DT, 'EQ:GHI', -217), 190 | Order(SENTINEL_DT, 'EQ:JKL', -48) 191 | ] 192 | ), 193 | ( 194 | 'non-empty portfolios, partially-intersecting symbols', 195 | {'EQ:ABC': {'quantity': 123}, 'EQ:DEF': {'quantity': 456}}, 196 | {'EQ:DEF': {'quantity': 217}, 'EQ:GHI': {'quantity': 48}}, 197 | [ 198 | Order(SENTINEL_DT, 'EQ:ABC', 123), 199 | Order(SENTINEL_DT, 'EQ:DEF', 239), 200 | Order(SENTINEL_DT, 'EQ:GHI', -48) 201 | ] 202 | ), 203 | ( 204 | 'non-empty portfolios, fully-intersecting symbols', 205 | {'EQ:ABC': {'quantity': 123}, 'EQ:DEF': {'quantity': 456}}, 206 | {'EQ:ABC': {'quantity': 217}, 'EQ:DEF': {'quantity': 48}}, 207 | [ 208 | Order(SENTINEL_DT, 'EQ:ABC', -94), 209 | Order(SENTINEL_DT, 'EQ:DEF', 408) 210 | ] 211 | ) 212 | ] 213 | ) 214 | def test_generate_rebalance_orders( 215 | helpers, description, target_portfolio, current_portfolio, expected 216 | ): 217 | """ 218 | Tests the _generate_rebalance_orders method of the 219 | PortfolioConstructionModel base class. 220 | """ 221 | port_id = '1234' 222 | 223 | broker = Mock() 224 | universe = Mock() 225 | order_sizer = Mock() 226 | optimiser = Mock() 227 | 228 | pcm = PortfolioConstructionModel( 229 | broker, port_id, universe, order_sizer, optimiser 230 | ) 231 | 232 | result = pcm._generate_rebalance_orders(SENTINEL_DT, target_portfolio, current_portfolio) 233 | helpers.assert_order_lists_equal(result, expected) 234 | -------------------------------------------------------------------------------- /tests/unit/signals/test_momentum.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import pytest 6 | import pytz 7 | 8 | from qstrader.signals.momentum import MomentumSignal 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'start_dt,lookbacks,prices,expected', 13 | [ 14 | ( 15 | pd.Timestamp('2019-01-01 14:30:00', tz=pytz.utc), 16 | [6, 12], 17 | [ 18 | 99.34, 101.87, 98.32, 92.98, 103.87, 19 | 104.51, 97.62, 95.22, 96.09, 100.34, 20 | 105.14, 107.49, 90.23, 89.43, 87.68 21 | ], 22 | [-0.08752211468415028, -0.10821806346623242] 23 | ) 24 | ] 25 | ) 26 | def test_momentum_signal(start_dt, lookbacks, prices, expected): 27 | """ 28 | Checks that the momentum signal correctly calculates the 29 | holding period return based momentum for various lookbacks. 30 | """ 31 | universe = Mock() 32 | universe.get_assets.return_value = ['EQ:SPY'] 33 | 34 | mom = MomentumSignal(start_dt, universe, lookbacks) 35 | for price_idx in range(len(prices)): 36 | mom.append('EQ:SPY', prices[price_idx]) 37 | 38 | for i, lookback in enumerate(lookbacks): 39 | assert np.isclose(mom('EQ:SPY', lookback), expected[i]) 40 | -------------------------------------------------------------------------------- /tests/unit/signals/test_sma.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import pytest 6 | import pytz 7 | 8 | from qstrader.signals.sma import SMASignal 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'start_dt,lookbacks,prices,expected', 13 | [ 14 | ( 15 | pd.Timestamp('2019-01-01 14:30:00', tz=pytz.utc), 16 | [6, 12], 17 | [ 18 | 99.34, 101.87, 98.32, 92.98, 103.87, 19 | 104.51, 97.62, 95.22, 96.09, 100.34, 20 | 105.14, 107.49, 90.23, 89.43, 87.68 21 | ], 22 | [96.71833333333333, 97.55] 23 | ) 24 | ] 25 | ) 26 | def test_sma_signal(start_dt, lookbacks, prices, expected): 27 | """ 28 | Checks that the SMA signal correctly calculates the 29 | simple moving average for various lookbacks. 30 | """ 31 | universe = Mock() 32 | universe.get_assets.return_value = ['EQ:SPY'] 33 | 34 | sma = SMASignal(start_dt, universe, lookbacks) 35 | for price_idx in range(len(prices)): 36 | sma.append('EQ:SPY', prices[price_idx]) 37 | 38 | for i, lookback in enumerate(lookbacks): 39 | assert np.isclose(sma('EQ:SPY', lookback), expected[i]) 40 | -------------------------------------------------------------------------------- /tests/unit/simulation/test_daily_bday.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.simulation.daily_bday import DailyBusinessDaySimulationEngine 6 | from qstrader.simulation.event import SimulationEvent 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "starting_day,ending_day,pre_market,post_market,expected_events", 11 | [ 12 | ( 13 | '2020-01-01', '2020-01-07', True, True, 14 | [ 15 | ('2020-01-01 00:00:00', 'pre_market'), 16 | ('2020-01-01 14:30:00', 'market_open'), 17 | ('2020-01-01 21:00:00', 'market_close'), 18 | ('2020-01-01 23:59:00', 'post_market'), 19 | ('2020-01-02 00:00:00', 'pre_market'), 20 | ('2020-01-02 14:30:00', 'market_open'), 21 | ('2020-01-02 21:00:00', 'market_close'), 22 | ('2020-01-02 23:59:00', 'post_market'), 23 | ('2020-01-03 00:00:00', 'pre_market'), 24 | ('2020-01-03 14:30:00', 'market_open'), 25 | ('2020-01-03 21:00:00', 'market_close'), 26 | ('2020-01-03 23:59:00', 'post_market'), 27 | ('2020-01-06 00:00:00', 'pre_market'), 28 | ('2020-01-06 14:30:00', 'market_open'), 29 | ('2020-01-06 21:00:00', 'market_close'), 30 | ('2020-01-06 23:59:00', 'post_market'), 31 | ('2020-01-07 00:00:00', 'pre_market'), 32 | ('2020-01-07 14:30:00', 'market_open'), 33 | ('2020-01-07 21:00:00', 'market_close'), 34 | ('2020-01-07 23:59:00', 'post_market'), 35 | ] 36 | ), 37 | ( 38 | '2020-01-01', '2020-01-07', False, False, 39 | [ 40 | ('2020-01-01 14:30:00', 'market_open'), 41 | ('2020-01-01 21:00:00', 'market_close'), 42 | ('2020-01-02 14:30:00', 'market_open'), 43 | ('2020-01-02 21:00:00', 'market_close'), 44 | ('2020-01-03 14:30:00', 'market_open'), 45 | ('2020-01-03 21:00:00', 'market_close'), 46 | ('2020-01-06 14:30:00', 'market_open'), 47 | ('2020-01-06 21:00:00', 'market_close'), 48 | ('2020-01-07 14:30:00', 'market_open'), 49 | ('2020-01-07 21:00:00', 'market_close'), 50 | ] 51 | ) 52 | ] 53 | ) 54 | def test_daily_rebalance( 55 | starting_day, ending_day, pre_market, post_market, expected_events 56 | ): 57 | """ 58 | Checks that the daily business day event generation provides 59 | the correct SimulationEvents for the given parameters. 60 | """ 61 | sd = pd.Timestamp(starting_day, tz=pytz.UTC) 62 | ed = pd.Timestamp(ending_day, tz=pytz.UTC) 63 | 64 | sim_engine = DailyBusinessDaySimulationEngine(sd, ed, pre_market, post_market) 65 | 66 | for sim_events in zip(sim_engine, expected_events): 67 | calculated_event = sim_events[0] 68 | expected_event = SimulationEvent(pd.Timestamp(sim_events[1][0], tz=pytz.UTC), sim_events[1][1]) 69 | assert calculated_event == expected_event 70 | -------------------------------------------------------------------------------- /tests/unit/simulation/test_event.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.simulation.event import SimulationEvent 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "sim_event_params,compare_event_params,expected_result", 10 | [ 11 | ( 12 | ('2020-01-01 00:00:00', 'pre_market'), 13 | ('2020-01-01 00:00:00', 'pre_market'), 14 | True, 15 | ), 16 | ( 17 | ('2020-01-01 00:00:00', 'pre_market'), 18 | ('2020-01-01 00:00:00', 'post_market'), 19 | False, 20 | ), 21 | ( 22 | ('2020-01-01 00:00:00', 'pre_market'), 23 | ('2020-01-02 00:00:00', 'pre_market'), 24 | False, 25 | ), 26 | ( 27 | ('2020-01-01 00:00:00', 'pre_market'), 28 | ('2020-01-02 00:00:00', 'post_market'), 29 | False, 30 | ) 31 | ] 32 | ) 33 | def test_sim_event_eq( 34 | sim_event_params, compare_event_params, expected_result 35 | ): 36 | """ 37 | Checks that the SimulationEvent __eq__ correctly 38 | compares SimulationEvent instances. 39 | """ 40 | sim_event = SimulationEvent(pd.Timestamp(sim_event_params[0], tz=pytz.UTC), sim_event_params[1]) 41 | compare_event = SimulationEvent(pd.Timestamp(compare_event_params[0], tz=pytz.UTC), compare_event_params[1]) 42 | 43 | assert expected_result == (sim_event == compare_event) 44 | -------------------------------------------------------------------------------- /tests/unit/system/rebalance/test_buy_and_hold_rebalance.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.system.rebalance.buy_and_hold import BuyAndHoldRebalance 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "start_dt, reb_dt", [ 10 | ('2020-01-01', '2020-01-01'), 11 | ('2020-02-02', '2020-02-03') 12 | ], 13 | ) 14 | def test_buy_and_hold_rebalance(start_dt, reb_dt): 15 | """ 16 | Checks that the buy and hold rebalance sets the 17 | appropriate rebalance dates, both for a business and 18 | a non-business day. 19 | 20 | Does not include holidays. 21 | """ 22 | sd = pd.Timestamp(start_dt, tz=pytz.UTC) 23 | rd = pd.Timestamp(reb_dt, tz=pytz.UTC) 24 | reb = BuyAndHoldRebalance(start_dt=sd) 25 | 26 | assert reb.start_dt == sd 27 | assert reb.rebalances == [rd] 28 | -------------------------------------------------------------------------------- /tests/unit/system/rebalance/test_daily_rebalance.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.system.rebalance.daily import DailyRebalance 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "start_date,end_date,pre_market,expected_dates,expected_time", 10 | [ 11 | ( 12 | '2020-03-11', '2020-03-17', False, [ 13 | '2020-03-11', '2020-03-12', '2020-03-13', 14 | '2020-03-16', '2020-03-17' 15 | ], '21:00:00' 16 | ), 17 | ( 18 | '2019-12-26', '2020-01-07', True, [ 19 | '2019-12-26', '2019-12-27', '2019-12-30', 20 | '2019-12-31', '2020-01-01', '2020-01-02', 21 | '2020-01-03', '2020-01-06', '2020-01-07' 22 | ], '14:30:00' 23 | ) 24 | ] 25 | ) 26 | def test_daily_rebalance( 27 | start_date, end_date, pre_market, expected_dates, expected_time 28 | ): 29 | """ 30 | Checks that the daily rebalance provides the correct business 31 | datetimes for the provided range. 32 | """ 33 | sd = pd.Timestamp(start_date, tz=pytz.UTC) 34 | ed = pd.Timestamp(end_date, tz=pytz.UTC) 35 | reb = DailyRebalance(start_date=sd, end_date=ed, pre_market=pre_market) 36 | actual_datetimes = reb._generate_rebalances() 37 | expected_datetimes = [ 38 | pd.Timestamp('%s %s' % (expected_date, expected_time), tz=pytz.UTC) 39 | for expected_date in expected_dates 40 | ] 41 | 42 | assert actual_datetimes == expected_datetimes 43 | -------------------------------------------------------------------------------- /tests/unit/system/rebalance/test_end_of_month_rebalance.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.system.rebalance.end_of_month import EndOfMonthRebalance 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "start_date,end_date,pre_market,expected_dates,expected_time", 10 | [ 11 | ( 12 | '2020-03-11', '2020-12-31', False, [ 13 | '2020-03-31', '2020-04-30', '2020-05-29', '2020-06-30', 14 | '2020-07-31', '2020-08-31', '2020-09-30', '2020-10-30', 15 | '2020-11-30', '2020-12-31' 16 | ], '21:00:00' 17 | ), 18 | ( 19 | '2019-12-26', '2020-09-01', True, [ 20 | '2019-12-31', '2020-01-31', '2020-02-28', '2020-03-31', 21 | '2020-04-30', '2020-05-29', '2020-06-30', '2020-07-31', 22 | '2020-08-31' 23 | ], '14:30:00' 24 | ) 25 | ] 26 | ) 27 | def test_monthly_rebalance( 28 | start_date, end_date, pre_market, expected_dates, expected_time 29 | ): 30 | """ 31 | Checks that the end of month (business day) rebalance provides 32 | the correct datetimes for the provided range. 33 | """ 34 | sd = pd.Timestamp(start_date, tz=pytz.UTC) 35 | ed = pd.Timestamp(end_date, tz=pytz.UTC) 36 | 37 | reb = EndOfMonthRebalance( 38 | start_dt=sd, end_dt=ed, pre_market=pre_market 39 | ) 40 | 41 | actual_datetimes = reb._generate_rebalances() 42 | 43 | expected_datetimes = [ 44 | pd.Timestamp('%s %s' % (expected_date, expected_time), tz=pytz.UTC) 45 | for expected_date in expected_dates 46 | ] 47 | 48 | assert actual_datetimes == expected_datetimes 49 | -------------------------------------------------------------------------------- /tests/unit/system/rebalance/test_weekly_rebalance.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | import pytz 4 | 5 | from qstrader.system.rebalance.weekly import WeeklyRebalance 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "start_date,end_date,weekday,pre_market,expected_dates,expected_time", 10 | [ 11 | ( 12 | '2020-03-11', '2020-05-17', 'MON', False, [ 13 | '2020-03-16', '2020-03-23', '2020-03-30', '2020-04-06', 14 | '2020-04-13', '2020-04-20', '2020-04-27', '2020-05-04', 15 | '2020-05-11' 16 | ], '21:00:00' 17 | ), 18 | ( 19 | '2019-12-26', '2020-02-07', 'WED', True, [ 20 | '2020-01-01', '2020-01-08', '2020-01-15', '2020-01-22', 21 | '2020-01-29', '2020-02-05' 22 | ], '14:30:00' 23 | ) 24 | ] 25 | ) 26 | def test_weekly_rebalance( 27 | start_date, end_date, weekday, pre_market, expected_dates, expected_time 28 | ): 29 | """ 30 | Checks that the weekly rebalance provides the correct business 31 | datetimes for the provided range. 32 | """ 33 | sd = pd.Timestamp(start_date, tz=pytz.UTC) 34 | ed = pd.Timestamp(end_date, tz=pytz.UTC) 35 | 36 | reb = WeeklyRebalance( 37 | start_date=sd, end_date=ed, weekday=weekday, pre_market=pre_market 38 | ) 39 | 40 | actual_datetimes = reb._generate_rebalances() 41 | 42 | expected_datetimes = [ 43 | pd.Timestamp('%s %s' % (expected_date, expected_time), tz=pytz.UTC) 44 | for expected_date in expected_dates 45 | ] 46 | 47 | assert actual_datetimes == expected_datetimes 48 | 49 | 50 | def test_check_weekday_raises_value_error(): 51 | """ 52 | Checks that initialisation of WeeklyRebalance raises 53 | a ValueError if the weekday string is in the incorrect 54 | format. 55 | """ 56 | sd = pd.Timestamp('2020-01-01', tz=pytz.UTC) 57 | ed = pd.Timestamp('2020-02-01', tz=pytz.UTC) 58 | pre_market = True 59 | weekday = 'SUN' 60 | 61 | with pytest.raises(ValueError): 62 | WeeklyRebalance( 63 | start_date=sd, end_date=ed, weekday=weekday, pre_market=pre_market 64 | ) 65 | -------------------------------------------------------------------------------- /tests/unit/utils/test_console.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from qstrader.utils.console import GREEN, BLUE, CYAN, string_colour 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "text,colour,expected", 8 | [ 9 | ('green colour', GREEN, "\x1b[1;32mgreen colour\x1b[0m"), 10 | ('blue colour', BLUE, "\x1b[1;34mblue colour\x1b[0m"), 11 | ('cyan colour', CYAN, "\x1b[1;36mcyan colour\x1b[0m"), 12 | ] 13 | ) 14 | def test_string_colour(text, colour, expected): 15 | """ 16 | Tests that the string colourisation for the terminal console 17 | produces the correct values. 18 | """ 19 | assert string_colour(text, colour=colour) == expected 20 | --------------------------------------------------------------------------------