mirror of
https://github.com/wassname/catalyst.git
synced 2026-08-15 12:15:22 +08:00
Changes BcolzDailyBarWriter to not be an abc, data is passed as an iterator of (sid, dataframe) pairs to the write method. Changes the AssetsDBWriter to be a single class which accepts an engine at construction time and has a `write` method for writing dataframes for the various tables. We no longer support writing the various other data types, callers should coerce their data into a dataframe themselves. See zipline.assets.synthetic for some helpers to do this. Adds many new fixtures and updates some existing fixtures to use the new ones: WithDefaultDateBounds A fixture that provides the suite a START_DATE and END_DATE. This is meant to make it easy for other fixtures to synchronize their date ranges without depending on eachother in strange ways. For example, WithBcolzMinuteBarReader and WithBcolzDailyBarReader by default should both have data for the same dates, so they may use depend on WithDefaultDates without forcing a dependency between them. WithTmpDir, WithInstanceTmpDir Provides the suite or individual test case a temporary directory. WithBcolzDailyBarReader Provides the suite a BcolzDailyBarReader which reads from bcolz data written to a temporary directory. The data will be read from dataframes and then converted to bcolz files with BcolzDailyBarWriter.write WithBcolzDailyBarReaderFromCSVs Provides the suite a BcolzDailyBarReader which reads from bcolz data written to a temporary directory. The data will be read from a collection of CSV files and then converted into the bcolz data through BcolzDailyBarWriter.write_csvs WithBcolzMinuteBarReader Provides the suite a BcolzMinuteBarReader which reads from bcolz data written to a temporary directory. The data will be read from dataframes and then converted to bcolz files with BcolzMinuteBarWriter.write WithAdjustmentReader Provides the suite a SQLiteAdjustmentReader which reads from an in memory sqlite database. The data will be read from dataframes and then converted into sqlite with SQLiteAdjustmentWriter.write WithDataPortal Provides each test case a DataPortal object with data from temporary resources.
592 lines
20 KiB
Python
592 lines
20 KiB
Python
"""
|
|
Tests for Algorithms using the Pipeline API.
|
|
"""
|
|
from os.path import (
|
|
dirname,
|
|
join,
|
|
realpath,
|
|
)
|
|
|
|
from nose_parameterized import parameterized
|
|
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 six import iteritems, itervalues
|
|
|
|
from zipline.algorithm import TradingAlgorithm
|
|
from zipline.api import (
|
|
attach_pipeline,
|
|
pipeline_output,
|
|
get_datetime,
|
|
)
|
|
from zipline.errors import (
|
|
AttachPipelineAfterInitialize,
|
|
PipelineOutputDuringInitialize,
|
|
NoSuchPipeline,
|
|
)
|
|
from zipline.lib.adjustment import MULTIPLY
|
|
from zipline.pipeline import Pipeline
|
|
from zipline.pipeline.factors import VWAP
|
|
from zipline.pipeline.data import USEquityPricing
|
|
from zipline.pipeline.loaders.frame import DataFrameLoader
|
|
from zipline.pipeline.loaders.equity_pricing_loader import (
|
|
USEquityPricingLoader,
|
|
)
|
|
from zipline.testing import (
|
|
str_to_seconds
|
|
)
|
|
from zipline.testing import (
|
|
create_empty_splits_mergers_frame,
|
|
FakeDataPortal,
|
|
)
|
|
from zipline.testing.fixtures import (
|
|
WithAdjustmentReader,
|
|
WithBcolzDailyBarReaderFromCSVs,
|
|
WithDataPortal,
|
|
ZiplineTestCase,
|
|
)
|
|
from zipline.utils.tradingcalendar import trading_day
|
|
|
|
|
|
TEST_RESOURCE_PATH = join(
|
|
dirname(dirname(realpath(__file__))), # zipline_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=trading_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_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)
|
|
|
|
# Add a split for 'A' on its second date.
|
|
cls.split_asset = cls.assets[0]
|
|
cls.split_date = cls.split_asset.start_date + 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 - trading_day,
|
|
end=self.last_asset_end + 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 - trading_day,
|
|
end=self.last_asset_end + 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 - trading_day,
|
|
end=self.last_asset_end + 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 - trading_day,
|
|
end=self.last_asset_end + 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')])
|
|
def test_assets_appear_on_correct_days(self, test_name, chunksize):
|
|
"""
|
|
Assert that assets appear at correct times during a backtest, with
|
|
correctly-adjusted close price values.
|
|
"""
|
|
|
|
if chunksize == 'all_but_one_day':
|
|
chunksize = (
|
|
self.dates.get_loc(self.last_asset_end) -
|
|
self.dates.get_loc(self.first_asset_start)
|
|
) - 1
|
|
|
|
def initialize(context):
|
|
p = attach_pipeline(Pipeline(), 'test', chunksize=chunksize)
|
|
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 - 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 spot_price(self, sid, day, column):
|
|
return 100.0
|
|
|
|
|
|
class PipelineAlgorithmTestCase(WithBcolzDailyBarReaderFromCSVs,
|
|
WithAdjustmentReader,
|
|
ZiplineTestCase):
|
|
AAPL = 1
|
|
MSFT = 2
|
|
BRK_A = 3
|
|
assets = ASSET_FINDER_EQUITY_SIDS = AAPL, MSFT, BRK_A
|
|
ASSET_FINDER_EQUITY_SYMBOLS = 'AAPL', 'MSFT', 'BRK_A'
|
|
START_DATE = Timestamp('2014')
|
|
END_DATE = Timestamp('2015')
|
|
BCOLZ_DAILY_BAR_USE_FULL_CALENDAR = True
|
|
|
|
@classmethod
|
|
def make_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 zipline
|
|
# 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_daily_bar_reader,
|
|
cls.adjustment_reader,
|
|
)
|
|
cls.dates = cls.raw_data[cls.AAPL].index.tz_localize('UTC')
|
|
cls.AAPL_split_date = Timestamp("2014-06-09", tz='UTC')
|
|
|
|
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, trading_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 - trading_day]
|
|
assert_almost_equal(before_split, 647.3499, decimal=2)
|
|
assert_almost_equal(
|
|
before_split,
|
|
raw[AAPL].loc[split_date - (2 * trading_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 - trading_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 + trading_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.AAPL, self.MSFT, self.BRK_A
|
|
|
|
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 = 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(),
|
|
# 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(),
|
|
overwrite_sim_params=False,
|
|
)
|
|
|
|
self.assertTrue(count[0] > 0)
|