diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index ab275647..22fc4c50 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -45,11 +45,14 @@ from zipline.test_algorithms import ( TestOrderAlgorithm, TestOrderInstantAlgorithm, TestOrderPercentAlgorithm, + TestOrderPercentAlgorithmPercentOf, TestOrderStyleForwardingAlgorithm, TestOrderValueAlgorithm, TestRegisterTransformAlgorithm, TestTargetAlgorithm, + TestTargetAlgorithm_NonInt, TestTargetPercentAlgorithm, + TestTargetPercentAlgorithmPercentOf, TestTargetValueAlgorithm, SetLongOnlyAlgorithm, SetMaxPositionSizeAlgorithm, @@ -303,6 +306,9 @@ class TestTransformAlgorithm(TestCase): self.panel_source, self.panel = \ factory.create_test_panel_source(self.sim_params) + self.df_large = pd.concat([self.df] * 10, 1) + self.df_large.columns = range(10) + def test_source_as_input(self): algo = TestRegisterTransformAlgorithm( sim_params=self.sim_params, @@ -374,6 +380,7 @@ class TestTransformAlgorithm(TestCase): AlgoClasses = [TestOrderAlgorithm, TestOrderValueAlgorithm, TestTargetAlgorithm, + TestTargetAlgorithm_NonInt, TestOrderPercentAlgorithm, TestTargetPercentAlgorithm, TestTargetValueAlgorithm] @@ -384,6 +391,16 @@ class TestTransformAlgorithm(TestCase): ) algo.run(self.df) + AlgoClasses2 = [ + TestOrderPercentAlgorithmPercentOf, + TestTargetPercentAlgorithmPercentOf] + + for AlgoClass in AlgoClasses2: + algo = AlgoClass( + sim_params=self.sim_params, + ) + algo.run(self.df_large) + def test_order_method_style_forwarding(self): method_names_to_test = ['order', diff --git a/zipline/algorithm.py b/zipline/algorithm.py index 9a705d86..fa6076f9 100644 --- a/zipline/algorithm.py +++ b/zipline/algorithm.py @@ -88,6 +88,21 @@ from zipline.history.history_container import HistoryContainer DEFAULT_CAPITAL_BASE = float("1.0e5") +def round_if_near_integer(a, epsilon=1e-4): + """ + Round a to the nearest integer if that integer is within an epsilon + of a. + """ + if abs(a - round(a)) <= epsilon: + return round(a) + else: + return a + + +def round_shares(shares): + return int(round_if_near_integer(shares)) + + class TradingAlgorithm(object): """ Base class for trading algorithms. Inherit and overload @@ -636,20 +651,10 @@ class TradingAlgorithm(object): Place an order using the specified parameters. """ - def round_if_near_integer(a, epsilon=1e-4): - """ - Round a to the nearest integer if that integer is within an epsilon - of a. - """ - if abs(a - round(a)) <= epsilon: - return round(a) - else: - return a - # Truncate to the integer share count that's either within .0001 of # amount or closer to zero. # E.g. 3.9999 -> 4.0; 5.5 -> 5.0; -5.5 -> -5.0 - amount = int(round_if_near_integer(amount)) + amount = round_shares(amount) # Raises a ZiplineError if invalid parameters are detected. self.validate_order_params(sid, @@ -864,16 +869,98 @@ class TradingAlgorithm(object): assert value in ('daily', 'minute') self.sim_params.data_frequency = value + def get_market_value(self, mv_type=None, filter_fn=None): + """ + Returns the requested market value. + + Options for mv_type are: + portfolio [default]: net exposure or portfolio value + net: net exposure or portfolio value + gross: gross exposure + cash: available cash + ex_cash: net invested capital, ex-cash + longs: long invested capital + longs_cash: long invested capital plus cash + shorts: short invested capital + + Alternatively, a filter_fn can be supplied. The filter_fn + should accept a Position and return True if that Position's + market_value should be included in the total and False otherwise. + """ + + period = self.perf_tracker.cumulative_performance + + # first try the filter_fn, if supplied + if filter_fn is not None: + positions = period.get_positions() + mv = sum( + p.amount * p.last_sale_price + for p in positions.values() + if filter_fn(p)) + + # net portfolio value + elif mv_type is None or mv_type == 'portfolio' or mv_type == 'net': + mv = period.ending_value + period.ending_cash + + elif mv_type == 'gross': + mv = period._gross_exposure() + + # portfolio cash + elif mv_type == 'cash': + mv = period.ending_cash + + # net invested capital + elif mv_type == 'ex_cash': + mv = period.ending_value + + # long invested capital + elif mv_type == 'longs': + mv = period._long_exposure() + + # long invested capital, plus cash + elif mv_type == 'longs_cash': + mv = period._long_exposure() + period.ending_cash + + # short invested capital + elif mv_type == 'shorts': + mv = period._short_exposure() + + else: + raise ValueError( + 'Unrecognized value for "mv_type": {}'.format(mv_type)) + + return mv + @api_method def order_percent(self, sid, percent, - limit_price=None, stop_price=None, style=None): + limit_price=None, + stop_price=None, + style=None, + percent_of=None, + percent_of_fn=None): """ Place an order in the specified security corresponding to the given - percent of the current portfolio value. + percent of some market value. If no market value is specified (via + `percent_of`) then the net portfolio value is used. + + Options for percent_of are: + portfolio [default]: net exposure or portfolio value + net: net exposure or portfolio value + gross: gross exposure + cash: available cash + ex_cash: net invested capital, ex-cash + longs: long invested capital + longs_cash: long invested capital plus cash + shorts: short invested capital + + Alternatively, a percent_of_fn can be supplied. The percent_of_fn + should accept a Position and return True if that Position's + market_value should be included in the total and False otherwise. Note that percent must expressed as a decimal (0.50 means 50\%). """ - value = self.portfolio.portfolio_value * percent + mv = self.get_market_value(mv_type=percent_of, filter_fn=percent_of_fn) + value = percent * mv return self.order_value(sid, value, limit_price=limit_price, stop_price=stop_price, @@ -891,7 +978,7 @@ class TradingAlgorithm(object): """ if sid in self.portfolio.positions: current_position = self.portfolio.positions[sid].amount - req_shares = target - current_position + req_shares = round_shares(target) - current_position return self.order(sid, req_shares, limit_price=limit_price, stop_price=stop_price, @@ -927,17 +1014,37 @@ class TradingAlgorithm(object): @api_method def order_target_percent(self, sid, target, - limit_price=None, stop_price=None, style=None): + limit_price=None, + stop_price=None, + style=None, + percent_of=None, + percent_of_fn=None): """ - Place an order to adjust a position to a target percent of the - current portfolio value. If the position doesn't already exist, this is + Place an order to adjust a position to a target percent of some market + value. If no market value is specified (via `percent_of`) then the net + portfolio value is used. If the position doesn't already exist, this is equivalent to placing a new order. If the position does exist, this is equivalent to placing an order for the difference between the target percent and the current percent. + Options for percent_of are: + portfolio [default]: net exposure or portfolio value + net: net exposure or portfolio value + gross: gross exposure + cash: available cash + ex_cash: net invested capital, ex-cash + longs: long invested capital + longs_cash: long invested capital plus cash + shorts: short invested capital + + Alternatively, a percent_of_fn can be supplied. The percent_of_fn + should accept a Position and return True if that Position's + market_value should be included in the total and False otherwise. + Note that target must expressed as a decimal (0.50 means 50\%). """ - target_value = self.portfolio.portfolio_value * target + mv = self.get_market_value(mv_type=percent_of, filter_fn=percent_of_fn) + target_value = target * mv return self.order_target_value(sid, target_value, limit_price=limit_price, stop_price=stop_price, diff --git a/zipline/test_algorithms.py b/zipline/test_algorithms.py index 132b6bb9..ed77470c 100644 --- a/zipline/test_algorithms.py +++ b/zipline/test_algorithms.py @@ -79,7 +79,7 @@ from nose.tools import assert_raises from six.moves import range from six import itervalues -from zipline.algorithm import TradingAlgorithm +from zipline.algorithm import TradingAlgorithm, round_shares from zipline.api import FixedSlippage from zipline.errors import UnsupportedOrderParameters from zipline.finance.execution import ( @@ -345,6 +345,33 @@ class TestTargetAlgorithm(TradingAlgorithm): self.order_target(0, self.target_shares) +class TestTargetAlgorithm_NonInt(TradingAlgorithm): + def initialize(self): + self.target_shares = 0 + self.sale_price = None + self.i = 0 + + def handle_data(self, data): + if self.target_shares == 0: + assert 0 not in self.portfolio.positions + else: + assert self.portfolio.positions[0]['amount'] == \ + self.target_shares, "Orders not filled correctly." + assert self.portfolio.positions[0]['last_sale_price'] == \ + data[0].price, "Orders not filled at current price." + + if self.i == 0: + self.target_shares = 5 + self.order_target(0, 5.1) + elif self.i == 1: + self.target_shares = 10 + self.order_target(0, 10.1) + elif self.i == 2: + self.target_shares = 5 + self.order_target(0, 5.1) + self.i += 1 + + class TestOrderPercentAlgorithm(TradingAlgorithm): def initialize(self): self.target_shares = 0 @@ -368,23 +395,126 @@ class TestOrderPercentAlgorithm(TradingAlgorithm): / data[0].price) +class TestOrderPercentAlgorithmPercentOf(TradingAlgorithm): + def initialize(self): + self.target_shares = dict([(i, 0) for i in range(8)]) + + def handle_data(self, data): + for sid, target_shares in self.target_shares.items(): + position = self.portfolio.positions[sid] + assert target_shares == position.amount + + # reduce sizes due to volume limitiations + full = 0.001 + half = 0.0005 + + pos_values = {} + for i in range(len(data.keys())): + pos_values[i] = ( + self.portfolio.positions[i].amount + * self.portfolio.positions[i].last_sale_price) + + port = self.portfolio.portfolio_value + cash = self.portfolio.cash + longs = sum(v for v in pos_values.values() if v > 0) + shorts = sum(v for v in pos_values.values() if v < 0) + gross = sum(abs(v) for v in pos_values.values()) + group = [0, 1, 2] + group_val = sum(v for sid, v in pos_values.items() if sid in group) + + def expected_shares(sid, value): + return round_shares(value / data[sid].price) + + self.order_percent(0, full, percent_of='portfolio') + self.order_percent(1, -half, percent_of='cash') + self.order_percent(2, half, percent_of='ex_cash') + self.order_percent(3, half, percent_of='longs') + self.order_percent(4, half, percent_of='longs_cash') + self.order_percent(5, half, percent_of='shorts') + self.order_percent( + 6, half, percent_of_fn=lambda p: p.sid in group) + self.order_percent(7, full, percent_of='gross') + + self.target_shares[0] += expected_shares(0, full * port) + self.target_shares[1] += expected_shares(1, -half * cash) + self.target_shares[2] += expected_shares(2, half * (port - cash)) + self.target_shares[3] += expected_shares(3, half * longs) + self.target_shares[4] += expected_shares(4, half * (longs + cash)) + self.target_shares[5] += expected_shares(5, half * shorts) + self.target_shares[6] += expected_shares(6, half * group_val) + self.target_shares[7] += expected_shares(7, full * gross) + + class TestTargetPercentAlgorithm(TradingAlgorithm): def initialize(self): self.target_shares = 0 self.sale_price = None + self.exp_value = 0 def handle_data(self, data): if self.target_shares == 0: assert 0 not in self.portfolio.positions self.target_shares = 1 else: - assert np.round(self.portfolio.portfolio_value * 0.002) == \ - self.portfolio.positions[0]['amount'] * self.sale_price, \ + value = self.portfolio.positions[0]['amount'] * self.sale_price + assert abs(value - self.exp_value) <= self.sale_price, \ "Orders not filled correctly." assert self.portfolio.positions[0]['last_sale_price'] == \ data[0].price, "Orders not filled at current price." self.sale_price = data[0].price self.order_target_percent(0, .002) + self.exp_value = self.portfolio.portfolio_value * 0.002 + + +class TestTargetPercentAlgorithmPercentOf(TradingAlgorithm): + def initialize(self): + self.target_shares = dict([(i, 0) for i in range(8)]) + + def handle_data(self, data): + + for sid, target_shares in self.target_shares.items(): + position = self.portfolio.positions[sid] + assert target_shares == position.amount + + # reduce sizes due to volume limitiations + full = 0.001 + half = 0.0005 + + pos_values = {} + for i in range(len(data.keys())): + pos_values[i] = ( + self.portfolio.positions[i].amount + * self.portfolio.positions[i].last_sale_price) + + port = self.portfolio.portfolio_value + cash = self.portfolio.cash + longs = sum(v for v in pos_values.values() if v > 0) + shorts = sum(v for v in pos_values.values() if v < 0) + gross = sum(abs(v) for v in pos_values.values()) + group = [0, 1, 2] + group_val = sum(v for sid, v in pos_values.items() if sid in group) + + def expected_shares(sid, value): + return round_shares(value / data[sid].price) + + self.order_target_percent(0, full, percent_of='portfolio') + self.order_target_percent(1, -half, percent_of='cash') + self.order_target_percent(2, half, percent_of='ex_cash') + self.order_target_percent(3, half, percent_of='longs') + self.order_target_percent(4, half, percent_of='longs_cash') + self.order_target_percent(5, half, percent_of='shorts') + self.order_target_percent( + 6, half, percent_of_fn=lambda p: p.sid in group) + self.order_target_percent(5, full, percent_of='gross') + + self.target_shares[0] = expected_shares(0, full * port) + self.target_shares[1] = expected_shares(1, -half * cash) + self.target_shares[2] = expected_shares(2, half * (port - cash)) + self.target_shares[3] = expected_shares(3, half * longs) + self.target_shares[4] = expected_shares(4, half * (longs + cash)) + self.target_shares[5] = expected_shares(5, half * shorts) + self.target_shares[6] = expected_shares(6, half * group_val) + self.target_shares[7] = expected_shares(7, full * gross) class TestTargetValueAlgorithm(TradingAlgorithm): @@ -406,7 +536,7 @@ class TestTargetValueAlgorithm(TradingAlgorithm): data[0].price, "Orders not filled at current price." self.order_target_value(0, 20) - self.target_shares = np.round(20 / data[0].price) + self.target_shares = round_shares(20 / data[0].price) ############################