Problem Description
Solution
To build a strategy that trades the price difference (arbitrage) between a spot asset and its future while explicitly managing the underlying currency (USDT) and the asset (BTC), you need to utilize the CashBook feature and a crypto-specific brokerage model in QuantConnect.
The following strategy implements a Cash-and-Carry (or reverse Cash-and-Carry) arbitrage. It monitors the spread between BTCUSDT Spot and the BTCUSDT Future.
Key Concepts Implemented
- Brokerage Model: Uses
BrokerageName.BYBITwithAccountType.Cashto simulate a realistic crypto environment where you hold actual coins and stablecoins. - Cash Management: Explicitly checks
self.portfolio.cash_book["USDT"]to ensure there is enough settled cash to open positions. - Sequential Execution: Uses
on_order_eventto execute the second leg of the trade only after the first leg is filled. This prevents "Insufficient Buying Power" errors common in crypto execution when trying to fire both orders simultaneously without sufficient pre-existing margin.
Strategy Code
# region imports
from AlgorithmImports import *
# endregion
class SpotFutureArbitrageAlgorithm(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2022, 5, 1)
self.set_end_date(2024, 1, 1)
self.set_cash(100000) # Set Strategy Cash (USDT)
# 1. Setup Brokerage for Crypto Cash Account
# We use Bybit to simulate a cash account where we hold USDT and BTC.
self.set_brokerage_model(BrokerageName.BYBIT, AccountType.Cash)
self.set_account_currency("USDT")
# 2. Add Assets
# Add Spot BTCUSDT
spot_crypto = self.add_crypto("BTCUSDT", Resolution.MINUTE, market=Market.BYBIT)
self.spot_symbol = spot_crypto.symbol
# Add Future BTCUSDT
future_crypto = self.add_crypto_future("BTCUSDT", Resolution.MINUTE, market=Market.BYBIT)
self.future_symbol = future_crypto.symbol
# 3. Strategy Parameters
self.threshold = 0.005 # 0.5% spread required to trade
self.qty = 0.1 # Quantity to trade per signal
# Seeder to ensure we have price data immediately upon initialization
self.set_security_initializer(
BrokerageModelSecurityInitializer(
self.brokerage_model,
FuncSecuritySeeder(self.get_last_known_prices)
)
)
def on_data(self, slice: Slice) -> None:
# Ensure we have data for both symbols
if not (slice.quote_bars.contains_key(self.spot_symbol) and
slice.quote_bars.contains_key(self.future_symbol)):
return
spot_price = slice.quote_bars[self.spot_symbol].close
future_price = slice.quote_bars[self.future_symbol].close
# Avoid trading if we are already invested
if self.portfolio.invested:
return
# Scenario 1: Spot is significantly higher than Future (Sell Spot, Buy Future)
# We need to own BTC to sell Spot.
# Note: In a real cold-start, you might need to buy BTC first or start with BTC in cashbook.
if spot_price >= future_price * (1 + self.threshold):
# Check if we have enough BTC to sell
if self.portfolio.cash_book["BTC"].amount >= self.qty:
self.sell(self.spot_symbol, self.qty)
self.debug(f"Arbitrage Signal: Selling Spot, Buying Future. Spread: {(spot_price/future_price)-1:.4f}")
# Scenario 2: Future is significantly higher than Spot (Buy Spot, Sell Future)
# We need USDT to buy Spot.
elif future_price >= spot_price * (1 + self.threshold):
# Check if we have enough USDT to buy spot
cost_to_buy = spot_price * self.qty
if self.portfolio.cash_book["USDT"].amount >= cost_to_buy:
self.buy(self.spot_symbol, self.qty)
self.debug(f"Arbitrage Signal: Buying Spot, Selling Future. Spread: {(future_price/spot_price)-1:.4f}")
def on_order_event(self, order_event: OrderEvent) -> None:
# We handle the second leg of the trade here to ensure the first leg is filled
# and funds/margin are available.
if order_event.status == OrderStatus.FILLED:
# Logic: If we just bought Spot (Quantity > 0), we need to Sell Future
if order_event.symbol == self.spot_symbol:
if order_event.quantity > 0:
# Leg 1 Complete: Bought Spot. Leg 2: Sell Future
self.sell(self.future_symbol, abs(order_event.fill_quantity))
elif order_event.quantity < 0:
# Leg 1 Complete: Sold Spot. Leg 2: Buy Future
self.buy(self.future_symbol, abs(order_event.fill_quantity))
# Note: If you initiated the trade via the Future, you would invert this logic.
# This example assumes the Spot trade is the "Lead" trade in OnData.
def calculate_initial_margin(self, symbol: Symbol, quantity: float) -> float:
"""
Helper to calculate margin requirements if you want to add stricter checks
before placing the Future order.
"""
security = self.securities[symbol]
# Create a parameter object for the margin model
# Note: This requires the specific API signature for InitialMarginParameters
# For simple checks, relying on Portfolio.MarginRemaining is often easier.
return 0.0
Detailed Breakdown
1. Brokerage and Currency Setup
self.set_brokerage_model(BrokerageName.BYBIT, AccountType.Cash)
self.set_account_currency("USDT")
This is critical. Standard equity backtests often assume a margin account where you can borrow cash instantly. In Crypto, specifically AccountType.Cash, you cannot buy Spot BTC if you do not have settled USDT, and you cannot sell Spot BTC if you do not hold the coin.
2. The Arbitrage Logic (on_data)
The strategy calculates the spread between the Spot price and the Future price.
- Contango (Future > Spot): The strategy buys the Spot asset (using USDT) and shorts the Future. This captures the funding rate or the convergence of price at expiry.
- Backwardation (Spot > Future): The strategy sells the Spot asset (requires holding BTC) and buys the Future.
3. Execution Management (on_order_event)
In multi-asset crypto strategies, execution timing is vital.
- If you send
Buy(Spot)andSell(Future)simultaneously, the engine might reject the Future order if the margin calculation happens before the Spot order updates your buying power. - By placing the second order inside
on_order_event(checking forOrderStatus.FILLED), we ensure the first transaction is complete and the portfolio state is updated before committing to the hedge.
Q&A
Q: Why do I need AccountType.Cash?
A: Most crypto exchanges separate Spot (Cash) wallets from Derivatives wallets, or use a Unified Margin account. AccountType.Cash in LEAN enforces strict balance checks, ensuring your simulation matches the reality of needing actual coins to trade spot pairs, preventing unrealistic backtest results.
Q: How does the strategy handle margin for the Futures contract?
A: When you trade the Future (e.g., BTCUSDT linear future), LEAN's BuyingPowerModel automatically calculates the required initial margin (usually in USDT for linear contracts). The strategy implicitly relies on the cash remaining after the Spot trade to cover this margin.
Q: Can I trade this on Binance instead of Bybit?
A: Yes. Change BrokerageName.BYBIT to BrokerageName.BINANCE and ensure the market parameter in add_crypto and add_crypto_future is set to Market.BINANCE.
Q: What happens if the prices never converge?
A: This basic example opens the position but does not include exit logic. A complete arbitrage strategy requires an exit condition in on_data (e.g., when abs(spot - future) < exit_threshold) to liquidate both positions and realize the profit.