diff --git a/tests/test_finance.py b/tests/test_finance.py index ef68ba1f..efe401e6 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -390,3 +390,38 @@ class FinanceTestCase(TestCase): self.assertTrue(sid in oo) order_list = oo[sid] self.assertEqual(0, len(order_list)) + + def test_blotter_processes_splits(self): + sim_params = factory.create_simulation_parameters() + blotter = Blotter() + blotter.set_date(sim_params.period_start) + + # set up two open limit orders with very low limit prices, + # one for sid 1 and one for sid 2 + blotter.order(1, 100, 10, None, None) + blotter.order(2, 100, 10, None, None) + + # send in a split for sid 2 + split_event = factory.create_split(2, 0.33333, + sim_params.period_start + + timedelta(days=1)) + + blotter.process_split(split_event) + + for sid in [1, 2]: + order_lists = blotter.open_orders[sid] + self.assertIsNotNone(order_lists) + self.assertEqual(1, len(order_lists)) + + aapl_order = blotter.open_orders[1][0].to_dict() + fls_order = blotter.open_orders[2][0].to_dict() + + # make sure the aapl order didn't change + self.assertEqual(100, aapl_order['amount']) + self.assertEqual(10, aapl_order['limit']) + self.assertEqual(1, aapl_order['sid']) + + # make sure the fls order did change + self.assertEqual(33, fls_order['amount']) + self.assertEqual(30, fls_order['limit']) + self.assertEqual(2, fls_order['sid']) diff --git a/tests/test_perf_tracking.py b/tests/test_perf_tracking.py index cf84b33c..7cf27e49 100644 --- a/tests/test_perf_tracking.py +++ b/tests/test_perf_tracking.py @@ -26,6 +26,7 @@ import itertools import zipline.utils.factory as factory import zipline.finance.performance as perf from zipline.finance.slippage import Transaction, create_transaction +import zipline.utils.math_utils as zp_math from zipline.gens.composites import date_sorted_sources from zipline.finance.trading import SimulationParameters @@ -86,6 +87,62 @@ def calculate_results(host, events): return results +class TestSplitPerformance(unittest.TestCase): + def setUp(self): + self.sim_params, self.dt, self.end_dt = \ + create_random_simulation_parameters() + + # start with $10,000 + self.sim_params.capital_base = 10e3 + + self.benchmark_events = benchmark_events_in_range(self.sim_params) + + def test_split_long_position(self): + with trading.TradingEnvironment() as env: + events = factory.create_trade_history( + 1, + [20, 20], + [100, 100], + oneday, + self.sim_params + ) + + # set up a long position in sid 1 + # 100 shares at $20 apiece = $2000 position + events.insert(0, create_txn(events[0], 20, 100)) + events.append(factory.create_split(1, 0.33333, + env.next_trading_day(events[1].dt))) + + results = calculate_results(self, events) + + # should have 33 shares (at $60 apiece) and $20 in cash + self.assertEqual(2, len(results)) + + latest_positions = results[1]['daily_perf']['positions'] + self.assertEqual(1, len(latest_positions)) + + # check the last position to make sure it's been updated + position = latest_positions[0] + + self.assertEqual(1, position['sid']) + self.assertEqual(33, position['amount']) + self.assertEqual(60, position['cost_basis']) + self.assertEqual(60, position['last_sale_price']) + + # since we started with $10000, and we spent $2000 on the + # position, but then got $20 back, we should have $8020 + # (or close to it) in cash. + + # we won't get exactly 8020 because sometimes a split is + # denoted as a ratio like 0.3333, and we lose some digits + # of precision. thus, make sure we're pretty close. + daily_perf = results[1]['daily_perf'] + + self.assertTrue( + zp_math.tolerant_equals(8020, + daily_perf['ending_cash'], 1)) + + class TestDividendPerformance(unittest.TestCase): def setUp(self): diff --git a/zipline/finance/blotter.py b/zipline/finance/blotter.py index 29ac0094..5460f830 100644 --- a/zipline/finance/blotter.py +++ b/zipline/finance/blotter.py @@ -142,6 +142,14 @@ class Blotter(object): # along with newly placed orders. self.new_orders.append(cur_order) + def process_split(self, split_event): + if split_event.sid not in self.open_orders: + return + + orders_to_modify = self.open_orders[split_event.sid] + for order in orders_to_modify: + order.handle_split(split_event) + def process_trade(self, trade_event): if trade_event.type != zp.DATASOURCE_TYPE.TRADE: return @@ -238,6 +246,26 @@ class Order(object): self.stop_reached = stop_reached self.limit_reached = limit_reached + def handle_split(self, split_event): + ratio = split_event.ratio + + # update the amount, limit_price, and stop_price + # by the split's ratio + + # info here: http://finra.complinet.com/en/display/display_plain.html? + # rbid=2403&element_id=8950&record_id=12208&print=1 + + # if we have an open order for 100 shares at $20, and we get + # a 3:1 split, we now want to have an open order for 33 shares at $60 + # for the amount, we round down to the nearest whole share + self.amount = int(self.amount * ratio) + + if self.limit: + self.limit = round(self.limit / ratio, 2) + + if self.stop: + self.stop = round(self.stop / ratio, 2) + @property def open(self): if self.status == ORDER_STATUS.CANCELLED: diff --git a/zipline/finance/performance.py b/zipline/finance/performance.py index 19e1422a..93f0de03 100644 --- a/zipline/finance/performance.py +++ b/zipline/finance/performance.py @@ -304,6 +304,10 @@ class PerformanceTracker(object): for perf_period in self.perf_periods: perf_period.add_dividend(event) + elif event.type == zp.DATASOURCE_TYPE.SPLIT: + for perf_period in self.perf_periods: + perf_period.handle_split(event) + elif event.type == zp.DATASOURCE_TYPE.ORDER: for perf_period in self.perf_periods: perf_period.record_order(event) @@ -407,7 +411,7 @@ class PerformanceTracker(object): # increment the day counter before we move markers forward. self.day_count += 1.0 - # Take a snapshot of our current peformance to return to the + # Take a snapshot of our current performance to return to the # browser. daily_update = self.to_dict() @@ -504,6 +508,43 @@ class Position(object): def add_dividend(self, dividend): self.dividends.append(dividend) + # Update the position by the split ratio, and return the + # resulting fractional share that will be converted into cash. + + # Returns the unused cash. + def handle_split(self, split): + if (self.sid != split.sid): + raise NameError("updating split with the wrong sid!") + + ratio = split.ratio + + # adjust the # of shares by the ratio + # (if we had 100 shares, and the ratio is 0.33333, + # we now have 33 shares) + + # ie, 33.333 + raw_share_count = self.amount * ratio + + # ie, 33 + full_share_count = math.floor(raw_share_count) + + # ie, 0.333 + fractional_share_count = raw_share_count - full_share_count + + # adjust the cost basis to the nearest cent, ie, 60.0 + new_cost_basis = round(self.cost_basis / ratio, 2) + + # adjust the last sale price + new_last_sale_price = round(self.last_sale_price / ratio, 2) + + self.cost_basis = new_cost_basis + self.last_sale_price = new_last_sale_price + self.amount = full_share_count + + # return the leftover cash, which will be converted into cash + # (rounded to the nearest cent) + return round(float(fractional_share_count * new_cost_basis), 2) + def update(self, txn): if(self.sid != txn.sid): raise NameError('updating position with txn for a different sid') @@ -621,10 +662,18 @@ class PerformancePeriod(object): # included in the event. self.positions[div.sid].add_dividend(div) + def handle_split(self, split): + # Make the position object handle the split. It returns the + # leftover cash from a fractional share, if there is any. + leftover_cash = self.positions[split.sid].handle_split(split) + + if leftover_cash > 0: + self.handle_cash_payment(leftover_cash) + def update_dividends(self, todays_date): """ Check the payment date and ex date against today's date - to detrmine if we are owed a dividend payment or if the + to determine if we are owed a dividend payment or if the payment has been disbursed. """ cash_payments = 0.0 @@ -634,16 +683,19 @@ class PerformancePeriod(object): # credit our cash balance with the dividend payments, or # if we are short, debit our cash balance with the # payments. - self.period_cash_flow += cash_payments # debit our cumulative cash spent with the dividend # payments, or credit our cumulative cash spent if we are # short the stock. - self.cumulative_capital_used -= cash_payments + self.handle_cash_payment(cash_payments) # recalculate performance, including the dividend - # paymtents + # payments self.calculate_performance() + def handle_cash_payment(self, payment_amount): + self.period_cash_flow += payment_amount + self.cumulative_capital_used -= payment_amount + def calculate_performance(self): self.ending_value = self.calculate_positions_value() diff --git a/zipline/gens/tradesimulation.py b/zipline/gens/tradesimulation.py index 167e73c3..521fa9f5 100644 --- a/zipline/gens/tradesimulation.py +++ b/zipline/gens/tradesimulation.py @@ -104,6 +104,9 @@ class AlgorithmSimulator(object): # and don't send a snapshot to handle_data. if date < self.algo_start: for event in snapshot: + if event.type == DATASOURCE_TYPE.SPLIT: + self.algo.blotter.process_split(event) + if event.type in (DATASOURCE_TYPE.TRADE, DATASOURCE_TYPE.CUSTOM): self.update_universe(event) @@ -112,6 +115,9 @@ class AlgorithmSimulator(object): else: for event in snapshot: + if event.type == DATASOURCE_TYPE.SPLIT: + self.algo.blotter.process_split(event) + if event.type in (DATASOURCE_TYPE.TRADE, DATASOURCE_TYPE.CUSTOM): self.update_universe(event) diff --git a/zipline/utils/factory.py b/zipline/utils/factory.py index a605543f..a0899ae3 100644 --- a/zipline/utils/factory.py +++ b/zipline/utils/factory.py @@ -175,6 +175,15 @@ def create_dividend(sid, payment, declared_date, ex_date, pay_date): return div +def create_split(sid, ratio, date): + return Event({ + 'sid': sid, + 'ratio': ratio, + 'dt': date.replace(hour=0, minute=0, second=0, microsecond=0), + 'type': DATASOURCE_TYPE.SPLIT + }) + + def create_txn(sid, price, amount, datetime): txn = Event({ 'sid': sid,