Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for shorting in the drift rebalancer; fixed a bug in limit price calculation. #611

Merged
merged 8 commits into from
Nov 8, 2024
46 changes: 21 additions & 25 deletions lumibot/example_strategies/classic_60_40.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,40 @@
from datetime import datetime

from lumibot.strategies.drift_rebalancer import DriftRebalancer
from lumibot.credentials import IS_BACKTESTING
from lumibot.example_strategies.drift_rebalancer import DriftRebalancer

"""
Strategy Description

This strategy rebalances a portfolio of assets to a target weight every time the asset drifts
This strategy demonstrates the DriftRebalancer by rebalancing to a classic 60% stocks, 40% bonds portfolio.
It rebalances a portfolio of assets to a target weight every time the asset drifts
by a certain threshold. The strategy will sell the assets that has drifted the most and buy the
assets that has drifted the least to bring the portfolio back to the target weights.
"""


if __name__ == "__main__":
is_live = False

parameters = {
"market": "NYSE",
"sleeptime": "1D",
"absolute_drift_threshold": "0.15",
"acceptable_slippage": "0.0005",
"fill_sleeptime": 15,
"target_weights": {
"SPY": "0.60",
"TLT": "0.40"
}
}

if is_live:
from credentials import ALPACA_CONFIG
from lumibot.brokers import Alpaca
from lumibot.traders import Trader
if not IS_BACKTESTING:
print("This strategy is not meant to be run live. Please set IS_BACKTESTING to True.")
exit()
else:

trader = Trader()
broker = Alpaca(ALPACA_CONFIG)
strategy = DriftRebalancer(broker=broker, parameters=parameters)
trader.add_strategy(strategy)
strategy_executors = trader.run_all()
parameters = {
"market": "NYSE",
"sleeptime": "1D",
"drift_threshold": "0.05",
"acceptable_slippage": "0.005", # 50 BPS
"fill_sleeptime": 15,
"target_weights": {
"SPY": "0.60",
"TLT": "0.40"
},
"shorting": False
}

else:
from lumibot.backtesting import YahooDataBacktesting

backtesting_start = datetime(2023, 1, 2)
backtesting_end = datetime(2024, 10, 31)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@
from typing import Dict, Any
from decimal import Decimal, ROUND_DOWN
import time
import logging

from lumibot.strategies.strategy import Strategy

logger = logging.getLogger(__name__)
# print_full_pandas_dataframes()
# set_pandas_float_precision(precision=15)

"""
The DriftRebalancer strategy is designed to maintain a portfolio's target asset allocation by
rebalancing assets based on their drift from target weights. The strategy calculates the
Expand All @@ -30,7 +25,7 @@ class DriftRebalancer(Strategy):
"""The DriftRebalancer strategy rebalances a portfolio based on drift from target weights.

The strategy calculates the drift of each asset in the portfolio and triggers a rebalance if the drift exceeds
the absolute_drift_threshold. The strategy will sell assets that have drifted above the threshold and
the drift_threshold. The strategy will sell assets that have drifted above the threshold and
buy assets that have drifted below the threshold.

The current version of the DriftRebalancer strategy only supports limit orders and whole share quantities.
Expand All @@ -46,14 +41,14 @@ class DriftRebalancer(Strategy):

### DriftRebalancer parameters

# This is the absolute drift threshold that will trigger a rebalance. If the target_weight is 0.30 and the
# absolute_drift_threshold is 0.05, then the rebalance will be triggered when the assets current_weight
# This is the drift threshold that will trigger a rebalance. If the target_weight is 0.30 and the
# drift_threshold is 0.05, then the rebalance will be triggered when the assets current_weight
# is less than 0.25 or greater than 0.35.
"absolute_drift_threshold": "0.05",
"drift_threshold": "0.05",

# This is the acceptable slippage that will be used when calculating the number of shares to buy or sell.
# The default is 0.0005 (5 BPS)
"acceptable_slippage": "0.0005",
# The default is 0.005 (50 BPS)
"acceptable_slippage": "0.005", # 50 BPS

# The amount of time to sleep between the sells and buys to give enough time for the orders to fill
"fill_sleeptime": 15,
Expand All @@ -64,39 +59,45 @@ class DriftRebalancer(Strategy):
"TLT": "0.40",
"USD": "0.00",
}

# If you want to allow shorting, set this to True.
shorting: False
}
"""

# noinspection PyAttributeOutsideInit
def initialize(self, parameters: Any = None) -> None:
self.set_market(self.parameters.get("market", "NYSE"))
self.sleeptime = self.parameters.get("sleeptime", "1D")
self.absolute_drift_threshold = Decimal(self.parameters.get("absolute_drift_threshold", "0.20"))
self.acceptable_slippage = Decimal(self.parameters.get("acceptable_slippage", "0.0005"))
self.drift_threshold = Decimal(self.parameters.get("drift_threshold", "0.20"))
self.acceptable_slippage = Decimal(self.parameters.get("acceptable_slippage", "0.005"))
self.fill_sleeptime = self.parameters.get("fill_sleeptime", 15)
self.target_weights = {k: Decimal(v) for k, v in self.parameters["target_weights"].items()}
self.shorting = self.parameters.get("shorting", False)
self.drift_df = pd.DataFrame()

# Sanity checks
if self.acceptable_slippage >= self.absolute_drift_threshold:
raise ValueError("acceptable_slippage must be less than absolute_drift_threshold")
if self.absolute_drift_threshold >= Decimal("1.0"):
raise ValueError("absolute_drift_threshold must be less than 1.0")
if self.acceptable_slippage >= self.drift_threshold:
raise ValueError("acceptable_slippage must be less than drift_threshold")
if self.drift_threshold >= Decimal("1.0"):
raise ValueError("drift_threshold must be less than 1.0")
for key, target_weight in self.target_weights.items():
if self.absolute_drift_threshold >= target_weight:
logger.warning(
f"absolute_drift_threshold of {self.absolute_drift_threshold} is "
if self.drift_threshold >= target_weight:
self.logger.warning(
f"drift_threshold of {self.drift_threshold} is "
f">= target_weight of {key}: {target_weight}. Drift in this asset will never trigger a rebalance."
)

# noinspection PyAttributeOutsideInit
def on_trading_iteration(self) -> None:
dt = self.get_datetime()
logger.info(f"{dt} on_trading_iteration called")
msg = f"{dt} on_trading_iteration called"
self.logger.info(msg)
self.log_message(msg, broadcast=True)
self.cancel_open_orders()

if self.cash < 0:
logger.error(f"Negative cash: {self.cash} but DriftRebalancer does not support short sales or margin yet.")
self.logger.error(f"Negative cash: {self.cash} but DriftRebalancer does not support short sales or margin yet.")

drift_calculator = DriftCalculationLogic(target_weights=self.target_weights)

Expand All @@ -123,36 +124,40 @@ def on_trading_iteration(self) -> None:
# Check if the absolute value of any drift is greater than the threshold
rebalance_needed = False
for index, row in self.drift_df.iterrows():
if row["absolute_drift"] > self.absolute_drift_threshold:
msg = (
f"Symbol: {row['symbol']} current_weight: {row['current_weight']:.2%} "
f"target_weight: {row['target_weight']:.2%} drift: {row['drift']:.2%}"
)
if abs(row["drift"]) > self.drift_threshold:
rebalance_needed = True
msg = (
f"Absolute drift for {row['symbol']} is {row['absolute_drift']:.2f} "
f"and exceeds threshold of {self.absolute_drift_threshold:.2f}"
msg += (
f" Absolute drift exceeds threshold of {self.drift_threshold:.2%}. Rebalance needed."
)
logger.info(msg)
self.log_message(msg, broadcast=True)
self.logger.info(msg)
self.log_message(msg, broadcast=True)

if rebalance_needed:
msg = f"Rebalancing portfolio."
logger.info(msg)
self.logger.info(msg)
self.log_message(msg, broadcast=True)
rebalance_logic = LimitOrderRebalanceLogic(
strategy=self,
df=self.drift_df,
fill_sleeptime=self.fill_sleeptime,
acceptable_slippage=self.acceptable_slippage
acceptable_slippage=self.acceptable_slippage,
shorting=self.shorting
)
rebalance_logic.rebalance()

def on_abrupt_closing(self):
dt = self.get_datetime()
logger.info(f"{dt} on_abrupt_closing called")
self.logger.info(f"{dt} on_abrupt_closing called")
self.log_message("On abrupt closing called.", broadcast=True)
self.cancel_open_orders()

def on_bot_crash(self, error):
dt = self.get_datetime()
logger.info(f"{dt} on_bot_crash called")
self.logger.info(f"{dt} on_bot_crash called")
self.log_message(f"Bot crashed with error: {error}", broadcast=True)
self.cancel_open_orders()

Expand All @@ -167,7 +172,7 @@ def __init__(self, target_weights: Dict[str, Decimal]) -> None:
"current_weight": Decimal(0),
"target_weight": [Decimal(weight) for weight in target_weights.values()],
"target_value": Decimal(0),
"absolute_drift": Decimal(0)
"drift": Decimal(0)
})

def add_position(self, *, symbol: str, is_quote_asset: bool, current_quantity: Decimal, current_value: Decimal) -> None:
Expand All @@ -184,7 +189,7 @@ def add_position(self, *, symbol: str, is_quote_asset: bool, current_quantity: D
"current_weight": Decimal(0),
"target_weight": Decimal(0),
"target_value": Decimal(0),
"absolute_drift": Decimal(0)
"drift": Decimal(0)
}
# Convert the dictionary to a DataFrame
new_row_df = pd.DataFrame([new_row])
Expand All @@ -205,14 +210,20 @@ def calculate_drift_row(row: pd.Series) -> Decimal:
if row["is_quote_asset"]:
# We can never buy or sell the quote asset
return Decimal(0)

# Check if we should sell everything
elif row["current_quantity"] > Decimal(0) and row["target_weight"] == Decimal(0):
return Decimal(-1)

# Check if we need to buy for the first time
elif row["current_quantity"] == Decimal(0) and row["target_weight"] > Decimal(0):
return Decimal(1)

# Otherwise we just need to adjust our holding
else:
return row["target_weight"] - row["current_weight"]

self.df["absolute_drift"] = self.df.apply(calculate_drift_row, axis=1)
self.df["drift"] = self.df.apply(calculate_drift_row, axis=1)
return self.df.copy()


Expand All @@ -223,67 +234,102 @@ def __init__(
strategy: Strategy,
df: pd.DataFrame,
fill_sleeptime: int = 15,
acceptable_slippage: Decimal = Decimal("0.0005")
acceptable_slippage: Decimal = Decimal("0.005"),
shorting: bool = False
) -> None:
self.strategy = strategy
self.df = df
self.fill_sleeptime = fill_sleeptime
self.acceptable_slippage = acceptable_slippage
self.shorting = shorting

def rebalance(self) -> None:
# Execute sells first
sell_orders = []
buy_orders = []
for index, row in self.df.iterrows():
if row["absolute_drift"] == -1:
if row["drift"] == -1:
# Sell everything
symbol = row["symbol"]
quantity = row["current_quantity"]
last_price = Decimal(self.strategy.get_last_price(symbol))
limit_price = self.calculate_limit_price(last_price=last_price, side="sell")
self.place_limit_order(symbol=symbol, quantity=quantity, limit_price=limit_price, side="sell")
elif row["absolute_drift"] < 0:
if quantity > 0 or (quantity == 0 and self.shorting):
order = self.place_limit_order(
symbol=symbol,
quantity=quantity,
limit_price=limit_price,
side="sell"
)
sell_orders.append(order)

elif row["drift"] < 0:
symbol = row["symbol"]
last_price = Decimal(self.strategy.get_last_price(symbol))
limit_price = self.calculate_limit_price(last_price=last_price, side="sell")
quantity = ((row["current_value"] - row["target_value"]) / limit_price).quantize(Decimal('1'), rounding=ROUND_DOWN)
if quantity > 0:
self.place_limit_order(symbol=symbol, quantity=quantity, limit_price=limit_price, side="sell")
if quantity > 0 and (quantity < row["current_quantity"] or self.shorting):
order = self.place_limit_order(
symbol=symbol,
quantity=quantity,
limit_price=limit_price,
side="sell"
)
sell_orders.append(order)

for order in sell_orders:
self.strategy.logger.info(f"Submitted sell order: {order}")

if not self.strategy.is_backtesting:
# Sleep to allow sell orders to fill
time.sleep(self.fill_sleeptime)
orders = self.strategy.broker._pull_all_orders(self.strategy.name, self.strategy)
for order in orders:
self.strategy.logger.info(f"Order at broker: {order}")

# Get current cash position from the broker
cash_position = self.get_current_cash_position()

# Execute buys
for index, row in self.df.iterrows():
if row["absolute_drift"] > 0:
if row["drift"] > 0:
symbol = row["symbol"]
last_price = Decimal(self.strategy.get_last_price(symbol))
limit_price = self.calculate_limit_price(last_price=last_price, side="buy")
order_value = row["target_value"] - row["current_value"]
quantity = (min(order_value, cash_position) / limit_price).quantize(Decimal('1'), rounding=ROUND_DOWN)
if quantity > 0:
self.place_limit_order(symbol=symbol, quantity=quantity, limit_price=limit_price, side="buy")
order = self.place_limit_order(symbol=symbol, quantity=quantity, limit_price=limit_price, side="buy")
buy_orders.append(order)
cash_position -= min(order_value, cash_position)
else:
logger.info(f"Ran out of cash to buy {symbol}. Cash: {cash_position} and limit_price: {limit_price:.2f}")
self.strategy.logger.info(f"Ran out of cash to buy {symbol}. Cash: {cash_position} and limit_price: {limit_price:.2f}")

for order in buy_orders:
self.strategy.logger.info(f"Submitted buy order: {order}")

if not self.strategy.is_backtesting:
# Sleep to allow orders to fill
time.sleep(self.fill_sleeptime)
orders = self.strategy.broker._pull_all_orders(self.strategy.name, self.strategy)
for order in orders:
self.strategy.logger.info(f"Order at broker: {order}")

def calculate_limit_price(self, *, last_price: Decimal, side: str) -> Decimal:
if side == "sell":
return last_price * (1 - self.acceptable_slippage / Decimal(10000))
return last_price * (1 - self.acceptable_slippage)
elif side == "buy":
return last_price * (1 + self.acceptable_slippage / Decimal(10000))
return last_price * (1 + self.acceptable_slippage)

def get_current_cash_position(self) -> Decimal:
self.strategy.update_broker_balances(force_update=True)
return Decimal(self.strategy.cash)

def place_limit_order(self, *, symbol: str, quantity: Decimal, limit_price: Decimal, side: str) -> None:
def place_limit_order(self, *, symbol: str, quantity: Decimal, limit_price: Decimal, side: str) -> Any:
limit_order = self.strategy.create_order(
asset=symbol,
quantity=quantity,
side=side,
limit_price=float(limit_price)
)
self.strategy.submit_order(limit_order)
return self.strategy.submit_order(limit_order)
Loading
Loading