diff --git a/lumibot/components/drift_rebalancer_logic.py b/lumibot/components/drift_rebalancer_logic.py index 1d637107..7439c1d1 100644 --- a/lumibot/components/drift_rebalancer_logic.py +++ b/lumibot/components/drift_rebalancer_logic.py @@ -84,6 +84,10 @@ class DriftRebalancerLogic: When set to True, the strategy will only use fractional shares. The default is False, which means the strategy will only use whole shares. + only_rebalance_drifted_assets : bool, optional + If True, the strategy will only rebalance assets whose drift exceeds the drift_threshold. + The default is False, which means the strategy will rebalance all assets in the portfolio. + """ def __init__( @@ -96,7 +100,8 @@ def __init__( acceptable_slippage: Decimal = Decimal("0.005"), fill_sleeptime: int = 15, shorting: bool = False, - fractional_shares: bool = False + fractional_shares: bool = False, + only_rebalance_drifted_assets: bool = False ) -> None: self.strategy = strategy self.calculation_logic = DriftCalculationLogic( @@ -111,7 +116,8 @@ def __init__( acceptable_slippage=acceptable_slippage, shorting=shorting, order_type=order_type, - fractional_shares=fractional_shares + fractional_shares=fractional_shares, + only_rebalance_drifted_assets=only_rebalance_drifted_assets ) def calculate(self, portfolio_weights: List[Dict[str, Any]]) -> pd.DataFrame: @@ -313,7 +319,8 @@ def __init__( acceptable_slippage: Decimal = Decimal("0.005"), shorting: bool = False, order_type: Order.OrderType = Order.OrderType.LIMIT, - fractional_shares: bool = False + fractional_shares: bool = False, + only_rebalance_drifted_assets: bool = False ) -> None: self.strategy = strategy self.drift_threshold = drift_threshold @@ -322,6 +329,7 @@ def __init__( self.shorting = shorting self.order_type = order_type self.fractional_shares = fractional_shares + self.only_rebalance_drifted_assets = only_rebalance_drifted_assets # Sanity checks if self.acceptable_slippage >= self.drift_threshold: @@ -379,6 +387,10 @@ def _rebalance(self, df: pd.DataFrame = None) -> None: sell_orders.append(order) elif row["drift"] < 0: + + if self.only_rebalance_drifted_assets and abs(row["drift"]) < self.drift_threshold: + continue + base_asset = row["base_asset"] last_price = Decimal(self.strategy.get_last_price(base_asset)) limit_price = self.calculate_limit_price(last_price=last_price, side="sell") @@ -426,6 +438,10 @@ def _rebalance(self, df: pd.DataFrame = None) -> None: cash_position -= quantity * limit_price elif row["drift"] > 0: + + if self.only_rebalance_drifted_assets and abs(row["drift"]) < self.drift_threshold: + continue + base_asset = row["base_asset"] last_price = Decimal(self.strategy.get_last_price(base_asset)) limit_price = self.calculate_limit_price(last_price=last_price, side="buy") diff --git a/lumibot/example_strategies/classic_60_40_config.py b/lumibot/example_strategies/classic_60_40_config.py index fd553690..1f115eaa 100644 --- a/lumibot/example_strategies/classic_60_40_config.py +++ b/lumibot/example_strategies/classic_60_40_config.py @@ -22,5 +22,7 @@ "weight": Decimal("0.4") } ], - "shorting": False + "shorting": False, + "fractional_shares": False, + "only_rebalance_drifted_assets": False, } diff --git a/lumibot/example_strategies/crypto_50_50.py b/lumibot/example_strategies/crypto_50_50.py index 7161c29a..e5f04fe3 100644 --- a/lumibot/example_strategies/crypto_50_50.py +++ b/lumibot/example_strategies/crypto_50_50.py @@ -23,7 +23,8 @@ "acceptable_slippage": "0.005", # 50 BPS "fill_sleeptime": 15, "shorting": False, - "fractional_shares": True + "fractional_shares": True, + "only_rebalance_drifted_assets": False, } if not is_live: diff --git a/lumibot/example_strategies/drift_rebalancer.py b/lumibot/example_strategies/drift_rebalancer.py index 62c1b2be..d60ef41f 100644 --- a/lumibot/example_strategies/drift_rebalancer.py +++ b/lumibot/example_strategies/drift_rebalancer.py @@ -41,7 +41,9 @@ class DriftRebalancer(Strategy): "weight": Decimal("0.4") } ], - "shorting": False + "shorting": False, + "fractional_shares": False, + "only_rebalance_drifted_assets": False, } Description of parameters: @@ -79,6 +81,7 @@ class DriftRebalancer(Strategy): - portfolio_weights: A list of dictionaries containing the base_asset and weight of each asset in the portfolio. - shorting: If you want to allow shorting, set this to True. Default is False. - fractional_shares: If you want to allow fractional shares, set this to True. Default is False. + - only_rebalance_drifted_assets: If you want to only rebalance assets that have drifted, set this to True. Default is False. """ @@ -94,6 +97,7 @@ def initialize(self, parameters: Any = None) -> None: self.portfolio_weights = self.parameters.get("portfolio_weights", {}) self.shorting = self.parameters.get("shorting", False) self.fractional_shares = self.parameters.get("fractional_shares", False) + self.only_rebalance_drifted_assets = self.parameters.get("only_rebalance_drifted_assets", False) self.drift_df = pd.DataFrame() self.drift_rebalancer_logic = DriftRebalancerLogic( strategy=self, @@ -104,6 +108,7 @@ def initialize(self, parameters: Any = None) -> None: fill_sleeptime=self.fill_sleeptime, shorting=self.shorting, fractional_shares=self.fractional_shares, + only_rebalance_drifted_assets=self.only_rebalance_drifted_assets, ) # noinspection PyAttributeOutsideInit diff --git a/tests/test_drift_rebalancer.py b/tests/test_drift_rebalancer.py index f0426bd7..dac7ecb4 100644 --- a/tests/test_drift_rebalancer.py +++ b/tests/test_drift_rebalancer.py @@ -954,6 +954,49 @@ def test_selling_part_of_a_holding_with_limit_order(self): assert strategy.orders[0].quantity == Decimal("5") assert strategy.orders[0].type == Order.OrderType.LIMIT + def test_selling_with_only_rebalance_drifted_assets_when_over_drift_threshold(self): + strategy = MockStrategyWithOrderLogic( + broker=self.backtesting_broker, + order_type=Order.OrderType.LIMIT + ) + df = pd.DataFrame({ + "symbol": ["AAPL"], + "base_asset": [Asset("AAPL", "stock")], + "is_quote_asset": False, + "current_quantity": [Decimal("10")], + "current_value": [Decimal("1000")], + "current_weight": [Decimal("1.0")], + "target_weight": Decimal("0.5"), + "target_value": Decimal("500"), + "drift": Decimal("-0.5") + }) + strategy.order_logic.only_rebalance_drifted_assets = True + strategy.order_logic.rebalance(drift_df=df) + assert len(strategy.orders) == 1 + assert strategy.orders[0].side == "sell" + assert strategy.orders[0].quantity == Decimal("5") + assert strategy.orders[0].type == Order.OrderType.LIMIT + + def test_selling_with_only_rebalance_drifted_assets_when_not_over_drift_threshold(self): + strategy = MockStrategyWithOrderLogic( + broker=self.backtesting_broker, + order_type=Order.OrderType.LIMIT + ) + df = pd.DataFrame({ + "symbol": ["AAPL"], + "base_asset": [Asset("AAPL", "stock")], + "is_quote_asset": False, + "current_quantity": [Decimal("10")], + "current_value": [Decimal("1000")], + "current_weight": [Decimal("1.0")], + "target_weight": Decimal("0.5"), + "target_value": Decimal("500"), + "drift": Decimal("-0.001") # make drift small + }) + strategy.order_logic.only_rebalance_drifted_assets = True + strategy.order_logic.rebalance(drift_df=df) + assert len(strategy.orders) == 0 + def test_selling_part_of_a_holding_with_market_order(self): strategy = MockStrategyWithOrderLogic( broker=self.backtesting_broker, @@ -1319,6 +1362,49 @@ def test_selling_some_with_fractional_limit_orders(self): assert strategy.orders[0].quantity == Decimal("1.507537688") assert strategy.orders[0].type == Order.OrderType.LIMIT + def test_buying_with_only_rebalance_drifted_assets_when_over_drift_threshold(self): + strategy = MockStrategyWithOrderLogic( + broker=self.backtesting_broker, + order_type=Order.OrderType.LIMIT + ) + df = pd.DataFrame({ + "symbol": ["AAPL"], + "base_asset": [Asset("AAPL", "stock")], + "is_quote_asset": False, + "current_quantity": [Decimal("0")], + "current_value": [Decimal("0")], + "current_weight": [Decimal("0.0")], + "target_weight": Decimal("0.5"), + "target_value": Decimal("500"), + "drift": Decimal("0.5") + }) + strategy.order_logic.only_rebalance_drifted_assets = True + strategy.order_logic.rebalance(drift_df=df) + assert len(strategy.orders) == 1 + assert strategy.orders[0].side == "buy" + assert strategy.orders[0].quantity == Decimal("4.0") # + assert strategy.orders[0].type == Order.OrderType.LIMIT + + def test_buying_with_only_rebalance_drifted_assets_when_not_over_drift_threshold(self): + strategy = MockStrategyWithOrderLogic( + broker=self.backtesting_broker, + order_type=Order.OrderType.LIMIT + ) + df = pd.DataFrame({ + "symbol": ["AAPL"], + "base_asset": [Asset("AAPL", "stock")], + "is_quote_asset": False, + "current_quantity": [Decimal("0")], + "current_value": [Decimal("0")], + "current_weight": [Decimal("0.0")], + "target_weight": Decimal("0.5"), + "target_value": Decimal("500"), + "drift": Decimal("0.001") # make drift small + }) + strategy.order_logic.only_rebalance_drifted_assets = True + strategy.order_logic.rebalance(drift_df=df) + assert len(strategy.orders) == 0 + # @pytest.mark.skip() class TestDriftRebalancer: