├── .gitignore ├── LICENSE ├── README.md ├── data ├── AAPL.csv ├── AGG.csv ├── AMZN.csv ├── GOOG.csv ├── MSFT.csv └── SPY.csv ├── examples ├── __init__.py ├── buy_and_hold_backtest.py ├── monthly_liquidate_rebalance_backtest.py ├── moving_average_cross_backtest.py └── test_examples.py ├── load_statistics.ipynb ├── out └── EMPTY ├── qstrader ├── __init__.py ├── compat.py ├── compliance │ ├── __init__.py │ ├── base.py │ └── example.py ├── event.py ├── exception.py ├── execution_handler │ ├── __init__.py │ ├── base.py │ └── ib_simulated.py ├── order │ ├── __init__.py │ └── suggested.py ├── portfolio.py ├── portfolio_handler.py ├── position.py ├── position_sizer │ ├── __init__.py │ ├── base.py │ ├── fixed.py │ ├── naive.py │ └── rebalance.py ├── price_handler │ ├── __init__.py │ ├── base.py │ ├── generic.py │ ├── historic_csv_tick.py │ ├── ig.py │ ├── iq_feed_intraday_csv_bar.py │ ├── iterator │ │ ├── __init__.py │ │ ├── base.py │ │ └── pandas │ │ │ ├── __init__.py │ │ │ ├── bar.py │ │ │ └── tick.py │ └── yahoo_daily_csv_bar.py ├── price_parser.py ├── profiling.py ├── risk_manager │ ├── __init__.py │ ├── base.py │ └── example.py ├── scripts │ ├── __init__.py │ ├── generate_simulated_prices.py │ └── test_scripts.py ├── sentiment_handler │ ├── __init__.py │ ├── base.py │ └── sentdex_sentiment_handler.py ├── settings.py ├── statistics │ ├── __init__.py │ ├── base.py │ ├── performance.py │ ├── simple.py │ └── tearsheet.py ├── strategy │ ├── __init__.py │ └── base.py ├── trading_session.py └── version.py ├── requirements.txt ├── setup.py └── tests ├── test_portfolio.py ├── test_portfolio_handler.py ├── test_position.py ├── test_price_handler.py ├── test_priceparser.py ├── test_rebalance_position_sizer.py └── test_statistics.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 QuantStart.com 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/quantstart/qstrader.svg?branch=master)](https://travis-ci.org/quantstart/qstrader) 2 | [![Coverage Status](https://coveralls.io/repos/github/quantstart/qstrader/badge.svg?branch=master)](https://coveralls.io/github/quantstart/qstrader?branch=master) 3 | 4 | # QSTrader 5 | 6 | QSTrader is an open-source event-driven backtesting platform for use in the equities markets, currently in an alpha state. 7 | 8 | It has been created as part of the Advanced Trading Infrastructure article series on QuantStart.com to provide the systematic trading community with a robust trading engine that allows straightforward equities strategy implementation and testing. 9 | 10 | The software is provided under a permissive "MIT" license (see below). 11 | 12 | # Current Features 13 | 14 | * **Open-Source** - QSTrader has been released under an extremely permissive open-source MIT License, which allows full usage in both research and commercial applications, without restriction, but with no warranty of any kind whatsoever. Thus you can use it at home to carry out retail trading or within a quant fund as a basis for your research and/or order management system. 15 | 16 | * **Free** - QSTrader is completely free and costs nothing to download or use. 17 | 18 | * **Collaboration** - As QSTrader is open-source many developers collaborate to improve the software. New features are added frequently. Any bugs are quickly determined and fixed. 19 | 20 | * **Software Development** - QSTrader is written in the Python programming language for straightforward cross-platform support. QSTrader contains a suite of unit tests for the majority of its calculation code and tests are constantly added for new features. 21 | 22 | * **Event-Driven Architecture** - QSTrader is completely event-driven, which leads to straightforward transitioning of strategies from a research phase to a live trading implementation. 23 | 24 | * **Backtesting** - QSTrader supports both intraday tick-resolution (top of order book bid/ask) datasets as well as OHLCV "bar" resolution data on various time scales. 25 | 26 | * **Private Components** - QSTrader allows you to include a repository of your own private strategies or components. Simply checkout your own repository within the root of QSTrader and rename the directory to `private_files`. This will ensure the QSTrader repository can be easily kept up to date without interfering with your private repository. 27 | 28 | * **Performance Metrics** - QSTrader supports both portfolio-level and trade-level performance measurement. It provides a comprehensive "tearsheet" (see below) with associated strategy statistics. 29 | 30 | # Planned Features 31 | 32 | * **Transaction Costs** - Commissions are currently supported using Interactive Brokers standard fees for North American equities. Slippage and market impact are planned, but are not currently supported. 33 | 34 | * **Trading** - QSTrader will support live intraday trading using the Interactive Brokers native Python API, initially for North American equities. 35 | 36 | # Installation and Example Usage 37 | 38 | QSTrader is in an early alpha state at the moment. It should only be used for exploratory backtesting research. The installation procedure is a little more involved than a standard Python package as it has not yet been added to the Python package repository. 39 | 40 | Ubuntu Linux is the recommended platform on which to install QSTrader, but it will also work on Windows or Mac OSX under the Anaconda distribution (https://www.continuum.io/downloads). 41 | 42 | For those that wish to create their own Python virtual environment the following steps are necessary to run both a basic Buy And Hold strategy as well as a slightly more complex Moving Average Crossover trend-following strategy. 43 | 44 | An example virtual environment directory ```~/venv/qstraderp3``` has been used here. If you wish to change this directory then rename it in the following steps. 45 | 46 | The following steps will create a virtual environment directory with Python 3 and then activate the environment: 47 | 48 | ``` 49 | mkdir -p ~/venv/qstraderp3 50 | cd ~/venv/qstraderp3 51 | virtualenv --no-site-packages -p python3 . 52 | source ~/venv/qstraderp3/bin/activate 53 | ``` 54 | 55 | At this point it is necessary to use pip to install QSTrader as a library and then manually install the requirements. The following steps will take some time (5-10 minutes) as QSTrader relies on NumPy, SciPy, Pandas, Matplotlib as well as many other libraries and hence they will all need to compile: 56 | 57 | ``` 58 | pip install git+https://github.com/quantstart/qstrader.git 59 | pip install -r https://raw.githubusercontent.com/quantstart/qstrader/master/requirements.txt 60 | ``` 61 | 62 | Now that the library itself and requirements have been installed it is necessary to create the default directories for the data and output. In addition it is possible to download the necessary data and example code to run a simple backtest of a Buy And Hold strategy on the S&P500 total return index: 63 | 64 | ``` 65 | mkdir -p ~/qstrader/examples ~/data ~/out 66 | cd ~/data 67 | wget https://raw.githubusercontent.com/quantstart/qstrader/master/data/SPY.csv 68 | cd ~/qstrader/examples 69 | wget https://raw.githubusercontent.com/quantstart/qstrader/master/examples/buy_and_hold_backtest.py 70 | ``` 71 | 72 | Finally, we can run the backtest itself: 73 | 74 | ``` 75 | python buy_and_hold_backtest.py 76 | ``` 77 | 78 | Once complete you will see a full "tearsheet" of results including: 79 | 80 | * Equity curve 81 | * Drawdown curve 82 | * Monthly returns heatmap 83 | * Yearly returns distribution 84 | * Portfolio-level statistics 85 | * Trade-level statistics 86 | 87 | The tearsheet will look similar to: 88 | 89 | ![alt tag](https://s3.amazonaws.com/quantstartmedia/images/qstrader-buy-and-hold-tearsheet.png) 90 | 91 | You can explore the ```buy_and_hold_backtest.py``` file to examine the API of QSTrader. You will see that it is relatively straightforward to set up a simple strategy and execute it. 92 | 93 | For slightly more complex buy and sell rules it is possible to consider a Moving Average Crossover strategy. 94 | 95 | The following strategy creates two Simple Moving Averages with respective lookback periods of 100 and 300 days. When the 100-period SMA exceeds the 300-period SMA 100 shares of AAPL are longed. When the 300-period SMA exceeds the 100-period SMA the position is closed out. To obtain the data for this strategy and execute it run the following code: 96 | 97 | ``` 98 | cd ~/data 99 | wget https://raw.githubusercontent.com/quantstart/qstrader/master/data/AAPL.csv 100 | cd ~/qstrader/examples 101 | wget https://raw.githubusercontent.com/quantstart/qstrader/master/examples/moving_average_cross_backtest.py 102 | ``` 103 | 104 | The backtest can be executed with the following command: 105 | 106 | ``` 107 | python moving_average_cross_backtest.py 108 | ``` 109 | 110 | Once complete a full tearsheet will be presented, this time with a benchmark: 111 | 112 | ![alt tag](https://s3.amazonaws.com/quantstartmedia/images/qstrader-moving-average-cross-tearsheet.png) 113 | 114 | Other example strategies can be found in the ```examples``` directory. Each example is self-contained in a ```****_backtest.py``` file, which can be used as templates for your own strategies. 115 | 116 | The project is constantly being developed, so unfortunately it is likely that the current API will experience backwards incompatibility until a mature beta version has been produced. 117 | 118 | If you have any questions about the installation then please feel free to email support@quantstart.com. 119 | 120 | If you notice any bugs or other issues that you think may be due to the codebase specifically, feel free to open a Github issue here: https://github.com/quantstart/qstrader/issues 121 | 122 | # License Terms 123 | 124 | Copyright (c) 2015-2018 QuantStart.com 125 | 126 | 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: 127 | 128 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 129 | 130 | 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. 131 | 132 | # Trading Disclaimer 133 | 134 | 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. 135 | -------------------------------------------------------------------------------- /data/AMZN.csv: -------------------------------------------------------------------------------- 1 | Ticker,Time,Bid,Ask 2 | AMZN,01.02.2016 00:00:01.562,502.10001,502.11999 3 | AMZN,01.02.2016 00:00:02.909,502.10003,502.11997 4 | AMZN,01.02.2016 00:00:04.396,502.10007,502.11993 5 | AMZN,01.02.2016 00:00:05.970,502.10008,502.11992 6 | AMZN,01.02.2016 00:00:07.402,502.10009,502.11991 7 | AMZN,01.02.2016 00:00:08.948,502.10012,502.11988 8 | AMZN,01.02.2016 00:00:10.316,502.10013,502.11987 9 | AMZN,01.02.2016 00:00:11.829,502.10015,502.11985 10 | AMZN,01.02.2016 00:00:13.212,502.10016,502.11984 11 | AMZN,01.02.2016 00:00:14.616,502.10015,502.11985 -------------------------------------------------------------------------------- /data/GOOG.csv: -------------------------------------------------------------------------------- 1 | Ticker,Time,Bid,Ask 2 | GOOG,01.02.2016 00:00:01.358,683.56000,683.58000 3 | GOOG,01.02.2016 00:00:02.544,683.55998,683.58002 4 | GOOG,01.02.2016 00:00:03.765,683.55999,683.58001 5 | GOOG,01.02.2016 00:00:05.215,683.56001,683.57999 6 | GOOG,01.02.2016 00:00:06.509,683.56002,683.57998 7 | GOOG,01.02.2016 00:00:07.964,683.55999,683.58001 8 | GOOG,01.02.2016 00:00:09.369,683.56000,683.58000 9 | GOOG,01.02.2016 00:00:10.823,683.56001,683.57999 10 | GOOG,01.02.2016 00:00:12.221,683.56000,683.58000 11 | GOOG,01.02.2016 00:00:13.546,683.56000,683.58000 -------------------------------------------------------------------------------- /data/MSFT.csv: -------------------------------------------------------------------------------- 1 | Ticker,Time,Bid,Ask 2 | MSFT,01.02.2016 00:00:01.578,50.14999,50.17001 3 | MSFT,01.02.2016 00:00:02.988,50.15002,50.16998 4 | MSFT,01.02.2016 00:00:04.360,50.15003,50.16997 5 | MSFT,01.02.2016 00:00:05.752,50.15004,50.16996 6 | MSFT,01.02.2016 00:00:07.148,50.15005,50.16995 7 | MSFT,01.02.2016 00:00:08.416,50.15003,50.16997 8 | MSFT,01.02.2016 00:00:09.904,50.15000,50.17000 9 | MSFT,01.02.2016 00:00:11.309,50.15001,50.16999 10 | MSFT,01.02.2016 00:00:12.655,50.15003,50.16997 11 | MSFT,01.02.2016 00:00:14.153,50.15005,50.16995 -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/examples/__init__.py -------------------------------------------------------------------------------- /examples/buy_and_hold_backtest.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from qstrader import settings 4 | from qstrader.strategy.base import AbstractStrategy 5 | from qstrader.event import SignalEvent, EventType 6 | from qstrader.compat import queue 7 | from qstrader.trading_session import TradingSession 8 | 9 | 10 | class BuyAndHoldStrategy(AbstractStrategy): 11 | """ 12 | A testing strategy that simply purchases (longs) an asset 13 | upon first receipt of the relevant bar event and 14 | then holds until the completion of a backtest. 15 | """ 16 | def __init__( 17 | self, ticker, events_queue, 18 | base_quantity=100 19 | ): 20 | self.ticker = ticker 21 | self.events_queue = events_queue 22 | self.base_quantity = base_quantity 23 | self.bars = 0 24 | self.invested = False 25 | 26 | def calculate_signals(self, event): 27 | if ( 28 | event.type in [EventType.BAR, EventType.TICK] and 29 | event.ticker == self.ticker 30 | ): 31 | if not self.invested and self.bars == 0: 32 | signal = SignalEvent( 33 | self.ticker, "BOT", 34 | suggested_quantity=self.base_quantity 35 | ) 36 | self.events_queue.put(signal) 37 | self.invested = True 38 | self.bars += 1 39 | 40 | 41 | def run(config, testing, tickers, filename): 42 | # Backtest information 43 | title = ['Buy and Hold Example on %s' % tickers[0]] 44 | initial_equity = 10000.0 45 | start_date = datetime.datetime(2000, 1, 1) 46 | end_date = datetime.datetime(2014, 1, 1) 47 | 48 | # Use the Buy and Hold Strategy 49 | events_queue = queue.Queue() 50 | strategy = BuyAndHoldStrategy(tickers[0], events_queue) 51 | 52 | # Set up the backtest 53 | backtest = TradingSession( 54 | config, strategy, tickers, 55 | initial_equity, start_date, end_date, 56 | events_queue, title=title 57 | ) 58 | results = backtest.start_trading(testing=testing) 59 | return results 60 | 61 | 62 | if __name__ == "__main__": 63 | # Configuration data 64 | testing = False 65 | config = settings.from_file( 66 | settings.DEFAULT_CONFIG_FILENAME, testing 67 | ) 68 | tickers = ["SPY"] 69 | filename = None 70 | run(config, testing, tickers, filename) 71 | -------------------------------------------------------------------------------- /examples/monthly_liquidate_rebalance_backtest.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime 3 | 4 | from qstrader import settings 5 | from qstrader.strategy.base import AbstractStrategy 6 | from qstrader.position_sizer.rebalance import LiquidateRebalancePositionSizer 7 | from qstrader.event import SignalEvent, EventType 8 | from qstrader.compat import queue 9 | from qstrader.trading_session import TradingSession 10 | 11 | 12 | class MonthlyLiquidateRebalanceStrategy(AbstractStrategy): 13 | """ 14 | A generic strategy that allows monthly rebalancing of a 15 | set of tickers, via full liquidation and dollar-weighting 16 | of new positions. 17 | 18 | Must be used in conjunction with the 19 | LiquidateRebalancePositionSizer object to work correctly. 20 | """ 21 | def __init__(self, tickers, events_queue): 22 | self.tickers = tickers 23 | self.events_queue = events_queue 24 | self.tickers_invested = self._create_invested_list() 25 | 26 | def _end_of_month(self, cur_time): 27 | """ 28 | Determine if the current day is at the end of the month. 29 | """ 30 | cur_day = cur_time.day 31 | end_day = calendar.monthrange(cur_time.year, cur_time.month)[1] 32 | return cur_day == end_day 33 | 34 | def _create_invested_list(self): 35 | """ 36 | Create a dictionary with each ticker as a key, with 37 | a boolean value depending upon whether the ticker has 38 | been "invested" yet. This is necessary to avoid sending 39 | a liquidation signal on the first allocation. 40 | """ 41 | tickers_invested = {ticker: False for ticker in self.tickers} 42 | return tickers_invested 43 | 44 | def calculate_signals(self, event): 45 | """ 46 | For a particular received BarEvent, determine whether 47 | it is the end of the month (for that bar) and generate 48 | a liquidation signal, as well as a purchase signal, 49 | for each ticker. 50 | """ 51 | if ( 52 | event.type in [EventType.BAR, EventType.TICK] and 53 | self._end_of_month(event.time) 54 | ): 55 | ticker = event.ticker 56 | if self.tickers_invested[ticker]: 57 | liquidate_signal = SignalEvent(ticker, "EXIT") 58 | self.events_queue.put(liquidate_signal) 59 | long_signal = SignalEvent(ticker, "BOT") 60 | self.events_queue.put(long_signal) 61 | self.tickers_invested[ticker] = True 62 | 63 | 64 | def run(config, testing, tickers, filename): 65 | # Backtest information 66 | title = [ 67 | 'Monthly Liquidate/Rebalance on 60%/40% SPY/AGG Portfolio' 68 | ] 69 | initial_equity = 500000.0 70 | start_date = datetime.datetime(2006, 11, 1) 71 | end_date = datetime.datetime(2016, 10, 12) 72 | 73 | # Use the Monthly Liquidate And Rebalance strategy 74 | events_queue = queue.Queue() 75 | strategy = MonthlyLiquidateRebalanceStrategy( 76 | tickers, events_queue 77 | ) 78 | 79 | # Use the liquidate and rebalance position sizer 80 | # with prespecified ticker weights 81 | ticker_weights = { 82 | "SPY": 0.6, 83 | "AGG": 0.4, 84 | } 85 | position_sizer = LiquidateRebalancePositionSizer( 86 | ticker_weights 87 | ) 88 | 89 | # Set up the backtest 90 | backtest = TradingSession( 91 | config, strategy, tickers, 92 | initial_equity, start_date, end_date, 93 | events_queue, position_sizer=position_sizer, 94 | title=title, benchmark=tickers[0], 95 | ) 96 | results = backtest.start_trading(testing=testing) 97 | return results 98 | 99 | 100 | if __name__ == "__main__": 101 | # Configuration data 102 | testing = False 103 | config = settings.from_file( 104 | settings.DEFAULT_CONFIG_FILENAME, testing 105 | ) 106 | tickers = ["SPY", "AGG"] 107 | filename = None 108 | run(config, testing, tickers, filename) 109 | -------------------------------------------------------------------------------- /examples/moving_average_cross_backtest.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | import datetime 3 | 4 | import numpy as np 5 | 6 | from qstrader import settings 7 | from qstrader.strategy.base import AbstractStrategy 8 | from qstrader.event import SignalEvent, EventType 9 | from qstrader.compat import queue 10 | from qstrader.trading_session import TradingSession 11 | 12 | 13 | class MovingAverageCrossStrategy(AbstractStrategy): 14 | """ 15 | Requires: 16 | ticker - The ticker symbol being used for moving averages 17 | events_queue - A handle to the system events queue 18 | short_window - Lookback period for short moving average 19 | long_window - Lookback period for long moving average 20 | """ 21 | def __init__( 22 | self, ticker, 23 | events_queue, 24 | short_window=100, 25 | long_window=300, 26 | base_quantity=100 27 | ): 28 | self.ticker = ticker 29 | self.events_queue = events_queue 30 | self.short_window = short_window 31 | self.long_window = long_window 32 | self.base_quantity = base_quantity 33 | self.bars = 0 34 | self.invested = False 35 | self.sw_bars = deque(maxlen=self.short_window) 36 | self.lw_bars = deque(maxlen=self.long_window) 37 | 38 | def calculate_signals(self, event): 39 | if ( 40 | event.type == EventType.BAR and 41 | event.ticker == self.ticker 42 | ): 43 | # Add latest adjusted closing price to the 44 | # short and long window bars 45 | self.lw_bars.append(event.adj_close_price) 46 | if self.bars > self.long_window - self.short_window: 47 | self.sw_bars.append(event.adj_close_price) 48 | 49 | # Enough bars are present for trading 50 | if self.bars > self.long_window: 51 | # Calculate the simple moving averages 52 | short_sma = np.mean(self.sw_bars) 53 | long_sma = np.mean(self.lw_bars) 54 | # Trading signals based on moving average cross 55 | if short_sma > long_sma and not self.invested: 56 | print("LONG %s: %s" % (self.ticker, event.time)) 57 | signal = SignalEvent( 58 | self.ticker, "BOT", 59 | suggested_quantity=self.base_quantity 60 | ) 61 | self.events_queue.put(signal) 62 | self.invested = True 63 | elif short_sma < long_sma and self.invested: 64 | print("SHORT %s: %s" % (self.ticker, event.time)) 65 | signal = SignalEvent( 66 | self.ticker, "SLD", 67 | suggested_quantity=self.base_quantity 68 | ) 69 | self.events_queue.put(signal) 70 | self.invested = False 71 | self.bars += 1 72 | 73 | 74 | def run(config, testing, tickers, filename): 75 | # Backtest information 76 | title = ['Moving Average Crossover Example on AAPL: 100x300'] 77 | initial_equity = 10000.0 78 | start_date = datetime.datetime(2000, 1, 1) 79 | end_date = datetime.datetime(2014, 1, 1) 80 | 81 | # Use the MAC Strategy 82 | events_queue = queue.Queue() 83 | strategy = MovingAverageCrossStrategy( 84 | tickers[0], events_queue, 85 | short_window=100, 86 | long_window=300 87 | ) 88 | 89 | # Set up the backtest 90 | backtest = TradingSession( 91 | config, strategy, tickers, 92 | initial_equity, start_date, end_date, 93 | events_queue, title=title, 94 | benchmark=tickers[1], 95 | ) 96 | results = backtest.start_trading(testing=testing) 97 | return results 98 | 99 | 100 | if __name__ == "__main__": 101 | # Configuration data 102 | testing = False 103 | config = settings.from_file( 104 | settings.DEFAULT_CONFIG_FILENAME, testing 105 | ) 106 | tickers = ["AAPL", "SPY"] 107 | filename = None 108 | run(config, testing, tickers, filename) 109 | -------------------------------------------------------------------------------- /examples/test_examples.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test examples 3 | 4 | One example can be test individually using: 5 | 6 | $ nosetests -s -v examples/test_examples.py:TestExamples.test_strategy_backtest 7 | 8 | """ 9 | import os 10 | import unittest 11 | 12 | from qstrader import settings 13 | import examples.buy_and_hold_backtest 14 | import examples.moving_average_cross_backtest 15 | import examples.monthly_liquidate_rebalance_backtest 16 | 17 | 18 | class TestExamples(unittest.TestCase): 19 | """ 20 | Test example are executing correctly 21 | """ 22 | def setUp(self): 23 | """ 24 | Set up configuration. 25 | """ 26 | self.config = settings.TEST 27 | self.testing = True 28 | 29 | def test_buy_and_hold_backtest(self): 30 | """ 31 | Test buy_and_hold 32 | Begins at 2000-01-01 00:00:00 33 | End at 2014-01-01 00:00:00 34 | """ 35 | tickers = ["SPY"] 36 | filename = os.path.join( 37 | settings.TEST.OUTPUT_DIR, 38 | "buy_and_hold_backtest.pkl" 39 | ) 40 | results = examples.buy_and_hold_backtest.run( 41 | self.config, self.testing, tickers, filename 42 | ) 43 | for (key, expected) in [ 44 | ('sharpe', 0.25234757), 45 | ('max_drawdown_pct', 0.79589309), 46 | ]: 47 | value = float(results[key]) 48 | self.assertAlmostEqual(expected, value) 49 | 50 | def test_moving_average_cross_backtest(self): 51 | """ 52 | Test moving average crossover backtest 53 | Begins at 2000-01-01 00:00:00 54 | End at 2014-01-01 00:00:00 55 | """ 56 | tickers = ["AAPL", "SPY"] 57 | filename = os.path.join( 58 | settings.TEST.OUTPUT_DIR, 59 | "mac_backtest.pkl" 60 | ) 61 | results = examples.moving_average_cross_backtest.run( 62 | self.config, self.testing, tickers, filename 63 | ) 64 | self.assertAlmostEqual( 65 | float(results['sharpe']), 0.643009566 66 | ) 67 | 68 | def test_monthly_liquidate_rebalance_backtest(self): 69 | """ 70 | Test monthly liquidation & rebalance strategy. 71 | """ 72 | tickers = ["SPY", "AGG"] 73 | filename = os.path.join( 74 | settings.TEST.OUTPUT_DIR, 75 | "monthly_liquidate_rebalance_backtest.pkl" 76 | ) 77 | results = examples.monthly_liquidate_rebalance_backtest.run( 78 | self.config, self.testing, tickers, filename 79 | ) 80 | self.assertAlmostEqual( 81 | float(results['sharpe']), 0.2710491397280638 82 | ) 83 | 84 | 85 | if __name__ == "__main__": 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /load_statistics.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": false 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "import os\n", 12 | "from qstrader import settings\n", 13 | "from qstrader.statistics import load" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": null, 19 | "metadata": { 20 | "collapsed": false 21 | }, 22 | "outputs": [], 23 | "source": [ 24 | "# config = settings.TEST\n", 25 | "config = settings.from_file()\n", 26 | "# config" 27 | ] 28 | }, 29 | { 30 | "cell_type": "code", 31 | "execution_count": null, 32 | "metadata": { 33 | "collapsed": false 34 | }, 35 | "outputs": [], 36 | "source": [ 37 | "out_dir = os.path.expanduser(config.OUTPUT_DIR)\n", 38 | "stats = load(os.path.join(out_dir, \"statistics_2016-07-04_192217.pkl\"))" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": null, 44 | "metadata": { 45 | "collapsed": false 46 | }, 47 | "outputs": [], 48 | "source": [ 49 | "results = stats.get_results()\n", 50 | "results" 51 | ] 52 | }, 53 | { 54 | "cell_type": "code", 55 | "execution_count": null, 56 | "metadata": { 57 | "collapsed": false 58 | }, 59 | "outputs": [], 60 | "source": [ 61 | "import pandas as pd\n", 62 | "ts = results['equity'][1:]\n", 63 | "ts.index.name = 'Date'\n", 64 | "ts.name='Equity'\n", 65 | "ts" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": null, 71 | "metadata": { 72 | "collapsed": true 73 | }, 74 | "outputs": [], 75 | "source": [ 76 | "%matplotlib inline\n", 77 | "from pylab import rcParams\n", 78 | "rcParams['figure.figsize'] = 12, 6" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": { 85 | "collapsed": false 86 | }, 87 | "outputs": [], 88 | "source": [ 89 | "import mpld3\n", 90 | "mpld3.enable_notebook()" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": null, 96 | "metadata": { 97 | "collapsed": false 98 | }, 99 | "outputs": [], 100 | "source": [ 101 | "ts.plot()" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": null, 107 | "metadata": { 108 | "collapsed": false 109 | }, 110 | "outputs": [], 111 | "source": [ 112 | "import bokeh\n", 113 | "from bokeh.plotting import figure\n", 114 | "from bokeh.io import output_notebook\n", 115 | "from bokeh.charts import show" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": null, 121 | "metadata": { 122 | "collapsed": false 123 | }, 124 | "outputs": [], 125 | "source": [ 126 | "output_notebook()" 127 | ] 128 | }, 129 | { 130 | "cell_type": "code", 131 | "execution_count": null, 132 | "metadata": { 133 | "collapsed": true 134 | }, 135 | "outputs": [], 136 | "source": [ 137 | "from bokeh.charts import TimeSeries\n", 138 | "PLOT_WIDTH, PLOT_HEIGHT = 900, 400" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": null, 144 | "metadata": { 145 | "collapsed": false 146 | }, 147 | "outputs": [], 148 | "source": [ 149 | "p = TimeSeries(ts, title=\"Results\", ylabel=ts.name, xlabel=ts.index.name, plot_width=PLOT_WIDTH, plot_height=PLOT_HEIGHT)\n", 150 | "show(p)" 151 | ] 152 | } 153 | ], 154 | "metadata": { 155 | "kernelspec": { 156 | "display_name": "Python [Root]", 157 | "language": "python", 158 | "name": "Python [Root]" 159 | }, 160 | "language_info": { 161 | "codemirror_mode": { 162 | "name": "ipython", 163 | "version": 3 164 | }, 165 | "file_extension": ".py", 166 | "mimetype": "text/x-python", 167 | "name": "python", 168 | "nbconvert_exporter": "python", 169 | "pygments_lexer": "ipython3", 170 | "version": "3.5.2" 171 | } 172 | }, 173 | "nbformat": 4, 174 | "nbformat_minor": 0 175 | } 176 | -------------------------------------------------------------------------------- /out/EMPTY: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/out/EMPTY -------------------------------------------------------------------------------- /qstrader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/__init__.py -------------------------------------------------------------------------------- /qstrader/compat.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | import sys 4 | 5 | PY2 = sys.version_info[0] == 2 6 | PY3 = (sys.version_info[0] >= 3) 7 | 8 | if PY2: 9 | import Queue as queue 10 | else: # PY3 11 | import queue 12 | 13 | try: 14 | import cPickle as pickle 15 | except ImportError: 16 | import pickle 17 | -------------------------------------------------------------------------------- /qstrader/compliance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/compliance/__init__.py -------------------------------------------------------------------------------- /qstrader/compliance/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AbstractCompliance(object): 5 | """ 6 | The Compliance component should be given every trade 7 | that occurs in qstrader. 8 | 9 | It is designed to keep track of anything that may 10 | be required for regulatory or audit (or debugging) 11 | purposes. Extended versions can write trades to a 12 | CSV, or a database. 13 | """ 14 | 15 | __metaclass__ = ABCMeta 16 | 17 | @abstractmethod 18 | def record_trade(self, fill): 19 | """ 20 | Takes a FillEvent from an ExecutionHandler 21 | and logs each of these. 22 | 23 | Parameters: 24 | fill - A FillEvent with information about the 25 | trade that has just been executed. 26 | """ 27 | raise NotImplementedError("Should implement record_trade()") 28 | -------------------------------------------------------------------------------- /qstrader/compliance/example.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import csv 4 | from qstrader.price_parser import PriceParser 5 | 6 | from .base import AbstractCompliance 7 | 8 | 9 | class ExampleCompliance(AbstractCompliance): 10 | """ 11 | A basic compliance module which writes trades to a 12 | CSV file in the output directory. 13 | """ 14 | 15 | def __init__(self, config): 16 | """ 17 | Wipe the existing trade log for the day, leaving only 18 | the headers in an empty CSV. 19 | 20 | It allows for multiple backtests to be run 21 | in a simple way, but quite likely makes it unsuitable for 22 | a production environment that requires strict record-keeping. 23 | """ 24 | self.config = config 25 | # Remove the previous CSV file 26 | today = datetime.datetime.utcnow().date() 27 | self.csv_filename = "tradelog_" + today.strftime("%Y-%m-%d") + ".csv" 28 | 29 | try: 30 | fname = os.path.expanduser(os.path.join(config.OUTPUT_DIR, self.csv_filename)) 31 | os.remove(fname) 32 | except (IOError, OSError): 33 | print("No tradelog files to clean.") 34 | 35 | # Write new file header 36 | fieldnames = [ 37 | "timestamp", "ticker", 38 | "action", "quantity", 39 | "exchange", "price", 40 | "commission" 41 | ] 42 | fname = os.path.expanduser(os.path.join(self.config.OUTPUT_DIR, self.csv_filename)) 43 | with open(fname, 'a') as csvfile: 44 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 45 | writer.writeheader() 46 | 47 | def record_trade(self, fill): 48 | """ 49 | Append all details about the FillEvent to the CSV trade log. 50 | """ 51 | fname = os.path.expanduser(os.path.join(self.config.OUTPUT_DIR, self.csv_filename)) 52 | with open(fname, 'a') as csvfile: 53 | writer = csv.writer(csvfile) 54 | writer.writerow([ 55 | fill.timestamp, fill.ticker, 56 | fill.action, fill.quantity, 57 | fill.exchange, PriceParser.display(fill.price, 4), 58 | PriceParser.display(fill.commission, 4) 59 | ]) 60 | -------------------------------------------------------------------------------- /qstrader/event.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from enum import Enum 4 | 5 | 6 | EventType = Enum("EventType", "TICK BAR SIGNAL ORDER FILL SENTIMENT") 7 | 8 | 9 | class Event(object): 10 | """ 11 | Event is base class providing an interface for all subsequent 12 | (inherited) events, that will trigger further events in the 13 | trading infrastructure. 14 | """ 15 | @property 16 | def typename(self): 17 | return self.type.name 18 | 19 | 20 | class TickEvent(Event): 21 | """ 22 | Handles the event of receiving a new market update tick, 23 | which is defined as a ticker symbol and associated best 24 | bid and ask from the top of the order book. 25 | """ 26 | def __init__(self, ticker, time, bid, ask): 27 | """ 28 | Initialises the TickEvent. 29 | 30 | Parameters: 31 | ticker - The ticker symbol, e.g. 'GOOG'. 32 | time - The timestamp of the tick 33 | bid - The best bid price at the time of the tick. 34 | ask - The best ask price at the time of the tick. 35 | """ 36 | self.type = EventType.TICK 37 | self.ticker = ticker 38 | self.time = time 39 | self.bid = bid 40 | self.ask = ask 41 | 42 | def __str__(self): 43 | return "Type: %s, Ticker: %s, Time: %s, Bid: %s, Ask: %s" % ( 44 | str(self.type), str(self.ticker), 45 | str(self.time), str(self.bid), str(self.ask) 46 | ) 47 | 48 | def __repr__(self): 49 | return str(self) 50 | 51 | 52 | class BarEvent(Event): 53 | """ 54 | Handles the event of receiving a new market 55 | open-high-low-close-volume bar, as would be generated 56 | via common data providers such as Yahoo Finance. 57 | """ 58 | def __init__( 59 | self, ticker, time, period, 60 | open_price, high_price, low_price, 61 | close_price, volume, adj_close_price=None 62 | ): 63 | """ 64 | Initialises the BarEvent. 65 | 66 | Parameters: 67 | ticker - The ticker symbol, e.g. 'GOOG'. 68 | time - The timestamp of the bar 69 | period - The time period covered by the bar in seconds 70 | open_price - The unadjusted opening price of the bar 71 | high_price - The unadjusted high price of the bar 72 | low_price - The unadjusted low price of the bar 73 | close_price - The unadjusted close price of the bar 74 | volume - The volume of trading within the bar 75 | adj_close_price - The vendor adjusted closing price 76 | (e.g. back-adjustment) of the bar 77 | 78 | Note: It is not advised to use 'open', 'close' instead 79 | of 'open_price', 'close_price' as 'open' is a reserved 80 | word in Python. 81 | """ 82 | self.type = EventType.BAR 83 | self.ticker = ticker 84 | self.time = time 85 | self.period = period 86 | self.open_price = open_price 87 | self.high_price = high_price 88 | self.low_price = low_price 89 | self.close_price = close_price 90 | self.volume = volume 91 | self.adj_close_price = adj_close_price 92 | self.period_readable = self._readable_period() 93 | 94 | def _readable_period(self): 95 | """ 96 | Creates a human-readable period from the number 97 | of seconds specified for 'period'. 98 | 99 | For instance, converts: 100 | * 1 -> '1sec' 101 | * 5 -> '5secs' 102 | * 60 -> '1min' 103 | * 300 -> '5min' 104 | 105 | If no period is found in the lookup table, the human 106 | readable period is simply passed through from period, 107 | in seconds. 108 | """ 109 | lut = { 110 | 1: "1sec", 111 | 5: "5sec", 112 | 10: "10sec", 113 | 15: "15sec", 114 | 30: "30sec", 115 | 60: "1min", 116 | 300: "5min", 117 | 600: "10min", 118 | 900: "15min", 119 | 1800: "30min", 120 | 3600: "1hr", 121 | 86400: "1day", 122 | 604800: "1wk" 123 | } 124 | if self.period in lut: 125 | return lut[self.period] 126 | else: 127 | return "%ssec" % str(self.period) 128 | 129 | def __str__(self): 130 | format_str = "Type: %s, Ticker: %s, Time: %s, Period: %s, " \ 131 | "Open: %s, High: %s, Low: %s, Close: %s, " \ 132 | "Adj Close: %s, Volume: %s" % ( 133 | str(self.type), str(self.ticker), str(self.time), 134 | str(self.period_readable), str(self.open_price), 135 | str(self.high_price), str(self.low_price), 136 | str(self.close_price), str(self.adj_close_price), 137 | str(self.volume) 138 | ) 139 | return format_str 140 | 141 | def __repr__(self): 142 | return str(self) 143 | 144 | 145 | class SignalEvent(Event): 146 | """ 147 | Handles the event of sending a Signal from a Strategy object. 148 | This is received by a Portfolio object and acted upon. 149 | """ 150 | def __init__(self, ticker, action, suggested_quantity=None): 151 | """ 152 | Initialises the SignalEvent. 153 | 154 | Parameters: 155 | ticker - The ticker symbol, e.g. 'GOOG'. 156 | action - 'BOT' (for long) or 'SLD' (for short). 157 | suggested_quantity - Optional positively valued integer 158 | representing a suggested absolute quantity of units 159 | of an asset to transact in, which is used by the 160 | PositionSizer and RiskManager. 161 | """ 162 | self.type = EventType.SIGNAL 163 | self.ticker = ticker 164 | self.action = action 165 | self.suggested_quantity = suggested_quantity 166 | 167 | 168 | class OrderEvent(Event): 169 | """ 170 | Handles the event of sending an Order to an execution system. 171 | The order contains a ticker (e.g. GOOG), action (BOT or SLD) 172 | and quantity. 173 | """ 174 | def __init__(self, ticker, action, quantity): 175 | """ 176 | Initialises the OrderEvent. 177 | 178 | Parameters: 179 | ticker - The ticker symbol, e.g. 'GOOG'. 180 | action - 'BOT' (for long) or 'SLD' (for short). 181 | quantity - The quantity of shares to transact. 182 | """ 183 | self.type = EventType.ORDER 184 | self.ticker = ticker 185 | self.action = action 186 | self.quantity = quantity 187 | 188 | def print_order(self): 189 | """ 190 | Outputs the values within the OrderEvent. 191 | """ 192 | print( 193 | "Order: Ticker=%s, Action=%s, Quantity=%s" % ( 194 | self.ticker, self.action, self.quantity 195 | ) 196 | ) 197 | 198 | 199 | class FillEvent(Event): 200 | """ 201 | Encapsulates the notion of a filled order, as returned 202 | from a brokerage. Stores the quantity of an instrument 203 | actually filled and at what price. In addition, stores 204 | the commission of the trade from the brokerage. 205 | 206 | TODO: Currently does not support filling positions at 207 | different prices. This will be simulated by averaging 208 | the cost. 209 | """ 210 | 211 | def __init__( 212 | self, timestamp, ticker, 213 | action, quantity, 214 | exchange, price, 215 | commission 216 | ): 217 | """ 218 | Initialises the FillEvent object. 219 | 220 | timestamp - The timestamp when the order was filled. 221 | ticker - The ticker symbol, e.g. 'GOOG'. 222 | action - 'BOT' (for long) or 'SLD' (for short). 223 | quantity - The filled quantity. 224 | exchange - The exchange where the order was filled. 225 | price - The price at which the trade was filled 226 | commission - The brokerage commission for carrying out the trade. 227 | """ 228 | self.type = EventType.FILL 229 | self.timestamp = timestamp 230 | self.ticker = ticker 231 | self.action = action 232 | self.quantity = quantity 233 | self.exchange = exchange 234 | self.price = price 235 | self.commission = commission 236 | 237 | 238 | class SentimentEvent(Event): 239 | """ 240 | Handles the event of streaming a "Sentiment" value associated 241 | with a ticker. Can be used for a generic "date-ticker-sentiment" 242 | service, often provided by many data vendors. 243 | """ 244 | def __init__(self, timestamp, ticker, sentiment): 245 | """ 246 | Initialises the SentimentEvent. 247 | 248 | Parameters: 249 | timestamp - The timestamp when the sentiment was generated. 250 | ticker - The ticker symbol, e.g. 'GOOG'. 251 | sentiment - A string, float or integer value of "sentiment", 252 | e.g. "bullish", -1, 5.4, etc. 253 | """ 254 | self.type = EventType.SENTIMENT 255 | self.timestamp = timestamp 256 | self.ticker = ticker 257 | self.sentiment = sentiment 258 | -------------------------------------------------------------------------------- /qstrader/exception.py: -------------------------------------------------------------------------------- 1 | class AbstractEmptyDataRow(Exception): 2 | pass 3 | 4 | 5 | class EmptyTickEvent(AbstractEmptyDataRow): 6 | pass 7 | 8 | 9 | class EmptyBarEvent(AbstractEmptyDataRow): 10 | pass 11 | -------------------------------------------------------------------------------- /qstrader/execution_handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/execution_handler/__init__.py -------------------------------------------------------------------------------- /qstrader/execution_handler/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AbstractExecutionHandler(object): 5 | """ 6 | The ExecutionHandler abstract class handles the interaction 7 | between a set of order objects generated by a PortfolioHandler 8 | and the set of Fill objects that actually occur in the 9 | market. 10 | 11 | The handlers can be used to subclass simulated brokerages 12 | or live brokerages, with identical interfaces. This allows 13 | strategies to be backtested in a very similar manner to the 14 | live trading engine. 15 | 16 | ExecutionHandler can link to an optional Compliance component 17 | for simple record-keeping, which will keep track of all executed 18 | orders. 19 | """ 20 | 21 | __metaclass__ = ABCMeta 22 | 23 | @abstractmethod 24 | def execute_order(self, event): 25 | """ 26 | Takes an OrderEvent and executes it, producing 27 | a FillEvent that gets placed onto the events queue. 28 | 29 | Parameters: 30 | event - Contains an Event object with order information. 31 | """ 32 | raise NotImplementedError("Should implement execute_order()") 33 | -------------------------------------------------------------------------------- /qstrader/execution_handler/ib_simulated.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractExecutionHandler 2 | from ..event import (FillEvent, EventType) 3 | from ..price_parser import PriceParser 4 | 5 | 6 | class IBSimulatedExecutionHandler(AbstractExecutionHandler): 7 | """ 8 | The simulated execution handler for Interactive Brokers 9 | converts all order objects into their equivalent fill 10 | objects automatically without latency, slippage or 11 | fill-ratio issues. 12 | 13 | This allows a straightforward "first go" test of any strategy, 14 | before implementation with a more sophisticated execution 15 | handler. 16 | """ 17 | 18 | def __init__(self, events_queue, price_handler, compliance=None): 19 | """ 20 | Initialises the handler, setting the event queue 21 | as well as access to local pricing. 22 | 23 | Parameters: 24 | events_queue - The Queue of Event objects. 25 | """ 26 | self.events_queue = events_queue 27 | self.price_handler = price_handler 28 | self.compliance = compliance 29 | 30 | def calculate_ib_commission(self, quantity, fill_price): 31 | """ 32 | Calculate the Interactive Brokers commission for 33 | a transaction. This is based on the US Fixed pricing, 34 | the details of which can be found here: 35 | https://www.interactivebrokers.co.uk/en/index.php?f=1590&p=stocks1 36 | """ 37 | commission = min( 38 | 0.5 * fill_price * quantity, 39 | max(1.0, 0.005 * quantity) 40 | ) 41 | return PriceParser.parse(commission) 42 | 43 | def execute_order(self, event): 44 | """ 45 | Converts OrderEvents into FillEvents "naively", 46 | i.e. without any latency, slippage or fill ratio problems. 47 | 48 | Parameters: 49 | event - An Event object with order information. 50 | """ 51 | if event.type == EventType.ORDER: 52 | # Obtain values from the OrderEvent 53 | timestamp = self.price_handler.get_last_timestamp(event.ticker) 54 | ticker = event.ticker 55 | action = event.action 56 | quantity = event.quantity 57 | 58 | # Obtain the fill price 59 | if self.price_handler.istick(): 60 | bid, ask = self.price_handler.get_best_bid_ask(ticker) 61 | if event.action == "BOT": 62 | fill_price = ask 63 | else: 64 | fill_price = bid 65 | else: 66 | close_price = self.price_handler.get_last_close(ticker) 67 | fill_price = close_price 68 | 69 | # Set a dummy exchange and calculate trade commission 70 | exchange = "ARCA" 71 | commission = self.calculate_ib_commission(quantity, fill_price) 72 | 73 | # Create the FillEvent and place on the events queue 74 | fill_event = FillEvent( 75 | timestamp, ticker, 76 | action, quantity, 77 | exchange, fill_price, 78 | commission 79 | ) 80 | self.events_queue.put(fill_event) 81 | 82 | if self.compliance is not None: 83 | self.compliance.record_trade(fill_event) 84 | -------------------------------------------------------------------------------- /qstrader/order/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/order/__init__.py -------------------------------------------------------------------------------- /qstrader/order/suggested.py: -------------------------------------------------------------------------------- 1 | class SuggestedOrder(object): 2 | """ 3 | A SuggestedOrder object is generated by the PortfolioHandler 4 | to be sent to the PositionSizer object and subsequently the 5 | RiskManager object. Creating a separate object type for 6 | suggested orders and final orders (OrderEvent objects) ensures 7 | that a suggested order is never transacted unless it has been 8 | scrutinised by the position sizing and risk management layers. 9 | """ 10 | def __init__(self, ticker, action, quantity=0): 11 | """ 12 | Initialises the SuggestedOrder. The quantity defaults 13 | to zero as the PortfolioHandler creates these objects 14 | prior to any position sizing. 15 | 16 | The PositionSizer object will "fill in" the correct 17 | value prior to sending the SuggestedOrder to the 18 | RiskManager. 19 | 20 | Parameters: 21 | ticker - The ticker symbol, e.g. 'GOOG'. 22 | action - 'BOT' (for long) or 'SLD' (for short) 23 | or 'EXIT' (for liquidation). 24 | quantity - The quantity of shares to transact. 25 | """ 26 | self.ticker = ticker 27 | self.action = action 28 | self.quantity = quantity 29 | -------------------------------------------------------------------------------- /qstrader/portfolio.py: -------------------------------------------------------------------------------- 1 | from .position import Position 2 | 3 | 4 | class Portfolio(object): 5 | def __init__(self, price_handler, cash): 6 | """ 7 | On creation, the Portfolio object contains no 8 | positions and all values are "reset" to the initial 9 | cash, with no PnL - realised or unrealised. 10 | 11 | Note that realised_pnl is the running tally pnl from closed 12 | positions (closed_pnl), as well as realised_pnl 13 | from currently open positions. 14 | """ 15 | self.price_handler = price_handler 16 | self.init_cash = cash 17 | self.equity = cash 18 | self.cur_cash = cash 19 | self.positions = {} 20 | self.closed_positions = [] 21 | self.realised_pnl = 0 22 | 23 | def _update_portfolio(self): 24 | """ 25 | Updates the value of all positions that are currently open. 26 | Value of closed positions is tallied as self.realised_pnl. 27 | """ 28 | self.unrealised_pnl = 0 29 | self.equity = self.realised_pnl 30 | self.equity += self.init_cash 31 | 32 | for ticker in self.positions: 33 | pt = self.positions[ticker] 34 | if self.price_handler.istick(): 35 | bid, ask = self.price_handler.get_best_bid_ask(ticker) 36 | else: 37 | close_price = self.price_handler.get_last_close(ticker) 38 | bid = close_price 39 | ask = close_price 40 | pt.update_market_value(bid, ask) 41 | self.unrealised_pnl += pt.unrealised_pnl 42 | self.equity += ( 43 | pt.market_value - pt.cost_basis + pt.realised_pnl 44 | ) 45 | 46 | def _add_position( 47 | self, action, ticker, 48 | quantity, price, commission 49 | ): 50 | """ 51 | Adds a new Position object to the Portfolio. This 52 | requires getting the best bid/ask price from the 53 | price handler in order to calculate a reasonable 54 | "market value". 55 | 56 | Once the Position is added, the Portfolio values 57 | are updated. 58 | """ 59 | if ticker not in self.positions: 60 | if self.price_handler.istick(): 61 | bid, ask = self.price_handler.get_best_bid_ask(ticker) 62 | else: 63 | close_price = self.price_handler.get_last_close(ticker) 64 | bid = close_price 65 | ask = close_price 66 | position = Position( 67 | action, ticker, quantity, 68 | price, commission, bid, ask 69 | ) 70 | self.positions[ticker] = position 71 | self._update_portfolio() 72 | else: 73 | print( 74 | "Ticker %s is already in the positions list. " 75 | "Could not add a new position." % ticker 76 | ) 77 | 78 | def _modify_position( 79 | self, action, ticker, 80 | quantity, price, commission 81 | ): 82 | """ 83 | Modifies a current Position object to the Portfolio. 84 | This requires getting the best bid/ask price from the 85 | price handler in order to calculate a reasonable 86 | "market value". 87 | 88 | Once the Position is modified, the Portfolio values 89 | are updated. 90 | """ 91 | if ticker in self.positions: 92 | self.positions[ticker].transact_shares( 93 | action, quantity, price, commission 94 | ) 95 | if self.price_handler.istick(): 96 | bid, ask = self.price_handler.get_best_bid_ask(ticker) 97 | else: 98 | close_price = self.price_handler.get_last_close(ticker) 99 | bid = close_price 100 | ask = close_price 101 | self.positions[ticker].update_market_value(bid, ask) 102 | 103 | if self.positions[ticker].quantity == 0: 104 | closed = self.positions.pop(ticker) 105 | self.realised_pnl += closed.realised_pnl 106 | self.closed_positions.append(closed) 107 | 108 | self._update_portfolio() 109 | else: 110 | print( 111 | "Ticker %s not in the current position list. " 112 | "Could not modify a current position." % ticker 113 | ) 114 | 115 | def transact_position( 116 | self, action, ticker, 117 | quantity, price, commission 118 | ): 119 | """ 120 | Handles any new position or modification to 121 | a current position, by calling the respective 122 | _add_position and _modify_position methods. 123 | 124 | Hence, this single method will be called by the 125 | PortfolioHandler to update the Portfolio itself. 126 | """ 127 | 128 | if action == "BOT": 129 | self.cur_cash -= ((quantity * price) + commission) 130 | elif action == "SLD": 131 | self.cur_cash += ((quantity * price) - commission) 132 | 133 | if ticker not in self.positions: 134 | self._add_position( 135 | action, ticker, quantity, 136 | price, commission 137 | ) 138 | else: 139 | self._modify_position( 140 | action, ticker, quantity, 141 | price, commission 142 | ) 143 | -------------------------------------------------------------------------------- /qstrader/portfolio_handler.py: -------------------------------------------------------------------------------- 1 | from .order.suggested import SuggestedOrder 2 | from .portfolio import Portfolio 3 | 4 | 5 | class PortfolioHandler(object): 6 | def __init__( 7 | self, initial_cash, events_queue, 8 | price_handler, position_sizer, risk_manager 9 | ): 10 | """ 11 | The PortfolioHandler is designed to interact with the 12 | backtesting or live trading overall event-driven 13 | architecture. It exposes two methods, on_signal and 14 | on_fill, which handle how SignalEvent and FillEvent 15 | objects are dealt with. 16 | 17 | Each PortfolioHandler contains a Portfolio object, 18 | which stores the actual Position objects. 19 | 20 | The PortfolioHandler takes a handle to a PositionSizer 21 | object which determines a mechanism, based on the current 22 | Portfolio, as to how to size a new Order. 23 | 24 | The PortfolioHandler also takes a handle to the 25 | RiskManager, which is used to modify any generated 26 | Orders to remain in line with risk parameters. 27 | """ 28 | self.initial_cash = initial_cash 29 | self.events_queue = events_queue 30 | self.price_handler = price_handler 31 | self.position_sizer = position_sizer 32 | self.risk_manager = risk_manager 33 | self.portfolio = Portfolio(price_handler, initial_cash) 34 | 35 | def _create_order_from_signal(self, signal_event): 36 | """ 37 | Take a SignalEvent object and use it to form a 38 | SuggestedOrder object. These are not OrderEvent objects, 39 | as they have yet to be sent to the RiskManager object. 40 | At this stage they are simply "suggestions" that the 41 | RiskManager will either verify, modify or eliminate. 42 | """ 43 | if signal_event.suggested_quantity is None: 44 | quantity = 0 45 | else: 46 | quantity = signal_event.suggested_quantity 47 | order = SuggestedOrder( 48 | signal_event.ticker, 49 | signal_event.action, 50 | quantity=quantity 51 | ) 52 | return order 53 | 54 | def _place_orders_onto_queue(self, order_list): 55 | """ 56 | Once the RiskManager has verified, modified or eliminated 57 | any order objects, they are placed onto the events queue, 58 | to ultimately be executed by the ExecutionHandler. 59 | """ 60 | for order_event in order_list: 61 | self.events_queue.put(order_event) 62 | 63 | def _convert_fill_to_portfolio_update(self, fill_event): 64 | """ 65 | Upon receipt of a FillEvent, the PortfolioHandler converts 66 | the event into a transaction that gets stored in the Portfolio 67 | object. This ensures that the broker and the local portfolio 68 | are "in sync". 69 | 70 | In addition, for backtesting purposes, the portfolio value can 71 | be reasonably estimated in a realistic manner, simply by 72 | modifying how the ExecutionHandler object handles slippage, 73 | transaction costs, liquidity and market impact. 74 | """ 75 | action = fill_event.action 76 | ticker = fill_event.ticker 77 | quantity = fill_event.quantity 78 | price = fill_event.price 79 | commission = fill_event.commission 80 | # Create or modify the position from the fill info 81 | self.portfolio.transact_position( 82 | action, ticker, quantity, 83 | price, commission 84 | ) 85 | 86 | def on_signal(self, signal_event): 87 | """ 88 | This is called by the backtester or live trading architecture 89 | to form the initial orders from the SignalEvent. 90 | 91 | These orders are sized by the PositionSizer object and then 92 | sent to the RiskManager to verify, modify or eliminate. 93 | 94 | Once received from the RiskManager they are converted into 95 | full OrderEvent objects and sent back to the events queue. 96 | """ 97 | # Create the initial order list from a signal event 98 | initial_order = self._create_order_from_signal(signal_event) 99 | # Size the quantity of the initial order 100 | sized_order = self.position_sizer.size_order( 101 | self.portfolio, initial_order 102 | ) 103 | # Refine or eliminate the order via the risk manager overlay 104 | order_events = self.risk_manager.refine_orders( 105 | self.portfolio, sized_order 106 | ) 107 | # Place orders onto events queue 108 | self._place_orders_onto_queue(order_events) 109 | 110 | def on_fill(self, fill_event): 111 | """ 112 | This is called by the backtester or live trading architecture 113 | to take a FillEvent and update the Portfolio object with new 114 | or modified Positions. 115 | 116 | In a backtesting environment these FillEvents will be simulated 117 | by a model representing the execution, whereas in live trading 118 | they will come directly from a brokerage (such as Interactive 119 | Brokers). 120 | """ 121 | self._convert_fill_to_portfolio_update(fill_event) 122 | 123 | def update_portfolio_value(self): 124 | """ 125 | Update the portfolio to reflect current market value as 126 | based on last bid/ask of each ticker. 127 | """ 128 | self.portfolio._update_portfolio() 129 | -------------------------------------------------------------------------------- /qstrader/position.py: -------------------------------------------------------------------------------- 1 | from numpy import sign 2 | 3 | 4 | class Position(object): 5 | def __init__( 6 | self, action, ticker, init_quantity, 7 | init_price, init_commission, 8 | bid, ask 9 | ): 10 | """ 11 | Set up the initial "account" of the Position to be 12 | zero for most items, with the exception of the initial 13 | purchase/sale. 14 | 15 | Then calculate the initial values and finally update the 16 | market value of the transaction. 17 | """ 18 | self.action = action 19 | self.ticker = ticker 20 | self.quantity = init_quantity 21 | self.init_price = init_price 22 | self.init_commission = init_commission 23 | 24 | self.realised_pnl = 0 25 | self.unrealised_pnl = 0 26 | 27 | self.buys = 0 28 | self.sells = 0 29 | self.avg_bot = 0 30 | self.avg_sld = 0 31 | self.total_bot = 0 32 | self.total_sld = 0 33 | self.total_commission = init_commission 34 | 35 | self._calculate_initial_value() 36 | self.update_market_value(bid, ask) 37 | 38 | def _calculate_initial_value(self): 39 | """ 40 | Depending upon whether the action was a buy or sell ("BOT" 41 | or "SLD") calculate the average bought cost, the total bought 42 | cost, the average price and the cost basis. 43 | 44 | Finally, calculate the net total with and without commission. 45 | """ 46 | 47 | if self.action == "BOT": 48 | self.buys = self.quantity 49 | self.avg_bot = self.init_price 50 | self.total_bot = self.buys * self.avg_bot 51 | self.avg_price = (self.init_price * self.quantity + self.init_commission) // self.quantity 52 | self.cost_basis = self.quantity * self.avg_price 53 | else: # action == "SLD" 54 | self.sells = self.quantity 55 | self.avg_sld = self.init_price 56 | self.total_sld = self.sells * self.avg_sld 57 | self.avg_price = (self.init_price * self.quantity - self.init_commission) // self.quantity 58 | self.cost_basis = -self.quantity * self.avg_price 59 | self.net = self.buys - self.sells 60 | self.net_total = self.total_sld - self.total_bot 61 | self.net_incl_comm = self.net_total - self.init_commission 62 | 63 | def update_market_value(self, bid, ask): 64 | """ 65 | The market value is tricky to calculate as we only have 66 | access to the top of the order book through Interactive 67 | Brokers, which means that the true redemption price is 68 | unknown until executed. 69 | 70 | However, it can be estimated via the mid-price of the 71 | bid-ask spread. Once the market value is calculated it 72 | allows calculation of the unrealised and realised profit 73 | and loss of any transactions. 74 | """ 75 | midpoint = (bid + ask) // 2 76 | self.market_value = self.quantity * midpoint * sign(self.net) 77 | self.unrealised_pnl = self.market_value - self.cost_basis 78 | 79 | def transact_shares(self, action, quantity, price, commission): 80 | """ 81 | Calculates the adjustments to the Position that occur 82 | once new shares are bought and sold. 83 | 84 | Takes care to update the average bought/sold, total 85 | bought/sold, the cost basis and PnL calculations, 86 | as carried out through Interactive Brokers TWS. 87 | """ 88 | self.total_commission += commission 89 | 90 | # Adjust total bought and sold 91 | if action == "BOT": 92 | self.avg_bot = ( 93 | self.avg_bot * self.buys + price * quantity 94 | ) // (self.buys + quantity) 95 | if self.action != "SLD": # Increasing long position 96 | self.avg_price = ( 97 | self.avg_price * self.buys + 98 | price * quantity + commission 99 | ) // (self.buys + quantity) 100 | elif self.action == "SLD": # Closed partial positions out 101 | self.realised_pnl += quantity * ( 102 | self.avg_price - price 103 | ) - commission # Adjust realised PNL 104 | self.buys += quantity 105 | self.total_bot = self.buys * self.avg_bot 106 | 107 | # action == "SLD" 108 | else: 109 | self.avg_sld = ( 110 | self.avg_sld * self.sells + price * quantity 111 | ) // (self.sells + quantity) 112 | if self.action != "BOT": # Increasing short position 113 | self.avg_price = ( 114 | self.avg_price * self.sells + 115 | price * quantity - commission 116 | ) // (self.sells + quantity) 117 | self.unrealised_pnl -= commission 118 | elif self.action == "BOT": # Closed partial positions out 119 | self.realised_pnl += quantity * ( 120 | price - self.avg_price 121 | ) - commission 122 | self.sells += quantity 123 | self.total_sld = self.sells * self.avg_sld 124 | 125 | # Adjust net values, including commissions 126 | self.net = self.buys - self.sells 127 | self.quantity = self.net 128 | self.net_total = self.total_sld - self.total_bot 129 | self.net_incl_comm = self.net_total - self.total_commission 130 | 131 | # Adjust average price and cost basis 132 | self.cost_basis = self.quantity * self.avg_price 133 | -------------------------------------------------------------------------------- /qstrader/position_sizer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/position_sizer/__init__.py -------------------------------------------------------------------------------- /qstrader/position_sizer/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AbstractPositionSizer(object): 5 | """ 6 | The AbstractPositionSizer abstract class modifies 7 | the quantity (or not) of any share transacted 8 | """ 9 | 10 | __metaclass__ = ABCMeta 11 | 12 | @abstractmethod 13 | def size_order(self, portfolio, initial_order): 14 | """ 15 | This TestPositionSizer object simply modifies 16 | the quantity to be 100 of any share transacted. 17 | """ 18 | raise NotImplementedError("Should implement size_order()") 19 | -------------------------------------------------------------------------------- /qstrader/position_sizer/fixed.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractPositionSizer 2 | 3 | 4 | class FixedPositionSizer(AbstractPositionSizer): 5 | def __init__(self, default_quantity=100): 6 | self.default_quantity = default_quantity 7 | 8 | def size_order(self, portfolio, initial_order): 9 | """ 10 | This FixedPositionSizer object simply modifies 11 | the quantity to be 100 of any share transacted. 12 | """ 13 | initial_order.quantity = self.default_quantity 14 | return initial_order 15 | -------------------------------------------------------------------------------- /qstrader/position_sizer/naive.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractPositionSizer 2 | 3 | 4 | class NaivePositionSizer(AbstractPositionSizer): 5 | def __init__(self, default_quantity=100): 6 | self.default_quantity = default_quantity 7 | 8 | def size_order(self, portfolio, initial_order): 9 | """ 10 | This NaivePositionSizer object follows all 11 | suggestions from the initial order without 12 | modification. Useful for testing simpler 13 | strategies that do not reside in a larger 14 | risk-managed portfolio. 15 | """ 16 | return initial_order 17 | -------------------------------------------------------------------------------- /qstrader/position_sizer/rebalance.py: -------------------------------------------------------------------------------- 1 | from math import floor 2 | 3 | from .base import AbstractPositionSizer 4 | from qstrader.price_parser import PriceParser 5 | 6 | 7 | class LiquidateRebalancePositionSizer(AbstractPositionSizer): 8 | """ 9 | Carries out a periodic full liquidation and rebalance of 10 | the Portfolio. 11 | 12 | This is achieved by determining whether an order type type 13 | is "EXIT" or "BOT/SLD". 14 | 15 | If the former, the current quantity of shares in the ticker 16 | is determined and then BOT or SLD to net the position to zero. 17 | 18 | If the latter, the current quantity of shares to obtain is 19 | determined by prespecified weights and adjusted to reflect 20 | current account equity. 21 | """ 22 | def __init__(self, ticker_weights): 23 | self.ticker_weights = ticker_weights 24 | 25 | def size_order(self, portfolio, initial_order): 26 | """ 27 | Size the order to reflect the dollar-weighting of the 28 | current equity account size based on pre-specified 29 | ticker weights. 30 | """ 31 | ticker = initial_order.ticker 32 | if initial_order.action == "EXIT": 33 | # Obtain current quantity and liquidate 34 | cur_quantity = portfolio.positions[ticker].quantity 35 | if cur_quantity > 0: 36 | initial_order.action = "SLD" 37 | initial_order.quantity = cur_quantity 38 | else: 39 | initial_order.action = "BOT" 40 | initial_order.quantity = cur_quantity 41 | else: 42 | weight = self.ticker_weights[ticker] 43 | # Determine total portfolio value, work out dollar weight 44 | # and finally determine integer quantity of shares to purchase 45 | price = portfolio.price_handler.tickers[ticker]["adj_close"] 46 | price = PriceParser.display(price) 47 | equity = PriceParser.display(portfolio.equity) 48 | dollar_weight = weight * equity 49 | weighted_quantity = int(floor(dollar_weight / price)) 50 | initial_order.quantity = weighted_quantity 51 | return initial_order 52 | -------------------------------------------------------------------------------- /qstrader/price_handler/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .generic import GenericPriceHandler 4 | -------------------------------------------------------------------------------- /qstrader/price_handler/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from abc import ABCMeta 4 | 5 | 6 | class AbstractPriceHandler(object): 7 | """ 8 | PriceHandler is a base class providing an interface for 9 | all subsequent (inherited) data handlers (both live and historic). 10 | 11 | The goal of a (derived) PriceHandler object is to output a set of 12 | TickEvents or BarEvents for each financial instrument and place 13 | them into an event queue. 14 | 15 | This will replicate how a live strategy would function as current 16 | tick/bar data would be streamed via a brokerage. Thus a historic and live 17 | system will be treated identically by the rest of the QSTrader suite. 18 | """ 19 | 20 | __metaclass__ = ABCMeta 21 | 22 | def unsubscribe_ticker(self, ticker): 23 | """ 24 | Unsubscribes the price handler from a current ticker symbol. 25 | """ 26 | try: 27 | self.tickers.pop(ticker, None) 28 | self.tickers_data.pop(ticker, None) 29 | except KeyError: 30 | print( 31 | "Could not unsubscribe ticker %s " 32 | "as it was never subscribed." % ticker 33 | ) 34 | 35 | def get_last_timestamp(self, ticker): 36 | """ 37 | Returns the most recent actual timestamp for a given ticker 38 | """ 39 | if ticker in self.tickers: 40 | timestamp = self.tickers[ticker]["timestamp"] 41 | return timestamp 42 | else: 43 | print( 44 | "Timestamp for ticker %s is not " 45 | "available from the %s." % (ticker, self.__class__.__name__) 46 | ) 47 | return None 48 | 49 | 50 | class AbstractTickPriceHandler(AbstractPriceHandler): 51 | def istick(self): 52 | return True 53 | 54 | def isbar(self): 55 | return False 56 | 57 | def _store_event(self, event): 58 | """ 59 | Store price event for bid/ask 60 | """ 61 | ticker = event.ticker 62 | self.tickers[ticker]["bid"] = event.bid 63 | self.tickers[ticker]["ask"] = event.ask 64 | self.tickers[ticker]["timestamp"] = event.time 65 | 66 | def get_best_bid_ask(self, ticker): 67 | """ 68 | Returns the most recent bid/ask price for a ticker. 69 | """ 70 | if ticker in self.tickers: 71 | bid = self.tickers[ticker]["bid"] 72 | ask = self.tickers[ticker]["ask"] 73 | return bid, ask 74 | else: 75 | print( 76 | "Bid/ask values for ticker %s are not " 77 | "available from the PriceHandler." % ticker 78 | ) 79 | return None, None 80 | 81 | 82 | class AbstractBarPriceHandler(AbstractPriceHandler): 83 | def istick(self): 84 | return False 85 | 86 | def isbar(self): 87 | return True 88 | 89 | def _store_event(self, event): 90 | """ 91 | Store price event for closing price and adjusted closing price 92 | """ 93 | ticker = event.ticker 94 | self.tickers[ticker]["close"] = event.close_price 95 | self.tickers[ticker]["adj_close"] = event.adj_close_price 96 | self.tickers[ticker]["timestamp"] = event.time 97 | 98 | def get_last_close(self, ticker): 99 | """ 100 | Returns the most recent actual (unadjusted) closing price. 101 | """ 102 | if ticker in self.tickers: 103 | close_price = self.tickers[ticker]["close"] 104 | return close_price 105 | else: 106 | print( 107 | "Close price for ticker %s is not " 108 | "available from the YahooDailyBarPriceHandler." 109 | ) 110 | return None 111 | -------------------------------------------------------------------------------- /qstrader/price_handler/generic.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractPriceHandler, AbstractBarPriceHandler, AbstractTickPriceHandler 2 | from .iterator.base import AbstractBarEventIterator, AbstractTickEventIterator 3 | from ..exception import EmptyTickEvent, EmptyBarEvent 4 | 5 | 6 | class AbstractGenericHandler(AbstractPriceHandler): 7 | def __init__(self, events_queue, price_event_iterator): 8 | self.events_queue = events_queue 9 | self.price_event_iterator = price_event_iterator 10 | self.continue_backtest = True 11 | self.tickers = {} 12 | for ticker in self.tickers_lst: 13 | self.tickers[ticker] = {} 14 | 15 | def stream_next(self): 16 | """ 17 | Place the next PriceEvent (BarEvent or TickEvent) onto the event queue. 18 | """ 19 | try: 20 | price_event = next(self.price_event_iterator) 21 | except StopIteration: 22 | self.continue_backtest = False 23 | return 24 | except (EmptyTickEvent, EmptyBarEvent): 25 | return 26 | self._store_event(price_event) 27 | self.events_queue.put(price_event) 28 | 29 | @property 30 | def tickers_lst(self): 31 | return self.price_event_iterator.tickers_lst 32 | 33 | 34 | class GenericBarHandler(AbstractGenericHandler, AbstractBarPriceHandler): 35 | pass 36 | 37 | 38 | class GenericTickHandler(AbstractGenericHandler, AbstractTickPriceHandler): 39 | pass 40 | 41 | 42 | def GenericPriceHandler(events_queue, price_event_iterator): 43 | if isinstance(price_event_iterator, AbstractBarEventIterator): 44 | return GenericBarHandler(events_queue, price_event_iterator) 45 | elif isinstance(price_event_iterator, AbstractTickEventIterator): 46 | return GenericTickHandler(events_queue, price_event_iterator) 47 | else: 48 | raise NotImplementedError("price_event_iterator must be instance of") 49 | -------------------------------------------------------------------------------- /qstrader/price_handler/historic_csv_tick.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | 5 | import pandas as pd 6 | 7 | from .base import AbstractTickPriceHandler 8 | from ..event import TickEvent 9 | from ..price_parser import PriceParser 10 | 11 | 12 | class HistoricCSVTickPriceHandler(AbstractTickPriceHandler): 13 | """ 14 | HistoricCSVPriceHandler is designed to read CSV files of 15 | tick data for each requested financial instrument and 16 | stream those to the provided events queue as TickEvents. 17 | """ 18 | def __init__(self, csv_dir, events_queue, init_tickers=None): 19 | """ 20 | Takes the CSV directory, the events queue and a possible 21 | list of initial ticker symbols, then creates an (optional) 22 | list of ticker subscriptions and associated prices. 23 | """ 24 | self.csv_dir = csv_dir 25 | self.events_queue = events_queue 26 | self.continue_backtest = True 27 | self.tickers = {} 28 | self.tickers_data = {} 29 | if init_tickers is not None: 30 | for ticker in init_tickers: 31 | self.subscribe_ticker(ticker) 32 | self.tick_stream = self._merge_sort_ticker_data() 33 | 34 | def _open_ticker_price_csv(self, ticker): 35 | """ 36 | Opens the CSV files containing the equities ticks from 37 | the specified CSV data directory, converting them into 38 | them into a pandas DataFrame, stored in a dictionary. 39 | """ 40 | ticker_path = os.path.join(self.csv_dir, "%s.csv" % ticker) 41 | self.tickers_data[ticker] = pd.io.parsers.read_csv( 42 | ticker_path, header=0, parse_dates=True, 43 | dayfirst=True, index_col=1, 44 | names=("Ticker", "Time", "Bid", "Ask") 45 | ) 46 | 47 | def _merge_sort_ticker_data(self): 48 | """ 49 | Concatenates all of the separate equities DataFrames 50 | into a single DataFrame that is time ordered, allowing tick 51 | data events to be added to the queue in a chronological fashion. 52 | 53 | Note that this is an idealised situation, utilised solely for 54 | backtesting. In live trading ticks may arrive "out of order". 55 | """ 56 | return pd.concat( 57 | self.tickers_data.values() 58 | ).sort_index().iterrows() 59 | 60 | def subscribe_ticker(self, ticker): 61 | """ 62 | Subscribes the price handler to a new ticker symbol. 63 | """ 64 | if ticker not in self.tickers: 65 | try: 66 | self._open_ticker_price_csv(ticker) 67 | dft = self.tickers_data[ticker] 68 | row0 = dft.iloc[0] 69 | ticker_prices = { 70 | "bid": PriceParser.parse(row0["Bid"]), 71 | "ask": PriceParser.parse(row0["Ask"]), 72 | "timestamp": dft.index[0] 73 | } 74 | self.tickers[ticker] = ticker_prices 75 | except OSError: 76 | print( 77 | "Could not subscribe ticker %s " 78 | "as no data CSV found for pricing." % ticker 79 | ) 80 | else: 81 | print( 82 | "Could not subscribe ticker %s " 83 | "as is already subscribed." % ticker 84 | ) 85 | 86 | def _create_event(self, index, ticker, row): 87 | """ 88 | Obtain all elements of the bar a row of dataframe 89 | and return a TickEvent 90 | """ 91 | bid = PriceParser.parse(row["Bid"]) 92 | ask = PriceParser.parse(row["Ask"]) 93 | tev = TickEvent(ticker, index, bid, ask) 94 | return tev 95 | 96 | def stream_next(self): 97 | """ 98 | Place the next TickEvent onto the event queue. 99 | """ 100 | try: 101 | index, row = next(self.tick_stream) 102 | except StopIteration: 103 | self.continue_backtest = False 104 | return 105 | ticker = row["Ticker"] 106 | tev = self._create_event(index, ticker, row) 107 | self._store_event(tev) 108 | self.events_queue.put(tev) 109 | -------------------------------------------------------------------------------- /qstrader/price_handler/ig.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from trading_ig.lightstreamer import Subscription 4 | 5 | from ..price_parser import PriceParser 6 | from ..event import TickEvent 7 | from .base import AbstractTickPriceHandler 8 | 9 | 10 | class IGTickPriceHandler(AbstractTickPriceHandler): 11 | def __init__(self, events_queue, ig_stream_service, tickers): 12 | self.price_event = None 13 | self.events_queue = events_queue 14 | self.continue_backtest = True 15 | self.ig_stream_service = ig_stream_service 16 | self.tickers_lst = tickers 17 | self.tickers = {} 18 | for ticker in self.tickers_lst: 19 | self.tickers[ticker] = {} 20 | 21 | # Making a new Subscription in MERGE mode 22 | subcription_prices = Subscription( 23 | mode="MERGE", 24 | items=tickers, 25 | fields=["UPDATE_TIME", "BID", "OFFER", "CHANGE", "MARKET_STATE"], 26 | # adapter="QUOTE_ADAPTER", 27 | ) 28 | 29 | # Adding the "on_price_update" function to Subscription 30 | subcription_prices.addlistener(self.on_prices_update) 31 | 32 | # Registering the Subscription 33 | self.ig_stream_service.ls_client.subscribe(subcription_prices) 34 | 35 | def on_prices_update(self, data): 36 | tev = self._create_event(data) 37 | if self.price_event is not None: 38 | print("losing %s" % self.price_event) 39 | self.price_event = tev 40 | 41 | def _create_event(self, data): 42 | ticker = data["name"] 43 | index = pd.to_datetime(data["values"]["UPDATE_TIME"]) 44 | bid = PriceParser.parse(data["values"]["BID"]) 45 | ask = PriceParser.parse(data["values"]["OFFER"]) 46 | return TickEvent(ticker, index, bid, ask) 47 | 48 | def stream_next(self): 49 | """ 50 | Place the next PriceEvent (BarEvent or TickEvent) onto the event queue. 51 | """ 52 | if self.price_event is not None: 53 | self._store_event(self.price_event) 54 | self.events_queue.put(self.price_event) 55 | self.price_event = None 56 | -------------------------------------------------------------------------------- /qstrader/price_handler/iq_feed_intraday_csv_bar.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | 5 | from ..price_parser import PriceParser 6 | from .base import AbstractBarPriceHandler 7 | from ..event import BarEvent 8 | 9 | 10 | class IQFeedIntradayCsvBarPriceHandler(AbstractBarPriceHandler): 11 | """ 12 | IQFeedIntradayCsvBarPriceHandler is designed to read 13 | intraday bar CSV files downloaded from DTN IQFeed, consisting 14 | of Open-Low-High-Close-Volume-OpenInterest (OHLCVI) data 15 | for each requested financial instrument and stream those to 16 | the provided events queue as BarEvents. 17 | """ 18 | def __init__( 19 | self, csv_dir, events_queue, 20 | init_tickers=None, 21 | start_date=None, end_date=None 22 | ): 23 | """ 24 | Takes the CSV directory, the events queue and a possible 25 | list of initial ticker symbols then creates an (optional) 26 | list of ticker subscriptions and associated prices. 27 | """ 28 | self.csv_dir = csv_dir 29 | self.events_queue = events_queue 30 | self.continue_backtest = True 31 | self.tickers = {} 32 | self.tickers_data = {} 33 | if init_tickers is not None: 34 | for ticker in init_tickers: 35 | self.subscribe_ticker(ticker) 36 | self.start_date = start_date 37 | self.end_date = end_date 38 | self.bar_stream = self._merge_sort_ticker_data() 39 | 40 | def _open_ticker_price_csv(self, ticker): 41 | """ 42 | Opens the CSV files containing the equities ticks from 43 | the specified CSV data directory, converting them into 44 | them into a pandas DataFrame, stored in a dictionary. 45 | """ 46 | ticker_path = os.path.join(self.csv_dir, "%s.csv" % ticker) 47 | 48 | self.tickers_data[ticker] = pd.read_csv( 49 | ticker_path, 50 | names=[ 51 | "Date", "Open", "Low", "High", 52 | "Close", "Volume", "OpenInterest" 53 | ], 54 | index_col="Date", parse_dates=True 55 | ) 56 | self.tickers_data[ticker]["Ticker"] = ticker 57 | 58 | def _merge_sort_ticker_data(self): 59 | """ 60 | Concatenates all of the separate equities DataFrames 61 | into a single DataFrame that is time ordered, allowing tick 62 | data events to be added to the queue in a chronological fashion. 63 | 64 | Note that this is an idealised situation, utilised solely for 65 | backtesting. In live trading ticks may arrive "out of order". 66 | """ 67 | df = pd.concat(self.tickers_data.values()).sort_index() 68 | start = None 69 | end = None 70 | if self.start_date is not None: 71 | start = df.index.searchsorted(self.start_date) 72 | if self.end_date is not None: 73 | end = df.index.searchsorted(self.end_date) 74 | # Determine how to slice 75 | if start is None and end is None: 76 | return df.iterrows() 77 | elif start is not None and end is None: 78 | return df.ix[start:].iterrows() 79 | elif start is None and end is not None: 80 | return df.ix[:end].iterrows() 81 | else: 82 | return df.ix[start:end].iterrows() 83 | 84 | def subscribe_ticker(self, ticker): 85 | """ 86 | Subscribes the price handler to a new ticker symbol. 87 | """ 88 | if ticker not in self.tickers: 89 | try: 90 | self._open_ticker_price_csv(ticker) 91 | dft = self.tickers_data[ticker] 92 | row0 = dft.iloc[0] 93 | 94 | close = PriceParser.parse(row0["Close"]) 95 | 96 | ticker_prices = { 97 | "close": close, 98 | "adj_close": close, 99 | "timestamp": dft.index[0] 100 | } 101 | self.tickers[ticker] = ticker_prices 102 | except OSError: 103 | print( 104 | "Could not subscribe ticker %s " 105 | "as no data CSV found for pricing." % ticker 106 | ) 107 | else: 108 | print( 109 | "Could not subscribe ticker %s " 110 | "as is already subscribed." % ticker 111 | ) 112 | 113 | def _create_event(self, index, period, ticker, row): 114 | """ 115 | Obtain all elements of the bar from a row of dataframe 116 | and return a BarEvent 117 | """ 118 | open_price = PriceParser.parse(row["Open"]) 119 | low_price = PriceParser.parse(row["Low"]) 120 | high_price = PriceParser.parse(row["High"]) 121 | close_price = PriceParser.parse(row["Close"]) 122 | adj_close_price = PriceParser.parse(row["Close"]) 123 | volume = int(row["Volume"]) 124 | bev = BarEvent( 125 | ticker, index, period, open_price, 126 | high_price, low_price, close_price, 127 | volume, adj_close_price 128 | ) 129 | return bev 130 | 131 | def stream_next(self): 132 | """ 133 | Place the next BarEvent onto the event queue. 134 | """ 135 | try: 136 | index, row = next(self.bar_stream) 137 | except StopIteration: 138 | self.continue_backtest = False 139 | return 140 | # Obtain all elements of the bar from the dataframe 141 | ticker = row["Ticker"] 142 | period = 60 # Seconds in a minute 143 | # Create the tick event for the queue 144 | bev = self._create_event(index, period, ticker, row) 145 | # Store event 146 | self._store_event(bev) 147 | # Send event to queue 148 | self.events_queue.put(bev) 149 | -------------------------------------------------------------------------------- /qstrader/price_handler/iterator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/price_handler/iterator/__init__.py -------------------------------------------------------------------------------- /qstrader/price_handler/iterator/base.py: -------------------------------------------------------------------------------- 1 | from ...price_parser import PriceParser 2 | from ...event import BarEvent, TickEvent 3 | from ...exception import EmptyTickEvent, EmptyBarEvent 4 | 5 | 6 | class AbstractPriceEventIterator(object): 7 | def __iter__(self): 8 | return self 9 | 10 | def next(self): 11 | return self.__next__() 12 | 13 | 14 | class AbstractBarEventIterator(AbstractPriceEventIterator): 15 | def _create_event(self, index, period, ticker, row): 16 | """ 17 | Obtain all elements of the bar from a row of dataframe 18 | and return a BarEvent 19 | """ 20 | try: 21 | open_price = PriceParser.parse(row["Open"]) 22 | high_price = PriceParser.parse(row["High"]) 23 | low_price = PriceParser.parse(row["Low"]) 24 | close_price = PriceParser.parse(row["Close"]) 25 | adj_close_price = PriceParser.parse(row["Adj Close"]) 26 | volume = int(row["Volume"]) 27 | 28 | # Create the tick event for the queue 29 | bev = BarEvent( 30 | ticker, index, period, open_price, 31 | high_price, low_price, close_price, 32 | volume, adj_close_price 33 | ) 34 | return bev 35 | except ValueError: 36 | raise EmptyBarEvent("row %s %s %s %s can't be convert to BarEvent" % (index, period, ticker, row)) 37 | 38 | 39 | class AbstractTickEventIterator(AbstractPriceEventIterator): 40 | def _create_event(self, index, ticker, row): 41 | """ 42 | Obtain all elements of the bar a row of dataframe 43 | and return a TickEvent 44 | """ 45 | try: 46 | bid = PriceParser.parse(row["Bid"]) 47 | ask = PriceParser.parse(row["Ask"]) 48 | tev = TickEvent(ticker, index, bid, ask) 49 | return tev 50 | except ValueError: 51 | raise EmptyTickEvent("row %s %s %s can't be convert to TickEvent" % (index, ticker, row)) 52 | -------------------------------------------------------------------------------- /qstrader/price_handler/iterator/pandas/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .bar import PandasBarEventIterator 4 | from .tick import PandasTickEventIterator 5 | -------------------------------------------------------------------------------- /qstrader/price_handler/iterator/pandas/bar.py: -------------------------------------------------------------------------------- 1 | from ..base import AbstractBarEventIterator 2 | 3 | 4 | class PandasDataFrameBarEventIterator(AbstractBarEventIterator): 5 | """ 6 | PandasDataFrameBarEventIterator is designed to read a Pandas DataFrame like 7 | 8 | Open High Low Close Volume Adj Close 9 | Date 10 | 2010-01-04 626.951088 629.511067 624.241073 626.751061 3927000 313.062468 11 | 2010-01-05 627.181073 627.841071 621.541045 623.991055 6031900 311.683844 12 | 2010-01-06 625.861078 625.861078 606.361042 608.261023 7987100 303.826685 13 | 2010-01-07 609.401025 610.001045 592.651008 594.101005 12876600 296.753749 14 | ... ... ... ... ... ... ... 15 | 2016-07-18 722.710022 736.130005 721.190002 733.780029 1283300 733.780029 16 | 2016-07-19 729.890015 736.989990 729.000000 736.960022 1222600 736.960022 17 | 2016-07-20 737.330017 742.130005 737.099976 741.190002 1278100 741.190002 18 | 2016-07-21 740.359985 741.690002 735.830994 738.630005 969100 738.630005 19 | 20 | [1649 rows x 6 columns] 21 | 22 | with Open-High-Low-Close-Volume (OHLCV) data (bar) 23 | for one financial instrument and iterate BarEvents. 24 | """ 25 | def __init__(self, df, period, ticker): 26 | """ 27 | Takes the the events queue, ticker and Pandas DataFrame 28 | """ 29 | self.data = df 30 | self.period = period 31 | self.ticker = ticker 32 | self.tickers_lst = [ticker] 33 | self._itr_bar = self.data.iterrows() 34 | 35 | def __next__(self): 36 | index, row = next(self._itr_bar) 37 | price_event = self._create_event(index, self.period, self.ticker, row) 38 | return price_event 39 | 40 | 41 | class PandasPanelBarEventIterator(AbstractBarEventIterator): 42 | """ 43 | PandasPanelBarEventIterator is designed to read a Pandas Panel like 44 | 45 | 46 | Dimensions: 6 (items) x 1649 (major_axis) x 2 (minor_axis) 47 | Items axis: Open to Adj Close 48 | Major_axis axis: 2010-01-04 00:00:00 to 2016-07-21 00:00:00 49 | Minor_axis axis: GOOG to IBM 50 | 51 | with Open-High-Low-Close-Volume (OHLCV) data (bar) 52 | for several financial instruments and iterate BarEvents. 53 | """ 54 | def __init__(self, panel, period): 55 | self.data = panel 56 | self.period = period 57 | self._itr_ticker_bar = self.data.transpose(1, 0, 2).iteritems() 58 | self.tickers_lst = self.data.minor_axis 59 | self._next_ticker_bar() 60 | 61 | def _next_ticker_bar(self): 62 | self.index, self.df = next(self._itr_ticker_bar) 63 | self._itr_bar = self.df.iteritems() 64 | 65 | def __next__(self): 66 | try: 67 | ticker, row = next(self._itr_bar) 68 | except StopIteration: 69 | self._next_ticker_bar() 70 | ticker, row = next(self._itr_bar) 71 | price_event = self._create_event(self.index, self.period, ticker, row) 72 | return price_event 73 | 74 | 75 | def PandasBarEventIterator(data, period, ticker=None): 76 | """ 77 | PandasBarEventIterator returns a price iterator designed to read 78 | a Pandas DataFrame (or a Pandas Panel) 79 | with Open-High-Low-Close-Volume (OHLCV) data (bar) 80 | for one (or several) financial instrument and iterate BarEvents. 81 | """ 82 | if hasattr(data, 'minor_axis'): 83 | return PandasPanelBarEventIterator(data, period) 84 | else: 85 | return PandasDataFrameBarEventIterator(data, period, ticker) 86 | -------------------------------------------------------------------------------- /qstrader/price_handler/iterator/pandas/tick.py: -------------------------------------------------------------------------------- 1 | from ..base import AbstractTickEventIterator 2 | 3 | 4 | class PandasDataFrameTickEventIterator(AbstractTickEventIterator): 5 | """ 6 | PandasPanelBarEventIterator is designed to read a Pandas DataFrame like 7 | 8 | Bid Ask 9 | Time 10 | 2016-02-01 00:00:01.358 683.56000 683.58000 11 | 2016-02-01 00:00:02.544 683.55998 683.58002 12 | 2016-02-01 00:00:03.765 683.55999 683.58001 13 | ... 14 | 2016-02-01 00:00:10.823 683.56001 683.57999 15 | 2016-02-01 00:00:12.221 683.56000 683.58000 16 | 2016-02-01 00:00:13.546 683.56000 683.58000 17 | 18 | with tick data (bid/ask) 19 | for one financial instrument and iterate TickEvents. 20 | """ 21 | def __init__(self, df, ticker): 22 | """ 23 | Takes the the events queue, ticker and Pandas DataFrame 24 | """ 25 | self.data = df 26 | self.ticker = ticker 27 | self.tickers_lst = [ticker] 28 | self._itr_bar = self.data.iterrows() 29 | 30 | def __next__(self): 31 | index, row = next(self._itr_bar) 32 | price_event = self._create_event(index, self.ticker, row) 33 | return price_event 34 | 35 | 36 | class PandasPanelTickEventIterator(AbstractTickEventIterator): 37 | """ 38 | PandasPanelBarEventIterator is designed to read a Pandas Panel like 39 | 40 | 41 | Dimensions: 2 (items) x 20 (major_axis) x 2 (minor_axis) 42 | Items axis: Bid to Ask 43 | Major_axis axis: 2016-02-01 00:00:01.358000 to 2016-02-01 00:00:14.153000 44 | Minor_axis axis: GOOG to MSFT 45 | 46 | with tick data (bid/ask) 47 | for several financial instruments and iterate TickEvents. 48 | """ 49 | def __init__(self, panel): 50 | self.data = panel 51 | self._itr_ticker_bar = self.data.transpose(1, 0, 2).iteritems() 52 | self.tickers_lst = self.data.minor_axis 53 | self._next_ticker_bar() 54 | 55 | def _next_ticker_bar(self): 56 | self.index, self.df = next(self._itr_ticker_bar) 57 | self._itr_bar = self.df.iteritems() 58 | 59 | def __next__(self): 60 | try: 61 | ticker, row = next(self._itr_bar) 62 | except StopIteration: 63 | self._next_ticker_bar() 64 | ticker, row = next(self._itr_bar) 65 | bev = self._create_event(self.index, ticker, row) 66 | return bev 67 | 68 | 69 | def PandasTickEventIterator(data, ticker=None): 70 | """ 71 | PandasTickEventIterator returns a price iterator designed to read 72 | a Pandas DataFrame (or a Pandas Panel) with tick data (bid/ask) 73 | for one (or several) financial instrument and iterate TickEvents. 74 | """ 75 | if hasattr(data, 'minor_axis'): 76 | return PandasPanelTickEventIterator(data) 77 | else: 78 | return PandasDataFrameTickEventIterator(data, ticker) 79 | -------------------------------------------------------------------------------- /qstrader/price_handler/yahoo_daily_csv_bar.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | 5 | from ..price_parser import PriceParser 6 | from .base import AbstractBarPriceHandler 7 | from ..event import BarEvent 8 | 9 | 10 | class YahooDailyCsvBarPriceHandler(AbstractBarPriceHandler): 11 | """ 12 | YahooDailyBarPriceHandler is designed to read CSV files of 13 | Yahoo Finance daily Open-High-Low-Close-Volume (OHLCV) data 14 | for each requested financial instrument and stream those to 15 | the provided events queue as BarEvents. 16 | """ 17 | def __init__( 18 | self, csv_dir, events_queue, 19 | init_tickers=None, 20 | start_date=None, end_date=None, 21 | calc_adj_returns=False 22 | ): 23 | """ 24 | Takes the CSV directory, the events queue and a possible 25 | list of initial ticker symbols then creates an (optional) 26 | list of ticker subscriptions and associated prices. 27 | """ 28 | self.csv_dir = csv_dir 29 | self.events_queue = events_queue 30 | self.continue_backtest = True 31 | self.tickers = {} 32 | self.tickers_data = {} 33 | if init_tickers is not None: 34 | for ticker in init_tickers: 35 | self.subscribe_ticker(ticker) 36 | self.start_date = start_date 37 | self.end_date = end_date 38 | self.bar_stream = self._merge_sort_ticker_data() 39 | self.calc_adj_returns = calc_adj_returns 40 | if self.calc_adj_returns: 41 | self.adj_close_returns = [] 42 | 43 | def _open_ticker_price_csv(self, ticker): 44 | """ 45 | Opens the CSV files containing the equities ticks from 46 | the specified CSV data directory, converting them into 47 | them into a pandas DataFrame, stored in a dictionary. 48 | """ 49 | ticker_path = os.path.join(self.csv_dir, "%s.csv" % ticker) 50 | self.tickers_data[ticker] = pd.io.parsers.read_csv( 51 | ticker_path, header=0, parse_dates=True, 52 | index_col=0, names=( 53 | "Date", "Open", "High", "Low", 54 | "Close", "Volume", "Adj Close" 55 | ) 56 | ) 57 | self.tickers_data[ticker]["Ticker"] = ticker 58 | 59 | def _merge_sort_ticker_data(self): 60 | """ 61 | Concatenates all of the separate equities DataFrames 62 | into a single DataFrame that is time ordered, allowing tick 63 | data events to be added to the queue in a chronological fashion. 64 | 65 | Note that this is an idealised situation, utilised solely for 66 | backtesting. In live trading ticks may arrive "out of order". 67 | """ 68 | df = pd.concat(self.tickers_data.values()).sort_index() 69 | start = None 70 | end = None 71 | if self.start_date is not None: 72 | start = df.index.searchsorted(self.start_date) 73 | if self.end_date is not None: 74 | end = df.index.searchsorted(self.end_date) 75 | # This is added so that the ticker events are 76 | # always deterministic, otherwise unit test values 77 | # will differ 78 | df['colFromIndex'] = df.index 79 | df = df.sort_values(by=["colFromIndex", "Ticker"]) 80 | if start is None and end is None: 81 | return df.iterrows() 82 | elif start is not None and end is None: 83 | return df.ix[start:].iterrows() 84 | elif start is None and end is not None: 85 | return df.ix[:end].iterrows() 86 | else: 87 | return df.ix[start:end].iterrows() 88 | 89 | def subscribe_ticker(self, ticker): 90 | """ 91 | Subscribes the price handler to a new ticker symbol. 92 | """ 93 | if ticker not in self.tickers: 94 | try: 95 | self._open_ticker_price_csv(ticker) 96 | dft = self.tickers_data[ticker] 97 | row0 = dft.iloc[0] 98 | 99 | close = PriceParser.parse(row0["Close"]) 100 | adj_close = PriceParser.parse(row0["Adj Close"]) 101 | 102 | ticker_prices = { 103 | "close": close, 104 | "adj_close": adj_close, 105 | "timestamp": dft.index[0] 106 | } 107 | self.tickers[ticker] = ticker_prices 108 | except OSError: 109 | print( 110 | "Could not subscribe ticker %s " 111 | "as no data CSV found for pricing." % ticker 112 | ) 113 | else: 114 | print( 115 | "Could not subscribe ticker %s " 116 | "as is already subscribed." % ticker 117 | ) 118 | 119 | def _create_event(self, index, period, ticker, row): 120 | """ 121 | Obtain all elements of the bar from a row of dataframe 122 | and return a BarEvent 123 | """ 124 | open_price = PriceParser.parse(row["Open"]) 125 | high_price = PriceParser.parse(row["High"]) 126 | low_price = PriceParser.parse(row["Low"]) 127 | close_price = PriceParser.parse(row["Close"]) 128 | adj_close_price = PriceParser.parse(row["Adj Close"]) 129 | volume = int(row["Volume"]) 130 | bev = BarEvent( 131 | ticker, index, period, open_price, 132 | high_price, low_price, close_price, 133 | volume, adj_close_price 134 | ) 135 | return bev 136 | 137 | def _store_event(self, event): 138 | """ 139 | Store price event for closing price and adjusted closing price 140 | """ 141 | ticker = event.ticker 142 | # If the calc_adj_returns flag is True, then calculate 143 | # and store the full list of adjusted closing price 144 | # percentage returns in a list 145 | # TODO: Make this faster 146 | if self.calc_adj_returns: 147 | prev_adj_close = self.tickers[ticker][ 148 | "adj_close" 149 | ] / float(PriceParser.PRICE_MULTIPLIER) 150 | cur_adj_close = event.adj_close_price / float( 151 | PriceParser.PRICE_MULTIPLIER 152 | ) 153 | self.tickers[ticker][ 154 | "adj_close_ret" 155 | ] = cur_adj_close / prev_adj_close - 1.0 156 | self.adj_close_returns.append(self.tickers[ticker]["adj_close_ret"]) 157 | self.tickers[ticker]["close"] = event.close_price 158 | self.tickers[ticker]["adj_close"] = event.adj_close_price 159 | self.tickers[ticker]["timestamp"] = event.time 160 | 161 | def stream_next(self): 162 | """ 163 | Place the next BarEvent onto the event queue. 164 | """ 165 | try: 166 | index, row = next(self.bar_stream) 167 | except StopIteration: 168 | self.continue_backtest = False 169 | return 170 | # Obtain all elements of the bar from the dataframe 171 | ticker = row["Ticker"] 172 | period = 86400 # Seconds in a day 173 | # Create the tick event for the queue 174 | bev = self._create_event(index, period, ticker, row) 175 | # Store event 176 | self._store_event(bev) 177 | # Send event to queue 178 | self.events_queue.put(bev) 179 | -------------------------------------------------------------------------------- /qstrader/price_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from multipledispatch import dispatch 3 | from .compat import PY2 4 | import numpy as np 5 | 6 | if PY2: 7 | int_t = (int, long, np.int64) 8 | else: 9 | int_t = (int, np.int64) 10 | 11 | 12 | class PriceParser(object): 13 | """ 14 | PriceParser is designed to abstract away the underlying number used as a price 15 | within qstrader. Due to efficiency and floating point precision limitations, 16 | QSTrader uses an integer to represent all prices. This means that $0.10 is, 17 | internally, 10,000,000. Because such large numbers are rather unwieldy 18 | for humans, the PriceParser will take "normal" 2dp numbers as input, and show 19 | "normal" 2dp numbers as output when requested to `display()` 20 | 21 | For consistency's sake, PriceParser should be used for ALL prices that enter 22 | the qstrader system. Numbers should also always be parsed correctly to view. 23 | 24 | """ 25 | 26 | # 10,000,000 27 | PRICE_MULTIPLIER = 10000000 28 | 29 | """Parse Methods. Multiplies a float out into an int if needed.""" 30 | 31 | @staticmethod 32 | @dispatch(int_t) 33 | def parse(x): # flake8: noqa 34 | return x 35 | 36 | @staticmethod 37 | @dispatch(str) 38 | def parse(x): # flake8: noqa 39 | return int(float(x) * PriceParser.PRICE_MULTIPLIER) 40 | 41 | @staticmethod 42 | @dispatch(float) 43 | def parse(x): # flake8: noqa 44 | return int(x * PriceParser.PRICE_MULTIPLIER) 45 | 46 | """Display Methods. Multiplies a float out into an int if needed.""" 47 | 48 | @staticmethod 49 | @dispatch(int_t) 50 | def display(x): # flake8: noqa 51 | return round(x / PriceParser.PRICE_MULTIPLIER, 2) 52 | 53 | @staticmethod 54 | @dispatch(float) 55 | def display(x): # flake8: noqa 56 | return round(x, 2) 57 | 58 | @staticmethod 59 | @dispatch(int_t, int) 60 | def display(x, dp): # flake8: noqa 61 | return round(x / PriceParser.PRICE_MULTIPLIER, dp) 62 | 63 | @staticmethod 64 | @dispatch(float, int) 65 | def display(x, dp): # flake8: noqa 66 | return round(x, dp) 67 | -------------------------------------------------------------------------------- /qstrader/profiling.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def speed(ticks, t0): 5 | return ticks / (time.time() - t0) 6 | 7 | 8 | def s_speed(time_event, ticks, t0): 9 | sp = speed(ticks, t0) 10 | s_typ = time_event.typename + "S" 11 | return "%d %s processed @ %f %s/s" % (ticks, s_typ, sp, s_typ) 12 | -------------------------------------------------------------------------------- /qstrader/risk_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/risk_manager/__init__.py -------------------------------------------------------------------------------- /qstrader/risk_manager/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AbstractRiskManager(object): 5 | """ 6 | The AbstractRiskManager abstract class lets the 7 | sized order through, creates the corresponding 8 | OrderEvent object and adds it to a list. 9 | """ 10 | 11 | __metaclass__ = ABCMeta 12 | 13 | def __init__(self): 14 | pass 15 | 16 | @abstractmethod 17 | def refine_orders(self, portfolio, sized_order): 18 | raise NotImplementedError("Should implement refine_orders()") 19 | -------------------------------------------------------------------------------- /qstrader/risk_manager/example.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractRiskManager 2 | from ..event import OrderEvent 3 | 4 | 5 | class ExampleRiskManager(AbstractRiskManager): 6 | def refine_orders(self, portfolio, sized_order): 7 | """ 8 | This ExampleRiskManager object simply lets the 9 | sized order through, creates the corresponding 10 | OrderEvent object and adds it to a list. 11 | """ 12 | order_event = OrderEvent( 13 | sized_order.ticker, 14 | sized_order.action, 15 | sized_order.quantity 16 | ) 17 | return [order_event] 18 | -------------------------------------------------------------------------------- /qstrader/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/scripts/__init__.py -------------------------------------------------------------------------------- /qstrader/scripts/generate_simulated_prices.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import click 4 | 5 | import calendar 6 | import copy 7 | import datetime 8 | import os 9 | import numpy as np 10 | from .. import settings 11 | 12 | 13 | def month_weekdays(year_int, month_int): 14 | """ 15 | Produces a list of datetime.date objects representing the 16 | weekdays in a particular month, given a year. 17 | """ 18 | cal = calendar.Calendar() 19 | return [ 20 | d for d in cal.itermonthdates(year_int, month_int) 21 | if d.weekday() < 5 and d.year == year_int 22 | ] 23 | 24 | 25 | def run(outdir, ticker, init_price, seed, s0, spread, mu_dt, sigma_dt, year, month, nb_days, config): 26 | if seed >= 0: 27 | np.random.seed(seed) 28 | 29 | if config is None: 30 | config = settings.DEFAULT 31 | 32 | if outdir == '': 33 | outdir = os.path.expanduser(config.CSV_DATA_DIR) 34 | else: 35 | outdir = os.path.expanduser(outdir) 36 | 37 | s0 = float(init_price) 38 | spread = 0.02 39 | mu_dt = 1400 # Milliseconds 40 | sigma_dt = 100 # Millseconds 41 | ask = copy.deepcopy(s0) + spread / 2.0 42 | bid = copy.deepcopy(s0) - spread / 2.0 43 | days = month_weekdays(year, month) 44 | current_time = datetime.datetime( 45 | days[0].year, days[0].month, days[0].day, 0, 0, 0, 46 | ) 47 | 48 | # Loop over every day in the month and create a CSV file 49 | # for each day, e.g. "GOOG_20150101.csv" 50 | for i, d in enumerate(days): 51 | print("Create '%s' data for %s" % (ticker, d)) 52 | current_time = current_time.replace(day=d.day) 53 | fname = os.path.join( 54 | outdir, 55 | "%s_%s.csv" % (ticker, d.strftime("%Y%m%d")) 56 | ) 57 | outfile = open(fname, "w") 58 | print("Save data to '%s'" % fname) 59 | outfile.write("Ticker,Time,Bid,Ask\n") 60 | 61 | # Create the random walk for the bid/ask prices 62 | # with fixed spread between them 63 | # for i in range(0, 10000): 64 | while True: 65 | dt = abs(np.random.normal(mu_dt, sigma_dt)) 66 | current_time += datetime.timedelta(0, 0, 0, dt) 67 | if current_time.day != d.day: 68 | outfile.close() 69 | break 70 | else: 71 | W = np.random.standard_normal() * dt / 1000.0 / 86400.0 72 | ask += W 73 | bid -= W 74 | line = "%s,%s,%s,%s\n" % ( 75 | ticker, 76 | current_time.strftime( 77 | "%d.%m.%Y %H:%M:%S.%f" 78 | )[:-3], 79 | "%0.5f" % bid, 80 | "%0.5f" % ask 81 | ) 82 | outfile.write(line) 83 | if nb_days > 0 and i >= nb_days - 1: 84 | break 85 | 86 | 87 | @click.command() 88 | @click.option('--outdir', default='', help='Ouput directory (CSV_DATA_DIR)') 89 | @click.option('--ticker', default='GOOG', help='Equity ticker symbol (GOOG, SP500TR...)') 90 | @click.option('--init_price', default=700, help='Init price') 91 | @click.option('--seed', default=42, help='Seed (Fix the randomness by default but use a negative value for true randomness)') 92 | @click.option('--s0', default=1.5000, help='s0') 93 | @click.option('--spread', default=0.02, help='spread') 94 | @click.option('--mu_dt', default=1400, help='mu_dt (Milliseconds)') 95 | @click.option('--sigma_dt', default=100, help='sigma_dt (Milliseconds)') 96 | @click.option('--year', default=2014, help='Year') 97 | @click.option('--month', default=1, help='Month') 98 | @click.option('--days', default=-1, help='Number days to process') 99 | def main(outdir, ticker, init_price, seed, s0, spread, mu_dt, sigma_dt, year, month, days, config=None): 100 | return run(outdir, ticker, init_price, seed, s0, spread, mu_dt, sigma_dt, year, month, days, config=config) 101 | 102 | 103 | if __name__ == "__main__": 104 | main() 105 | -------------------------------------------------------------------------------- /qstrader/scripts/test_scripts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test scripts 3 | """ 4 | import unittest 5 | 6 | from qstrader import settings 7 | import qstrader.scripts.generate_simulated_prices 8 | 9 | 10 | class TestScripts(unittest.TestCase): 11 | """ 12 | Test example are executing correctly 13 | """ 14 | def setUp(self): 15 | """ 16 | Set up configuration. 17 | """ 18 | self.config = settings.TEST 19 | 20 | def test_generate_simulated_prices(self): 21 | """ 22 | Test generate_simulated_prices 23 | """ 24 | qstrader.scripts.generate_simulated_prices.run( 25 | '', # outdir 26 | 'GOOG', # ticker 27 | 700, # init_price 28 | 42, # seed 29 | 1.5000, # s0 30 | 0.02, # spread 31 | 400, # mu_dt 32 | 100, # sigma_dt 33 | 2014, # year 34 | 1, # month 35 | 3, # nb_days (number of days of data to create) 36 | config=self.config 37 | ) 38 | -------------------------------------------------------------------------------- /qstrader/sentiment_handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/sentiment_handler/__init__.py -------------------------------------------------------------------------------- /qstrader/sentiment_handler/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from abc import ABCMeta 4 | 5 | 6 | class AbstractSentimentHandler(object): 7 | """ 8 | AbstractSentimentHandler is an abstract base class providing 9 | an interface for all inherited sentiment analysis event handlers. 10 | 11 | Its goal is to allow subclassing for objects that read in file-based 12 | sentiment data (such as CSV files of date-asset-sentiment tuples), or 13 | streamed sentiment data from an API, and produce an event-driven output 14 | that sends SentimentEvent objects to the events queue. 15 | """ 16 | 17 | __metaclass__ = ABCMeta 18 | 19 | def stream_next(self, stream_date=None): 20 | """ 21 | Interface method for streaming the next SentimentEvent 22 | object to the events queue. 23 | """ 24 | raise NotImplementedError("stream_next is not implemented in the base class!") 25 | -------------------------------------------------------------------------------- /qstrader/sentiment_handler/sentdex_sentiment_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | 5 | from .base import AbstractSentimentHandler 6 | from ..event import SentimentEvent 7 | 8 | 9 | class SentdexSentimentHandler(AbstractSentimentHandler): 10 | """ 11 | SentdexSentimentHandler is designed to provide a backtesting 12 | sentiment analysis handler for the Sentdex sentiment analysis 13 | provider (http://sentdex.com/financial-analysis/). 14 | 15 | It uses a CSV file with date-ticker-sentiment tuples/rows. 16 | Hence in order to avoid implicit lookahead bias a specific 17 | method is provided "stream_sentiment_events_on_date" that only 18 | allows sentiment signals to be retrieved for a particular date. 19 | """ 20 | def __init__( 21 | self, csv_dir, filename, 22 | events_queue, tickers=None, 23 | start_date=None, end_date=None 24 | ): 25 | self.csv_dir = csv_dir 26 | self.filename = filename 27 | self.events_queue = events_queue 28 | self.tickers = tickers 29 | self.start_date = start_date 30 | self.end_date = end_date 31 | self.sent_df = self._open_sentiment_csv() 32 | 33 | def _open_sentiment_csv(self): 34 | """ 35 | Opens the CSV file containing the sentiment analysis 36 | information for all represented stocks and places 37 | it into a pandas DataFrame. 38 | """ 39 | sentiment_path = os.path.join(self.csv_dir, self.filename) 40 | sent_df = pd.read_csv( 41 | sentiment_path, parse_dates=True, 42 | header=0, index_col=0, 43 | names=("Date", "Ticker", "Sentiment") 44 | ) 45 | if self.start_date is not None: 46 | sent_df = sent_df[self.start_date.strftime("%Y-%m-%d"):] 47 | if self.end_date is not None: 48 | sent_df = sent_df[:self.end_date.strftime("%Y-%m-%d")] 49 | if self.tickers is not None: 50 | sent_df = sent_df[sent_df["Ticker"].isin(self.tickers)] 51 | return sent_df 52 | 53 | def stream_next(self, stream_date=None): 54 | """ 55 | Stream the next set of ticker sentiment values into 56 | SentimentEvent objects. 57 | """ 58 | if stream_date is not None: 59 | stream_date_str = stream_date.strftime("%Y-%m-%d") 60 | date_df = self.sent_df.ix[stream_date_str:stream_date_str] 61 | for row in date_df.iterrows(): 62 | sev = SentimentEvent( 63 | stream_date, row[1]["Ticker"], 64 | row[1]["Sentiment"] 65 | ) 66 | self.events_queue.put(sev) 67 | else: 68 | print("No stream_date provided for stream_next sentiment event!") 69 | -------------------------------------------------------------------------------- /qstrader/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import warnings 4 | import yaml 5 | from munch import munchify, unmunchify 6 | 7 | 8 | ENV_VAR_ROOT = 'QSTRADER' 9 | DEFAULT_CONFIG_FILENAME = '~/qstrader.yml' 10 | 11 | 12 | def from_env(key, default_value=None, root=ENV_VAR_ROOT): 13 | """Returns a value (url, login, password) 14 | using either default_value or using environment variable""" 15 | if root != "": 16 | ENV_VAR_KEY = root + "_" + key.upper() 17 | else: 18 | ENV_VAR_KEY = key.upper() 19 | if default_value == '' or default_value is None: 20 | try: 21 | return(os.environ[ENV_VAR_KEY]) 22 | except Exception: 23 | warnings.warn("You should pass %s using --%s or using environment variable %r" % (key, key, ENV_VAR_KEY)) 24 | return(default_value) 25 | else: 26 | return(default_value) 27 | 28 | 29 | DEFAULT = munchify({ 30 | "CSV_DATA_DIR": from_env("CSV_DATA_DIR", "~/data"), 31 | "OUTPUT_DIR": from_env("OUTPUT_DIR", "~/out") 32 | }) 33 | 34 | 35 | TEST = munchify({ 36 | "CSV_DATA_DIR": "data", 37 | "OUTPUT_DIR": "out" 38 | }) 39 | 40 | 41 | def from_file(fname=DEFAULT_CONFIG_FILENAME, testing=False): 42 | if testing: 43 | return TEST 44 | try: 45 | with open(os.path.expanduser(fname)) as fd: 46 | conf = yaml.load(fd) 47 | conf = munchify(conf) 48 | return conf 49 | except IOError: 50 | print("A configuration file named '%s' is missing" % fname) 51 | s_conf = yaml.dump(unmunchify(DEFAULT), explicit_start=True, indent=True, default_flow_style=False) 52 | print(""" 53 | Creating this file 54 | 55 | %s 56 | 57 | You still have to create directories with data and put your data in! 58 | """ % s_conf) 59 | time.sleep(3) 60 | try: 61 | with open(os.path.expanduser(fname), "w") as fd: 62 | fd.write(s_conf) 63 | except IOError: 64 | print("Can create '%s'" % fname) 65 | print("Trying anyway with default configuration") 66 | return DEFAULT 67 | -------------------------------------------------------------------------------- /qstrader/statistics/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .base import load 3 | -------------------------------------------------------------------------------- /qstrader/statistics/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from ..compat import pickle 4 | 5 | 6 | class AbstractStatistics(object): 7 | """ 8 | Statistics is an abstract class providing an interface for 9 | all inherited statistic classes (live, historic, custom, etc). 10 | 11 | The goal of a Statistics object is to keep a record of useful 12 | information about one or many trading strategies as the strategy 13 | is running. This is done by hooking into the main event loop and 14 | essentially updating the object according to portfolio performance 15 | over time. 16 | 17 | Ideally, Statistics should be subclassed according to the strategies 18 | and timeframes-traded by the user. Different trading strategies 19 | may require different metrics or frequencies-of-metrics to be updated, 20 | however the example given is suitable for longer timeframes. 21 | """ 22 | 23 | __metaclass__ = ABCMeta 24 | 25 | @abstractmethod 26 | def update(self): 27 | """ 28 | Update all the statistics according to values of the portfolio 29 | and open positions. This should be called from within the 30 | event loop. 31 | """ 32 | raise NotImplementedError("Should implement update()") 33 | 34 | @abstractmethod 35 | def get_results(self): 36 | """ 37 | Return a dict containing all statistics. 38 | """ 39 | raise NotImplementedError("Should implement get_results()") 40 | 41 | @abstractmethod 42 | def plot_results(self): 43 | """ 44 | Plot all statistics collected up until 'now' 45 | """ 46 | raise NotImplementedError("Should implement plot_results()") 47 | 48 | @abstractmethod 49 | def save(self, filename): 50 | """ 51 | Save statistics results to filename 52 | """ 53 | raise NotImplementedError("Should implement save()") 54 | 55 | @classmethod 56 | def load(cls, filename): 57 | with open(filename, 'rb') as fd: 58 | stats = pickle.load(fd) 59 | return stats 60 | 61 | 62 | def load(filename): 63 | return AbstractStatistics.load(filename) 64 | -------------------------------------------------------------------------------- /qstrader/statistics/performance.py: -------------------------------------------------------------------------------- 1 | from itertools import groupby 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from scipy.stats import linregress 6 | 7 | 8 | def aggregate_returns(returns, convert_to): 9 | """ 10 | Aggregates returns by day, week, month, or year. 11 | """ 12 | def cumulate_returns(x): 13 | return np.exp(np.log(1 + x).cumsum())[-1] - 1 14 | 15 | if convert_to == 'weekly': 16 | return returns.groupby( 17 | [lambda x: x.year, 18 | lambda x: x.month, 19 | lambda x: x.isocalendar()[1]]).apply(cumulate_returns) 20 | elif convert_to == 'monthly': 21 | return returns.groupby( 22 | [lambda x: x.year, lambda x: x.month]).apply(cumulate_returns) 23 | elif convert_to == 'yearly': 24 | return returns.groupby( 25 | [lambda x: x.year]).apply(cumulate_returns) 26 | else: 27 | ValueError('convert_to must be weekly, monthly or yearly') 28 | 29 | 30 | def create_cagr(equity, periods=252): 31 | """ 32 | Calculates the Compound Annual Growth Rate (CAGR) 33 | for the portfolio, by determining the number of years 34 | and then creating a compound annualised rate based 35 | on the total return. 36 | 37 | Parameters: 38 | equity - A pandas Series representing the equity curve. 39 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 40 | """ 41 | years = len(equity) / float(periods) 42 | return (equity[-1] ** (1.0 / years)) - 1.0 43 | 44 | 45 | def create_sharpe_ratio(returns, periods=252): 46 | """ 47 | Create the Sharpe ratio for the strategy, based on a 48 | benchmark of zero (i.e. no risk-free rate information). 49 | 50 | Parameters: 51 | returns - A pandas Series representing period percentage returns. 52 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 53 | """ 54 | return np.sqrt(periods) * (np.mean(returns)) / np.std(returns) 55 | 56 | 57 | def create_sortino_ratio(returns, periods=252): 58 | """ 59 | Create the Sortino ratio for the strategy, based on a 60 | benchmark of zero (i.e. no risk-free rate information). 61 | 62 | Parameters: 63 | returns - A pandas Series representing period percentage returns. 64 | periods - Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. 65 | """ 66 | return np.sqrt(periods) * (np.mean(returns)) / np.std(returns[returns < 0]) 67 | 68 | 69 | def create_drawdowns(returns): 70 | """ 71 | Calculate the largest peak-to-trough drawdown of the equity curve 72 | as well as the duration of the drawdown. Requires that the 73 | pnl_returns is a pandas Series. 74 | 75 | Parameters: 76 | equity - A pandas Series representing period percentage returns. 77 | 78 | Returns: 79 | drawdown, drawdown_max, duration 80 | """ 81 | # Calculate the cumulative returns curve 82 | # and set up the High Water Mark 83 | idx = returns.index 84 | hwm = np.zeros(len(idx)) 85 | 86 | # Create the high water mark 87 | for t in range(1, len(idx)): 88 | hwm[t] = max(hwm[t - 1], returns.ix[t]) 89 | 90 | # Calculate the drawdown and duration statistics 91 | perf = pd.DataFrame(index=idx) 92 | perf["Drawdown"] = (hwm - returns) / hwm 93 | perf["Drawdown"].ix[0] = 0.0 94 | perf["DurationCheck"] = np.where(perf["Drawdown"] == 0, 0, 1) 95 | duration = max( 96 | sum(1 for i in g if i == 1) 97 | for k, g in groupby(perf["DurationCheck"]) 98 | ) 99 | return perf["Drawdown"], np.max(perf["Drawdown"]), duration 100 | 101 | 102 | def rsquared(x, y): 103 | """ 104 | Return R^2 where x and y are array-like. 105 | """ 106 | slope, intercept, r_value, p_value, std_err = linregress(x, y) 107 | return r_value**2 108 | -------------------------------------------------------------------------------- /qstrader/statistics/simple.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractStatistics 2 | from ..compat import pickle 3 | from ..price_parser import PriceParser 4 | 5 | import datetime 6 | import os 7 | import pandas as pd 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | import seaborn as sns 11 | 12 | 13 | class SimpleStatistics(AbstractStatistics): 14 | """ 15 | Simple Statistics provides a bare-bones example of statistics 16 | that can be collected through trading. 17 | 18 | Statistics included are Sharpe Ratio, Drawdown, Max Drawdown, 19 | Max Drawdown Duration. 20 | 21 | TODO think about Alpha/Beta, compare strategy of benchmark. 22 | TODO think about speed -- will be bad doing for every tick 23 | on anything that trades sub-minute. 24 | TODO think about slippage, fill rate, etc 25 | TODO brokerage costs? 26 | 27 | TODO need some kind of trading-frequency parameter in setup. 28 | Sharpe calculations need to know if daily, hourly, minutely, etc. 29 | """ 30 | def __init__(self, config, portfolio_handler): 31 | """ 32 | Takes in a portfolio handler. 33 | """ 34 | self.config = config 35 | self.drawdowns = [0] 36 | self.equity = [] 37 | self.equity_returns = [0.0] 38 | # Initialize timeseries. Correct timestamp not available yet. 39 | self.timeseries = ["0000-00-00 00:00:00"] 40 | # Initialize in order for first-step calculations to be correct. 41 | current_equity = PriceParser.display(portfolio_handler.portfolio.equity) 42 | self.hwm = [current_equity] 43 | self.equity.append(current_equity) 44 | 45 | def update(self, timestamp, portfolio_handler): 46 | """ 47 | Update all statistics that must be tracked over time. 48 | """ 49 | if timestamp != self.timeseries[-1]: 50 | # Retrieve equity value of Portfolio 51 | current_equity = PriceParser.display(portfolio_handler.portfolio.equity) 52 | self.equity.append(current_equity) 53 | self.timeseries.append(timestamp) 54 | 55 | # Calculate percentage return between current and previous equity value. 56 | pct = ((self.equity[-1] - self.equity[-2]) / self.equity[-1]) * 100 57 | self.equity_returns.append(round(pct, 4)) 58 | # Calculate Drawdown. 59 | self.hwm.append(max(self.hwm[-1], self.equity[-1])) 60 | self.drawdowns.append(self.hwm[-1] - self.equity[-1]) 61 | 62 | def get_results(self): 63 | """ 64 | Return a dict with all important results & stats. 65 | """ 66 | 67 | # Modify timeseries in local scope only. We initialize with 0-date, 68 | # but would rather show a realistic starting date. 69 | timeseries = self.timeseries 70 | timeseries[0] = pd.to_datetime(timeseries[1]) - pd.Timedelta(days=1) 71 | 72 | statistics = {} 73 | statistics["sharpe"] = self.calculate_sharpe() 74 | statistics["drawdowns"] = pd.Series(self.drawdowns, index=timeseries) 75 | statistics["max_drawdown"] = max(self.drawdowns) 76 | statistics["max_drawdown_pct"] = self.calculate_max_drawdown_pct() 77 | statistics["equity"] = pd.Series(self.equity, index=timeseries) 78 | statistics["equity_returns"] = pd.Series(self.equity_returns, index=timeseries) 79 | 80 | return statistics 81 | 82 | def calculate_sharpe(self, benchmark_return=0.00): 83 | """ 84 | Calculate the sharpe ratio of our equity_returns. 85 | 86 | Expects benchmark_return to be, for example, 0.01 for 1% 87 | """ 88 | excess_returns = pd.Series(self.equity_returns) - benchmark_return / 252 89 | 90 | # Return the annualised Sharpe ratio based on the excess daily returns 91 | return round(self.annualised_sharpe(excess_returns), 4) 92 | 93 | def annualised_sharpe(self, returns, N=252): 94 | """ 95 | Calculate the annualised Sharpe ratio of a returns stream 96 | based on a number of trading periods, N. N defaults to 252, 97 | which then assumes a stream of daily returns. 98 | 99 | The function assumes that the returns are the excess of 100 | those compared to a benchmark. 101 | """ 102 | return np.sqrt(N) * returns.mean() / returns.std() 103 | 104 | def calculate_max_drawdown_pct(self): 105 | """ 106 | Calculate the percentage drop related to the "worst" 107 | drawdown seen. 108 | """ 109 | drawdown_series = pd.Series(self.drawdowns) 110 | equity_series = pd.Series(self.equity) 111 | bottom_index = drawdown_series.idxmax() 112 | try: 113 | top_index = equity_series[:bottom_index].idxmax() 114 | pct = ( 115 | (equity_series.ix[top_index] - equity_series.ix[bottom_index]) / 116 | equity_series.ix[top_index] * 100 117 | ) 118 | return round(pct, 4) 119 | except ValueError: 120 | return np.nan 121 | 122 | def plot_results(self): 123 | """ 124 | A simple script to plot the balance of the portfolio, or 125 | "equity curve", as a function of time. 126 | """ 127 | sns.set_palette("deep", desat=.6) 128 | sns.set_context(rc={"figure.figsize": (8, 4)}) 129 | 130 | # Plot two charts: Equity curve, period returns 131 | fig = plt.figure() 132 | fig.patch.set_facecolor('white') 133 | 134 | df = pd.DataFrame() 135 | df["equity"] = pd.Series(self.equity, index=self.timeseries) 136 | df["equity_returns"] = pd.Series(self.equity_returns, index=self.timeseries) 137 | df["drawdowns"] = pd.Series(self.drawdowns, index=self.timeseries) 138 | 139 | # Plot the equity curve 140 | ax1 = fig.add_subplot(311, ylabel='Equity Value') 141 | df["equity"].plot(ax=ax1, color=sns.color_palette()[0]) 142 | 143 | # Plot the returns 144 | ax2 = fig.add_subplot(312, ylabel='Equity Returns') 145 | df['equity_returns'].plot(ax=ax2, color=sns.color_palette()[1]) 146 | 147 | # drawdown, max_dd, dd_duration = self.create_drawdowns(df["Equity"]) 148 | ax3 = fig.add_subplot(313, ylabel='Drawdowns') 149 | df['drawdowns'].plot(ax=ax3, color=sns.color_palette()[2]) 150 | 151 | # Rotate dates 152 | fig.autofmt_xdate() 153 | 154 | # Plot the figure 155 | plt.show() 156 | 157 | def get_filename(self, filename=""): 158 | if filename == "": 159 | now = datetime.datetime.utcnow() 160 | filename = "statistics_" + now.strftime("%Y-%m-%d_%H%M%S") + ".pkl" 161 | filename = os.path.expanduser(os.path.join(self.config.OUTPUT_DIR, filename)) 162 | return filename 163 | 164 | def save(self, filename=""): 165 | filename = self.get_filename(filename) 166 | print("Save results to '%s'" % filename) 167 | with open(filename, 'wb') as fd: 168 | pickle.dump(self, fd) 169 | -------------------------------------------------------------------------------- /qstrader/statistics/tearsheet.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractStatistics 2 | from ..price_parser import PriceParser 3 | 4 | from matplotlib.ticker import FuncFormatter 5 | from matplotlib import cm 6 | from datetime import datetime 7 | 8 | import qstrader.statistics.performance as perf 9 | 10 | import pandas as pd 11 | import numpy as np 12 | import matplotlib.pyplot as plt 13 | import matplotlib.gridspec as gridspec 14 | import matplotlib.dates as mdates 15 | import seaborn as sns 16 | import os 17 | 18 | 19 | class TearsheetStatistics(AbstractStatistics): 20 | """ 21 | Displays a Matplotlib-generated 'one-pager' as often 22 | found in institutional strategy performance reports. 23 | 24 | Includes an equity curve, drawdown curve, monthly 25 | returns heatmap, yearly returns summary, strategy- 26 | level statistics and trade-level statistics. 27 | 28 | Also includes an optional annualised rolling Sharpe 29 | ratio chart. 30 | """ 31 | def __init__( 32 | self, config, portfolio_handler, 33 | title=None, benchmark=None, periods=252, 34 | rolling_sharpe=False 35 | ): 36 | """ 37 | Takes in a portfolio handler. 38 | """ 39 | self.config = config 40 | self.portfolio_handler = portfolio_handler 41 | self.price_handler = portfolio_handler.price_handler 42 | self.title = '\n'.join(title) 43 | self.benchmark = benchmark 44 | self.periods = periods 45 | self.rolling_sharpe = rolling_sharpe 46 | self.equity = {} 47 | self.equity_benchmark = {} 48 | self.log_scale = False 49 | 50 | def update(self, timestamp, portfolio_handler): 51 | """ 52 | Update equity curve and benchmark equity curve that must be tracked 53 | over time. 54 | """ 55 | self.equity[timestamp] = PriceParser.display( 56 | self.portfolio_handler.portfolio.equity 57 | ) 58 | if self.benchmark is not None: 59 | self.equity_benchmark[timestamp] = PriceParser.display( 60 | self.price_handler.get_last_close(self.benchmark) 61 | ) 62 | 63 | def get_results(self): 64 | """ 65 | Return a dict with all important results & stats. 66 | """ 67 | # Equity 68 | equity_s = pd.Series(self.equity).sort_index() 69 | 70 | # Returns 71 | returns_s = equity_s.pct_change().fillna(0.0) 72 | 73 | # Rolling Annualised Sharpe 74 | rolling = returns_s.rolling(window=self.periods) 75 | rolling_sharpe_s = np.sqrt(self.periods) * ( 76 | rolling.mean() / rolling.std() 77 | ) 78 | 79 | # Cummulative Returns 80 | cum_returns_s = np.exp(np.log(1 + returns_s).cumsum()) 81 | 82 | # Drawdown, max drawdown, max drawdown duration 83 | dd_s, max_dd, dd_dur = perf.create_drawdowns(cum_returns_s) 84 | 85 | statistics = {} 86 | 87 | # Equity statistics 88 | statistics["sharpe"] = perf.create_sharpe_ratio( 89 | returns_s, self.periods 90 | ) 91 | statistics["drawdowns"] = dd_s 92 | # TODO: need to have max_drawdown so it can be printed at end of test 93 | statistics["max_drawdown"] = max_dd 94 | statistics["max_drawdown_pct"] = max_dd 95 | statistics["max_drawdown_duration"] = dd_dur 96 | statistics["equity"] = equity_s 97 | statistics["returns"] = returns_s 98 | statistics["rolling_sharpe"] = rolling_sharpe_s 99 | statistics["cum_returns"] = cum_returns_s 100 | 101 | positions = self._get_positions() 102 | if positions is not None: 103 | statistics["positions"] = positions 104 | 105 | # Benchmark statistics if benchmark ticker specified 106 | if self.benchmark is not None: 107 | equity_b = pd.Series(self.equity_benchmark).sort_index() 108 | returns_b = equity_b.pct_change().fillna(0.0) 109 | rolling_b = returns_b.rolling(window=self.periods) 110 | rolling_sharpe_b = np.sqrt(self.periods) * ( 111 | rolling_b.mean() / rolling_b.std() 112 | ) 113 | cum_returns_b = np.exp(np.log(1 + returns_b).cumsum()) 114 | dd_b, max_dd_b, dd_dur_b = perf.create_drawdowns(cum_returns_b) 115 | statistics["sharpe_b"] = perf.create_sharpe_ratio(returns_b) 116 | statistics["drawdowns_b"] = dd_b 117 | statistics["max_drawdown_pct_b"] = max_dd_b 118 | statistics["max_drawdown_duration_b"] = dd_dur_b 119 | statistics["equity_b"] = equity_b 120 | statistics["returns_b"] = returns_b 121 | statistics["rolling_sharpe_b"] = rolling_sharpe_b 122 | statistics["cum_returns_b"] = cum_returns_b 123 | 124 | return statistics 125 | 126 | def _get_positions(self): 127 | """ 128 | Retrieve the list of closed Positions objects from the portfolio 129 | and reformat into a pandas dataframe to be returned 130 | """ 131 | def x(p): 132 | return PriceParser.display(p) 133 | 134 | pos = self.portfolio_handler.portfolio.closed_positions 135 | a = [] 136 | for p in pos: 137 | a.append(p.__dict__) 138 | if len(a) == 0: 139 | # There are no closed positions 140 | return None 141 | else: 142 | df = pd.DataFrame(a) 143 | df['avg_bot'] = df['avg_bot'].apply(x) 144 | df['avg_price'] = df['avg_price'].apply(x) 145 | df['avg_sld'] = df['avg_sld'].apply(x) 146 | df['cost_basis'] = df['cost_basis'].apply(x) 147 | df['init_commission'] = df['init_commission'].apply(x) 148 | df['init_price'] = df['init_price'].apply(x) 149 | df['market_value'] = df['market_value'].apply(x) 150 | df['net'] = df['net'].apply(x) 151 | df['net_incl_comm'] = df['net_incl_comm'].apply(x) 152 | df['net_total'] = df['net_total'].apply(x) 153 | df['realised_pnl'] = df['realised_pnl'].apply(x) 154 | df['total_bot'] = df['total_bot'].apply(x) 155 | df['total_commission'] = df['total_commission'].apply(x) 156 | df['total_sld'] = df['total_sld'].apply(x) 157 | df['unrealised_pnl'] = df['unrealised_pnl'].apply(x) 158 | df['trade_pct'] = (df['avg_sld'] / df['avg_bot'] - 1.0) 159 | return df 160 | 161 | def _plot_equity(self, stats, ax=None, **kwargs): 162 | """ 163 | Plots cumulative rolling returns versus some benchmark. 164 | """ 165 | def format_two_dec(x, pos): 166 | return '%.2f' % x 167 | 168 | equity = stats['cum_returns'] 169 | 170 | if ax is None: 171 | ax = plt.gca() 172 | 173 | y_axis_formatter = FuncFormatter(format_two_dec) 174 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 175 | ax.xaxis.set_tick_params(reset=True) 176 | ax.yaxis.grid(linestyle=':') 177 | ax.xaxis.set_major_locator(mdates.YearLocator(1)) 178 | ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y')) 179 | ax.xaxis.grid(linestyle=':') 180 | 181 | if self.benchmark is not None: 182 | benchmark = stats['cum_returns_b'] 183 | benchmark.plot( 184 | lw=2, color='gray', label=self.benchmark, alpha=0.60, 185 | ax=ax, **kwargs 186 | ) 187 | 188 | equity.plot(lw=2, color='green', alpha=0.6, x_compat=False, 189 | label='Backtest', ax=ax, **kwargs) 190 | 191 | ax.axhline(1.0, linestyle='--', color='black', lw=1) 192 | ax.set_ylabel('Cumulative returns') 193 | ax.legend(loc='best') 194 | ax.set_xlabel('') 195 | plt.setp(ax.get_xticklabels(), visible=True, rotation=0, ha='center') 196 | 197 | if self.log_scale: 198 | ax.set_yscale('log') 199 | 200 | return ax 201 | 202 | def _plot_rolling_sharpe(self, stats, ax=None, **kwargs): 203 | """ 204 | Plots the curve of rolling Sharpe ratio. 205 | """ 206 | def format_two_dec(x, pos): 207 | return '%.2f' % x 208 | 209 | sharpe = stats['rolling_sharpe'] 210 | 211 | if ax is None: 212 | ax = plt.gca() 213 | 214 | y_axis_formatter = FuncFormatter(format_two_dec) 215 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 216 | ax.xaxis.set_tick_params(reset=True) 217 | ax.yaxis.grid(linestyle=':') 218 | ax.xaxis.set_major_locator(mdates.YearLocator(1)) 219 | ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y')) 220 | ax.xaxis.grid(linestyle=':') 221 | 222 | if self.benchmark is not None: 223 | benchmark = stats['rolling_sharpe_b'] 224 | benchmark.plot( 225 | lw=2, color='gray', label=self.benchmark, alpha=0.60, 226 | ax=ax, **kwargs 227 | ) 228 | 229 | sharpe.plot(lw=2, color='green', alpha=0.6, x_compat=False, 230 | label='Backtest', ax=ax, **kwargs) 231 | 232 | ax.axvline(sharpe.index[252], linestyle="dashed", c="gray", lw=2) 233 | ax.set_ylabel('Rolling Annualised Sharpe') 234 | ax.legend(loc='best') 235 | ax.set_xlabel('') 236 | plt.setp(ax.get_xticklabels(), visible=True, rotation=0, ha='center') 237 | 238 | return ax 239 | 240 | def _plot_drawdown(self, stats, ax=None, **kwargs): 241 | """ 242 | Plots the underwater curve 243 | """ 244 | def format_perc(x, pos): 245 | return '%.0f%%' % x 246 | 247 | drawdown = stats['drawdowns'] 248 | 249 | if ax is None: 250 | ax = plt.gca() 251 | 252 | y_axis_formatter = FuncFormatter(format_perc) 253 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 254 | ax.yaxis.grid(linestyle=':') 255 | ax.xaxis.set_tick_params(reset=True) 256 | ax.xaxis.set_major_locator(mdates.YearLocator(1)) 257 | ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y')) 258 | ax.xaxis.grid(linestyle=':') 259 | 260 | underwater = -100 * drawdown 261 | underwater.plot(ax=ax, lw=2, kind='area', color='red', alpha=0.3, **kwargs) 262 | ax.set_ylabel('') 263 | ax.set_xlabel('') 264 | plt.setp(ax.get_xticklabels(), visible=True, rotation=0, ha='center') 265 | ax.set_title('Drawdown (%)', fontweight='bold') 266 | return ax 267 | 268 | def _plot_monthly_returns(self, stats, ax=None, **kwargs): 269 | """ 270 | Plots a heatmap of the monthly returns. 271 | """ 272 | returns = stats['returns'] 273 | if ax is None: 274 | ax = plt.gca() 275 | 276 | monthly_ret = perf.aggregate_returns(returns, 'monthly') 277 | monthly_ret = monthly_ret.unstack() 278 | monthly_ret = np.round(monthly_ret, 3) 279 | monthly_ret.rename( 280 | columns={1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 281 | 5: 'May', 6: 'Jun', 7: 'Jul', 8: 'Aug', 282 | 9: 'Sep', 10: 'Oct', 11: 'Nov', 12: 'Dec'}, 283 | inplace=True 284 | ) 285 | 286 | sns.heatmap( 287 | monthly_ret.fillna(0) * 100.0, 288 | annot=True, 289 | fmt="0.1f", 290 | annot_kws={"size": 8}, 291 | alpha=1.0, 292 | center=0.0, 293 | cbar=False, 294 | cmap=cm.RdYlGn, 295 | ax=ax, **kwargs) 296 | ax.set_title('Monthly Returns (%)', fontweight='bold') 297 | ax.set_ylabel('') 298 | ax.set_yticklabels(ax.get_yticklabels(), rotation=0) 299 | ax.set_xlabel('') 300 | 301 | return ax 302 | 303 | def _plot_yearly_returns(self, stats, ax=None, **kwargs): 304 | """ 305 | Plots a barplot of returns by year. 306 | """ 307 | def format_perc(x, pos): 308 | return '%.0f%%' % x 309 | 310 | returns = stats['returns'] 311 | 312 | if ax is None: 313 | ax = plt.gca() 314 | 315 | y_axis_formatter = FuncFormatter(format_perc) 316 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 317 | ax.yaxis.grid(linestyle=':') 318 | 319 | yly_ret = perf.aggregate_returns(returns, 'yearly') * 100.0 320 | yly_ret.plot(ax=ax, kind="bar") 321 | ax.set_title('Yearly Returns (%)', fontweight='bold') 322 | ax.set_ylabel('') 323 | ax.set_xlabel('') 324 | ax.set_xticklabels(ax.get_xticklabels(), rotation=45) 325 | ax.xaxis.grid(False) 326 | 327 | return ax 328 | 329 | def _plot_txt_curve(self, stats, ax=None, **kwargs): 330 | """ 331 | Outputs the statistics for the equity curve. 332 | """ 333 | def format_perc(x, pos): 334 | return '%.0f%%' % x 335 | 336 | returns = stats["returns"] 337 | cum_returns = stats['cum_returns'] 338 | 339 | if 'positions' not in stats: 340 | trd_yr = 0 341 | else: 342 | positions = stats['positions'] 343 | trd_yr = positions.shape[0] / ( 344 | (returns.index[-1] - returns.index[0]).days / 365.0 345 | ) 346 | 347 | if ax is None: 348 | ax = plt.gca() 349 | 350 | y_axis_formatter = FuncFormatter(format_perc) 351 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 352 | 353 | tot_ret = cum_returns[-1] - 1.0 354 | cagr = perf.create_cagr(cum_returns, self.periods) 355 | sharpe = perf.create_sharpe_ratio(returns, self.periods) 356 | sortino = perf.create_sortino_ratio(returns, self.periods) 357 | rsq = perf.rsquared(range(cum_returns.shape[0]), cum_returns) 358 | dd, dd_max, dd_dur = perf.create_drawdowns(cum_returns) 359 | 360 | ax.text(0.25, 8.9, 'Total Return', fontsize=8) 361 | ax.text(7.50, 8.9, '{:.0%}'.format(tot_ret), fontweight='bold', horizontalalignment='right', fontsize=8) 362 | 363 | ax.text(0.25, 7.9, 'CAGR', fontsize=8) 364 | ax.text(7.50, 7.9, '{:.2%}'.format(cagr), fontweight='bold', horizontalalignment='right', fontsize=8) 365 | 366 | ax.text(0.25, 6.9, 'Sharpe Ratio', fontsize=8) 367 | ax.text(7.50, 6.9, '{:.2f}'.format(sharpe), fontweight='bold', horizontalalignment='right', fontsize=8) 368 | 369 | ax.text(0.25, 5.9, 'Sortino Ratio', fontsize=8) 370 | ax.text(7.50, 5.9, '{:.2f}'.format(sortino), fontweight='bold', horizontalalignment='right', fontsize=8) 371 | 372 | ax.text(0.25, 4.9, 'Annual Volatility', fontsize=8) 373 | ax.text(7.50, 4.9, '{:.2%}'.format(returns.std() * np.sqrt(252)), fontweight='bold', horizontalalignment='right', fontsize=8) 374 | 375 | ax.text(0.25, 3.9, 'R-Squared', fontsize=8) 376 | ax.text(7.50, 3.9, '{:.2f}'.format(rsq), fontweight='bold', horizontalalignment='right', fontsize=8) 377 | 378 | ax.text(0.25, 2.9, 'Max Daily Drawdown', fontsize=8) 379 | ax.text(7.50, 2.9, '{:.2%}'.format(dd_max), color='red', fontweight='bold', horizontalalignment='right', fontsize=8) 380 | 381 | ax.text(0.25, 1.9, 'Max Drawdown Duration', fontsize=8) 382 | ax.text(7.50, 1.9, '{:.0f}'.format(dd_dur), fontweight='bold', horizontalalignment='right', fontsize=8) 383 | 384 | ax.text(0.25, 0.9, 'Trades per Year', fontsize=8) 385 | ax.text(7.50, 0.9, '{:.1f}'.format(trd_yr), fontweight='bold', horizontalalignment='right', fontsize=8) 386 | ax.set_title('Curve', fontweight='bold') 387 | 388 | if self.benchmark is not None: 389 | returns_b = stats['returns_b'] 390 | equity_b = stats['cum_returns_b'] 391 | tot_ret_b = equity_b[-1] - 1.0 392 | cagr_b = perf.create_cagr(equity_b) 393 | sharpe_b = perf.create_sharpe_ratio(returns_b) 394 | sortino_b = perf.create_sortino_ratio(returns_b) 395 | rsq_b = perf.rsquared(range(equity_b.shape[0]), equity_b) 396 | dd_b, dd_max_b, dd_dur_b = perf.create_drawdowns(equity_b) 397 | 398 | ax.text(9.75, 8.9, '{:.0%}'.format(tot_ret_b), fontweight='bold', horizontalalignment='right', fontsize=8) 399 | ax.text(9.75, 7.9, '{:.2%}'.format(cagr_b), fontweight='bold', horizontalalignment='right', fontsize=8) 400 | ax.text(9.75, 6.9, '{:.2f}'.format(sharpe_b), fontweight='bold', horizontalalignment='right', fontsize=8) 401 | ax.text(9.75, 5.9, '{:.2f}'.format(sortino_b), fontweight='bold', horizontalalignment='right', fontsize=8) 402 | ax.text(9.75, 4.9, '{:.2%}'.format(returns_b.std() * np.sqrt(252)), fontweight='bold', horizontalalignment='right', fontsize=8) 403 | ax.text(9.75, 3.9, '{:.2f}'.format(rsq_b), fontweight='bold', horizontalalignment='right', fontsize=8) 404 | ax.text(9.75, 2.9, '{:.2%}'.format(dd_max_b), color='red', fontweight='bold', horizontalalignment='right', fontsize=8) 405 | ax.text(9.75, 1.9, '{:.0f}'.format(dd_dur_b), fontweight='bold', horizontalalignment='right', fontsize=8) 406 | 407 | ax.set_title('Curve vs. Benchmark', fontweight='bold') 408 | 409 | ax.grid(False) 410 | ax.spines['top'].set_linewidth(2.0) 411 | ax.spines['bottom'].set_linewidth(2.0) 412 | ax.spines['right'].set_visible(False) 413 | ax.spines['left'].set_visible(False) 414 | ax.get_yaxis().set_visible(False) 415 | ax.get_xaxis().set_visible(False) 416 | ax.set_ylabel('') 417 | ax.set_xlabel('') 418 | 419 | ax.axis([0, 10, 0, 10]) 420 | return ax 421 | 422 | def _plot_txt_trade(self, stats, ax=None, **kwargs): 423 | """ 424 | Outputs the statistics for the trades. 425 | """ 426 | def format_perc(x, pos): 427 | return '%.0f%%' % x 428 | 429 | if ax is None: 430 | ax = plt.gca() 431 | 432 | if 'positions' not in stats: 433 | num_trades = 0 434 | win_pct = "N/A" 435 | win_pct_str = "N/A" 436 | avg_trd_pct = "N/A" 437 | avg_win_pct = "N/A" 438 | avg_loss_pct = "N/A" 439 | max_win_pct = "N/A" 440 | max_loss_pct = "N/A" 441 | else: 442 | pos = stats['positions'] 443 | num_trades = pos.shape[0] 444 | win_pct = pos[pos["trade_pct"] > 0].shape[0] / float(num_trades) 445 | win_pct_str = '{:.0%}'.format(win_pct) 446 | avg_trd_pct = '{:.2%}'.format(np.mean(pos["trade_pct"])) 447 | avg_win_pct = '{:.2%}'.format(np.mean(pos[pos["trade_pct"] > 0]["trade_pct"])) 448 | avg_loss_pct = '{:.2%}'.format(np.mean(pos[pos["trade_pct"] <= 0]["trade_pct"])) 449 | max_win_pct = '{:.2%}'.format(np.max(pos["trade_pct"])) 450 | max_loss_pct = '{:.2%}'.format(np.min(pos["trade_pct"])) 451 | 452 | y_axis_formatter = FuncFormatter(format_perc) 453 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 454 | 455 | # TODO: Position class needs entry date 456 | max_loss_dt = 'TBD' # pos[pos["trade_pct"] == np.min(pos["trade_pct"])].entry_date.values[0] 457 | avg_dit = '0.0' # = '{:.2f}'.format(np.mean(pos.time_in_pos)) 458 | 459 | ax.text(0.5, 8.9, 'Trade Winning %', fontsize=8) 460 | ax.text(9.5, 8.9, win_pct_str, fontsize=8, fontweight='bold', horizontalalignment='right') 461 | 462 | ax.text(0.5, 7.9, 'Average Trade %', fontsize=8) 463 | ax.text(9.5, 7.9, avg_trd_pct, fontsize=8, fontweight='bold', horizontalalignment='right') 464 | 465 | ax.text(0.5, 6.9, 'Average Win %', fontsize=8) 466 | ax.text(9.5, 6.9, avg_win_pct, fontsize=8, fontweight='bold', color='green', horizontalalignment='right') 467 | 468 | ax.text(0.5, 5.9, 'Average Loss %', fontsize=8) 469 | ax.text(9.5, 5.9, avg_loss_pct, fontsize=8, fontweight='bold', color='red', horizontalalignment='right') 470 | 471 | ax.text(0.5, 4.9, 'Best Trade %', fontsize=8) 472 | ax.text(9.5, 4.9, max_win_pct, fontsize=8, fontweight='bold', color='green', horizontalalignment='right') 473 | 474 | ax.text(0.5, 3.9, 'Worst Trade %', fontsize=8) 475 | ax.text(9.5, 3.9, max_loss_pct, color='red', fontsize=8, fontweight='bold', horizontalalignment='right') 476 | 477 | ax.text(0.5, 2.9, 'Worst Trade Date', fontsize=8) 478 | ax.text(9.5, 2.9, max_loss_dt, fontsize=8, fontweight='bold', horizontalalignment='right') 479 | 480 | ax.text(0.5, 1.9, 'Avg Days in Trade', fontsize=8) 481 | ax.text(9.5, 1.9, avg_dit, fontsize=8, fontweight='bold', horizontalalignment='right') 482 | 483 | ax.text(0.5, 0.9, 'Trades', fontsize=8) 484 | ax.text(9.5, 0.9, num_trades, fontsize=8, fontweight='bold', horizontalalignment='right') 485 | 486 | ax.set_title('Trade', fontweight='bold') 487 | ax.grid(False) 488 | ax.spines['top'].set_linewidth(2.0) 489 | ax.spines['bottom'].set_linewidth(2.0) 490 | ax.spines['right'].set_visible(False) 491 | ax.spines['left'].set_visible(False) 492 | ax.get_yaxis().set_visible(False) 493 | ax.get_xaxis().set_visible(False) 494 | ax.set_ylabel('') 495 | ax.set_xlabel('') 496 | 497 | ax.axis([0, 10, 0, 10]) 498 | return ax 499 | 500 | def _plot_txt_time(self, stats, ax=None, **kwargs): 501 | """ 502 | Outputs the statistics for various time frames. 503 | """ 504 | def format_perc(x, pos): 505 | return '%.0f%%' % x 506 | 507 | returns = stats['returns'] 508 | 509 | if ax is None: 510 | ax = plt.gca() 511 | 512 | y_axis_formatter = FuncFormatter(format_perc) 513 | ax.yaxis.set_major_formatter(FuncFormatter(y_axis_formatter)) 514 | 515 | mly_ret = perf.aggregate_returns(returns, 'monthly') 516 | yly_ret = perf.aggregate_returns(returns, 'yearly') 517 | 518 | mly_pct = mly_ret[mly_ret >= 0].shape[0] / float(mly_ret.shape[0]) 519 | mly_avg_win_pct = np.mean(mly_ret[mly_ret >= 0]) 520 | mly_avg_loss_pct = np.mean(mly_ret[mly_ret < 0]) 521 | mly_max_win_pct = np.max(mly_ret) 522 | mly_max_loss_pct = np.min(mly_ret) 523 | yly_pct = yly_ret[yly_ret >= 0].shape[0] / float(yly_ret.shape[0]) 524 | yly_max_win_pct = np.max(yly_ret) 525 | yly_max_loss_pct = np.min(yly_ret) 526 | 527 | ax.text(0.5, 8.9, 'Winning Months %', fontsize=8) 528 | ax.text(9.5, 8.9, '{:.0%}'.format(mly_pct), fontsize=8, fontweight='bold', 529 | horizontalalignment='right') 530 | 531 | ax.text(0.5, 7.9, 'Average Winning Month %', fontsize=8) 532 | ax.text(9.5, 7.9, '{:.2%}'.format(mly_avg_win_pct), fontsize=8, fontweight='bold', 533 | color='red' if mly_avg_win_pct < 0 else 'green', 534 | horizontalalignment='right') 535 | 536 | ax.text(0.5, 6.9, 'Average Losing Month %', fontsize=8) 537 | ax.text(9.5, 6.9, '{:.2%}'.format(mly_avg_loss_pct), fontsize=8, fontweight='bold', 538 | color='red' if mly_avg_loss_pct < 0 else 'green', 539 | horizontalalignment='right') 540 | 541 | ax.text(0.5, 5.9, 'Best Month %', fontsize=8) 542 | ax.text(9.5, 5.9, '{:.2%}'.format(mly_max_win_pct), fontsize=8, fontweight='bold', 543 | color='red' if mly_max_win_pct < 0 else 'green', 544 | horizontalalignment='right') 545 | 546 | ax.text(0.5, 4.9, 'Worst Month %', fontsize=8) 547 | ax.text(9.5, 4.9, '{:.2%}'.format(mly_max_loss_pct), fontsize=8, fontweight='bold', 548 | color='red' if mly_max_loss_pct < 0 else 'green', 549 | horizontalalignment='right') 550 | 551 | ax.text(0.5, 3.9, 'Winning Years %', fontsize=8) 552 | ax.text(9.5, 3.9, '{:.0%}'.format(yly_pct), fontsize=8, fontweight='bold', 553 | horizontalalignment='right') 554 | 555 | ax.text(0.5, 2.9, 'Best Year %', fontsize=8) 556 | ax.text(9.5, 2.9, '{:.2%}'.format(yly_max_win_pct), fontsize=8, 557 | fontweight='bold', color='red' if yly_max_win_pct < 0 else 'green', 558 | horizontalalignment='right') 559 | 560 | ax.text(0.5, 1.9, 'Worst Year %', fontsize=8) 561 | ax.text(9.5, 1.9, '{:.2%}'.format(yly_max_loss_pct), fontsize=8, 562 | fontweight='bold', color='red' if yly_max_loss_pct < 0 else 'green', 563 | horizontalalignment='right') 564 | 565 | # ax.text(0.5, 0.9, 'Positive 12 Month Periods', fontsize=8) 566 | # ax.text(9.5, 0.9, num_trades, fontsize=8, fontweight='bold', horizontalalignment='right') 567 | 568 | ax.set_title('Time', fontweight='bold') 569 | ax.grid(False) 570 | ax.spines['top'].set_linewidth(2.0) 571 | ax.spines['bottom'].set_linewidth(2.0) 572 | ax.spines['right'].set_visible(False) 573 | ax.spines['left'].set_visible(False) 574 | ax.get_yaxis().set_visible(False) 575 | ax.get_xaxis().set_visible(False) 576 | ax.set_ylabel('') 577 | ax.set_xlabel('') 578 | 579 | ax.axis([0, 10, 0, 10]) 580 | return ax 581 | 582 | def plot_results(self, filename=None): 583 | """ 584 | Plot the Tearsheet 585 | """ 586 | rc = { 587 | 'lines.linewidth': 1.0, 588 | 'axes.facecolor': '0.995', 589 | 'figure.facecolor': '0.97', 590 | 'font.family': 'serif', 591 | 'font.serif': 'Ubuntu', 592 | 'font.monospace': 'Ubuntu Mono', 593 | 'font.size': 10, 594 | 'axes.labelsize': 10, 595 | 'axes.labelweight': 'bold', 596 | 'axes.titlesize': 10, 597 | 'xtick.labelsize': 8, 598 | 'ytick.labelsize': 8, 599 | 'legend.fontsize': 10, 600 | 'figure.titlesize': 12 601 | } 602 | sns.set_context(rc) 603 | sns.set_style("whitegrid") 604 | sns.set_palette("deep", desat=.6) 605 | 606 | if self.rolling_sharpe: 607 | offset_index = 1 608 | else: 609 | offset_index = 0 610 | vertical_sections = 5 + offset_index 611 | fig = plt.figure(figsize=(10, vertical_sections * 3.5)) 612 | fig.suptitle(self.title, y=0.94, weight='bold') 613 | gs = gridspec.GridSpec(vertical_sections, 3, wspace=0.25, hspace=0.5) 614 | 615 | stats = self.get_results() 616 | ax_equity = plt.subplot(gs[:2, :]) 617 | if self.rolling_sharpe: 618 | ax_sharpe = plt.subplot(gs[2, :]) 619 | ax_drawdown = plt.subplot(gs[2 + offset_index, :]) 620 | ax_monthly_returns = plt.subplot(gs[3 + offset_index, :2]) 621 | ax_yearly_returns = plt.subplot(gs[3 + offset_index, 2]) 622 | ax_txt_curve = plt.subplot(gs[4 + offset_index, 0]) 623 | ax_txt_trade = plt.subplot(gs[4 + offset_index, 1]) 624 | ax_txt_time = plt.subplot(gs[4 + offset_index, 2]) 625 | 626 | self._plot_equity(stats, ax=ax_equity) 627 | if self.rolling_sharpe: 628 | self._plot_rolling_sharpe(stats, ax=ax_sharpe) 629 | self._plot_drawdown(stats, ax=ax_drawdown) 630 | self._plot_monthly_returns(stats, ax=ax_monthly_returns) 631 | self._plot_yearly_returns(stats, ax=ax_yearly_returns) 632 | self._plot_txt_curve(stats, ax=ax_txt_curve) 633 | self._plot_txt_trade(stats, ax=ax_txt_trade) 634 | self._plot_txt_time(stats, ax=ax_txt_time) 635 | 636 | # Plot the figure 637 | plt.show(block=False) 638 | 639 | if filename is not None: 640 | fig.savefig(filename, dpi=150, bbox_inches='tight') 641 | 642 | def get_filename(self, filename=""): 643 | if filename == "": 644 | now = datetime.utcnow() 645 | filename = "tearsheet_" + now.strftime("%Y-%m-%d_%H%M%S") + ".png" 646 | filename = os.path.expanduser(os.path.join(self.config.OUTPUT_DIR, filename)) 647 | return filename 648 | 649 | def save(self, filename=""): 650 | filename = self.get_filename(filename) 651 | self.plot_results 652 | -------------------------------------------------------------------------------- /qstrader/strategy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantstart/qstrader/e6d86a3ac3dc507b26e27b1f20c2949a69438ef7/qstrader/strategy/__init__.py -------------------------------------------------------------------------------- /qstrader/strategy/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class AbstractStrategy(object): 5 | """ 6 | AbstractStrategy is an abstract base class providing an interface for 7 | all subsequent (inherited) strategy handling objects. 8 | 9 | The goal of a (derived) Strategy object is to generate Signal 10 | objects for particular symbols based on the inputs of ticks 11 | generated from a PriceHandler (derived) object. 12 | 13 | This is designed to work both with historic and live data as 14 | the Strategy object is agnostic to data location. 15 | """ 16 | 17 | __metaclass__ = ABCMeta 18 | 19 | @abstractmethod 20 | def calculate_signals(self, event): 21 | """ 22 | Provides the mechanisms to calculate the list of signals. 23 | """ 24 | raise NotImplementedError("Should implement calculate_signals()") 25 | 26 | 27 | class Strategies(AbstractStrategy): 28 | """ 29 | Strategies is a collection of strategy 30 | """ 31 | def __init__(self, *strategies): 32 | self._lst_strategies = strategies 33 | 34 | def calculate_signals(self, event): 35 | for strategy in self._lst_strategies: 36 | strategy.calculate_signals(event) 37 | -------------------------------------------------------------------------------- /qstrader/trading_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from datetime import datetime 3 | from .compat import queue 4 | from .event import EventType 5 | from .price_handler.yahoo_daily_csv_bar import YahooDailyCsvBarPriceHandler 6 | from .price_parser import PriceParser 7 | from .position_sizer.fixed import FixedPositionSizer 8 | from .risk_manager.example import ExampleRiskManager 9 | from .portfolio_handler import PortfolioHandler 10 | from .compliance.example import ExampleCompliance 11 | from .execution_handler.ib_simulated import IBSimulatedExecutionHandler 12 | from .statistics.tearsheet import TearsheetStatistics 13 | 14 | 15 | class TradingSession(object): 16 | """ 17 | Enscapsulates the settings and components for 18 | carrying out either a backtest or live trading session. 19 | """ 20 | def __init__( 21 | self, config, strategy, tickers, 22 | equity, start_date, end_date, events_queue, 23 | session_type="backtest", end_session_time=None, 24 | price_handler=None, portfolio_handler=None, 25 | compliance=None, position_sizer=None, 26 | execution_handler=None, risk_manager=None, 27 | statistics=None, sentiment_handler=None, 28 | title=None, benchmark=None 29 | ): 30 | """ 31 | Set up the backtest variables according to 32 | what has been passed in. 33 | """ 34 | self.config = config 35 | self.strategy = strategy 36 | self.tickers = tickers 37 | self.equity = PriceParser.parse(equity) 38 | self.start_date = start_date 39 | self.end_date = end_date 40 | self.events_queue = events_queue 41 | self.price_handler = price_handler 42 | self.portfolio_handler = portfolio_handler 43 | self.compliance = compliance 44 | self.execution_handler = execution_handler 45 | self.position_sizer = position_sizer 46 | self.risk_manager = risk_manager 47 | self.statistics = statistics 48 | self.sentiment_handler = sentiment_handler 49 | self.title = title 50 | self.benchmark = benchmark 51 | self.session_type = session_type 52 | self._config_session() 53 | self.cur_time = None 54 | 55 | if self.session_type == "live": 56 | if self.end_session_time is None: 57 | raise Exception("Must specify an end_session_time when live trading") 58 | 59 | def _config_session(self): 60 | """ 61 | Initialises the necessary classes used 62 | within the session. 63 | """ 64 | if self.price_handler is None and self.session_type == "backtest": 65 | self.price_handler = YahooDailyCsvBarPriceHandler( 66 | self.config.CSV_DATA_DIR, self.events_queue, 67 | self.tickers, start_date=self.start_date, 68 | end_date=self.end_date 69 | ) 70 | 71 | if self.position_sizer is None: 72 | self.position_sizer = FixedPositionSizer() 73 | 74 | if self.risk_manager is None: 75 | self.risk_manager = ExampleRiskManager() 76 | 77 | if self.portfolio_handler is None: 78 | self.portfolio_handler = PortfolioHandler( 79 | self.equity, 80 | self.events_queue, 81 | self.price_handler, 82 | self.position_sizer, 83 | self.risk_manager 84 | ) 85 | 86 | if self.compliance is None: 87 | self.compliance = ExampleCompliance(self.config) 88 | 89 | if self.execution_handler is None: 90 | self.execution_handler = IBSimulatedExecutionHandler( 91 | self.events_queue, 92 | self.price_handler, 93 | self.compliance 94 | ) 95 | 96 | if self.statistics is None: 97 | self.statistics = TearsheetStatistics( 98 | self.config, self.portfolio_handler, 99 | self.title, self.benchmark 100 | ) 101 | 102 | def _continue_loop_condition(self): 103 | if self.session_type == "backtest": 104 | return self.price_handler.continue_backtest 105 | else: 106 | return datetime.now() < self.end_session_time 107 | 108 | def _run_session(self): 109 | """ 110 | Carries out an infinite while loop that polls the 111 | events queue and directs each event to either the 112 | strategy component of the execution handler. The 113 | loop continue until the event queue has been 114 | emptied. 115 | """ 116 | if self.session_type == "backtest": 117 | print("Running Backtest...") 118 | else: 119 | print("Running Realtime Session until %s" % self.end_session_time) 120 | 121 | while self._continue_loop_condition(): 122 | try: 123 | event = self.events_queue.get(False) 124 | except queue.Empty: 125 | self.price_handler.stream_next() 126 | else: 127 | if event is not None: 128 | if ( 129 | event.type == EventType.TICK or 130 | event.type == EventType.BAR 131 | ): 132 | self.cur_time = event.time 133 | # Generate any sentiment events here 134 | if self.sentiment_handler is not None: 135 | self.sentiment_handler.stream_next( 136 | stream_date=self.cur_time 137 | ) 138 | self.strategy.calculate_signals(event) 139 | self.portfolio_handler.update_portfolio_value() 140 | self.statistics.update(event.time, self.portfolio_handler) 141 | elif event.type == EventType.SENTIMENT: 142 | self.strategy.calculate_signals(event) 143 | elif event.type == EventType.SIGNAL: 144 | self.portfolio_handler.on_signal(event) 145 | elif event.type == EventType.ORDER: 146 | self.execution_handler.execute_order(event) 147 | elif event.type == EventType.FILL: 148 | self.portfolio_handler.on_fill(event) 149 | else: 150 | raise NotImplemented("Unsupported event.type '%s'" % event.type) 151 | 152 | def start_trading(self, testing=False): 153 | """ 154 | Runs either a backtest or live session, and outputs performance when complete. 155 | """ 156 | self._run_session() 157 | results = self.statistics.get_results() 158 | print("---------------------------------") 159 | print("Backtest complete.") 160 | print("Sharpe Ratio: %0.2f" % results["sharpe"]) 161 | print( 162 | "Max Drawdown: %0.2f%%" % ( 163 | results["max_drawdown_pct"] * 100.0 164 | ) 165 | ) 166 | if not testing: 167 | self.statistics.plot_results() 168 | return results 169 | -------------------------------------------------------------------------------- /qstrader/version.py: -------------------------------------------------------------------------------- 1 | __author__ = "QuantStart" 2 | __copyright__ = "Copyright 2015-2018" 3 | __license__ = "MIT" 4 | __version__ = "0.0.1" 5 | __maintainer__ = "quantstart" 6 | __email__ = "" 7 | __status__ = "Development" 8 | __url__ = "https://github.com/quantstart/qstrader" 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | decorator==4.0.9 2 | ipython==4.2.0 3 | ipywidgets==5.1.5 4 | ipython-genutils==0.1.0 5 | matplotlib==1.5.3 6 | numpy==1.10.4 7 | pandas==0.18.1 8 | pandas_datareader==0.2.1 9 | path.py==8.1.2 10 | pexpect==4.0.1 11 | pickleshare==0.6 12 | ptyprocess==0.5.1 13 | python-dateutil==2.4.2 14 | pytz==2015.7 15 | requests_cache==0.4.12 16 | scipy==0.17.0 17 | simplegeneric==0.8.1 18 | traitlets==4.1.0 19 | seaborn>=0.7.0 20 | click>=6.6 21 | pyyaml>=3.11 22 | munch>=2.0.4 23 | enum34>=1.1.6 24 | multipledispatch>=0.4.8 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages # Always prefer setuptools over distutils 5 | from os import path 6 | import io 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | 10 | try: 11 | import pypandoc 12 | long_description = pypandoc.convert('README.md', 'rst') 13 | except(IOError, ImportError): 14 | print("Can't import pypandoc - using README.md without converting to RST") 15 | long_description = open('README.md').read() 16 | 17 | NAME = 'qstrader' 18 | with io.open(path.join(here, NAME, 'version.py'), 'rt', encoding='UTF-8') as f: 19 | exec(f.read()) 20 | 21 | setup( 22 | name=NAME, 23 | 24 | # Versions should comply with PEP440. For a discussion on single-sourcing 25 | # the version across setup.py and the project code, see 26 | # https://packaging.python.org/en/latest/development.html#single-sourcing-the-version 27 | #version='0.0.2', 28 | version=__version__, 29 | 30 | description='QuantStart.com - Advanced Trading Infrastructure', 31 | long_description=long_description, 32 | 33 | # The project's main homepage. 34 | url=__url__, 35 | 36 | # Author details 37 | author=__author__, 38 | author_email=__email__, 39 | 40 | # Choose your license 41 | license=__license__, 42 | 43 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 44 | classifiers=[ 45 | # How mature is this project? Common values are 46 | # 3 - Alpha 47 | # 4 - Beta 48 | # 5 - Production/Stable 49 | 'Development Status :: 3 - Alpha', 50 | 51 | # Indicate who your project is intended for 52 | 'Environment :: Console', 53 | #'Topic :: Software Development :: Build Tools', 54 | 'Intended Audience :: Developers', 55 | 'Operating System :: OS Independent', 56 | 57 | # Specify the Python versions you support here. In particular, ensure 58 | # that you indicate whether you support Python 2, Python 3 or both. 59 | 'Programming Language :: Cython', 60 | 61 | 'Programming Language :: Python', 62 | #'Programming Language :: Python :: 2', 63 | #'Programming Language :: Python :: 2.6', 64 | 'Programming Language :: Python :: 2.7', 65 | #'Programming Language :: Python :: 3', 66 | #'Programming Language :: Python :: 3.2', 67 | #'Programming Language :: Python :: 3.3', 68 | 'Programming Language :: Python :: 3.4', 69 | 'Programming Language :: Python :: 3.5', 70 | 71 | # Pick your license as you wish (should match "license" above) 72 | 'License :: OSI Approved :: MIT License', 73 | 74 | ], 75 | 76 | # What does your project relate to? 77 | keywords='python trading technical analysis backtesting', 78 | 79 | # You can just specify the packages manually here if your project is 80 | # simple. Or you can use find_packages(). 81 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 82 | 83 | # List run-time dependencies here. These will be installed by pip when your 84 | # project is installed. For an analysis of "install_requires" vs pip's 85 | # requirements files see: 86 | # https://packaging.python.org/en/latest/technical.html#install-requires-vs-requirements-files 87 | install_requires=['pandas'], 88 | 89 | # List additional groups of dependencies here (e.g. development dependencies). 90 | # You can install these using the following syntax, for example: 91 | # $ pip install -e .[dev,test] 92 | extras_require = { 93 | 'dev': ['check-manifest', 'nose'], 94 | 'test': ['coverage', 'nose'], 95 | }, 96 | 97 | # If there are data files included in your packages that need to be 98 | # installed, specify them here. If using Python 2.6 or less, then these 99 | # have to be included in MANIFEST.in as well. 100 | #package_data={ 101 | # 'samples': ['samples/*.py'], 102 | #}, 103 | 104 | # Although 'package_data' is the preferred approach, in some case you may 105 | # need to place data files outside of your packages. 106 | # see http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files 107 | # In this case, 'data_file' will be installed into '/my_data' 108 | #data_files=[('my_data', ['data/data_file'])], 109 | 110 | # To provide executable scripts, use entry points in preference to the 111 | # "scripts" keyword. Entry points provide cross-platform support and allow 112 | # pip to create the appropriate form of executable for the target platform. 113 | #entry_points={ 114 | # 'console_scripts': [ 115 | # 'sample=sample:main', 116 | # ], 117 | #}, 118 | #tests_require=['xlrd'], 119 | #test_suite='tests', 120 | ) 121 | -------------------------------------------------------------------------------- /tests/test_portfolio.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from qstrader.portfolio import Portfolio 4 | from qstrader.price_parser import PriceParser 5 | from qstrader.price_handler.base import AbstractTickPriceHandler 6 | 7 | 8 | class PriceHandlerMock(AbstractTickPriceHandler): 9 | def get_best_bid_ask(self, ticker): 10 | prices = { 11 | "GOOG": (PriceParser.parse(705.46), PriceParser.parse(705.46)), 12 | "AMZN": (PriceParser.parse(564.14), PriceParser.parse(565.14)), 13 | } 14 | return prices[ticker] 15 | 16 | 17 | class TestAmazonGooglePortfolio(unittest.TestCase): 18 | """ 19 | Test a portfolio consisting of Amazon and 20 | Google/Alphabet with various orders to create 21 | round-trips for both. 22 | 23 | These orders were carried out in the Interactive Brokers 24 | demo account and checked for cash, equity and PnL 25 | equality. 26 | """ 27 | def setUp(self): 28 | """ 29 | Set up the Portfolio object that will store the 30 | collection of Position objects, supplying it with 31 | $500,000.00 USD in initial cash. 32 | """ 33 | ph = PriceHandlerMock() 34 | cash = PriceParser.parse(500000.00) 35 | self.portfolio = Portfolio(ph, cash) 36 | 37 | def test_calculate_round_trip(self): 38 | """ 39 | Purchase/sell multiple lots of AMZN and GOOG 40 | at various prices/commissions to check the 41 | arithmetic and cost handling. 42 | """ 43 | # Buy 300 of AMZN over two transactions 44 | self.portfolio.transact_position( 45 | "BOT", "AMZN", 100, 46 | PriceParser.parse(566.56), PriceParser.parse(1.00) 47 | ) 48 | self.portfolio.transact_position( 49 | "BOT", "AMZN", 200, 50 | PriceParser.parse(566.395), PriceParser.parse(1.00) 51 | ) 52 | # Buy 200 GOOG over one transaction 53 | self.portfolio.transact_position( 54 | "BOT", "GOOG", 200, 55 | PriceParser.parse(707.50), PriceParser.parse(1.00) 56 | ) 57 | # Add to the AMZN position by 100 shares 58 | self.portfolio.transact_position( 59 | "SLD", "AMZN", 100, 60 | PriceParser.parse(565.83), PriceParser.parse(1.00) 61 | ) 62 | # Add to the GOOG position by 200 shares 63 | self.portfolio.transact_position( 64 | "BOT", "GOOG", 200, 65 | PriceParser.parse(705.545), PriceParser.parse(1.00) 66 | ) 67 | # Sell 200 of the AMZN shares 68 | self.portfolio.transact_position( 69 | "SLD", "AMZN", 200, 70 | PriceParser.parse(565.59), PriceParser.parse(1.00) 71 | ) 72 | # Multiple transactions bundled into one (in IB) 73 | # Sell 300 GOOG from the portfolio 74 | self.portfolio.transact_position( 75 | "SLD", "GOOG", 100, 76 | PriceParser.parse(704.92), PriceParser.parse(1.00) 77 | ) 78 | self.portfolio.transact_position( 79 | "SLD", "GOOG", 100, 80 | PriceParser.parse(704.90), PriceParser.parse(0.00) 81 | ) 82 | self.portfolio.transact_position( 83 | "SLD", "GOOG", 100, 84 | PriceParser.parse(704.92), PriceParser.parse(0.50) 85 | ) 86 | # Finally, sell the remaining GOOG 100 shares 87 | self.portfolio.transact_position( 88 | "SLD", "GOOG", 100, 89 | PriceParser.parse(704.78), PriceParser.parse(1.00) 90 | ) 91 | 92 | # The figures below are derived from Interactive Brokers 93 | # demo account using the above trades with prices provided 94 | # by their demo feed. 95 | self.assertEqual(len(self.portfolio.positions), 0) 96 | self.assertEqual(len(self.portfolio.closed_positions), 2) 97 | self.assertEqual(PriceParser.display(self.portfolio.cur_cash), 499100.50) 98 | self.assertEqual(PriceParser.display(self.portfolio.equity), 499100.50) 99 | self.assertEqual(PriceParser.display(self.portfolio.unrealised_pnl), 0.00) 100 | self.assertEqual(PriceParser.display(self.portfolio.realised_pnl), -899.50) 101 | 102 | 103 | if __name__ == "__main__": 104 | unittest.main() 105 | -------------------------------------------------------------------------------- /tests/test_portfolio_handler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from decimal import Decimal 3 | import unittest 4 | 5 | from qstrader.event import FillEvent, OrderEvent, SignalEvent 6 | from qstrader.portfolio_handler import PortfolioHandler 7 | from qstrader.price_handler.base import AbstractTickPriceHandler 8 | from qstrader.compat import queue 9 | 10 | 11 | class PriceHandlerMock(AbstractTickPriceHandler): 12 | def __init__(self): 13 | pass 14 | 15 | def get_best_bid_ask(self, ticker): 16 | prices = { 17 | "MSFT": (Decimal("50.28"), Decimal("50.31")), 18 | "GOOG": (Decimal("705.46"), Decimal("705.46")), 19 | "AMZN": (Decimal("564.14"), Decimal("565.14")), 20 | } 21 | return prices[ticker] 22 | 23 | 24 | class PositionSizerMock(object): 25 | def __init__(self): 26 | pass 27 | 28 | def size_order(self, portfolio, initial_order): 29 | """ 30 | This PositionSizerMock object simply modifies 31 | the quantity to be 100 of any share transacted. 32 | """ 33 | initial_order.quantity = 100 34 | return initial_order 35 | 36 | 37 | class RiskManagerMock(object): 38 | def __init__(self): 39 | pass 40 | 41 | def refine_orders(self, portfolio, sized_order): 42 | """ 43 | This RiskManagerMock object simply lets the 44 | sized order through, creates the corresponding 45 | OrderEvent object and adds it to a list. 46 | """ 47 | order_event = OrderEvent( 48 | sized_order.ticker, 49 | sized_order.action, 50 | sized_order.quantity 51 | ) 52 | return [order_event] 53 | 54 | 55 | class TestSimpleSignalOrderFillCycleForPortfolioHandler(unittest.TestCase): 56 | """ 57 | Tests a simple Signal, Order and Fill cycle for the 58 | PortfolioHandler. This is, in effect, a sanity check. 59 | """ 60 | def setUp(self): 61 | """ 62 | Set up the PortfolioHandler object supplying it with 63 | $500,000.00 USD in initial cash. 64 | """ 65 | initial_cash = Decimal("500000.00") 66 | events_queue = queue.Queue() 67 | price_handler = PriceHandlerMock() 68 | position_sizer = PositionSizerMock() 69 | risk_manager = RiskManagerMock() 70 | # Create the PortfolioHandler object from the rest 71 | self.portfolio_handler = PortfolioHandler( 72 | initial_cash, events_queue, price_handler, 73 | position_sizer, risk_manager 74 | ) 75 | 76 | def test_create_order_from_signal_basic_check(self): 77 | """ 78 | Tests the "_create_order_from_signal" method 79 | as a basic sanity check. 80 | """ 81 | signal_event = SignalEvent("MSFT", "BOT") 82 | order = self.portfolio_handler._create_order_from_signal(signal_event) 83 | self.assertEqual(order.ticker, "MSFT") 84 | self.assertEqual(order.action, "BOT") 85 | self.assertEqual(order.quantity, 0) 86 | 87 | def test_place_orders_onto_queue_basic_check(self): 88 | """ 89 | Tests the "_place_orders_onto_queue" method 90 | as a basic sanity check. 91 | """ 92 | order = OrderEvent("MSFT", "BOT", 100) 93 | order_list = [order] 94 | self.portfolio_handler._place_orders_onto_queue(order_list) 95 | ret_order = self.portfolio_handler.events_queue.get() 96 | self.assertEqual(ret_order.ticker, "MSFT") 97 | self.assertEqual(ret_order.action, "BOT") 98 | self.assertEqual(ret_order.quantity, 100) 99 | 100 | def test_convert_fill_to_portfolio_update_basic_check(self): 101 | """ 102 | Tests the "_convert_fill_to_portfolio_update" method 103 | as a basic sanity check. 104 | """ 105 | fill_event_buy = FillEvent( 106 | datetime.datetime.utcnow(), "MSFT", "BOT", 107 | 100, "ARCA", Decimal("50.25"), Decimal("1.00") 108 | ) 109 | self.portfolio_handler._convert_fill_to_portfolio_update(fill_event_buy) 110 | # Check the Portfolio values within the PortfolioHandler 111 | port = self.portfolio_handler.portfolio 112 | self.assertEqual(port.cur_cash, Decimal("494974.00")) 113 | 114 | # TODO: Finish this off and check it works via Interactive Brokers 115 | fill_event_sell = FillEvent( 116 | datetime.datetime.utcnow(), "MSFT", "SLD", 117 | 100, "ARCA", Decimal("50.25"), Decimal("1.00") 118 | ) 119 | self.portfolio_handler._convert_fill_to_portfolio_update(fill_event_sell) 120 | 121 | def test_on_signal_basic_check(self): 122 | """ 123 | Tests the "on_signal" method as a basic sanity check. 124 | """ 125 | signal_event = SignalEvent("MSFT", "BOT") 126 | self.portfolio_handler.on_signal(signal_event) 127 | ret_order = self.portfolio_handler.events_queue.get() 128 | self.assertEqual(ret_order.ticker, "MSFT") 129 | self.assertEqual(ret_order.action, "BOT") 130 | self.assertEqual(ret_order.quantity, 100) 131 | 132 | 133 | if __name__ == "__main__": 134 | unittest.main() 135 | -------------------------------------------------------------------------------- /tests/test_position.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from qstrader.position import Position 4 | from qstrader.price_parser import PriceParser 5 | 6 | 7 | class TestRoundTripXOMPosition(unittest.TestCase): 8 | """ 9 | Test a round-trip trade in Exxon-Mobil where the initial 10 | trade is a buy/long of 100 shares of XOM, at a price of 11 | $74.78, with $1.00 commission. 12 | """ 13 | def setUp(self): 14 | """ 15 | Set up the Position object that will store the PnL. 16 | """ 17 | self.position = Position( 18 | "BOT", "XOM", 100, 19 | PriceParser.parse(74.78), PriceParser.parse(1.00), 20 | PriceParser.parse(74.78), PriceParser.parse(74.80) 21 | ) 22 | 23 | def test_calculate_round_trip(self): 24 | """ 25 | After the subsequent purchase, carry out two more buys/longs 26 | and then close the position out with two additional sells/shorts. 27 | 28 | The following prices have been tested against those calculated 29 | via Interactive Brokers' Trader Workstation (TWS). 30 | """ 31 | self.position.transact_shares( 32 | "BOT", 100, PriceParser.parse(74.63), PriceParser.parse(1.00) 33 | ) 34 | self.position.transact_shares( 35 | "BOT", 250, PriceParser.parse(74.620), PriceParser.parse(1.25) 36 | ) 37 | self.position.transact_shares( 38 | "SLD", 200, PriceParser.parse(74.58), PriceParser.parse(1.00) 39 | ) 40 | self.position.transact_shares( 41 | "SLD", 250, PriceParser.parse(75.26), PriceParser.parse(1.25) 42 | ) 43 | self.position.update_market_value( 44 | PriceParser.parse(77.75), PriceParser.parse(77.77) 45 | ) 46 | 47 | self.assertEqual(self.position.action, "BOT") 48 | self.assertEqual(self.position.ticker, "XOM") 49 | self.assertEqual(self.position.quantity, 0) 50 | 51 | self.assertEqual(self.position.buys, 450) 52 | self.assertEqual(self.position.sells, 450) 53 | self.assertEqual(self.position.net, 0) 54 | self.assertEqual( 55 | PriceParser.display(self.position.avg_bot, 5), 74.65778 56 | ) 57 | self.assertEqual( 58 | PriceParser.display(self.position.avg_sld, 5), 74.95778 59 | ) 60 | self.assertEqual(PriceParser.display(self.position.total_bot), 33596.00) 61 | self.assertEqual(PriceParser.display(self.position.total_sld), 33731.00) 62 | self.assertEqual(PriceParser.display(self.position.net_total), 135.00) 63 | self.assertEqual(PriceParser.display(self.position.total_commission), 5.50) 64 | self.assertEqual(PriceParser.display(self.position.net_incl_comm), 129.50) 65 | 66 | self.assertEqual( 67 | PriceParser.display(self.position.avg_price, 3), 74.665 68 | ) 69 | self.assertEqual(PriceParser.display(self.position.cost_basis), 0.00) 70 | self.assertEqual(PriceParser.display(self.position.market_value), 0.00) 71 | self.assertEqual(PriceParser.display(self.position.unrealised_pnl), 0.00) 72 | self.assertEqual(PriceParser.display(self.position.realised_pnl), 129.50) 73 | 74 | 75 | class TestRoundTripPGPosition(unittest.TestCase): 76 | """ 77 | Test a round-trip trade in Proctor & Gamble where the initial 78 | trade is a sell/short of 100 shares of PG, at a price of 79 | $77.69, with $1.00 commission. 80 | """ 81 | def setUp(self): 82 | self.position = Position( 83 | "SLD", "PG", 100, 84 | PriceParser.parse(77.69), PriceParser.parse(1.00), 85 | PriceParser.parse(77.68), PriceParser.parse(77.70) 86 | ) 87 | 88 | def test_calculate_round_trip(self): 89 | """ 90 | After the subsequent sale, carry out two more sells/shorts 91 | and then close the position out with two additional buys/longs. 92 | 93 | The following prices have been tested against those calculated 94 | via Interactive Brokers' Trader Workstation (TWS). 95 | """ 96 | self.position.transact_shares( 97 | "SLD", 100, PriceParser.parse(77.68), PriceParser.parse(1.00) 98 | ) 99 | self.position.transact_shares( 100 | "SLD", 50, PriceParser.parse(77.70), PriceParser.parse(1.00) 101 | ) 102 | self.position.transact_shares( 103 | "BOT", 100, PriceParser.parse(77.77), PriceParser.parse(1.00) 104 | ) 105 | self.position.transact_shares( 106 | "BOT", 150, PriceParser.parse(77.73), PriceParser.parse(1.00) 107 | ) 108 | self.position.update_market_value( 109 | PriceParser.parse(77.72), PriceParser.parse(77.72) 110 | ) 111 | 112 | self.assertEqual(self.position.action, "SLD") 113 | self.assertEqual(self.position.ticker, "PG") 114 | self.assertEqual(self.position.quantity, 0) 115 | 116 | self.assertEqual(self.position.buys, 250) 117 | self.assertEqual(self.position.sells, 250) 118 | self.assertEqual(self.position.net, 0) 119 | self.assertEqual( 120 | PriceParser.display(self.position.avg_bot, 3), 77.746 121 | ) 122 | self.assertEqual( 123 | PriceParser.display(self.position.avg_sld, 3), 77.688 124 | ) 125 | self.assertEqual(PriceParser.display(self.position.total_bot), 19436.50) 126 | self.assertEqual(PriceParser.display(self.position.total_sld), 19422.00) 127 | self.assertEqual(PriceParser.display(self.position.net_total), -14.50) 128 | self.assertEqual(PriceParser.display(self.position.total_commission), 5.00) 129 | self.assertEqual(PriceParser.display(self.position.net_incl_comm), -19.50) 130 | 131 | self.assertEqual( 132 | PriceParser.display(self.position.avg_price, 5), 77.67600 133 | ) 134 | self.assertEqual(PriceParser.display(self.position.cost_basis), 0.00) 135 | self.assertEqual(PriceParser.display(self.position.market_value), 0.00) 136 | self.assertEqual(PriceParser.display(self.position.unrealised_pnl), 0.00) 137 | self.assertEqual(PriceParser.display(self.position.realised_pnl), -19.50) 138 | 139 | 140 | class TestShortPosition(unittest.TestCase): 141 | """ 142 | Test a short position in Proctor & Gamble where the initial 143 | trade is a sell/short of 100 shares of PG, at a price of 144 | $77.69, with $1.00 commission. 145 | """ 146 | def setUp(self): 147 | self.position = Position( 148 | "SLD", "PG", 100, 149 | PriceParser.parse(77.69), PriceParser.parse(1.00), 150 | PriceParser.parse(77.68), PriceParser.parse(77.70) 151 | ) 152 | 153 | def test_open_short_position(self): 154 | self.assertEqual(PriceParser.display(self.position.cost_basis), -7768.00) 155 | self.assertEqual(PriceParser.display(self.position.market_value), -7769.00) 156 | self.assertEqual(PriceParser.display(self.position.unrealised_pnl), -1.00) 157 | self.assertEqual(PriceParser.display(self.position.realised_pnl), 0.00) 158 | 159 | self.position.update_market_value( 160 | PriceParser.parse(77.72), PriceParser.parse(77.72) 161 | ) 162 | 163 | self.assertEqual(PriceParser.display(self.position.cost_basis), -7768.00) 164 | self.assertEqual(PriceParser.display(self.position.market_value), -7772.00) 165 | self.assertEqual(PriceParser.display(self.position.unrealised_pnl), -4.00) 166 | self.assertEqual(PriceParser.display(self.position.realised_pnl), 0.00) 167 | 168 | 169 | class TestProfitLossBuying(unittest.TestCase): 170 | """ 171 | Tests that the unrealised and realised pnls are 172 | working after position initialization, every 173 | transaction, and every price update 174 | """ 175 | def setUp(self): 176 | self.position = Position( 177 | "BOT", "XOM", 100, 178 | PriceParser.parse(74.78), PriceParser.parse(1.00), 179 | PriceParser.parse(74.77), PriceParser.parse(74.79) 180 | ) 181 | 182 | def test_realised_unrealised_calcs(self): 183 | self.assertEqual( 184 | PriceParser.display(self.position.unrealised_pnl), -1.00 185 | ) 186 | self.assertEqual( 187 | PriceParser.display(self.position.realised_pnl), 0.00 188 | ) 189 | 190 | self.position.update_market_value( 191 | PriceParser.parse(75.77), PriceParser.parse(75.79) 192 | ) 193 | self.assertEqual( 194 | PriceParser.display(self.position.unrealised_pnl), 99.00 195 | ) 196 | self.position.transact_shares( 197 | "SLD", 100, 198 | PriceParser.parse(75.78), PriceParser.parse(1.00) 199 | ) 200 | self.assertEqual( 201 | PriceParser.display(self.position.unrealised_pnl), 99.00 202 | ) # still high 203 | self.assertEqual( 204 | PriceParser.display(self.position.realised_pnl), 98.00 205 | ) 206 | 207 | self.position.update_market_value( 208 | PriceParser.parse(75.77), PriceParser.parse(75.79) 209 | ) 210 | self.assertEqual( 211 | PriceParser.display(self.position.unrealised_pnl), 0.00 212 | ) 213 | 214 | 215 | if __name__ == "__main__": 216 | unittest.main() 217 | -------------------------------------------------------------------------------- /tests/test_price_handler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from qstrader.price_parser import PriceParser 4 | from qstrader.price_handler.historic_csv_tick import HistoricCSVTickPriceHandler 5 | from qstrader.compat import queue 6 | from qstrader import settings 7 | 8 | 9 | class TestPriceHandlerSimpleCase(unittest.TestCase): 10 | """ 11 | Test the initialisation of a PriceHandler object with 12 | a small list of tickers. Concatenate the ticker data ( 13 | pre-generated and stored as a fixture) and stream the 14 | subsequent ticks, checking that the correct bid-ask 15 | values are returned. 16 | """ 17 | def setUp(self): 18 | """ 19 | Set up the PriceHandler object with a small 20 | set of initial tickers. 21 | """ 22 | self.config = settings.TEST 23 | fixtures_path = self.config.CSV_DATA_DIR 24 | events_queue = queue.Queue() 25 | init_tickers = ["GOOG", "AMZN", "MSFT"] 26 | self.price_handler = HistoricCSVTickPriceHandler( 27 | fixtures_path, events_queue, init_tickers 28 | ) 29 | 30 | def test_stream_all_ticks(self): 31 | """ 32 | The initialisation of the class will open the three 33 | test CSV files, then merge and sort them. They will 34 | then be stored in a member "tick_stream". This will 35 | be used for streaming the ticks. 36 | """ 37 | # Stream to Tick #1 (GOOG) 38 | self.price_handler.stream_next() 39 | self.assertEqual( 40 | self.price_handler.tickers["GOOG"]["timestamp"].strftime( 41 | "%d-%m-%Y %H:%M:%S.%f" 42 | ), 43 | "01-02-2016 00:00:01.358000" 44 | ) 45 | self.assertEqual( 46 | PriceParser.display(self.price_handler.tickers["GOOG"]["bid"], 5), 47 | 683.56000 48 | ) 49 | self.assertEqual( 50 | PriceParser.display(self.price_handler.tickers["GOOG"]["ask"], 5), 51 | 683.58000 52 | ) 53 | 54 | # Stream to Tick #2 (AMZN) 55 | self.price_handler.stream_next() 56 | self.assertEqual( 57 | self.price_handler.tickers["AMZN"]["timestamp"].strftime( 58 | "%d-%m-%Y %H:%M:%S.%f" 59 | ), 60 | "01-02-2016 00:00:01.562000" 61 | ) 62 | self.assertEqual( 63 | PriceParser.display(self.price_handler.tickers["AMZN"]["bid"], 5), 64 | 502.10001 65 | ) 66 | self.assertEqual( 67 | PriceParser.display(self.price_handler.tickers["AMZN"]["ask"], 5), 68 | 502.11999 69 | ) 70 | 71 | # Stream to Tick #3 (MSFT) 72 | self.price_handler.stream_next() 73 | self.assertEqual( 74 | self.price_handler.tickers["MSFT"]["timestamp"].strftime( 75 | "%d-%m-%Y %H:%M:%S.%f" 76 | ), 77 | "01-02-2016 00:00:01.578000" 78 | ) 79 | self.assertEqual( 80 | PriceParser.display(self.price_handler.tickers["MSFT"]["bid"], 5), 81 | 50.14999 82 | ) 83 | self.assertEqual( 84 | PriceParser.display(self.price_handler.tickers["MSFT"]["ask"], 5), 85 | 50.17001 86 | ) 87 | 88 | # Stream to Tick #10 (GOOG) 89 | for i in range(4, 11): 90 | self.price_handler.stream_next() 91 | self.assertEqual( 92 | self.price_handler.tickers["GOOG"]["timestamp"].strftime( 93 | "%d-%m-%Y %H:%M:%S.%f" 94 | ), 95 | "01-02-2016 00:00:05.215000" 96 | ) 97 | self.assertEqual( 98 | PriceParser.display(self.price_handler.tickers["GOOG"]["bid"], 5), 99 | 683.56001 100 | ) 101 | self.assertEqual( 102 | PriceParser.display(self.price_handler.tickers["GOOG"]["ask"], 5), 103 | 683.57999 104 | ) 105 | 106 | # Stream to Tick #20 (GOOG) 107 | for i in range(11, 21): 108 | self.price_handler.stream_next() 109 | self.assertEqual( 110 | self.price_handler.tickers["MSFT"]["timestamp"].strftime( 111 | "%d-%m-%Y %H:%M:%S.%f" 112 | ), 113 | "01-02-2016 00:00:09.904000" 114 | ) 115 | self.assertEqual( 116 | PriceParser.display(self.price_handler.tickers["MSFT"]["bid"], 5), 117 | 50.15000 118 | ) 119 | self.assertEqual( 120 | PriceParser.display(self.price_handler.tickers["MSFT"]["ask"], 5), 121 | 50.17000 122 | ) 123 | 124 | # Stream to Tick #30 (final tick, AMZN) 125 | for i in range(21, 31): 126 | self.price_handler.stream_next() 127 | self.assertEqual( 128 | self.price_handler.tickers["AMZN"]["timestamp"].strftime( 129 | "%d-%m-%Y %H:%M:%S.%f" 130 | ), 131 | "01-02-2016 00:00:14.616000" 132 | ) 133 | self.assertEqual( 134 | PriceParser.display(self.price_handler.tickers["AMZN"]["bid"], 5), 135 | 502.10015 136 | ) 137 | self.assertEqual( 138 | PriceParser.display(self.price_handler.tickers["AMZN"]["ask"], 5), 139 | 502.11985 140 | ) 141 | 142 | def test_subscribe_unsubscribe(self): 143 | """ 144 | Tests the 'subscribe_ticker' and 'unsubscribe_ticker' 145 | methods, and check that they raise exceptions when 146 | appropriate. 147 | """ 148 | 149 | # Check unsubscribing a ticker that isn't 150 | # in the price handler list 151 | # self.assertRaises( 152 | # KeyError, lambda: self.price_handler.unsubscribe_ticker("PG") 153 | # ) 154 | 155 | # Check a ticker that is already subscribed 156 | # to make sure that it doesn't raise an exception 157 | try: 158 | self.price_handler.subscribe_ticker("GOOG") 159 | except Exception as E: 160 | self.fail("subscribe_ticker() raised %s unexpectedly" % E) 161 | 162 | # Subscribe a new ticker, without CSV 163 | # self.assertRaises( 164 | # IOError, lambda: self.price_handler.subscribe_ticker("XOM") 165 | # ) 166 | 167 | # Unsubscribe a current ticker 168 | self.assertTrue("GOOG" in self.price_handler.tickers) 169 | self.assertTrue("GOOG" in self.price_handler.tickers_data) 170 | self.price_handler.unsubscribe_ticker("GOOG") 171 | self.assertTrue("GOOG" not in self.price_handler.tickers) 172 | self.assertTrue("GOOG" not in self.price_handler.tickers_data) 173 | 174 | def test_get_best_bid_ask(self): 175 | """ 176 | Tests that the 'get_best_bid_ask' method produces the 177 | correct values depending upon validity of ticker. 178 | """ 179 | bid, ask = self.price_handler.get_best_bid_ask("AMZN") 180 | self.assertEqual(PriceParser.display(bid, 5), 502.10001) 181 | self.assertEqual(PriceParser.display(ask, 5), 502.11999) 182 | 183 | bid, ask = self.price_handler.get_best_bid_ask("C") 184 | # TODO WHAT TO DO HERE?. 185 | # self.assertEqual(PriceParser.display(bid, 5), None) 186 | # self.assertEqual(PriceParser.display(ask, 5), None) 187 | 188 | 189 | if __name__ == "__main__": 190 | unittest.main() 191 | -------------------------------------------------------------------------------- /tests/test_priceparser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | from qstrader.price_parser import PriceParser 4 | from qstrader.compat import PY2 5 | 6 | 7 | class TestPriceParser(unittest.TestCase): 8 | def setUp(self): 9 | self.int = 200 10 | 11 | if PY2: 12 | self.long = long(self.int) # noqa 13 | else: 14 | self.long = self.int 15 | 16 | self.int64 = np.int64(self.int) 17 | 18 | self.float = 10.1234567 19 | self.rounded_float = 10.0 20 | 21 | self.vol = 30 22 | 23 | def test_price_from_float(self): 24 | parsed = PriceParser.parse(self.float) 25 | self.assertEqual(parsed, 101234567) 26 | self.assertIsInstance(parsed, int) 27 | 28 | def test_price_from_int(self): 29 | parsed = PriceParser.parse(self.int) 30 | self.assertEqual(parsed, 200) 31 | self.assertIsInstance(parsed, int) 32 | 33 | def test_price_from_long(self): 34 | parsed = PriceParser.parse(self.long) 35 | self.assertEqual(parsed, 200) 36 | if PY2: 37 | self.assertIsInstance(parsed, long) # noqa 38 | else: 39 | self.assertIsInstance(parsed, int) 40 | 41 | def test_price_from_int64(self): 42 | parsed = PriceParser.parse(self.int64) 43 | self.assertEqual(parsed, 200) 44 | self.assertIsInstance(parsed, np.int64) 45 | 46 | def test_rounded_float(self): 47 | parsed = PriceParser.parse(self.rounded_float) 48 | # Expect 100,000,000 49 | self.assertEqual(parsed, 100000000) 50 | self.assertIsInstance(parsed, int) 51 | 52 | def test_display(self): 53 | parsed = PriceParser.parse(self.float) 54 | displayed = PriceParser.display(parsed) 55 | self.assertEqual(displayed, 10.12) 56 | 57 | def test_unparsed_display(self): 58 | displayed = PriceParser.display(self.float) 59 | self.assertEqual(displayed, 10.12) 60 | 61 | 62 | if __name__ == "__main__": 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /tests/test_rebalance_position_sizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from qstrader.price_handler.base import AbstractBarPriceHandler 4 | from qstrader.order.suggested import SuggestedOrder 5 | from qstrader.price_parser import PriceParser 6 | from qstrader.portfolio import Portfolio 7 | from qstrader.position_sizer.rebalance import LiquidateRebalancePositionSizer 8 | 9 | 10 | class PriceHandlerMock(AbstractBarPriceHandler): 11 | def __init__(self): 12 | self.tickers = { 13 | "AAA": {"adj_close": PriceParser.parse(50.00)}, 14 | "BBB": {"adj_close": PriceParser.parse(100.00)}, 15 | "CCC": {"adj_close": PriceParser.parse(1.00)}, 16 | } 17 | 18 | def get_last_close(self, ticker): 19 | return self.tickers[ticker]["adj_close"] 20 | 21 | 22 | class TestLiquidateRebalancePositionSizer(unittest.TestCase): 23 | def setUp(self): 24 | price_handler_mock = PriceHandlerMock() 25 | ticker_weights = { 26 | "AAA": 0.3, 27 | "BBB": 0.7 28 | } 29 | self.position_sizer = LiquidateRebalancePositionSizer(ticker_weights) 30 | self.portfolio = Portfolio(price_handler_mock, PriceParser.parse(10000.00)) 31 | 32 | def test_will_add_positions(self): 33 | """ 34 | Tests that the position sizer will open up new positions with 35 | the correct weights. 36 | """ 37 | order_a = SuggestedOrder("AAA", "BOT", 0) 38 | order_b = SuggestedOrder("BBB", "BOT", 0) 39 | sized_a = self.position_sizer.size_order(self.portfolio, order_a) 40 | sized_b = self.position_sizer.size_order(self.portfolio, order_b) 41 | 42 | self.assertEqual(sized_a.action, "BOT") 43 | self.assertEqual(sized_b.action, "BOT") 44 | self.assertEqual(sized_a.quantity, 60) 45 | self.assertEqual(sized_b.quantity, 70) 46 | 47 | def test_will_liquidate_positions(self): 48 | """ 49 | Ensure positions will be liquidated completely when asked. 50 | Include a long & a short. 51 | """ 52 | self.portfolio._add_position( 53 | "BOT", "AAA", 100, PriceParser.parse(60.00), 0.0 54 | ) 55 | self.portfolio._add_position( 56 | "BOT", "BBB", -100, PriceParser.parse(60.00), 0.0 57 | ) 58 | 59 | exit_a = SuggestedOrder("AAA", "EXIT", 0) 60 | exit_b = SuggestedOrder("BBB", "EXIT", 0) 61 | sized_a = self.position_sizer.size_order(self.portfolio, exit_a) 62 | sized_b = self.position_sizer.size_order(self.portfolio, exit_b) 63 | 64 | self.assertEqual(sized_a.action, "SLD") 65 | self.assertEqual(sized_b.action, "BOT") 66 | self.assertEqual(sized_a.quantity, 100) 67 | self.assertEqual(sized_a.quantity, 100) 68 | 69 | 70 | if __name__ == "__main__": 71 | unittest.main() 72 | -------------------------------------------------------------------------------- /tests/test_statistics.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from test_portfolio import PriceHandlerMock 4 | 5 | from qstrader import settings 6 | from qstrader.price_parser import PriceParser 7 | from qstrader.portfolio import Portfolio 8 | from qstrader.statistics.simple import SimpleStatistics 9 | 10 | 11 | class PortfolioHandlerMock(object): 12 | def __init__(self, portfolio): 13 | self.portfolio = portfolio 14 | 15 | 16 | class TestSimpleStatistics(unittest.TestCase): 17 | """ 18 | Test the statistics on a portfolio consisting of 19 | AMZN and GOOG with various orders to create 20 | round-trips for both. 21 | 22 | We run a simple and short backtest, and check 23 | arithmetic for equity, return and drawdown 24 | calculations along the way. 25 | """ 26 | def setUp(self): 27 | self.config = settings.TEST 28 | 29 | def test_calculating_statistics(self): 30 | """ 31 | Purchase/sell multiple lots of AMZN, GOOG 32 | at various prices/commissions to ensure 33 | the arithmetic in calculating equity, drawdowns 34 | and sharpe ratio is correct. 35 | """ 36 | # Create Statistics object 37 | price_handler = PriceHandlerMock() 38 | self.portfolio = Portfolio(price_handler, PriceParser.parse(500000.00)) 39 | 40 | portfolio_handler = PortfolioHandlerMock(self.portfolio) 41 | statistics = SimpleStatistics(self.config, portfolio_handler) 42 | 43 | # Check initialization was correct 44 | self.assertEqual(PriceParser.display(statistics.equity[0]), 500000.00) 45 | self.assertEqual(PriceParser.display(statistics.drawdowns[0]), 00) 46 | self.assertEqual(statistics.equity_returns[0], 0.0) 47 | 48 | # Perform transaction and test statistics at this tick 49 | self.portfolio.transact_position( 50 | "BOT", "AMZN", 100, 51 | PriceParser.parse(566.56), PriceParser.parse(1.00) 52 | ) 53 | t = "2000-01-01 00:00:00" 54 | statistics.update(t, portfolio_handler) 55 | self.assertEqual(PriceParser.display(statistics.equity[1]), 499807.00) 56 | self.assertEqual(PriceParser.display(statistics.drawdowns[1]), 193.00) 57 | self.assertEqual(statistics.equity_returns[1], -0.0386) 58 | 59 | # Perform transaction and test statistics at this tick 60 | self.portfolio.transact_position( 61 | "BOT", "AMZN", 200, 62 | PriceParser.parse(566.395), PriceParser.parse(1.00) 63 | ) 64 | t = "2000-01-02 00:00:00" 65 | statistics.update(t, portfolio_handler) 66 | self.assertEqual(PriceParser.display(statistics.equity[2]), 499455.00) 67 | self.assertEqual(PriceParser.display(statistics.drawdowns[2]), 545.00) 68 | self.assertEqual(statistics.equity_returns[2], -0.0705) 69 | 70 | # Perform transaction and test statistics at this tick 71 | self.portfolio.transact_position( 72 | "BOT", "GOOG", 200, 73 | PriceParser.parse(707.50), PriceParser.parse(1.00) 74 | ) 75 | t = "2000-01-03 00:00:00" 76 | statistics.update(t, portfolio_handler) 77 | self.assertEqual(PriceParser.display(statistics.equity[3]), 499046.00) 78 | self.assertEqual(PriceParser.display(statistics.drawdowns[3]), 954.00) 79 | self.assertEqual(statistics.equity_returns[3], -0.0820) 80 | 81 | # Perform transaction and test statistics at this tick 82 | self.portfolio.transact_position( 83 | "SLD", "AMZN", 100, 84 | PriceParser.parse(565.83), PriceParser.parse(1.00) 85 | ) 86 | t = "2000-01-04 00:00:00" 87 | statistics.update(t, portfolio_handler) 88 | self.assertEqual(PriceParser.display(statistics.equity[4]), 499164.00) 89 | self.assertEqual(PriceParser.display(statistics.drawdowns[4]), 836.00) 90 | self.assertEqual(statistics.equity_returns[4], 0.0236) 91 | 92 | # Perform transaction and test statistics at this tick 93 | self.portfolio.transact_position( 94 | "BOT", "GOOG", 200, 95 | PriceParser.parse(705.545), PriceParser.parse(1.00) 96 | ) 97 | t = "2000-01-05 00:00:00" 98 | statistics.update(t, portfolio_handler) 99 | self.assertEqual(PriceParser.display(statistics.equity[5]), 499146.00) 100 | self.assertEqual(PriceParser.display(statistics.drawdowns[5]), 854.00) 101 | self.assertEqual(statistics.equity_returns[5], -0.0036) 102 | 103 | # Perform transaction and test statistics at this tick 104 | self.portfolio.transact_position( 105 | "SLD", "AMZN", 200, 106 | PriceParser.parse(565.59), PriceParser.parse(1.00) 107 | ) 108 | t = "2000-01-06 00:00:00" 109 | statistics.update(t, portfolio_handler) 110 | self.assertEqual(PriceParser.display(statistics.equity[6]), 499335.00) 111 | self.assertEqual(PriceParser.display(statistics.drawdowns[6]), 665.00) 112 | self.assertEqual(statistics.equity_returns[6], 0.0379) 113 | 114 | # Perform transaction and test statistics at this tick 115 | self.portfolio.transact_position( 116 | "SLD", "GOOG", 100, 117 | PriceParser.parse(707.92), PriceParser.parse(1.00) 118 | ) 119 | t = "2000-01-07 00:00:00" 120 | statistics.update(t, portfolio_handler) 121 | self.assertEqual(PriceParser.display(statistics.equity[7]), 499580.00) 122 | self.assertEqual(PriceParser.display(statistics.drawdowns[7]), 420.00) 123 | self.assertEqual(statistics.equity_returns[7], 0.0490) 124 | 125 | # Perform transaction and test statistics at this tick 126 | self.portfolio.transact_position( 127 | "SLD", "GOOG", 100, 128 | PriceParser.parse(707.90), PriceParser.parse(0.00) 129 | ) 130 | t = "2000-01-08 00:00:00" 131 | statistics.update(t, portfolio_handler) 132 | self.assertEqual(PriceParser.display(statistics.equity[8]), 499824.00) 133 | self.assertEqual(PriceParser.display(statistics.drawdowns[8]), 176.00) 134 | self.assertEqual(statistics.equity_returns[8], 0.0488) 135 | 136 | # Perform transaction and test statistics at this tick 137 | self.portfolio.transact_position( 138 | "SLD", "GOOG", 100, 139 | PriceParser.parse(707.92), PriceParser.parse(0.50) 140 | ) 141 | t = "2000-01-09 00:00:00" 142 | statistics.update(t, portfolio_handler) 143 | self.assertEqual(PriceParser.display(statistics.equity[9]), 500069.50) 144 | self.assertEqual(PriceParser.display(statistics.drawdowns[9]), 00.00) 145 | self.assertEqual(statistics.equity_returns[9], 0.0491) 146 | 147 | # Perform transaction and test statistics at this tick 148 | self.portfolio.transact_position( 149 | "SLD", "GOOG", 100, 150 | PriceParser.parse(707.78), PriceParser.parse(1.00) 151 | ) 152 | t = "2000-01-10 00:00:00" 153 | statistics.update(t, portfolio_handler) 154 | self.assertEqual(PriceParser.display(statistics.equity[10]), 500300.50) 155 | self.assertEqual(PriceParser.display(statistics.drawdowns[10]), 00.00) 156 | self.assertEqual(statistics.equity_returns[10], 0.0462) 157 | 158 | # Test that results are calculated correctly. 159 | results = statistics.get_results() 160 | self.assertEqual(results["max_drawdown"], 954.00) 161 | self.assertEqual(results["max_drawdown_pct"], 0.1908) 162 | self.assertAlmostEqual(float(results["sharpe"]), 1.7575) 163 | 164 | 165 | if __name__ == "__main__": 166 | unittest.main() 167 | --------------------------------------------------------------------------------