diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index 1b754e02..5d988a7d 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -3502,6 +3502,115 @@ class TestFutureFlip(WithDataPortal, WithSimParams, ZiplineTestCase): format(i, actual_position, expected_positions[i])) +class TestFuturesAlgo(WithDataPortal, WithSimParams, ZiplineTestCase): + START_DATE = pd.Timestamp('2016-01-06', tz='utc') + END_DATE = pd.Timestamp('2016-01-07', tz='utc') + FUTURE_MINUTE_BAR_START_DATE = pd.Timestamp('2016-01-05', tz='UTC') + + SIM_PARAMS_DATA_FREQUENCY = 'minute' + + TRADING_CALENDAR_STRS = ('us_futures',) + TRADING_CALENDAR_PRIMARY_CAL = 'us_futures' + + @classmethod + def make_futures_info(cls): + return pd.DataFrame.from_dict( + { + 1: { + 'symbol': 'CLG16', + 'root_symbol': 'CL', + 'start_date': pd.Timestamp('2015-12-01', tz='UTC'), + 'notice_date': pd.Timestamp('2016-01-20', tz='UTC'), + 'expiration_date': pd.Timestamp('2016-02-19', tz='UTC'), + 'auto_close_date': pd.Timestamp('2016-01-18', tz='UTC'), + 'exchange': 'TEST', + }, + }, + orient='index', + ) + + def test_futures_history(self): + algo_code = dedent( + """ + from datetime import time + from zipline.api import ( + date_rules, + get_datetime, + schedule_function, + sid, + time_rules, + ) + + def initialize(context): + context.history_values = [] + + schedule_function( + make_history_call, + date_rules.every_day(), + time_rules.market_open(), + ) + + schedule_function( + check_market_close_time, + date_rules.every_day(), + time_rules.market_close(), + ) + + def make_history_call(context, data): + # Ensure that the market open is 6:31am US/Eastern. + open_time = get_datetime().tz_convert('US/Eastern').time() + assert open_time == time(6, 31) + context.history_values.append( + data.history(sid(1), 'close', 5, '1m'), + ) + + def check_market_close_time(context, data): + # Ensure that this function is called at 4:59pm US/Eastern. + # By default, `market_close()` uses an offset of 1 minute. + close_time = get_datetime().tz_convert('US/Eastern').time() + assert close_time == time(16, 59) + """ + ) + + algo = TradingAlgorithm( + script=algo_code, + sim_params=self.sim_params, + env=self.env, + trading_calendar=get_calendar('us_futures'), + ) + algo.run(self.data_portal) + + # Assert that we were able to retrieve history data for minutes outside + # of the 6:31am US/Eastern to 5:00pm US/Eastern futures open times. + np.testing.assert_array_equal( + algo.history_values[0].index, + pd.date_range( + '2016-01-06 6:27', + '2016-01-06 6:31', + freq='min', + tz='US/Eastern', + ), + ) + np.testing.assert_array_equal( + algo.history_values[1].index, + pd.date_range( + '2016-01-07 6:27', + '2016-01-07 6:31', + freq='min', + tz='US/Eastern', + ), + ) + + # Expected prices here are given by the range values created by the + # default `make_future_minute_bar_data` method. + np.testing.assert_array_equal( + algo.history_values[0].values, list(map(float, range(2196, 2201))), + ) + np.testing.assert_array_equal( + algo.history_values[1].values, list(map(float, range(3636, 3641))), + ) + + class TestTradingAlgorithm(ZiplineTestCase): def test_analyze_called(self): self.perf_ref = None diff --git a/zipline/algorithm.py b/zipline/algorithm.py index 9ed90fe8..6cf43d0d 100644 --- a/zipline/algorithm.py +++ b/zipline/algorithm.py @@ -531,6 +531,17 @@ class TradingAlgorithm(object): # as the last minute of the session. market_opens = market_closes + # The calendar's execution times are the minutes over which we actually + # want to run the clock. Typically the execution times simply adhere to + # the market open and close times. In the case of the futures calendar, + # for example, we only want to simulate over a subset of the full 24 + # hour calendar, so the execution times dictate a market open time of + # 6:31am US/Eastern and a close of 5:00pm US/Eastern. + execution_opens = \ + self.trading_calendar.execution_time_from_open(market_opens) + execution_closes = \ + self.trading_calendar.execution_time_from_close(market_closes) + # FIXME generalize these values before_trading_start_minutes = days_at_time( self.sim_params.sessions, @@ -540,8 +551,8 @@ class TradingAlgorithm(object): return MinuteSimulationClock( self.sim_params.sessions, - market_opens, - market_closes, + execution_opens, + execution_closes, before_trading_start_minutes, minute_emission=minutely_emission, ) diff --git a/zipline/utils/calendars/trading_calendar.py b/zipline/utils/calendars/trading_calendar.py index 05a7b104..0a42304c 100644 --- a/zipline/utils/calendars/trading_calendar.py +++ b/zipline/utils/calendars/trading_calendar.py @@ -667,6 +667,12 @@ class TradingCalendar(with_metaclass(ABCMeta)): def last_session(self): return self.all_sessions[-1] + def execution_time_from_open(self, open_dates): + return open_dates + + def execution_time_from_close(self, close_dates): + return close_dates + @lazyval def all_minutes(self): """ diff --git a/zipline/utils/calendars/us_futures_calendar.py b/zipline/utils/calendars/us_futures_calendar.py index 519ffd8c..c5bf1637 100644 --- a/zipline/utils/calendars/us_futures_calendar.py +++ b/zipline/utils/calendars/us_futures_calendar.py @@ -1,6 +1,6 @@ from datetime import time -from pandas import Timestamp +from pandas import Timedelta, Timestamp from pandas.tseries.holiday import GoodFriday from pytz import timezone @@ -13,6 +13,12 @@ from zipline.utils.calendars.us_holidays import ( Christmas ) +# Number of hours of offset between the open and close times dictated by this +# calendar versus the 6:31am to 5:00pm times over which we want to simulate +# futures algos. +FUTURES_OPEN_TIME_OFFSET = 12.5 +FUTURES_CLOSE_TIME_OFFSET = -1 + class QuantopianUSFuturesCalendar(TradingCalendar): """Synthetic calendar for trading US futures. @@ -63,6 +69,12 @@ class QuantopianUSFuturesCalendar(TradingCalendar): def open_offset(self): return -1 + def execution_time_from_open(self, open_dates): + return open_dates + Timedelta(hours=FUTURES_OPEN_TIME_OFFSET) + + def execution_time_from_close(self, close_dates): + return close_dates + Timedelta(hours=FUTURES_CLOSE_TIME_OFFSET) + @property def regular_holidays(self): return HolidayCalendar([ diff --git a/zipline/utils/events.py b/zipline/utils/events.py index 2e2e2c43..657d5ace 100644 --- a/zipline/utils/events.py +++ b/zipline/utils/events.py @@ -348,11 +348,19 @@ class AfterOpen(StatelessRule): self._one_minute = datetime.timedelta(minutes=1) def calculate_dates(self, dt): - # given a dt, find that day's open and period end (open + offset) - self._period_start, self._period_close = \ - self.cal.open_and_close_for_session( - self.cal.minute_to_session_label(dt) - ) + """ + Given a date, find that day's open and period end (open + offset). + """ + period_start, period_close = self.cal.open_and_close_for_session( + self.cal.minute_to_session_label(dt), + ) + + # Align the market open and close times here with the execution times + # used by the simulation clock. This ensures that scheduled functions + # trigger at the correct times. + self._period_start = self.cal.execution_time_from_open(period_start) + self._period_close = self.cal.execution_time_from_close(period_close) + self._period_end = self._period_start + self.offset - self._one_minute def should_trigger(self, dt): @@ -396,11 +404,17 @@ class BeforeClose(StatelessRule): self._one_minute = datetime.timedelta(minutes=1) def calculate_dates(self, dt): - # given a dt, find that day's close and period start (close - offset) - self._period_end = \ - self.cal.open_and_close_for_session( - self.cal.minute_to_session_label(dt) - )[1] + """ + Given a dt, find that day's close and period start (close - offset). + """ + period_end = self.cal.open_and_close_for_session( + self.cal.minute_to_session_label(dt), + )[1] + + # Align the market close time here with the execution time used by the + # simulation clock. This ensures that scheduled functions trigger at + # the correct times. + self._period_end = self.cal.execution_time_from_close(period_end) self._period_start = self._period_end - self.offset self._period_close = self._period_end