diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index f4fc5a5c54..b0c132d3bc 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -1392,6 +1392,13 @@ def setUp(self): DATASOURCE_TYPE.CLOSE_POSITION]}, index=self.index) }) + self.no_close_panel = pd.Panel({1: pd.DataFrame({ + 'price': [1, 2, 4], 'volume': [1e9, 0, 0], + 'type': [DATASOURCE_TYPE.TRADE, + DATASOURCE_TYPE.TRADE, + DATASOURCE_TYPE.TRADE]}, + index=self.index) + }) def test_close_position_equity(self): metadata = {1: {'symbol': 'TEST', @@ -1412,8 +1419,7 @@ def test_close_position_equity(self): def test_close_position_future(self): metadata = {1: {'symbol': 'TEST', 'asset_type': 'future', - 'notice_date': self.days[2], - 'expiration_date': self.days[3]}} + }} self.algo = TestAlgorithm(sid=1, amount=1, order_count=1, instant_fill=True, commission=PerShare(0), asset_metadata=metadata) @@ -1426,6 +1432,25 @@ def test_close_position_future(self): self.check_algo_pnl(results, expected_pnl) self.check_algo_positions(results, expected_positions) + def test_auto_close_future(self): + metadata = {1: {'symbol': 'TEST', + 'asset_type': 'future', + 'notice_date': self.days[3], + 'expiration_date': self.days[4]}} + self.algo = TestAlgorithm(sid=1, amount=1, order_count=1, + instant_fill=True, commission=PerShare(0), + asset_metadata=metadata) + self.data = DataPanelSource(self.no_close_panel) + + # Check results + results = self.run_algo() + + expected_pnl = [0, 1, 2] + self.check_algo_pnl(results, expected_pnl) + + expected_positions = [1, 1, 0] + self.check_algo_positions(results, expected_positions) + def run_algo(self): results = self.algo.run(self.data) return results diff --git a/tests/test_perf_tracking.py b/tests/test_perf_tracking.py index bb127c1c0d..a1f3b747c8 100644 --- a/tests/test_perf_tracking.py +++ b/tests/test_perf_tracking.py @@ -2009,7 +2009,7 @@ def test_close_position_event(self, env=None): source = DataPanelSource(pan) for i, event in enumerate(source): - txn = pt.create_close_position_transaction(event) + txn = pt.maybe_create_close_position_transaction(event) if event.sid == 1: # Test owned long self.assertEqual(-120, txn.amount) diff --git a/zipline/finance/performance/position_tracker.py b/zipline/finance/performance/position_tracker.py index 37628199fd..143166192d 100644 --- a/zipline/finance/performance/position_tracker.py +++ b/zipline/finance/performance/position_tracker.py @@ -11,6 +11,7 @@ from collections import OrderedDict from six import iteritems, itervalues +from zipline.protocol import Event, DATASOURCE_TYPE from zipline.finance.slippage import Transaction from zipline.utils.serialization_utils import ( VERSION_LABEL @@ -42,11 +43,15 @@ def __init__(self): ) self._positions_store = zp.Positions() + # Dict, keyed on dates, that contains lists of close position events + # for any Assets in this tracker's positions + self.auto_close_position_dates = {} + @with_environment() def _retrieve_asset(self, sid, env=None): return env.asset_finder.retrieve_asset(sid) - def _update_multipliers(self, sid): + def _update_asset(self, sid): try: self._position_value_multipliers[sid] self._position_exposure_multipliers[sid] @@ -64,6 +69,71 @@ def _update_multipliers(self, sid): asset.contract_multiplier self._position_payout_multipliers[sid] = \ asset.contract_multiplier + # Futures are closed on their notice_date + if asset.notice_date: + self._insert_auto_close_position_date( + dt=asset.notice_date, + sid=sid + ) + # If the Future does not have a notice_date, it will be closed + # on its expiration_date + elif asset.expiration_date: + self._insert_auto_close_position_date( + dt=asset.expiration_date, + sid=sid + ) + + def _insert_auto_close_position_date(self, dt, sid): + """ + Inserts the given SID in to the list of positions to be auto-closed by + the given dt. + + Parameters + ---------- + dt : pandas.Timestamp + The date before-which the given SID will be auto-closed + sid : int + The SID of the Asset to be auto-closed + """ + date_sids = self.auto_close_position_dates.setdefault(dt, []) + if sid not in date_sids: + date_sids.append(sid) + + def auto_close_position_events(self, next_trading_day): + """ + Generates CLOSE_POSITION events for any SIDs whose auto-close date is + before the given date. + + Parameters + ---------- + next_trading_day : pandas.Timestamp + The time before-which certain Assets need to be closed + + Yields + ------ + Event + A close position event for any sids that should be closed before + the next_trading_day parameter + """ + past_asset_end_dates = set() + + # Check the auto_close_position_dates dict for SIDs to close + for date, sids in self.auto_close_position_dates.items(): + if date > next_trading_day: + continue + past_asset_end_dates.add(date) + for sid in sids: + # Yield a CLOSE_POSITION event + event = Event({ + 'dt': date, + 'type': DATASOURCE_TYPE.CLOSE_POSITION, + 'sid': sid, + }) + yield event + + # Clear out past dates + while len(past_asset_end_dates) > 0: + self.auto_close_position_dates.pop(past_asset_end_dates.pop()) def update_last_sale(self, event): # NOTE, PerformanceTracker already vetted as TRADE type @@ -92,7 +162,7 @@ def update_positions(self, positions): for sid, pos in iteritems(positions): self._position_amounts[sid] = pos.amount self._position_last_sale_prices[sid] = pos.last_sale_price - self._update_multipliers(sid) + self._update_asset(sid) def update_position(self, sid, amount=None, last_sale_price=None, last_sale_date=None, cost_basis=None): @@ -102,7 +172,7 @@ def update_position(self, sid, amount=None, last_sale_price=None, pos.amount = amount self._position_amounts[sid] = amount self._position_values = None # invalidate cache - self._update_multipliers(sid=sid) + self._update_asset(sid=sid) if last_sale_price is not None: pos.last_sale_price = last_sale_price self._position_last_sale_prices[sid] = last_sale_price @@ -120,7 +190,7 @@ def execute_transaction(self, txn): position.update(txn) self._position_amounts[sid] = position.amount self._position_last_sale_prices[sid] = position.last_sale_price - self._update_multipliers(sid) + self._update_asset(sid) def handle_commission(self, commission): # Adjust the cost basis of the stock if we own it @@ -203,7 +273,7 @@ def handle_split(self, split): self._position_amounts[split.sid] = position.amount self._position_last_sale_prices[split.sid] = \ position.last_sale_price - self._update_multipliers(split.sid) + self._update_asset(split.sid) return leftover_cash def _maybe_earn_dividend(self, dividend): @@ -270,7 +340,7 @@ def pay_dividends(self, dividend_frame): position.amount += share_count self._position_amounts[stock] = position.amount self._position_last_sale_prices[stock] = position.last_sale_price - self._update_multipliers(stock) + self._update_asset(stock) # Add cash equal to the net cash payed from all dividends. Note that # "negative cash" is effectively paid if we're short an asset, @@ -279,14 +349,18 @@ def pay_dividends(self, dividend_frame): net_cash_payment = payments['cash_amount'].fillna(0).sum() return net_cash_payment - def create_close_position_transaction(self, event): + def maybe_create_close_position_transaction(self, event): if not self._position_amounts.get(event.sid): return None + if 'price' in event: + price = event.price + else: + price = self._position_last_sale_prices[event.sid] txn = Transaction( sid=event.sid, amount=(-1 * self._position_amounts[event.sid]), dt=event.dt, - price=event.price, + price=price, commission=0, order_id=0 ) @@ -354,5 +428,6 @@ def __setstate__(self, state): self._position_value_multipliers = OrderedDict() self._position_exposure_multipliers = OrderedDict() self._position_payout_multipliers = OrderedDict() + self.auto_close_position_dates = {} self.update_positions(state['positions']) diff --git a/zipline/finance/performance/tracker.py b/zipline/finance/performance/tracker.py index f459605f20..86c8befe25 100644 --- a/zipline/finance/performance/tracker.py +++ b/zipline/finance/performance/tracker.py @@ -358,15 +358,17 @@ def process_benchmark(self, event): def process_close_position(self, event): - # CLOSE_POSITION events contain prices that must be handled as a final - # trade event - self.process_trade(event) + # CLOSE_POSITION events that contain prices that must be handled as + # a final trade event + if 'price' in event: + self.process_trade(event) - txn = self.position_tracker.create_close_position_transaction(event) + txn = self.position_tracker.\ + maybe_create_close_position_transaction(event) if txn: self.process_transaction(txn) - def check_upcoming_dividends(self, completed_date): + def check_upcoming_dividends(self, next_trading_day): """ Check if we currently own any stocks with dividends whose ex_date is the next trading day. Track how much we should be payed on those @@ -381,13 +383,6 @@ def check_upcoming_dividends(self, completed_date): # period, so bail. return - # Get the next trading day and, if it is outside the bounds of the - # simulation, bail. - next_trading_day = TradingEnvironment.instance().\ - next_trading_day(completed_date) - if (next_trading_day is None) or (next_trading_day >= self.last_close): - return - # Dividends whose ex_date is the next trading day. We need to check if # we own any of these stocks so we know to pay them out when the pay # date comes. @@ -413,6 +408,22 @@ def check_upcoming_dividends(self, completed_date): # notify periods to update their stats period.handle_dividends_paid(net_cash_payment) + def check_asset_auto_closes(self, next_trading_day): + """ + Check if we currently own any futures whose notice_date is + the next trading day. Close those positions. + + Parameters + ---------- + next_trading_day : pandas.Timestamp + The next trading day of the simulation + """ + auto_close_events = self.position_tracker.auto_close_position_events( + next_trading_day=next_trading_day + ) + for event in auto_close_events: + self.process_close_position(event) + def handle_minute_close(self, dt): """ Handles the close of the given minute. This includes handling @@ -477,6 +488,16 @@ def _handle_market_close(self, completed_date): # increment the day counter before we move markers forward. self.day_count += 1.0 + # Get the next trading day and, if it is past the bounds of this + # simulation, return the daily perf packet + next_trading_day = TradingEnvironment.instance().\ + next_trading_day(completed_date) + + # Check if any assets need to be auto-closed before generating today's + # perf period + if next_trading_day: + self.check_asset_auto_closes(next_trading_day=next_trading_day) + # Take a snapshot of our current performance to return to the # browser. daily_update = self.to_dict(emission_type='daily') @@ -498,9 +519,13 @@ def _handle_market_close(self, completed_date): self.todays_performance.period_open = self.market_open self.todays_performance.period_close = self.market_close - # Check for any dividends - self.check_upcoming_dividends(completed_date) + # If the next trading day is irrelevant, then return the daily packet + if (next_trading_day is None) or (next_trading_day >= self.last_close): + return daily_update + # Check for any dividends and auto-closes, then return the daily perf + # packet + self.check_upcoming_dividends(next_trading_day=next_trading_day) return daily_update def handle_simulation_end(self):