Files
catalyst/tests/pipeline/test_pipeline_algo.py
2017-06-19 14:43:10 -07:00

663 lines
22 KiB
Python

"""
Tests for Algorithms using the Pipeline API.
"""
from os.path import (
dirname,
join,
realpath,
)
from nose_parameterized import parameterized
import numpy as np
from numpy import (
array,
arange,
full_like,
float64,
nan,
uint32,
)
from numpy.testing import assert_almost_equal
import pandas as pd
from pandas import (
concat,
DataFrame,
date_range,
read_csv,
Series,
Timestamp,
)
from pandas.tseries.tools import normalize_date
from six import iteritems, itervalues
from catalyst.algorithm import TradingAlgorithm
from catalyst.api import (
attach_pipeline,
pipeline_output,
get_datetime,
)
from catalyst.errors import (
AttachPipelineAfterInitialize,
PipelineOutputDuringInitialize,
NoSuchPipeline,
)
from catalyst.lib.adjustment import MULTIPLY
from catalyst.pipeline import Pipeline
from catalyst.pipeline.factors.equity import VWAP
from catalyst.pipeline.data import USEquityPricing
from catalyst.pipeline.loaders.frame import DataFrameLoader
from catalyst.pipeline.loaders.equity_pricing_loader import (
USEquityPricingLoader,
)
from catalyst.testing import (
str_to_seconds
)
from catalyst.testing import (
create_empty_splits_mergers_frame,
FakeDataPortal,
)
from catalyst.testing.fixtures import (
WithAdjustmentReader,
WithBcolzEquityDailyBarReaderFromCSVs,
WithDataPortal,
ZiplineTestCase,
)
from catalyst.utils.calendars import get_calendar
TEST_RESOURCE_PATH = join(
dirname(dirname(realpath(__file__))), # catalyst_repo/tests
'resources',
'pipeline_inputs',
)
def rolling_vwap(df, length):
"Simple rolling vwap implementation for testing"
closes = df['close'].values
volumes = df['volume'].values
product = closes * volumes
out = full_like(closes, nan)
for upper_bound in range(length, len(closes) + 1):
bounds = slice(upper_bound - length, upper_bound)
out[upper_bound - 1] = product[bounds].sum() / volumes[bounds].sum()
return Series(out, index=df.index)
class ClosesOnly(WithDataPortal, ZiplineTestCase):
sids = 1, 2, 3
START_DATE = pd.Timestamp('2014-01-01', tz='utc')
END_DATE = pd.Timestamp('2014-02-01', tz='utc')
dates = date_range(START_DATE, END_DATE, freq=get_calendar("NYSE").day,
tz='utc')
@classmethod
def make_equity_info(cls):
cls.equity_info = ret = DataFrame.from_records([
{
'sid': 1,
'symbol': 'A',
'start_date': cls.dates[10],
'end_date': cls.dates[13],
'exchange': 'TEST',
},
{
'sid': 2,
'symbol': 'B',
'start_date': cls.dates[11],
'end_date': cls.dates[14],
'exchange': 'TEST',
},
{
'sid': 3,
'symbol': 'C',
'start_date': cls.dates[12],
'end_date': cls.dates[15],
'exchange': 'TEST',
},
])
return ret
@classmethod
def make_equity_daily_bar_data(cls):
cls.closes = DataFrame(
{sid: arange(1, len(cls.dates) + 1) * sid for sid in cls.sids},
index=cls.dates,
dtype=float,
)
for sid in cls.sids:
yield sid, DataFrame(
{
'open': cls.closes[sid].values,
'high': cls.closes[sid].values,
'low': cls.closes[sid].values,
'close': cls.closes[sid].values,
'volume': cls.closes[sid].values,
},
index=cls.dates,
)
@classmethod
def init_class_fixtures(cls):
super(ClosesOnly, cls).init_class_fixtures()
cls.first_asset_start = min(cls.equity_info.start_date)
cls.last_asset_end = max(cls.equity_info.end_date)
cls.assets = cls.asset_finder.retrieve_all(cls.sids)
cls.trading_day = cls.trading_calendar.day
# Add a split for 'A' on its second date.
cls.split_asset = cls.assets[0]
cls.split_date = cls.split_asset.start_date + cls.trading_day
cls.split_ratio = 0.5
cls.adjustments = DataFrame.from_records([
{
'sid': cls.split_asset.sid,
'value': cls.split_ratio,
'kind': MULTIPLY,
'start_date': Timestamp('NaT'),
'end_date': cls.split_date,
'apply_date': cls.split_date,
}
])
def init_instance_fixtures(self):
super(ClosesOnly, self).init_instance_fixtures()
# View of the data on/after the split.
self.adj_closes = adj_closes = self.closes.copy()
adj_closes.ix[:self.split_date, self.split_asset] *= self.split_ratio
self.pipeline_loader = DataFrameLoader(
column=USEquityPricing.close,
baseline=self.closes,
adjustments=self.adjustments,
)
def expected_close(self, date, asset):
if date < self.split_date:
lookup = self.closes
else:
lookup = self.adj_closes
return lookup.loc[date, asset]
def exists(self, date, asset):
return asset.start_date <= date <= asset.end_date
def test_attach_pipeline_after_initialize(self):
"""
Assert that calling attach_pipeline after initialize raises correctly.
"""
def initialize(context):
pass
def late_attach(context, data):
attach_pipeline(Pipeline(), 'test')
raise AssertionError("Shouldn't make it past attach_pipeline!")
algo = TradingAlgorithm(
initialize=initialize,
handle_data=late_attach,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.first_asset_start - self.trading_day,
end=self.last_asset_end + self.trading_day,
env=self.env,
)
with self.assertRaises(AttachPipelineAfterInitialize):
algo.run(self.data_portal)
def barf(context, data):
raise AssertionError("Shouldn't make it past before_trading_start")
algo = TradingAlgorithm(
initialize=initialize,
before_trading_start=late_attach,
handle_data=barf,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.first_asset_start - self.trading_day,
end=self.last_asset_end + self.trading_day,
env=self.env,
)
with self.assertRaises(AttachPipelineAfterInitialize):
algo.run(self.data_portal)
def test_pipeline_output_after_initialize(self):
"""
Assert that calling pipeline_output after initialize raises correctly.
"""
def initialize(context):
attach_pipeline(Pipeline(), 'test')
pipeline_output('test')
raise AssertionError("Shouldn't make it past pipeline_output()")
def handle_data(context, data):
raise AssertionError("Shouldn't make it past initialize!")
def before_trading_start(context, data):
raise AssertionError("Shouldn't make it past initialize!")
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.first_asset_start - self.trading_day,
end=self.last_asset_end + self.trading_day,
env=self.env,
)
with self.assertRaises(PipelineOutputDuringInitialize):
algo.run(self.data_portal)
def test_get_output_nonexistent_pipeline(self):
"""
Assert that calling add_pipeline after initialize raises appropriately.
"""
def initialize(context):
attach_pipeline(Pipeline(), 'test')
def handle_data(context, data):
raise AssertionError("Shouldn't make it past before_trading_start")
def before_trading_start(context, data):
pipeline_output('not_test')
raise AssertionError("Shouldn't make it past pipeline_output!")
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.first_asset_start - self.trading_day,
end=self.last_asset_end + self.trading_day,
env=self.env,
)
with self.assertRaises(NoSuchPipeline):
algo.run(self.data_portal)
@parameterized.expand([('default', None),
('day', 1),
('week', 5),
('year', 252),
('all_but_one_day', 'all_but_one_day'),
('custom_iter', 'custom_iter')])
def test_assets_appear_on_correct_days(self, test_name, chunks):
"""
Assert that assets appear at correct times during a backtest, with
correctly-adjusted close price values.
"""
if chunks == 'all_but_one_day':
chunks = (
self.dates.get_loc(self.last_asset_end) -
self.dates.get_loc(self.first_asset_start)
) - 1
elif chunks == 'custom_iter':
chunks = []
st = np.random.RandomState(12345)
remaining = (
self.dates.get_loc(self.last_asset_end) -
self.dates.get_loc(self.first_asset_start)
)
while remaining > 0:
chunk = st.randint(3)
chunks.append(chunk)
remaining -= chunk
def initialize(context):
p = attach_pipeline(Pipeline(), 'test', chunks=chunks)
p.add(USEquityPricing.close.latest, 'close')
def handle_data(context, data):
results = pipeline_output('test')
date = get_datetime().normalize()
for asset in self.assets:
# Assets should appear iff they exist today and yesterday.
exists_today = self.exists(date, asset)
existed_yesterday = self.exists(date - self.trading_day, asset)
if exists_today and existed_yesterday:
latest = results.loc[asset, 'close']
self.assertEqual(latest, self.expected_close(date, asset))
else:
self.assertNotIn(asset, results.index)
before_trading_start = handle_data
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.first_asset_start,
end=self.last_asset_end,
env=self.env,
)
# Run for a week in the middle of our data.
algo.run(self.data_portal)
class MockDailyBarSpotReader(object):
"""
A BcolzDailyBarReader which returns a constant value for spot price.
"""
def get_value(self, sid, day, column):
return 100.0
class PipelineAlgorithmTestCase(WithBcolzEquityDailyBarReaderFromCSVs,
WithAdjustmentReader,
ZiplineTestCase):
AAPL = 1
MSFT = 2
BRK_A = 3
ASSET_FINDER_EQUITY_SIDS = AAPL, MSFT, BRK_A
ASSET_FINDER_EQUITY_SYMBOLS = 'AAPL', 'MSFT', 'BRK_A'
START_DATE = Timestamp('2014')
END_DATE = Timestamp('2015')
@classmethod
def make_equity_daily_bar_data(cls):
resources = {
cls.AAPL: join(TEST_RESOURCE_PATH, 'AAPL.csv'),
cls.MSFT: join(TEST_RESOURCE_PATH, 'MSFT.csv'),
cls.BRK_A: join(TEST_RESOURCE_PATH, 'BRK-A.csv'),
}
cls.raw_data = raw_data = {
asset: read_csv(path, parse_dates=['day']).set_index('day')
for asset, path in resources.items()
}
# Add 'price' column as an alias because all kinds of stuff in catalyst
# depends on it being present. :/
for frame in raw_data.values():
frame['price'] = frame['close']
return resources
@classmethod
def make_splits_data(cls):
return DataFrame.from_records([
{
'effective_date': str_to_seconds('2014-06-09'),
'ratio': (1 / 7.0),
'sid': cls.AAPL,
}
])
@classmethod
def make_mergers_data(cls):
return create_empty_splits_mergers_frame()
@classmethod
def make_dividends_data(cls):
return pd.DataFrame(array([], dtype=[
('sid', uint32),
('amount', float64),
('record_date', 'datetime64[ns]'),
('ex_date', 'datetime64[ns]'),
('declared_date', 'datetime64[ns]'),
('pay_date', 'datetime64[ns]'),
]))
@classmethod
def init_class_fixtures(cls):
super(PipelineAlgorithmTestCase, cls).init_class_fixtures()
cls.pipeline_loader = USEquityPricingLoader(
cls.bcolz_equity_daily_bar_reader,
cls.adjustment_reader,
USEquityPricing,
)
cls.dates = cls.raw_data[cls.AAPL].index.tz_localize('UTC')
cls.AAPL_split_date = Timestamp("2014-06-09", tz='UTC')
cls.assets = cls.asset_finder.retrieve_all(
cls.ASSET_FINDER_EQUITY_SIDS
)
def compute_expected_vwaps(self, window_lengths):
AAPL, MSFT, BRK_A = self.AAPL, self.MSFT, self.BRK_A
# Our view of the data before AAPL's split on June 9, 2014.
raw = {k: v.copy() for k, v in iteritems(self.raw_data)}
split_date = self.AAPL_split_date
split_loc = self.dates.get_loc(split_date)
split_ratio = 7.0
# Our view of the data after AAPL's split. All prices from before June
# 9 get divided by the split ratio, and volumes get multiplied by the
# split ratio.
adj = {k: v.copy() for k, v in iteritems(self.raw_data)}
for column in 'open', 'high', 'low', 'close':
adj[AAPL].ix[:split_loc, column] /= split_ratio
adj[AAPL].ix[:split_loc, 'volume'] *= split_ratio
# length -> asset -> expected vwap
vwaps = {length: {} for length in window_lengths}
for length in window_lengths:
for asset in AAPL, MSFT, BRK_A:
raw_vwap = rolling_vwap(raw[asset], length)
adj_vwap = rolling_vwap(adj[asset], length)
# Shift computed results one day forward so that they're
# labelled by the date on which they'll be seen in the
# algorithm. (We can't show the close price for day N until day
# N + 1.)
vwaps[length][asset] = concat(
[
raw_vwap[:split_loc - 1],
adj_vwap[split_loc - 1:]
]
).shift(1, self.trading_calendar.day)
# Make sure all the expected vwaps have the same dates.
vwap_dates = vwaps[1][self.AAPL].index
for dict_ in itervalues(vwaps):
# Each value is a dict mapping sid -> expected series.
for series in itervalues(dict_):
self.assertTrue((vwap_dates == series.index).all())
# Spot check expectations near the AAPL split.
# length 1 vwap for the morning before the split should be the close
# price of the previous day.
before_split = vwaps[1][AAPL].loc[split_date -
self.trading_calendar.day]
assert_almost_equal(before_split, 647.3499, decimal=2)
assert_almost_equal(
before_split,
raw[AAPL].loc[split_date - (2 * self.trading_calendar.day),
'close'],
decimal=2,
)
# length 1 vwap for the morning of the split should be the close price
# of the previous day, **ADJUSTED FOR THE SPLIT**.
on_split = vwaps[1][AAPL].loc[split_date]
assert_almost_equal(on_split, 645.5700 / split_ratio, decimal=2)
assert_almost_equal(
on_split,
raw[AAPL].loc[split_date -
self.trading_calendar.day, 'close'] / split_ratio,
decimal=2,
)
# length 1 vwap on the day after the split should be the as-traded
# close on the split day.
after_split = vwaps[1][AAPL].loc[split_date +
self.trading_calendar.day]
assert_almost_equal(after_split, 93.69999, decimal=2)
assert_almost_equal(
after_split,
raw[AAPL].loc[split_date, 'close'],
decimal=2,
)
return vwaps
@parameterized.expand([
(True,),
(False,),
])
def test_handle_adjustment(self, set_screen):
AAPL, MSFT, BRK_A = assets = self.assets
window_lengths = [1, 2, 5, 10]
vwaps = self.compute_expected_vwaps(window_lengths)
def vwap_key(length):
return "vwap_%d" % length
def initialize(context):
pipeline = Pipeline()
context.vwaps = []
for length in vwaps:
name = vwap_key(length)
factor = VWAP(window_length=length)
context.vwaps.append(factor)
pipeline.add(factor, name=name)
filter_ = (USEquityPricing.close.latest > 300)
pipeline.add(filter_, 'filter')
if set_screen:
pipeline.set_screen(filter_)
attach_pipeline(pipeline, 'test')
def handle_data(context, data):
today = normalize_date(get_datetime())
results = pipeline_output('test')
expect_over_300 = {
AAPL: today < self.AAPL_split_date,
MSFT: False,
BRK_A: True,
}
for asset in assets:
should_pass_filter = expect_over_300[asset]
if set_screen and not should_pass_filter:
self.assertNotIn(asset, results.index)
continue
asset_results = results.loc[asset]
self.assertEqual(asset_results['filter'], should_pass_filter)
for length in vwaps:
computed = results.loc[asset, vwap_key(length)]
expected = vwaps[length][asset].loc[today]
# Only having two places of precision here is a bit
# unfortunate.
assert_almost_equal(computed, expected, decimal=2)
# Do the same checks in before_trading_start
before_trading_start = handle_data
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.dates[max(window_lengths)],
end=self.dates[-1],
env=self.env,
)
algo.run(
FakeDataPortal(self.env),
# Yes, I really do want to use the start and end dates I passed to
# TradingAlgorithm.
overwrite_sim_params=False,
)
def test_empty_pipeline(self):
# For ensuring we call before_trading_start.
count = [0]
def initialize(context):
pipeline = attach_pipeline(Pipeline(), 'test')
vwap = VWAP(window_length=10)
pipeline.add(vwap, 'vwap')
# Nothing should have prices less than 0.
pipeline.set_screen(vwap < 0)
def handle_data(context, data):
pass
def before_trading_start(context, data):
context.results = pipeline_output('test')
self.assertTrue(context.results.empty)
count[0] += 1
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.dates[0],
end=self.dates[-1],
env=self.env,
)
algo.run(
FakeDataPortal(self.env),
overwrite_sim_params=False,
)
self.assertTrue(count[0] > 0)
def test_pipeline_beyond_daily_bars(self):
"""
Ensure that we can run an algo with pipeline beyond the max date
of the daily bars.
"""
# For ensuring we call before_trading_start.
count = [0]
current_day = self.trading_calendar.next_session_label(
self.pipeline_loader.raw_price_loader.last_available_dt,
)
def initialize(context):
pipeline = attach_pipeline(Pipeline(), 'test')
vwap = VWAP(window_length=10)
pipeline.add(vwap, 'vwap')
# Nothing should have prices less than 0.
pipeline.set_screen(vwap < 0)
def handle_data(context, data):
pass
def before_trading_start(context, data):
context.results = pipeline_output('test')
self.assertTrue(context.results.empty)
count[0] += 1
algo = TradingAlgorithm(
initialize=initialize,
handle_data=handle_data,
before_trading_start=before_trading_start,
data_frequency='daily',
get_pipeline_loader=lambda column: self.pipeline_loader,
start=self.dates[0],
end=current_day,
env=self.env,
)
algo.run(
FakeDataPortal(self.env),
overwrite_sim_params=False,
)
self.assertTrue(count[0] > 0)