diff --git a/tests/finance/test_blotter.py b/tests/finance/test_blotter.py index aaaa2bf7..9c39115a 100644 --- a/tests/finance/test_blotter.py +++ b/tests/finance/test_blotter.py @@ -28,7 +28,7 @@ from zipline.finance.execution import ( ) from zipline.finance.order import ORDER_STATUS, Order from zipline.finance.slippage import ( - DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT, + DEFAULT_EQUITY_VOLUME_SLIPPAGE_BAR_LIMIT, FixedSlippage, ) from zipline.gens.sim_engine import BAR, SESSION_END @@ -292,7 +292,7 @@ class BlotterTestCase(WithCreateBarData, order_size = 100 expected_filled = int(trade_amt * - DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT) + DEFAULT_EQUITY_VOLUME_SLIPPAGE_BAR_LIMIT) expected_open = order_size - expected_filled expected_status = ORDER_STATUS.OPEN if expected_open else \ ORDER_STATUS.FILLED diff --git a/tests/finance/test_commissions.py b/tests/finance/test_commissions.py index 7bedb51a..ae1e009d 100644 --- a/tests/finance/test_commissions.py +++ b/tests/finance/test_commissions.py @@ -1,8 +1,18 @@ from datetime import timedelta from textwrap import dedent +from nose_parameterized import parameterized +from pandas import DataFrame + from zipline import TradingAlgorithm -from zipline.finance.commission import PerTrade, PerShare, PerDollar +from zipline.errors import IncompatibleCommissionModel +from zipline.finance.commission import ( + PerContract, + PerDollar, + PerFutureTrade, + PerShare, + PerTrade, +) from zipline.finance.order import Order from zipline.finance.transaction import Transaction from zipline.testing import ZiplineTestCase, trades_by_sid_to_dfs @@ -17,83 +27,199 @@ from zipline.utils import factory class CommissionUnitTests(WithAssetFinder, ZiplineTestCase): ASSET_FINDER_EQUITY_SIDS = 1, 2 - def generate_order_and_txns(self): - asset1 = self.asset_finder.retrieve_asset(1) + @classmethod + def make_futures_info(cls): + return DataFrame({ + 'sid': [1000, 1001], + 'root_symbol': ['CL', 'FV'], + 'symbol': ['CLF07', 'FVF07'], + 'start_date': [cls.START_DATE, cls.START_DATE], + 'end_date': [cls.END_DATE, cls.END_DATE], + 'notice_date': [cls.END_DATE, cls.END_DATE], + 'expiration_date': [cls.END_DATE, cls.END_DATE], + 'multiplier': [500, 500], + 'exchange': ['CME', 'CME'], + }) + + def generate_order_and_txns(self, sid, order_amount, fill_amounts): + asset1 = self.asset_finder.retrieve_asset(sid) # one order - order = Order(dt=None, asset=asset1, amount=500) + order = Order(dt=None, asset=asset1, amount=order_amount) # three fills - txn1 = Transaction(asset=asset1, amount=230, dt=None, + txn1 = Transaction(asset=asset1, amount=fill_amounts[0], dt=None, price=100, order_id=order.id) - txn2 = Transaction(asset=asset1, amount=170, dt=None, + txn2 = Transaction(asset=asset1, amount=fill_amounts[1], dt=None, price=101, order_id=order.id) - txn3 = Transaction(asset=asset1, amount=100, dt=None, + txn3 = Transaction(asset=asset1, amount=fill_amounts[2], dt=None, price=102, order_id=order.id) return order, [txn1, txn2, txn3] - def test_per_trade(self): - model = PerTrade(cost=10) + def verify_per_trade_commissions(self, + model, + expected_commission, + sid, + order_amount=None, + fill_amounts=None): + fill_amounts = fill_amounts or [230, 170, 100] + order_amount = order_amount or sum(fill_amounts) - order, txns = self.generate_order_and_txns() + order, txns = self.generate_order_and_txns( + sid, order_amount, fill_amounts, + ) - self.assertEqual(10, model.calculate(order, txns[0])) + self.assertEqual(expected_commission, model.calculate(order, txns[0])) - order.commission = 10 + order.commission = expected_commission self.assertEqual(0, model.calculate(order, txns[1])) self.assertEqual(0, model.calculate(order, txns[2])) + def test_per_trade(self): + # Test per trade model for equities. + model = PerTrade(cost=10) + self.verify_per_trade_commissions(model, expected_commission=10, sid=1) + + # Test per trade model for futures. + model = PerFutureTrade(cost=10) + self.verify_per_trade_commissions( + model, expected_commission=10, sid=1000, + ) + + # Test per trade model with custom costs per future symbol. + model = PerFutureTrade(cost={'CL': 5, 'FV': 10}) + self.verify_per_trade_commissions( + model, expected_commission=5, sid=1000, + ) + self.verify_per_trade_commissions( + model, expected_commission=10, sid=1001, + ) + def test_per_share_no_minimum(self): model = PerShare(cost=0.0075, min_trade_cost=None) - order, txns = self.generate_order_and_txns() + order, txns = self.generate_order_and_txns( + sid=1, order_amount=500, fill_amounts=[230, 170, 100], + ) # make sure each commission is pro-rated self.assertAlmostEqual(1.725, model.calculate(order, txns[0])) self.assertAlmostEqual(1.275, model.calculate(order, txns[1])) self.assertAlmostEqual(0.75, model.calculate(order, txns[2])) - def verify_per_share_commissions(self, model, commission_totals): - order, txns = self.generate_order_and_txns() + def verify_per_unit_commissions(self, + model, + commission_totals, + sid, + order_amount=None, + fill_amounts=None): + fill_amounts = fill_amounts or [230, 170, 100] + order_amount = order_amount or sum(fill_amounts) + + order, txns = self.generate_order_and_txns( + sid, order_amount, fill_amounts, + ) for i, commission_total in enumerate(commission_totals): order.commission += model.calculate(order, txns[i]) self.assertAlmostEqual(commission_total, order.commission) order.filled += txns[i].amount + def test_per_contract_no_minimum(self): + # Note that the exchange fee is a one-time cost that is only applied to + # the first fill of an order. + # + # The commission on the first fill is (230 * 0.01) + 0.3 = 2.6 + # The commission on the second fill is 170 * 0.01 = 1.7 + # The total after the second fill is 2.6 + 1.7 = 4.3 + # The commission on the third fill is 100 * 0.01 = 1.0 + # The total after the third fill is 5.3 + model = PerContract(cost=0.01, exchange_fee=0.3, min_trade_cost=None) + self.verify_per_unit_commissions( + model=model, + commission_totals=[2.6, 4.3, 5.3], + sid=1000, + order_amount=500, + fill_amounts=[230, 170, 100], + ) + + # Test using custom costs and fees. + model = PerContract( + cost={'CL': 0.01, 'FV': 0.0075}, + exchange_fee={'CL': 0.3, 'FV': 0.5}, + min_trade_cost=None, + ) + self.verify_per_unit_commissions(model, [2.6, 4.3, 5.3], sid=1000) + self.verify_per_unit_commissions(model, [2.225, 3.5, 4.25], sid=1001) + def test_per_share_with_minimum(self): # minimum is met by the first trade - self.verify_per_share_commissions( + self.verify_per_unit_commissions( PerShare(cost=0.0075, min_trade_cost=1), - [1.725, 3, 3.75] + commission_totals=[1.725, 3, 3.75], + sid=1, ) # minimum is met by the second trade - self.verify_per_share_commissions( + self.verify_per_unit_commissions( PerShare(cost=0.0075, min_trade_cost=2.5), - [2.5, 3, 3.75] + commission_totals=[2.5, 3, 3.75], + sid=1, ) # minimum is met by the third trade - self.verify_per_share_commissions( + self.verify_per_unit_commissions( PerShare(cost=0.0075, min_trade_cost=3.5), - [3.5, 3.5, 3.75] + commission_totals=[3.5, 3.5, 3.75], + sid=1, ) # minimum is not met by any of the trades - self.verify_per_share_commissions( + self.verify_per_unit_commissions( PerShare(cost=0.0075, min_trade_cost=5.5), - [5.5, 5.5, 5.5] + commission_totals=[5.5, 5.5, 5.5], + sid=1, + ) + + def test_per_contract_with_minimum(self): + # Minimum is met by the first trade. + self.verify_per_unit_commissions( + PerContract(cost=.01, exchange_fee=0.3, min_trade_cost=1), + commission_totals=[2.6, 4.3, 5.3], + sid=1000, + ) + + # Minimum is met by the second trade. + self.verify_per_unit_commissions( + PerContract(cost=.01, exchange_fee=0.3, min_trade_cost=3), + commission_totals=[3.0, 4.3, 5.3], + sid=1000, + ) + + # Minimum is met by the third trade. + self.verify_per_unit_commissions( + PerContract(cost=.01, exchange_fee=0.3, min_trade_cost=5), + commission_totals=[5.0, 5.0, 5.3], + sid=1000, + ) + + # Minimum is not met by any of the trades. + self.verify_per_unit_commissions( + PerContract(cost=.01, exchange_fee=0.3, min_trade_cost=7), + commission_totals=[7.0, 7.0, 7.0], + sid=1000, ) def test_per_dollar(self): model = PerDollar(cost=0.0015) - order, txns = self.generate_order_and_txns() + order, txns = self.generate_order_and_txns( + sid=1, order_amount=500, fill_amounts=[230, 170, 100], + ) # make sure each commission is pro-rated self.assertAlmostEqual(34.5, model.calculate(order, txns[0])) @@ -116,19 +242,36 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): def initialize(context): # for these tests, let us take out the entire bar with no price # impact - set_slippage(slippage.VolumeShareSlippage(1.0, 0)) + set_slippage( + us_equities=slippage.VolumeShareSlippage(1.0, 0), + us_futures=slippage.VolumeShareSlippage(1.0, 0), + ) - {0} + {commission} context.ordered = False def handle_data(context, data): if not context.ordered: - order(sid(133), {1}) + order(sid({sid}), {amount}) context.ordered = True """, ) + @classmethod + def make_futures_info(cls): + return DataFrame({ + 'sid': [1000, 1001], + 'root_symbol': ['CL', 'FV'], + 'symbol': ['CLF07', 'FVF07'], + 'start_date': [cls.START_DATE, cls.START_DATE], + 'end_date': [cls.END_DATE, cls.END_DATE], + 'notice_date': [cls.END_DATE, cls.END_DATE], + 'expiration_date': [cls.END_DATE, cls.END_DATE], + 'multiplier': [500, 500], + 'exchange': ['CME', 'CME'], + }) + @classmethod def make_equity_daily_bar_data(cls): num_days = len(cls.sim_params.sessions) @@ -158,7 +301,11 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): def test_per_trade(self): results = self.get_results( - self.code.format("set_commission(commission.PerTrade(1))", 300) + self.code.format( + commission="set_commission(commission.PerTrade(1))", + sid=133, + amount=300, + ) ) # should be 3 fills at 100 shares apiece @@ -169,10 +316,30 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): self.verify_capital_used(results, [-1001, -1000, -1000]) + def test_futures_per_trade(self): + results = self.get_results( + self.code.format( + commission=( + 'set_commission(us_futures=commission.PerFutureTrade(1))' + ), + sid=1000, + amount=10, + ) + ) + + # The capital used is only -1.0 (the commission cost) because no + # capital is actually spent to enter into a long position on a futures + # contract. + self.assertEqual(results.orders[1][0]['commission'], 1.0) + self.assertEqual(results.capital_used[1], -1.0) + def test_per_share_no_minimum(self): results = self.get_results( - self.code.format("set_commission(commission.PerShare(0.05, None))", - 300) + self.code.format( + commission="set_commission(commission.PerShare(0.05, None))", + sid=133, + amount=300, + ) ) # should be 3 fills at 100 shares apiece @@ -186,8 +353,11 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): def test_per_share_with_minimum(self): # minimum hit by first trade results = self.get_results( - self.code.format("set_commission(commission.PerShare(0.05, 3))", - 300) + self.code.format( + commission="set_commission(commission.PerShare(0.05, 3))", + sid=133, + amount=300, + ) ) # commissions should be 5, 10, 15 @@ -198,8 +368,11 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): # minimum hit by second trade results = self.get_results( - self.code.format("set_commission(commission.PerShare(0.05, 8))", - 300) + self.code.format( + commission="set_commission(commission.PerShare(0.05, 8))", + sid=133, + amount=300, + ) ) # commissions should be 8, 10, 15 @@ -211,8 +384,11 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): # minimum hit by third trade results = self.get_results( - self.code.format("set_commission(commission.PerShare(0.05, 12))", - 300) + self.code.format( + commission="set_commission(commission.PerShare(0.05, 12))", + sid=133, + amount=300, + ) ) # commissions should be 12, 12, 15 @@ -224,8 +400,11 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): # minimum never hit results = self.get_results( - self.code.format("set_commission(commission.PerShare(0.05, 18))", - 300) + self.code.format( + commission="set_commission(commission.PerShare(0.05, 18))", + sid=133, + amount=300, + ) ) # commissions should be 18, 18, 18 @@ -235,9 +414,40 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): self.verify_capital_used(results, [-1018, -1000, -1000]) + @parameterized.expand([ + # The commission is (10 * 0.05) + 1.3 = 1.8, and the capital used is + # the same as the commission cost because no capital is actually spent + # to enter into a long position on a futures contract. + (None, 1.8), + # Minimum hit by first trade. + (1, 1.8), + # Minimum not hit by first trade, so use the minimum. + (3, 3.0), + ]) + def test_per_contract(self, min_trade_cost, expected_commission): + results = self.get_results( + self.code.format( + commission=( + 'set_commission(us_futures=commission.PerContract(' + 'cost=0.05, exchange_fee=1.3, min_trade_cost={}))' + ).format(min_trade_cost), + sid=1000, + amount=10, + ), + ) + + self.assertEqual( + results.orders[1][0]['commission'], expected_commission, + ) + self.assertEqual(results.capital_used[1], -expected_commission) + def test_per_dollar(self): results = self.get_results( - self.code.format("set_commission(commission.PerDollar(0.01))", 300) + self.code.format( + commission="set_commission(commission.PerDollar(0.01))", + sid=133, + amount=300, + ) ) # should be 3 fills at 100 shares apiece, each fill is worth $1k, so @@ -249,6 +459,18 @@ class CommissionAlgorithmTests(WithDataPortal, WithSimParams, ZiplineTestCase): self.verify_capital_used(results, [-1010, -1010, -1010]) + def test_incorrectly_set_futures_model(self): + with self.assertRaises(IncompatibleCommissionModel): + # Passing a futures commission model as the first argument, which + # is for setting equity models, should fail. + self.get_results( + self.code.format( + commission='set_commission(commission.PerContract(0, 0))', + sid=1000, + amount=10, + ) + ) + def verify_capital_used(self, results, values): self.assertEqual(values[0], results.capital_used[1]) self.assertEqual(values[1], results.capital_used[2]) diff --git a/tests/finance/test_slippage.py b/tests/finance/test_slippage.py index 85b07a73..95717834 100644 --- a/tests/finance/test_slippage.py +++ b/tests/finance/test_slippage.py @@ -16,24 +16,32 @@ ''' Unit tests for finance.slippage ''' -import datetime from collections import namedtuple - -import pytz +import datetime +from math import sqrt from nose_parameterized import parameterized - -import pandas as pd from pandas.tslib import normalize_date +import numpy as np +import pandas as pd +import pytz -from zipline.finance.slippage import VolumeShareSlippage, \ - fill_price_worse_than_limit_price - -from zipline.protocol import DATASOURCE_TYPE, BarData -from zipline.finance.blotter import Order -from zipline.finance.asset_restrictions import NoRestrictions +from zipline.assets import Equity from zipline.data.data_portal import DataPortal -from zipline.testing import tmp_bcolz_equity_minute_bar_reader +from zipline.finance.asset_restrictions import NoRestrictions +from zipline.finance.order import Order +from zipline.finance.slippage import ( + fill_price_worse_than_limit_price, + MarketImpactBase, + NO_DATA_VOLATILITY_SLIPPAGE_IMPACT, + VolatilityVolumeShare, + VolumeShareSlippage, +) +from zipline.protocol import DATASOURCE_TYPE, BarData +from zipline.testing import ( + create_minute_bar_data, + tmp_bcolz_equity_minute_bar_reader, +) from zipline.testing.fixtures import ( WithCreateBarData, WithDataPortal, @@ -560,10 +568,36 @@ class VolumeShareSlippageTestCase(WithCreateBarData, index=[cls.minutes[0]], ) + @classmethod + def make_futures_info(cls): + return pd.DataFrame({ + 'sid': [1000], + 'root_symbol': ['CL'], + 'symbol': ['CLF06'], + 'start_date': [cls.ASSET_FINDER_EQUITY_START_DATE], + 'end_date': [cls.ASSET_FINDER_EQUITY_END_DATE], + 'multiplier': [500], + 'exchange': ['CME'], + }) + + @classmethod + def make_future_minute_bar_data(cls): + yield 1000, pd.DataFrame( + { + 'open': [5.00], + 'high': [5.15], + 'low': [4.85], + 'close': [5.00], + 'volume': [100], + }, + index=[cls.minutes[0]], + ) + @classmethod def init_class_fixtures(cls): super(VolumeShareSlippageTestCase, cls).init_class_fixtures() cls.ASSET133 = cls.env.asset_finder.retrieve_asset(133) + cls.ASSET1000 = cls.env.asset_finder.retrieve_asset(1000) def test_volume_share_slippage(self): @@ -631,6 +665,235 @@ class VolumeShareSlippageTestCase(WithCreateBarData, self.assertEquals(len(orders_txns), 0) + def test_volume_share_slippage_with_future(self): + slippage_model = VolumeShareSlippage(volume_limit=1, price_impact=0.3) + + open_orders = [ + Order( + dt=datetime.datetime(2006, 1, 5, 14, 30, tzinfo=pytz.utc), + amount=10, + filled=0, + asset=self.ASSET1000, + ), + ] + + bar_data = self.create_bardata( + simulation_dt_func=lambda: self.minutes[0], + ) + + orders_txns = list( + slippage_model.simulate(bar_data, self.ASSET1000, open_orders) + ) + + self.assertEquals(len(orders_txns), 1) + _, txn = orders_txns[0] + + # We expect to fill the order for all 10 contracts. The volume for the + # futures contract in this bar is 100, so our volume share is: + # 10.0 / 100 = 0.1 + # The current price is 5.0 and the price impact is 0.3, so the expected + # impacted price is: + # 5.0 + (5.0 * (0.1 ** 2) * 0.3) = 5.015 + expected_txn = { + 'price': 5.015, + 'dt': datetime.datetime(2006, 1, 5, 14, 31, tzinfo=pytz.utc), + 'amount': 10, + 'asset': self.ASSET1000, + 'commission': None, + 'type': DATASOURCE_TYPE.TRANSACTION, + 'order_id': open_orders[0].id, + } + + self.assertIsNotNone(txn) + self.assertEquals(expected_txn, txn.__dict__) + + +class VolatilityVolumeShareTestCase(WithCreateBarData, + WithSimParams, + WithDataPortal, + ZiplineTestCase): + + ASSET_START_DATE = pd.Timestamp('2006-02-10') + + TRADING_CALENDAR_STRS = ('NYSE', 'us_futures') + TRADING_CALENDAR_PRIMARY_CAL = 'us_futures' + + @classmethod + def init_class_fixtures(cls): + super(VolatilityVolumeShareTestCase, cls).init_class_fixtures() + cls.ASSET = cls.asset_finder.retrieve_asset(1000) + + @classmethod + def make_futures_info(cls): + return pd.DataFrame({ + 'sid': [1000], + 'root_symbol': ['CL'], + 'symbol': ['CLF07'], + 'start_date': [cls.ASSET_START_DATE], + 'end_date': [cls.END_DATE], + 'multiplier': [500], + 'exchange': ['CME'], + }) + + @classmethod + def make_future_minute_bar_data(cls): + data = list( + super( + VolatilityVolumeShareTestCase, cls, + ).make_future_minute_bar_data() + ) + # Make the first month's worth of data NaN to simulate cases where a + # futures contract does not exist yet. + data[0][1].loc[:cls.ASSET_START_DATE] = np.NaN + return data + + def test_calculate_impact_buy(self): + answer_key = [ + # We ordered 10 contracts, but are capped at 100 * 0.05 = 5 + (91485.500085168125, 5), + (91486.500085169057, 5), + (None, None), + ] + order = Order( + dt=pd.Timestamp.now(tz='utc').round('min'), + asset=self.ASSET, + amount=10, + ) + self._calculate_impact(order, answer_key) + + def test_calculate_impact_sell(self): + answer_key = [ + # We ordered -10 contracts, but are capped at -(100 * 0.05) = -5 + (91485.499914831875, -5), + (91486.499914830943, -5), + (None, None), + ] + order = Order( + dt=pd.Timestamp.now(tz='utc').round('min'), + asset=self.ASSET, + amount=-10, + ) + self._calculate_impact(order, answer_key) + + def _calculate_impact(self, test_order, answer_key): + model = VolatilityVolumeShare(volume_limit=0.05) + first_minute = pd.Timestamp('2006-03-31 11:35AM', tz='UTC') + + next_3_minutes = self.trading_calendar.minutes_window(first_minute, 3) + remaining_shares = test_order.open_amount + + for i, minute in enumerate(next_3_minutes): + data = self.create_bardata(simulation_dt_func=lambda: minute) + new_order = Order( + dt=data.current_dt, asset=self.ASSET, amount=remaining_shares, + ) + price, amount = model.process_order(data, new_order) + + self.assertEqual(price, answer_key[i][0]) + self.assertEqual(amount, answer_key[i][1]) + + amount = amount or 0 + if remaining_shares < 0: + remaining_shares = min(0, remaining_shares - amount) + else: + remaining_shares = max(0, remaining_shares - amount) + + def test_calculate_impact_without_history(self): + model = VolatilityVolumeShare(volume_limit=1) + minutes = [ + # Start day of the futures contract; no history yet. + pd.Timestamp('2006-02-10 11:35AM', tz='UTC'), + # Only a week's worth of history data. + pd.Timestamp('2006-02-17 11:35AM', tz='UTC'), + ] + + for minute in minutes: + data = self.create_bardata(simulation_dt_func=lambda: minute) + + order = Order(dt=data.current_dt, asset=self.ASSET, amount=10) + price, amount = model.process_order(data, order) + + avg_price = ( + data.current(self.ASSET, 'high') + + data.current(self.ASSET, 'low') + ) / 2 + expected_price = \ + avg_price + (avg_price * NO_DATA_VOLATILITY_SLIPPAGE_IMPACT) + + self.assertEqual(price, expected_price) + self.assertEqual(amount, 10) + + def test_impacted_price_worse_than_limit(self): + model = VolatilityVolumeShare(volume_limit=0.05) + + # Use all the same numbers from the 'calculate_impact' tests. Since the + # impacted price is 59805.5, which is worse than the limit price of + # 59800, the model should return None. + minute = pd.Timestamp('2006-03-01 11:35AM', tz='UTC') + data = self.create_bardata(simulation_dt_func=lambda: minute) + order = Order( + dt=data.current_dt, asset=self.ASSET, amount=10, limit=59800, + ) + price, amount = model.process_order(data, order) + + self.assertIsNone(price) + self.assertIsNone(amount) + + +class MarketImpactTestCase(WithCreateBarData, ZiplineTestCase): + + ASSET_FINDER_EQUITY_SIDS = (1,) + + @classmethod + def make_equity_minute_bar_data(cls): + trading_calendar = cls.trading_calendars[Equity] + return create_minute_bar_data( + trading_calendar.minutes_for_sessions_in_range( + cls.equity_minute_bar_days[0], + cls.equity_minute_bar_days[-1], + ), + cls.asset_finder.equities_sids, + ) + + def test_window_data(self): + session = pd.Timestamp('2006-03-01') + minute = self.trading_calendar.minutes_for_session(session)[1] + data = self.create_bardata(simulation_dt_func=lambda: minute) + asset = self.asset_finder.retrieve_asset(1) + + mean_volume, volatility = MarketImpactBase()._get_window_data( + data, asset, window_length=20, + ) + + # close volume + # 2006-01-31 00:00:00+00:00 29.0 119.0 + # 2006-02-01 00:00:00+00:00 30.0 120.0 + # 2006-02-02 00:00:00+00:00 31.0 121.0 + # 2006-02-03 00:00:00+00:00 32.0 122.0 + # 2006-02-06 00:00:00+00:00 33.0 123.0 + # 2006-02-07 00:00:00+00:00 34.0 124.0 + # 2006-02-08 00:00:00+00:00 35.0 125.0 + # 2006-02-09 00:00:00+00:00 36.0 126.0 + # 2006-02-10 00:00:00+00:00 37.0 127.0 + # 2006-02-13 00:00:00+00:00 38.0 128.0 + # 2006-02-14 00:00:00+00:00 39.0 129.0 + # 2006-02-15 00:00:00+00:00 40.0 130.0 + # 2006-02-16 00:00:00+00:00 41.0 131.0 + # 2006-02-17 00:00:00+00:00 42.0 132.0 + # 2006-02-21 00:00:00+00:00 43.0 133.0 + # 2006-02-22 00:00:00+00:00 44.0 134.0 + # 2006-02-23 00:00:00+00:00 45.0 135.0 + # 2006-02-24 00:00:00+00:00 46.0 136.0 + # 2006-02-27 00:00:00+00:00 47.0 137.0 + # 2006-02-28 00:00:00+00:00 48.0 138.0 + + # Mean volume is (119 + 138) / 2 = 128.5 + self.assertEqual(mean_volume, 128.5) + + # Volatility is closes.pct_change().std() * sqrt(252) + reference_vol = pd.Series(range(29, 49)).pct_change().std() * sqrt(252) + self.assertEqual(volatility, reference_vol) + class OrdersStopTestCase(WithSimParams, WithTradingEnvironment, diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index e44b14b9..31853fd0 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -56,6 +56,7 @@ from zipline.data.us_equity_pricing import ( from zipline.errors import ( AccountControlViolation, CannotOrderDelistedAsset, + IncompatibleSlippageModel, OrderDuringInitialize, OrderInBeforeTradingStart, RegisterTradingControlPostInit, @@ -1738,6 +1739,27 @@ def handle_data(context, data): finally: tempdir.cleanup() + def test_incorrectly_set_futures_slippage_model(self): + code = dedent( + """ + from zipline.api import set_slippage, slippage + + class MySlippage(slippage.FutureSlippageModel): + def process_order(self, data, order): + return data.current(order.asset, 'price'), order.amount + + def initialize(context): + set_slippage(MySlippage()) + """ + ) + test_algo = TradingAlgorithm( + script=code, sim_params=self.sim_params, env=self.env, + ) + with self.assertRaises(IncompatibleSlippageModel): + # Passing a futures slippage model as the first argument, which is + # for setting equity models, should fail. + test_algo.run(self.data_portal) + def test_algo_record_vars(self): test_algo = TradingAlgorithm( script=record_variables, @@ -3655,6 +3677,99 @@ class TestFuturesAlgo(WithDataPortal, WithSimParams, ZiplineTestCase): algo.history_values[1].values, list(map(float, range(3636, 3641))), ) + @staticmethod + def algo_with_slippage(slippage_model): + return dedent( + """ + from zipline.api import ( + commission, + order, + set_commission, + set_slippage, + sid, + slippage, + get_datetime, + ) + + def initialize(context): + commission_model = commission.PerFutureTrade(0) + set_commission(us_futures=commission_model) + slippage_model = slippage.{model} + set_slippage(us_futures=slippage_model) + context.ordered = False + + def handle_data(context, data): + if not context.ordered: + order(sid(1), 10) + context.ordered = True + context.order_price = data.current(sid(1), 'price') + """ + ).format(model=slippage_model) + + def test_fixed_future_slippage(self): + algo_code = self.algo_with_slippage('FixedSlippage(spread=0.10)') + algo = TradingAlgorithm( + script=algo_code, + sim_params=self.sim_params, + env=self.env, + trading_calendar=get_calendar('us_futures'), + ) + results = algo.run(self.data_portal) + + # Flatten the list of transactions. + all_txns = [ + val for sublist in results['transactions'].tolist() + for val in sublist + ] + + self.assertEqual(len(all_txns), 1) + txn = all_txns[0] + + # Add 1 to the expected price because the order does not fill until the + # bar after the price is recorded. + expected_spread = 0.05 + expected_price = (algo.order_price + 1) + expected_spread + + # Capital used should be 0 because there is no commission, and the cost + # to enter into a long position on a futures contract is 0. + self.assertEqual(txn['price'], expected_price) + self.assertEqual(results['orders'][0][0]['commission'], 0.0) + self.assertEqual(results.capital_used[0], 0.0) + + def test_volume_contract_slippage(self): + algo_code = self.algo_with_slippage( + 'VolumeShareSlippage(volume_limit=0.05, price_impact=0.1)', + ) + algo = TradingAlgorithm( + script=algo_code, + sim_params=self.sim_params, + env=self.env, + trading_calendar=get_calendar('us_futures'), + ) + results = algo.run(self.data_portal) + + # There should be no commissions. + self.assertEqual(results['orders'][0][0]['commission'], 0.0) + + # Flatten the list of transactions. + all_txns = [ + val for sublist in results['transactions'].tolist() + for val in sublist + ] + + # With a volume limit of 0.05, and a total volume of 100 contracts + # traded per minute, we should require 2 transactions to order 10 + # contracts. + self.assertEqual(len(all_txns), 2) + + for i, txn in enumerate(all_txns): + # Add 1 to the order price because the order does not fill until + # the bar after the price is recorded. + order_price = algo.order_price + i + 1 + expected_impact = order_price * 0.1 * (0.05 ** 2) + expected_price = order_price + expected_impact + self.assertEqual(txn['price'], expected_price) + class TestTradingAlgorithm(ZiplineTestCase): def test_analyze_called(self): diff --git a/zipline/algorithm.py b/zipline/algorithm.py index 8ec1883b..ccbd1fae 100644 --- a/zipline/algorithm.py +++ b/zipline/algorithm.py @@ -49,6 +49,8 @@ from zipline.errors import ( AttachPipelineAfterInitialize, CannotOrderDelistedAsset, HistoryInInitialize, + IncompatibleCommissionModel, + IncompatibleSlippageModel, NoSuchPipeline, OrderDuringInitialize, OrderInBeforeTradingStart, @@ -61,14 +63,11 @@ from zipline.errors import ( SetCommissionPostInit, SetSlippagePostInit, UnsupportedCancelPolicy, - UnsupportedCommissionModel, UnsupportedDatetimeFormat, UnsupportedOrderParameters, - UnsupportedSlippageModel, ) from zipline.finance.trading import TradingEnvironment from zipline.finance.blotter import Blotter -from zipline.finance.commission import CommissionModel from zipline.finance.controls import ( LongOnly, MaxOrderCount, @@ -85,7 +84,6 @@ from zipline.finance.execution import ( ) from zipline.finance.performance import PerformanceTracker from zipline.finance.asset_restrictions import Restrictions -from zipline.finance.slippage import SlippageModel from zipline.finance.cancel_policy import NeverCancel, CancelPolicy from zipline.finance.asset_restrictions import ( NoRestrictions, @@ -1656,34 +1654,51 @@ class TradingAlgorithm(object): return dt @api_method - def set_slippage(self, slippage): - """Set the slippage model for the simulation. + def set_slippage(self, us_equities=None, us_futures=None): + """Set the slippage models for the simulation. Parameters ---------- - slippage : SlippageModel - The slippage model to use. + us_equities : EquitySlippageModel + The slippage model to use for trading US equities. + us_futures : FutureSlippageModel + The slippage model to use for trading US futures. See Also -------- :class:`zipline.finance.slippage.SlippageModel` """ - if not isinstance(slippage, SlippageModel): - raise UnsupportedSlippageModel() if self.initialized: raise SetSlippagePostInit() - # TODO: Create separate API methods for setting Equity and Future - # slippage models. - self.blotter.slippage_models[Equity] = slippage + + if us_equities is not None: + if Equity not in us_equities.allowed_asset_types: + raise IncompatibleSlippageModel( + asset_type='equities', + given_model=us_equities, + supported_asset_types=us_equities.allowed_asset_types, + ) + self.blotter.slippage_models[Equity] = us_equities + + if us_futures is not None: + if Future not in us_futures.allowed_asset_types: + raise IncompatibleSlippageModel( + asset_type='futures', + given_model=us_futures, + supported_asset_types=us_futures.allowed_asset_types, + ) + self.blotter.slippage_models[Future] = us_futures @api_method - def set_commission(self, commission): - """Sets the commission model for the simulation. + def set_commission(self, us_equities=None, us_futures=None): + """Sets the commission models for the simulation. Parameters ---------- - commission : CommissionModel - The commission model to use. + us_equities : EquityCommissionModel + The commission model to use for trading US equities. + us_futures : FutureCommissionModel + The commission model to use for trading US futures. See Also -------- @@ -1691,15 +1706,26 @@ class TradingAlgorithm(object): :class:`zipline.finance.commission.PerTrade` :class:`zipline.finance.commission.PerDollar` """ - if not isinstance(commission, CommissionModel): - raise UnsupportedCommissionModel() - if self.initialized: raise SetCommissionPostInit() - # TODO: Create separate API methods for setting Equity and Future - # commission models. - self.blotter.commission_models[Equity] = commission + if us_equities is not None: + if Equity not in us_equities.allowed_asset_types: + raise IncompatibleCommissionModel( + asset_type='equities', + given_model=us_equities, + supported_asset_types=us_equities.allowed_asset_types, + ) + self.blotter.commission_models[Equity] = us_equities + + if us_futures is not None: + if Future not in us_futures.allowed_asset_types: + raise IncompatibleCommissionModel( + asset_type='futures', + given_model=us_futures, + supported_asset_types=us_futures.allowed_asset_types, + ) + self.blotter.commission_models[Future] = us_futures @api_method def set_cancel_policy(self, cancel_policy): diff --git a/zipline/errors.py b/zipline/errors.py index 04080510..015818c8 100644 --- a/zipline/errors.py +++ b/zipline/errors.py @@ -82,6 +82,17 @@ Please use VolumeShareSlippage or FixedSlippage. """.strip() +class IncompatibleSlippageModel(ZiplineError): + """ + Raised if a user tries to set a futures slippage model for equities or vice + versa. + """ + msg = """ +You attempted to set an incompatible slippage model for {asset_type}. \ +The slippage model '{given_model}' only supports {supported_asset_types}. +""".strip() + + class SetSlippagePostInit(ZiplineError): # Raised if a users script calls set_slippage magic # after the initialize method has returned. @@ -130,6 +141,17 @@ Please use PerShare or PerTrade. """.strip() +class IncompatibleCommissionModel(ZiplineError): + """ + Raised if a user tries to set a futures commission model for equities or + vice versa. + """ + msg = """ +You attempted to set an incompatible commission model for {asset_type}. \ +The commission model '{given_model}' only supports {supported_asset_types}. +""".strip() + + class UnsupportedCancelPolicy(ZiplineError): """ Raised if a user script calls set_cancel_policy with an object that isn't diff --git a/zipline/finance/blotter.py b/zipline/finance/blotter.py index 11d58a11..52ef5995 100644 --- a/zipline/finance/blotter.py +++ b/zipline/finance/blotter.py @@ -20,11 +20,16 @@ from six import iteritems from zipline.assets import Equity, Future, Asset from zipline.finance.order import Order -from zipline.finance.slippage import VolumeShareSlippage +from zipline.finance.slippage import ( + DEFAULT_FUTURE_VOLUME_SLIPPAGE_BAR_LIMIT, + VolatilityVolumeShare, + VolumeShareSlippage, +) from zipline.finance.commission import ( - DEFAULT_FUTURE_COST_PER_TRADE, + DEFAULT_PER_CONTRACT_COST, + FUTURE_EXCHANGE_FEES_BY_SYMBOL, + PerContract, PerShare, - PerTrade, ) from zipline.finance.cancel_policy import NeverCancel from zipline.utils.input_validation import expect_types @@ -51,12 +56,15 @@ class Blotter(object): self.slippage_models = { Equity: equity_slippage or VolumeShareSlippage(), - Future: future_slippage or VolumeShareSlippage(), + Future: future_slippage or VolatilityVolumeShare( + volume_limit=DEFAULT_FUTURE_VOLUME_SLIPPAGE_BAR_LIMIT, + ), } self.commission_models = { Equity: equity_commission or PerShare(), - Future: future_commission or PerTrade( - cost=DEFAULT_FUTURE_COST_PER_TRADE, + Future: future_commission or PerContract( + cost=DEFAULT_PER_CONTRACT_COST, + exchange_fee=FUTURE_EXCHANGE_FEES_BY_SYMBOL, ), } diff --git a/zipline/finance/commission.py b/zipline/finance/commission.py index 60b9cfd8..61a17711 100644 --- a/zipline/finance/commission.py +++ b/zipline/finance/commission.py @@ -13,13 +13,21 @@ # See the License for the specific language governing permissions and # limitations under the License. import abc +from abc import abstractmethod, abstractproperty +from collections import defaultdict -from abc import abstractmethod from six import with_metaclass +from toolz import merge -DEFAULT_PER_SHARE_COST = 0.0075 # 0.75 cents per share -DEFAULT_MINIMUM_COST_PER_TRADE = 1.0 # $1 per trade -DEFAULT_FUTURE_COST_PER_TRADE = 2.35 +from zipline.assets import Equity, Future +from zipline.finance.constants import FUTURE_EXCHANGE_FEES_BY_SYMBOL +from zipline.utils.dummy import DummyMapping + +DEFAULT_PER_SHARE_COST = 0.0075 # 0.75 cents per share +DEFAULT_PER_CONTRACT_COST = 0.85 # $0.85 per future contract +DEFAULT_PER_DOLLAR_COST = 0.0015 # 0.15 cents per dollar +DEFAULT_MINIMUM_COST_PER_EQUITY_TRADE = 1.0 # $1 per trade +DEFAULT_MINIMUM_COST_PER_FUTURE_TRADE = 1.0 # $1 per trade class CommissionModel(with_metaclass(abc.ABCMeta)): @@ -31,6 +39,13 @@ class CommissionModel(with_metaclass(abc.ABCMeta)): on each transaction. """ + @abstractproperty + def allowed_asset_types(self): + """ + Return a tuple of asset types that are compatible with the given model. + """ + raise NotImplementedError('allowed_asset_types') + @abstractmethod def calculate(self, order, transaction): """ @@ -59,7 +74,60 @@ class CommissionModel(with_metaclass(abc.ABCMeta)): raise NotImplementedError('calculate') -class PerShare(CommissionModel): +class EquityCommissionModel(CommissionModel): + """ + Base class for commission models which only support equities. + """ + allowed_asset_types = (Equity,) + + +class FutureCommissionModel(CommissionModel): + """ + Base class for commission models which only support futures. + """ + allowed_asset_types = (Future,) + + +def calculate_per_unit_commission(order, + transaction, + cost_per_unit, + initial_commission, + min_trade_cost): + """ + If there is a minimum commission: + If the order hasn't had a commission paid yet, pay the minimum + commission. + + If the order has paid a commission, start paying additional + commission once the minimum commission has been reached. + + If there is no minimum commission: + Pay commission based on number of units in the transaction. + """ + additional_commission = abs(transaction.amount * cost_per_unit) + + if order.commission == 0: + # no commission paid yet, pay at least the minimum plus a one-time + # exchange fee. + return max(min_trade_cost, additional_commission + initial_commission) + else: + # we've already paid some commission, so figure out how much we + # would be paying if we only counted per unit. + per_unit_total = \ + (order.filled * cost_per_unit) + \ + additional_commission + \ + initial_commission + + if per_unit_total < min_trade_cost: + # if we haven't hit the minimum threshold yet, don't pay + # additional commission + return 0 + else: + # we've exceeded the threshold, so pay more commission. + return per_unit_total - order.commission + + +class PerShare(EquityCommissionModel): """ Calculates a commission for a transaction based on a per share cost with an optional minimum cost per trade. @@ -68,70 +136,138 @@ class PerShare(CommissionModel): ---------- cost : float, optional The amount of commissions paid per share traded. - min_trade_cost : optional + min_trade_cost : float, optional The minimum amount of commissions paid per trade. """ def __init__(self, cost=DEFAULT_PER_SHARE_COST, - min_trade_cost=DEFAULT_MINIMUM_COST_PER_TRADE): + min_trade_cost=DEFAULT_MINIMUM_COST_PER_EQUITY_TRADE): self.cost_per_share = float(cost) - self.min_trade_cost = min_trade_cost + self.min_trade_cost = min_trade_cost or 0 def __repr__(self): - return "{class_name}(cost_per_share={cost_per_share}, " \ - "min_trade_cost={min_trade_cost})" \ - .format(class_name=self.__class__.__name__, - cost_per_share=self.cost_per_share, - min_trade_cost=self.min_trade_cost) + return ( + '{class_name}(cost_per_share={cost_per_share}, ' + 'min_trade_cost={min_trade_cost})' + .format( + class_name=self.__class__.__name__, + cost_per_share=self.cost_per_share, + min_trade_cost=self.min_trade_cost, + ) + ) def calculate(self, order, transaction): - """ - If there is a minimum commission: - If the order hasn't had a commission paid yet, pay the minimum - commission. + return calculate_per_unit_commission( + order=order, + transaction=transaction, + cost_per_unit=self.cost_per_share, + initial_commission=0, + min_trade_cost=self.min_trade_cost, + ) - If the order has paid a commission, start paying additional - commission once the minimum commission has been reached. - If there is no minimum commission: - Pay commission based on number of shares in the transaction. - """ - additional_commission = abs(transaction.amount * self.cost_per_share) +class PerContract(FutureCommissionModel): + """ + Calculates a commission for a transaction based on a per contract cost with + an optional minimum cost per trade. - if self.min_trade_cost is None: - # no min trade cost, so just return the cost for this transaction - return additional_commission + Parameters + ---------- + cost : float or dict + The amount of commissions paid per contract traded. If given a float, + the commission for all futures contracts is the same. If given a + dictionary, it must map root symbols to the commission cost for + contracts of that symbol. + exchange_fee : float or dict + A flat-rate fee charged by the exchange per trade. This value is a + constant, one-time charge no matter how many contracts are being + traded. If given a float, the fee for all contracts is the same. If + given a dictionary, it must map root symbols to the fee for contracts + of that symbol. + min_trade_cost : float, optional + The minimum amount of commissions paid per trade. + """ - if order.commission == 0: - # no commission paid yet, pay at least the minimum - return max(self.min_trade_cost, additional_commission) + def __init__(self, + cost, + exchange_fee, + min_trade_cost=DEFAULT_MINIMUM_COST_PER_FUTURE_TRADE): + # If 'cost' or 'exchange fee' are constants, use a dummy mapping to + # treat them as a dictionary that always returns the same value. + # NOTE: These dictionary does not handle unknown root symbols, so it + # may be worth revisiting this behavior. + if isinstance(cost, (int, float)): + self._cost_per_contract = DummyMapping(float(cost)) else: - # we've already paid some commission, so figure out how much we - # would be paying if we only counted per share. - per_share_total = \ - (order.filled * self.cost_per_share) + additional_commission + # Cost per contract is a dictionary. If the user's dictionary does + # not provide a commission cost for a certain contract, fall back + # on the pre-defined cost values per root symbol. + self._cost_per_contract = defaultdict( + lambda: DEFAULT_PER_CONTRACT_COST, **cost + ) - if per_share_total < self.min_trade_cost: - # if we haven't hit the minimum threshold yet, don't pay - # additional commission - return 0 - else: - # we've exceeded the threshold, so pay more commission. - return per_share_total - order.commission + if isinstance(exchange_fee, (int, float)): + self._exchange_fee = DummyMapping(float(exchange_fee)) + else: + # Exchange fee is a dictionary. If the user's dictionary does not + # provide an exchange fee for a certain contract, fall back on the + # pre-defined exchange fees per root symbol. + self._exchange_fee = merge( + FUTURE_EXCHANGE_FEES_BY_SYMBOL, exchange_fee, + ) + + self.min_trade_cost = min_trade_cost or 0 + + def __repr__(self): + if isinstance(self._cost_per_contract, DummyMapping): + # Cost per contract is a constant, so extract it. + cost_per_contract = self._cost_per_contract['dummy key'] + else: + cost_per_contract = '' + + if isinstance(self._exchange_fee, DummyMapping): + # Exchange fee is a constant, so extract it. + exchange_fee = self._exchange_fee['dummy key'] + else: + exchange_fee = '' + + return ( + '{class_name}(cost_per_contract={cost_per_contract}, ' + 'exchange_fee={exchange_fee}, min_trade_cost={min_trade_cost})' + .format( + class_name=self.__class__.__name__, + cost_per_contract=cost_per_contract, + exchange_fee=exchange_fee, + min_trade_cost=self.min_trade_cost, + ) + ) + + def calculate(self, order, transaction): + root_symbol = order.asset.root_symbol + cost_per_contract = self._cost_per_contract[root_symbol] + exchange_fee = self._exchange_fee[root_symbol] + + return calculate_per_unit_commission( + order=order, + transaction=transaction, + cost_per_unit=cost_per_contract, + initial_commission=exchange_fee, + min_trade_cost=self.min_trade_cost, + ) -class PerTrade(CommissionModel): +class PerEquityTrade(EquityCommissionModel): """ Calculates a commission for a transaction based on a per trade cost. Parameters ---------- cost : float, optional - The flat amount of commissions paid per trade. + The flat amount of commissions paid per equity trade. """ - def __init__(self, cost=DEFAULT_MINIMUM_COST_PER_TRADE): + def __init__(self, cost=DEFAULT_MINIMUM_COST_PER_EQUITY_TRADE): """ Cost parameter is the cost of a trade, regardless of share count. $5.00 per trade is fairly typical of discount brokers. @@ -140,6 +276,11 @@ class PerTrade(CommissionModel): # logic does not floor to an integer. self.cost = float(cost) + def __repr__(self): + return '{class_name}(cost_per_trade={cost})'.format( + class_name=self.__class__.__name__, cost=self.cost, + ) + def calculate(self, order, transaction): """ If the order hasn't had a commission paid yet, pay the fixed @@ -155,17 +296,49 @@ class PerTrade(CommissionModel): return 0.0 -class PerDollar(CommissionModel): +class PerFutureTrade(PerContract): """ Calculates a commission for a transaction based on a per trade cost. Parameters ---------- - cost : float - The flat amount of commissions paid per trade. + cost : float or dict + The flat amount of commissions paid per trade, regardless of the number + of contracts being traded. If given a float, the commission for all + futures contracts is the same. If given a dictionary, it must map root + symbols to the commission cost for trading contracts of that symbol. """ - def __init__(self, cost=0.0015): + def __init__(self, cost=DEFAULT_MINIMUM_COST_PER_FUTURE_TRADE): + # The per-trade cost can be represented as the exchange fee in a + # per-contract model because the exchange fee is just a one time cost + # incurred on the first fill. + super(PerFutureTrade, self).__init__( + cost=0, exchange_fee=cost, min_trade_cost=0, + ) + self._cost_per_trade = self._exchange_fee + + def __repr__(self): + if isinstance(self._cost_per_trade, DummyMapping): + # Cost per trade is a constant, so extract it. + cost_per_trade = self._cost_per_trade['dummy key'] + else: + cost_per_trade = '' + return '{class_name}(cost_per_trade={cost_per_trade})'.format( + class_name=self.__class__.__name__, cost_per_trade=cost_per_trade, + ) + + +class PerDollar(EquityCommissionModel): + """ + Calculates a commission for a transaction based on a per dollar cost. + + Parameters + ---------- + cost : float + The flat amount of commissions paid per dollar of equities traded. + """ + def __init__(self, cost=DEFAULT_PER_DOLLAR_COST): """ Cost parameter is the cost of a trade per-dollar. 0.0015 on $1 million means $1,500 commission (=1M * 0.0015) @@ -183,3 +356,7 @@ class PerDollar(CommissionModel): """ cost_per_share = transaction.price * self.cost_per_dollar return abs(transaction.amount) * cost_per_share + + +# Alias PerTrade for backwards compatibility. +PerTrade = PerEquityTrade diff --git a/zipline/finance/constants.py b/zipline/finance/constants.py index 9dbc6a3c..19632d08 100644 --- a/zipline/finance/constants.py +++ b/zipline/finance/constants.py @@ -21,3 +21,159 @@ ANNUALIZER = {'daily': TRADING_DAYS_IN_YEAR, 'hourly': TRADING_DAYS_IN_YEAR * TRADING_HOURS_IN_DAY, 'minute': TRADING_DAYS_IN_YEAR * TRADING_HOURS_IN_DAY * MINUTES_IN_HOUR} + +# NOTE: It may be worth revisiting how the keys for this dictionary are +# specified, for instance making them ContinuousFuture objects instead of +# static strings. +FUTURE_EXCHANGE_FEES_BY_SYMBOL = { + 'AD': 1.60, # AUD + 'AI': 0.96, # Bloomberg Commodity Index + 'BD': 1.50, # Big Dow + 'BO': 1.95, # Soybean Oil + 'BP': 1.60, # GBP + 'CD': 1.60, # CAD + 'CL': 1.50, # Crude Oil + 'CM': 1.03, # Corn e-mini + 'CN': 1.95, # Corn + 'DJ': 1.50, # Dow Jones + 'EC': 1.60, # Euro FX + 'ED': 1.25, # Eurodollar + 'EE': 1.50, # Euro FX e-mini + 'EI': 1.50, # MSCI Emerging Markets mini + 'EL': 1.50, # Eurodollar NYSE LIFFE + 'ER': 0.65, # Russell2000 e-mini + 'ES': 1.18, # SP500 e-mini + 'ET': 1.50, # Ethanol + 'EU': 1.50, # Eurodollar e-micro + 'FC': 2.03, # Feeder Cattle + 'FF': 0.96, # 3-Day Federal Funds + 'FI': 0.56, # Deliverable Interest Rate Swap 5y + 'FS': 1.50, # Interest Rate Swap 5y + 'FV': 0.65, # US 5y + 'GC': 1.50, # Gold + 'HG': 1.50, # Copper + 'HO': 1.50, # Heating Oil + 'HU': 1.50, # Unleaded Gasoline + 'JE': 0.16, # JPY e-mini + 'JY': 1.60, # JPY + 'LB': 2.03, # Lumber + 'LC': 2.03, # Live Cattle + 'LH': 2.03, # Lean Hogs + 'MB': 1.50, # Municipal Bonds + 'MD': 1.50, # SP400 Midcap + 'ME': 1.60, # MXN + 'MG': 1.50, # MSCI EAFE mini + 'MI': 1.18, # SP400 Midcap e-mini + 'MS': 1.03, # Soybean e-mini + 'MW': 1.03, # Wheat e-mini + 'ND': 1.50, # Nasdaq100 + 'NG': 1.50, # Natural Gas + 'NK': 2.15, # Nikkei225 + 'NQ': 1.18, # Nasdaq100 e-mini + 'NZ': 1.60, # NZD + 'OA': 1.95, # Oats + 'PA': 1.50, # Palladium + 'PB': 1.50, # Pork Bellies + 'PL': 1.50, # Platinum + 'QG': 0.50, # Natural Gas e-mini + 'QM': 1.20, # Crude Oil e-mini + 'RM': 1.50, # Russell1000 e-mini + 'RR': 1.95, # Rough Rice + 'SB': 2.10, # Sugar + 'SF': 1.60, # CHF + 'SM': 1.95, # Soybean Meal + 'SP': 2.40, # SP500 + 'SV': 1.50, # Silver + 'SY': 1.95, # Soybean + 'TB': 1.50, # Treasury Bills + 'TN': 0.56, # Deliverable Interest Rate Swap 10y + 'TS': 1.50, # Interest Rate Swap 10y + 'TU': 1.50, # US 2y + 'TY': 0.75, # US 10y + 'UB': 0.85, # Ultra Tbond + 'US': 0.80, # US 30y + 'VX': 1.50, # VIX + 'WC': 1.95, # Wheat + 'XB': 1.50, # RBOB Gasoline + 'XG': 0.75, # Gold e-mini + 'YM': 1.50, # Dow Jones e-mini + 'YS': 0.75, # Silver e-mini +} + +# See `zipline.finance.slippage.VolatilityVolumeShare` for more information on +# how these constants are used. +DEFAULT_ETA = 0.049018143225019836 +ROOT_SYMBOL_TO_ETA = { + 'AD': DEFAULT_ETA, # AUD + 'AI': DEFAULT_ETA, # Bloomberg Commodity Index + 'BD': 0.050346811117733474, # Big Dow + 'BO': 0.054930995070046298, # Soybean Oil + 'BP': 0.047841544238716338, # GBP + 'CD': 0.051124420640250717, # CAD + 'CL': 0.04852544628414196, # Crude Oil + 'CM': 0.052683478163348625, # Corn e-mini + 'CN': 0.053499718390037809, # Corn + 'DJ': 0.02313009072076987, # Dow Jones + 'EC': 0.04885131067661861, # Euro FX + 'ED': 0.094184297090245755, # Eurodollar + 'EE': 0.048713151357687556, # Euro FX e-mini + 'EI': 0.031712708439692663, # MSCI Emerging Markets mini + 'EL': 0.044207422018209361, # Eurodollar NYSE LIFFE + 'ER': 0.045930567737711307, # Russell2000 e-mini + 'ES': 0.047304418321993502, # SP500 e-mini + 'ET': DEFAULT_ETA, # Ethanol + 'EU': 0.049750396084029064, # Eurodollar e-micro + 'FC': 0.058728734202178494, # Feeder Cattle + 'FF': 0.048970591527624042, # 3-Day Federal Funds + 'FI': 0.033477176738170772, # Deliverable Interest Rate Swap 5y + 'FS': 0.034557788010453824, # Interest Rate Swap 5y + 'FV': 0.046544427716056963, # US 5y + 'GC': 0.048933313546125207, # Gold + 'HG': 0.052238417524987799, # Copper + 'HO': 0.045061318412156062, # Heating Oil + 'HU': 0.017154313062463938, # Unleaded Gasoline + 'JE': 0.013948949613401812, # JPY e-mini + 'JY': DEFAULT_ETA, # JPY + 'LB': 0.06146586386903994, # Lumber + 'LC': 0.055853801862858619, # Live Cattle + 'LH': 0.057557004630219781, # Lean Hogs + 'MB': DEFAULT_ETA, # Municipal Bonds + 'MD': DEFAULT_ETA, # SP400 Midcap + 'ME': 0.030383767727818548, # MXN + 'MG': 0.029579261656151684, # MSCI EAFE mini + 'MI': 0.041026288873007355, # SP400 Midcap e-mini + 'MS': DEFAULT_ETA, # Soybean e-mini + 'MW': 0.052579919663880245, # Wheat e-mini + 'ND': DEFAULT_ETA, # Nasdaq100 + 'NG': 0.047897809233755716, # Natural Gas + 'NK': 0.044555435054791433, # Nikkei225 + 'NQ': 0.044772425085977945, # Nasdaq100 e-mini + 'NZ': 0.049170418073872041, # NZD + 'OA': 0.056973267232775522, # Oats + 'PA': DEFAULT_ETA, # Palladium + 'PB': DEFAULT_ETA, # Pork Bellies + 'PL': 0.054579379665647493, # Platinum + 'QG': DEFAULT_ETA, # Natural Gas e-mini + 'QM': DEFAULT_ETA, # Crude Oil e-mini + 'RM': 0.037425041244579654, # Russell1000 e-mini + 'RR': DEFAULT_ETA, # Rough Rice + 'SB': 0.057388160345668134, # Sugar + 'SF': 0.047784825569615726, # CHF + 'SM': 0.048552860559844223, # Soybean Meal + 'SP': DEFAULT_ETA, # SP500 + 'SV': 0.052691435039931109, # Silver + 'SY': 0.052041703657281613, # Soybean + 'TB': DEFAULT_ETA, # Treasury Bills + 'TN': 0.033363465365262503, # Deliverable Interest Rate Swap 10y + 'TS': 0.032908878455069152, # Interest Rate Swap 10y + 'TU': 0.063867646063840794, # US 2y + 'TY': 0.050586988554700826, # US 10y + 'UB': DEFAULT_ETA, # Ultra Tbond + 'US': 0.047984179873590722, # US 30y + 'VX': DEFAULT_ETA, # VIX + 'WC': 0.052636542119329242, # Wheat + 'XB': 0.044444916388854484, # RBOB Gasoline + 'XG': DEFAULT_ETA, # Gold e-mini + 'YM': DEFAULT_ETA, # Dow Jones e-mini + 'YS': DEFAULT_ETA, # Silver e-mini +} diff --git a/zipline/finance/slippage.py b/zipline/finance/slippage.py index 3a35d636..09e72666 100644 --- a/zipline/finance/slippage.py +++ b/zipline/finance/slippage.py @@ -14,27 +14,36 @@ # limitations under the License. from __future__ import division -import abc +from abc import ABCMeta, abstractmethod, abstractproperty import math from six import with_metaclass, iteritems +from toolz import merge +import numpy as np from pandas import isnull +from zipline.assets import Equity, Future +from zipline.finance.constants import ROOT_SYMBOL_TO_ETA from zipline.finance.transaction import create_transaction +from zipline.utils.cache import ExpiringCache +from zipline.utils.dummy import DummyMapping SELL = 1 << 0 BUY = 1 << 1 STOP = 1 << 2 LIMIT = 1 << 3 +SQRT_252 = math.sqrt(252) + +DEFAULT_EQUITY_VOLUME_SLIPPAGE_BAR_LIMIT = 0.025 +DEFAULT_FUTURE_VOLUME_SLIPPAGE_BAR_LIMIT = 0.025 +NO_DATA_VOLATILITY_SLIPPAGE_IMPACT = 10.0 / 10000 + class LiquidityExceeded(Exception): pass -DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT = 0.025 - - def fill_price_worse_than_limit_price(fill_price, order): """ Checks whether the fill price is worse than the order's limit price. @@ -68,7 +77,7 @@ def fill_price_worse_than_limit_price(fill_price, order): return False -class SlippageModel(with_metaclass(abc.ABCMeta)): +class SlippageModel(with_metaclass(ABCMeta)): """Abstract interface for defining a slippage model. """ def __init__(self): @@ -78,7 +87,14 @@ class SlippageModel(with_metaclass(abc.ABCMeta)): def volume_for_bar(self): return self._volume_for_bar - @abc.abstractproperty + @abstractproperty + def allowed_asset_types(self): + """ + Return a tuple of asset types that are compatible with the given model. + """ + raise NotImplementedError('allowed_asset_types') + + @abstractproperty def process_order(self, data, order): """Process how orders get filled. @@ -162,11 +178,27 @@ class SlippageModel(with_metaclass(abc.ABCMeta)): return self.__dict__ -class VolumeShareSlippage(SlippageModel): - """Model slippage as a function of the volume of shares traded. +class EquitySlippageModel(SlippageModel): """ + Base class for slippage models which only support equities. + """ + allowed_asset_types = (Equity,) - def __init__(self, volume_limit=DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT, + +class FutureSlippageModel(SlippageModel): + """ + Base class for slippage models which only support futures. + """ + allowed_asset_types = (Future,) + + +class VolumeShareSlippage(SlippageModel): + """ + Model slippage as a function of the volume of contracts traded. + """ + allowed_asset_types = (Equity, Future) + + def __init__(self, volume_limit=DEFAULT_EQUITY_VOLUME_SLIPPAGE_BAR_LIMIT, price_impact=0.1): self.volume_limit = volume_limit @@ -234,17 +266,24 @@ class VolumeShareSlippage(SlippageModel): class FixedSlippage(SlippageModel): - """Model slippage as a fixed spread. + """ + Model slippage as a fixed spread. Parameters ---------- spread : float, optional spread / 2 will be added to buys and subtracted from sells. """ + allowed_asset_types = (Equity, Future) def __init__(self, spread=0.0): self.spread = spread + def __repr__(self): + return '{class_name}(spread={spread})'.format( + class_name=self.__class__.__name__, spread=self.spread, + ) + def process_order(self, data, order): price = data.current(order.asset, "close") @@ -252,3 +291,213 @@ class FixedSlippage(SlippageModel): price + (self.spread / 2.0 * order.direction), order.amount ) + + +class MarketImpactBase(object): + """ + Base class for slippage models which compute a simulated price impact + according to a history lookback. + """ + + def __init__(self): + super(MarketImpactBase, self).__init__() + self._window_data_cache = ExpiringCache() + + @abstractmethod + def get_txn_volume(self, data, order): + """ + Return the number of shares we would like to order in this minute. + + Parameters + ---------- + data : BarData + order : Order + + Return + ------ + int : the number of shares + """ + raise NotImplementedError('get_txn_volume') + + @abstractmethod + def simulated_impact(self, + order, + current_price, + current_volume, + txn_volume, + mean_volume, + volatility): + """ + Calculate simulated price impact. + + Parameters + ---------- + order : The order being processed. + current_price : Current price of the asset being ordered. + current_volume : Volume of the asset being ordered for the current bar. + txn_volume : Number of shares/contracts being ordered. + mean_volume : Trailing ADV of the asset. + volatility : Annualized daily volatility of volume. + + Return + ------ + int : impact on the current price. + """ + raise NotImplementedError('simulated_impact') + + def process_order(self, data, order): + if order.open_amount == 0: + return None, None + + minute_data = data.current(order.asset, ['volume', 'high', 'low']) + mean_volume, volatility = self._get_window_data(data, order.asset, 20) + + # Price to use is the average of the minute bar's open and close. + price = np.mean([minute_data['high'], minute_data['low']]) + + volume = minute_data['volume'] + if not volume: + return None, None + + txn_volume = int( + min(self.get_txn_volume(data, order), abs(order.open_amount)) + ) + + if mean_volume == 0 or np.isnan(volatility): + # If this is the first day the contract exists or there is no + # volume history, default to a conservative estimate of impact. + simulated_impact = price * NO_DATA_VOLATILITY_SLIPPAGE_IMPACT + else: + simulated_impact = self.get_simulated_impact( + order=order, + current_price=price, + current_volume=volume, + txn_volume=txn_volume, + mean_volume=mean_volume, + volatility=volatility, + ) + + impacted_price = \ + price + math.copysign(simulated_impact, order.direction) + + if fill_price_worse_than_limit_price(impacted_price, order): + return None, None + + return impacted_price, math.copysign(txn_volume, order.direction) + + def _get_window_data(self, data, asset, window_length): + """ + Internal utility method to return the trailing mean volume over the + past 'window_length' days, and volatility of close prices for a + specific asset. + + Parameters + ---------- + data : The BarData from which to fetch the daily windows. + asset : The Asset whose data we are fetching. + window_length : Number of days of history used to calculate the mean + volume and close price volatility. + + Returns + ------- + (mean volume, volatility) + """ + try: + values = self._window_data_cache.get(asset, data.current_session) + except KeyError: + # Add a day because we want 'window_length' complete days, + # excluding the current day. + volume_history = data.history( + asset, 'volume', window_length + 1, '1d', + ) + close_history = data.history( + asset, 'close', window_length + 1, '1d', + ) + # Exclude the first value of the percent change array because it is + # always just NaN. + close_volatility = close_history[:-1].pct_change()[1:].std( + skipna=False, + ) + values = { + 'volume': volume_history[:-1].mean(), + 'close': close_volatility * SQRT_252, + } + self._window_data_cache.set(asset, values, data.current_session) + + return values['volume'], values['close'] + + +class VolatilityVolumeShare(MarketImpactBase, FutureSlippageModel): + """ + Model slippage for futures contracts according to the following formula: + + new_price = price + (price * MI / 10000), + + where 'MI' is market impact, which is defined as: + + MI = eta * sigma * sqrt(psi) + + Eta is a constant which varies by root symbol. + Sigma is 20-day annualized volatility. + Psi is the volume traded in the given bar divided by 20-day ADV. + + Parameters + ---------- + volume_limit : float + Maximum percentage (as a decimal) of a bar's total volume that can be + traded. + eta : float or dict + Constant used in the market impact formula. If given a float, the eta + for all futures contracts is the same. If given a dictionary, it must + map root symbols to the eta for contracts of that symbol. + """ + allowed_asset_types = (Future,) + + def __init__(self, volume_limit, eta=ROOT_SYMBOL_TO_ETA): + super(VolatilityVolumeShare, self).__init__() + self.volume_limit = volume_limit + + # If 'eta' is a constant, use a dummy mapping to treat it as a + # dictionary that always returns the same value. + # NOTE: This dictionary does not handle unknown root symbols, so it may + # be worth revisiting this behavior. + if isinstance(eta, (int, float)): + self._eta = DummyMapping(float(eta)) + else: + # Eta is a dictionary. If the user's dictionary does not provide a + # value for a certain contract, fall back on the pre-defined eta + # values per root symbol. + self._eta = merge(ROOT_SYMBOL_TO_ETA, eta) + + def __repr__(self): + if isinstance(self._eta, DummyMapping): + # Eta is a constant, so extract it. + eta = self._eta['dummy key'] + else: + eta = '' + return '{class_name}(volume_limit={volume_limit}, eta={eta})'.format( + class_name=self.__class__.__name__, + volume_limit=self.volume_limit, + eta=eta, + ) + + def get_simulated_impact(self, + order, + current_price, + current_volume, + txn_volume, + mean_volume, + volatility): + eta = self._eta[order.asset.root_symbol] + psi = txn_volume / mean_volume + + market_impact = eta * volatility * math.sqrt(psi) + + # We divide by 10,000 because this model computes to basis points. + # To convert from bps to % we need to divide by 100, then again to + # convert from % to fraction. + return (current_price * market_impact) / 10000 + + def get_txn_volume(self, data, order): + volume = data.current(order.asset, 'volume') + return volume * self.volume_limit diff --git a/zipline/testing/slippage.py b/zipline/testing/slippage.py index 5b2cbd31..6e16f7dc 100644 --- a/zipline/testing/slippage.py +++ b/zipline/testing/slippage.py @@ -1,3 +1,4 @@ +from zipline.assets import Equity from zipline.finance.slippage import SlippageModel from zipline.utils.sentinel import sentinel @@ -19,6 +20,8 @@ class TestingSlippage(SlippageModel): """ ALL = sentinel('ALL') + allowed_asset_types = (Equity,) + def __init__(self, filled_per_tick): self.filled_per_tick = filled_per_tick diff --git a/zipline/utils/dummy.py b/zipline/utils/dummy.py new file mode 100644 index 00000000..1d6bd213 --- /dev/null +++ b/zipline/utils/dummy.py @@ -0,0 +1,10 @@ + +class DummyMapping(object): + """ + Dummy object used to provide a mapping interface for singular values. + """ + def __init__(self, value): + self._value = value + + def __getitem__(self, key): + return self._value