Skip to content

Commit

Permalink
Added more unit tests. Added more Python versions. Removed need to se…
Browse files Browse the repository at this point in the history
…t ENVVAR for example scripts.
  • Loading branch information
mhallsmoore committed May 24, 2020
1 parent 6f9ed09 commit 47b9b32
Show file tree
Hide file tree
Showing 19 changed files with 238 additions and 47 deletions.
7 changes: 7 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[report]
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover

# Don't complain if tests don't hit defensive assertion code:
raise NotImplementedError
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
language: python

python:
- "3.5"
- "3.6"
- "3.7"
- "3.8"

before_install:
- export PYTHONPATH=$PYTHONPATH:$(pwd)
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.1.1

* Removed the need to specify a CSV data directory as an environment variable by adding a default of the current working directory of the executed script
* Addes CI support for Python 3.5, 3.6 and 3.8 in addition to 3.7
* Added some unit tests to improve test coverage slightly

# 0.1.0

* Initial relase of QSTrader to PyPI
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1 @@
include README.md LICENSE
include README.md LICENSE CHANGELOG.md
31 changes: 12 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
![GitHub](https://img.shields.io/github/license/mhallsmoore/qstrader)
[![Build Status](https://travis-ci.org/mhallsmoore/qstrader.svg?branch=master)](https://travis-ci.org/mhallsmoore/qstrader)
[![Coverage Status](https://coveralls.io/repos/github/mhallsmoore/qstrader/badge.svg?branch=development)](https://coveralls.io/github/mhallsmoore/qstrader?branch=master)
[![PyPI](https://img.shields.io/pypi/v/qstrader)](https://pypi.org/project/qstrader)
[![Python Version](https://img.shields.io/pypi/pyversions/qstrader)](https://pypi.org/project/qstrader)

# QSTrader

| Development | Details |
| ------------- | ------------- |
| Test Status | [![Build Status](https://img.shields.io/travis/mhallsmoore/qstrader?label=TravisCI&style=flat-square)](https://travis-ci.org/mhallsmoore/qstrader) [![Coverage Status](https://img.shields.io/coveralls/github/mhallsmoore/qstrader?style=flat-square&label=Coverage)](https://coveralls.io/github/mhallsmoore/qstrader?branch=master) |
| Version Info | [![PyPI](https://img.shields.io/pypi/v/qstrader?style=flat-square&label=PyPI&color=blue)](https://pypi.org/project/qstrader) [![PyPI Downloads](https://img.shields.io/pypi/dm/qstrader?style=flat-square&label=PyPI%20Downloads)](https://pypi.org/project/qstrader) |
| Compatibility | [![Python Version](https://img.shields.io/pypi/pyversions/qstrader?style=flat-square&label=Python%20Versions)](https://pypi.org/project/qstrader) |
| License | ![GitHub](https://img.shields.io/github/license/mhallsmoore/qstrader?style=flat-square&label=License) |

QSTrader is a free Python-based open-source modular schedule-driven backtesting framework for long-only equities and ETF based systematic trading strategies.

QSTrader can be best described as a loosely-coupled collection of modules for carrying out end-to-end backtests with realistic trading mechanics.

The default modules provide useful functionality for certain types of systematic trading strategies and can be utilised without modification. However the intent of QSTrader is for the users to extend, inherit or fully replace each module in order to provide custom functionality for their own use case.

The software is currently under active development (semantic version 0.x.x) and is provided under a permissive "MIT" license.
The software is currently under active development and is provided under a permissive "MIT" license.

# Previous Version and Advanced Algorithmic Trading

Expand All @@ -36,17 +37,9 @@ The QSTrader repository provides some simple example strategies at [/examples](h

Within this quickstart section a classic 60/40 equities/bonds portfolio will be backtested with monthly rebalancing on the last day of the calendar month.

To get started download the [sixty_forty.py](https://github.com/mhallsmoore/qstrader/blob/master/examples/sixty_forty.py) and place into the directory of your choice.

The 60/40 script makes use of OHLC 'daily bar' data from Yahoo Finance. In particular it requires the [SPY](https://finance.yahoo.com/quote/SPY/history?p=SPY) and [AGG](https://finance.yahoo.com/quote/AGG/history?p=AGG) ETFs data. Download the full history for each and save as CSV files in the directory of your choice.

In order to help QSTrader find your data files it is necessary to set an environment variable called ``QSTRADER_CSV_DATA_DIR``. As an example, on a Linux based system, if you have placed your data files underneath the ``/home/myusername/data`` directory then the following line will set the correct value:

```
export QSTRADER_CSV_DATA_DIR='/home/myusername/data'
```
To get started download the [sixty_forty.py](https://github.com/mhallsmoore/qstrader/blob/master/examples/sixty_forty.py) file and place into the directory of your choice.

Alternatively it is possible to add the above line to your environment configuration (such as within the ``.bashrc`` file) to ensure QSTrader can find data files with any new terminal session.
The 60/40 script makes use of OHLC 'daily bar' data from Yahoo Finance. In particular it requires the [SPY](https://finance.yahoo.com/quote/SPY/history?p=SPY) and [AGG](https://finance.yahoo.com/quote/AGG/history?p=AGG) ETFs data. Download the full history for each and save as CSV files in same directory as ``sixty_forty.py``.

Assuming that an appropriate Python environment exists and QSTrader has been installed via pip (see **Installation** above), make sure to activate the virtual environment, navigate to the directory with ``sixty_forty.py`` and type:

Expand All @@ -58,9 +51,9 @@ You will then see some console output as the backtest simulation engine runs thr

![Image of 60/40 Backtest](https://quantstartmedia.s3.amazonaws.com/images/qstrader_sixty_forty_backtest.png)

You can examine the commented ``sixty_forty.py`` file to see the current QSTrader API.
You can examine the commented ``sixty_forty.py`` file to see the current QSTrader backtesting API.

If you have any questions about the installation or example usage then please feel free to email [support@quantstart.com](mailto:support@quantstart.com).
If you have any questions about the installation or example usage then please feel free to email [support@quantstart.com](mailto:support@quantstart.com) or raise an issue [here](https://github.com/mhallsmoore/qstrader/issues).

# Current Features

Expand Down
2 changes: 1 addition & 1 deletion examples/buy_and_hold.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

# To avoid loading all CSV files in the directory, set the
# data source to load only those provided symbols
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR')
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.')
data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols)
data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source])

Expand Down
2 changes: 1 addition & 1 deletion examples/sixty_forty.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

# To avoid loading all CSV files in the directory, set the
# data source to load only those provided symbols
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR')
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.')
data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols)
data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source])

Expand Down
2 changes: 1 addition & 1 deletion examples/sixty_forty_fees.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

# To avoid loading all CSV files in the directory, set the
# data source to load only those provided symbols
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR')
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.')
data_source = CSVDailyBarDataSource(csv_dir, Equity, csv_symbols=strategy_symbols)
data_handler = BacktestDataHandler(strategy_universe, data_sources=[data_source])

Expand Down
2 changes: 1 addition & 1 deletion qstrader/alpha_model/single_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ def __call__(self, dt):
The Asset symbol keyed scalar-valued signals.
"""
assets = self.universe.get_assets(dt)
return {asset.symbol: self.signal for asset in assets}
return {asset: self.signal for asset in assets}
11 changes: 0 additions & 11 deletions qstrader/system/rebalance/end_of_month.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,3 @@ def _generate_rebalances(self):
for date in rebalance_dates
]
return rebalance_times

def output_rebalances(self):
"""
Output the rebalance timestamp list.
Returns
-------
`list[pd.Timestamp]`
The list of rebalance timestamps.
"""
return self.rebalances
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Click>=7.0
matplotlib>=3.1.1
matplotlib>=3.0.3
numpy>=1.17.1
pandas>=0.25.1
seaborn>=0.9.0
2 changes: 1 addition & 1 deletion scripts/static_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def obtain_allocations(allocations):
@click.option('--id', 'strat_id', help='Backtest strategy ID string')
@click.option('--tearsheet', 'tearsheet', is_flag=True, default=False, help='Whether to display the (blocking) tearsheet plot')
def cli(start_date, end_date, allocations, strat_title, strat_id, tearsheet):
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR')
csv_dir = os.environ.get('QSTRADER_CSV_DATA_DIR', '.')

start_dt = pd.Timestamp('%s 00:00:00' % start_date, tz=pytz.UTC)

Expand Down
19 changes: 10 additions & 9 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="qstrader",
version="0.1.0",
version="0.1.1",
description="QSTrader backtesting simulation engine",
long_description=long_description,
long_description_content_type="text/markdown",
Expand All @@ -15,19 +15,20 @@
license="MIT",
classifiers=[
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8"
],
packages=find_packages(exclude=("tests",)),
include_package_data=True,
install_requires=[
"Click==7.1.2",
"matplotlib==3.2.1",
"Click==7.1.2",
"matplotlib==3.0.3",
"numpy==1.18.4",
"pandas==1.0.3",
"seaborn==0.10.1"
"pandas==1.0.3",
"seaborn==0.10.1"
]
)

28 changes: 28 additions & 0 deletions tests/unit/alpha_model/test_fixed_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from unittest.mock import Mock

import pandas as pd
import pytest
import pytz

from qstrader.alpha_model.fixed_signals import FixedSignalsAlphaModel


@pytest.mark.parametrize(
'signals',
[
({'EQ:SPY': 0.75, 'EQ:AGG': 0.75, 'EQ:GLD': 0.75}),
({'EQ:SPY': -0.25, 'EQ:AGG': -0.25, 'EQ:GLD': -0.25})
]
)
def test_fixed_signals_alpha_model(signals):
"""
Checks that the fixed signals alpha model correctly produces
the same signals for each asset in the universe.
"""
universe = Mock()
universe.get_assets.return_value = ['EQ:SPY', 'EQ:AGG', 'EQ:GLD']

alpha = FixedSignalsAlphaModel(universe=universe, signal_weights=signals)
dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc)

assert alpha(dt) == signals
28 changes: 28 additions & 0 deletions tests/unit/alpha_model/test_single_signal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from unittest.mock import Mock

import pandas as pd
import pytest
import pytz

from qstrader.alpha_model.single_signal import SingleSignalAlphaModel


@pytest.mark.parametrize(
'signal,expected_signals',
[
(0.75, {'EQ:SPY': 0.75, 'EQ:AGG': 0.75, 'EQ:GLD': 0.75}),
(-0.25, {'EQ:SPY': -0.25, 'EQ:AGG': -0.25, 'EQ:GLD': -0.25})
]
)
def test_single_signal_alpha_model(signal, expected_signals):
"""
Checks that the single signal alpha model correctly produces
the same signal for each asset in the universe.
"""
universe = Mock()
universe.get_assets.return_value = ['EQ:SPY', 'EQ:AGG', 'EQ:GLD']

alpha = SingleSignalAlphaModel(universe=universe, signal=signal)
dt = pd.Timestamp('2019-01-01 15:00:00', tz=pytz.utc)

assert alpha(dt) == expected_signals
3 changes: 2 additions & 1 deletion tests/unit/portcon/test_pcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
[
(
'empty on both sides',
{}, [], []),
{}, [], []
),
(
'partially intersecting set of assets',
{
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/system/rebalance/test_buy_and_hold_rebalance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pandas as pd
import pytest
import pytz

from qstrader.system.rebalance.buy_and_hold import BuyAndHoldRebalance


@pytest.mark.parametrize(
"start_dt", [('2020-01-01'), ('2020-02-02')]
)
def test_buy_and_hold_rebalance(start_dt):
"""
Checks that the buy and hold rebalance sets the
appropriate internal attributes.
"""
sd = pd.Timestamp(start_dt, tz=pytz.UTC)
reb = BuyAndHoldRebalance(start_dt=sd)

assert reb.start_dt == sd
assert reb.rebalances == [sd]
48 changes: 48 additions & 0 deletions tests/unit/system/rebalance/test_end_of_month_rebalance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pandas as pd
import pytest
import pytz

from qstrader.system.rebalance.end_of_month import EndOfMonthRebalance


@pytest.mark.parametrize(
"start_date,end_date,pre_market,expected_dates,expected_time",
[
(
'2020-03-11', '2020-12-31', False, [
'2020-03-31', '2020-04-30', '2020-05-29', '2020-06-30',
'2020-07-31', '2020-08-31', '2020-09-30', '2020-10-30',
'2020-11-30', '2020-12-31'
], '21:00:00'
),
(
'2019-12-26', '2020-09-01', True, [
'2019-12-31', '2020-01-31', '2020-02-28', '2020-03-31',
'2020-04-30', '2020-05-29', '2020-06-30', '2020-07-31',
'2020-08-31'
], '14:30:00'
)
]
)
def test_monthly_rebalance(
start_date, end_date, pre_market, expected_dates, expected_time
):
"""
Checks that the end of month (business day) rebalance provides
the correct datetimes for the provided range.
"""
sd = pd.Timestamp(start_date, tz=pytz.UTC)
ed = pd.Timestamp(end_date, tz=pytz.UTC)

reb = EndOfMonthRebalance(
start_dt=sd, end_dt=ed, pre_market=pre_market
)

actual_datetimes = reb._generate_rebalances()

expected_datetimes = [
pd.Timestamp('%s %s' % (expected_date, expected_time), tz=pytz.UTC)
for expected_date in expected_dates
]

assert actual_datetimes == expected_datetimes
64 changes: 64 additions & 0 deletions tests/unit/system/rebalance/test_weekly_rebalance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import pandas as pd
import pytest
import pytz

from qstrader.system.rebalance.weekly import WeeklyRebalance


@pytest.mark.parametrize(
"start_date,end_date,weekday,pre_market,expected_dates,expected_time",
[
(
'2020-03-11', '2020-05-17', 'MON', False, [
'2020-03-16', '2020-03-23', '2020-03-30', '2020-04-06',
'2020-04-13', '2020-04-20', '2020-04-27', '2020-05-04',
'2020-05-11'
], '21:00:00'
),
(
'2019-12-26', '2020-02-07', 'WED', True, [
'2020-01-01', '2020-01-08', '2020-01-15', '2020-01-22',
'2020-01-29', '2020-02-05'
], '14:30:00'
)
]
)
def test_weekly_rebalance(
start_date, end_date, weekday, pre_market, expected_dates, expected_time
):
"""
Checks that the weekly rebalance provides the correct business
datetimes for the provided range.
"""
sd = pd.Timestamp(start_date, tz=pytz.UTC)
ed = pd.Timestamp(end_date, tz=pytz.UTC)

reb = WeeklyRebalance(
start_date=sd, end_date=ed, weekday=weekday, pre_market=pre_market
)

actual_datetimes = reb._generate_rebalances()

expected_datetimes = [
pd.Timestamp('%s %s' % (expected_date, expected_time), tz=pytz.UTC)
for expected_date in expected_dates
]

assert actual_datetimes == expected_datetimes


def test_check_weekday_raises_value_error():
"""
Checks that initialisation of WeeklyRebalance raises
a ValueError if the weekday string is in the incorrect
format.
"""
sd = pd.Timestamp('2020-01-01', tz=pytz.UTC)
ed = pd.Timestamp('2020-02-01', tz=pytz.UTC)
pre_market = True
weekday = 'SUN'

with pytest.raises(ValueError):
WeeklyRebalance(
start_date=sd, end_date=ed, weekday=weekday, pre_market=pre_market
)

0 comments on commit 47b9b32

Please sign in to comment.