| Overall Statistics |
|
Total Orders 136 Average Win 2.15% Average Loss -5.06% Compounding Annual Return -12.934% Drawdown 62.200% Expectancy -0.224 Start Equity 1000000 End Equity 415123.4 Net Profit -58.488% Sharpe Ratio -0.786 Sortino Ratio -0.564 Probabilistic Sharpe Ratio 0.000% Loss Rate 46% Win Rate 54% Profit-Loss Ratio 0.43 Alpha 0 Beta 0 Annual Standard Deviation 0.146 Annual Variance 0.021 Information Ratio -0.559 Tracking Error 0.146 Treynor Ratio 0 Total Fees $5631.60 Estimated Strategy Capacity $0 Lowest Capacity Asset LE Z3PBNQ1P4A2P Portfolio Turnover 6.57% Drawdown Recovery 0 |
#region imports
from AlgorithmImports import *
#endregion
import numpy as np
from collections import deque
"""
CORN / LIVE CATTLE LONG-SHORT COMMODITY PAIR TRADE
WITH MODERATE RISK MANAGEMENT AND TRUE RESTART AFTER COOL-OFF
This strategy trades a relative-value pair between continuous futures:
1. Corn futures
2. Live Cattle futures
The model calculates:
Corn price / Live Cattle price
It then computes a rolling z-score of that ratio.
Trading logic:
- If z-score is high:
Corn is expensive relative to Live Cattle
short Corn, long Live Cattle
- If z-score is low:
Corn is cheap relative to Live Cattle
long Corn, short Live Cattle
- If z-score mean-reverts:
exit both legs
Risk management:
1. Spread stop loss:
Exit if the combined spread loses more than 12.5% of initial gross exposure.
2. Single-leg stop loss:
Exit if the worst leg loses more than 10% of initial gross spread exposure.
3. Adverse z-score stop:
Exit if the z-score moves materially further against the open trade.
4. Maximum holding period:
Exit after 45 calendar days.
5. Cool-off:
After a stop loss, wait 10 calendar days, then restart normally.
Important design change:
The model resets its risk state after the cool-off period. This prevents the
algorithm from remaining stuck in a permanent defensive state.
Benchmark:
Cash is the benchmark because this is a long/short relative-value strategy.
"""
class CommodityPairsTradingAlgorithm(QCAlgorithm):
def Initialize(self):
# ------------------------------------------------------------
# 1. BACKTEST SETTINGS
# ------------------------------------------------------------
self.SetStartDate(2020, 1, 1)
self.SetEndDate(2026, 5, 5)
self.initial_cash = 1000000
self.SetCash(self.initial_cash)
# ------------------------------------------------------------
# 2. ADD CONTINUOUS FUTURES
# ------------------------------------------------------------
self.corn = self.AddFuture(
Futures.Grains.Corn,
Resolution.Daily,
dataNormalizationMode=DataNormalizationMode.BackwardsRatio,
dataMappingMode=DataMappingMode.OpenInterest,
contractDepthOffset=0,
extendedMarketHours=True
)
self.cattle = self.AddFuture(
Futures.Meats.LiveCattle,
Resolution.Daily,
dataNormalizationMode=DataNormalizationMode.BackwardsRatio,
dataMappingMode=DataMappingMode.OpenInterest,
contractDepthOffset=0,
extendedMarketHours=True
)
self.corn_symbol = self.corn.Symbol
self.cattle_symbol = self.cattle.Symbol
# ------------------------------------------------------------
# 3. SIGNAL PARAMETERS
# ------------------------------------------------------------
self.window = 45
self.entry_threshold = 2.0
self.exit_threshold = 0.25
# ------------------------------------------------------------
# 4. MODERATE RISK PARAMETERS
# ------------------------------------------------------------
self.leg_weight = 0.05
self.spread_stop_loss_pct = 0.125
self.single_leg_stop_loss_pct = 0.10
self.adverse_z_stop = 4.0
self.max_holding_days = 45
self.cooloff_days = 10
self.cooloff_until = datetime.min.date()
self.cooloff_was_active = False
# ------------------------------------------------------------
# 5. ROLLING DATA
# ------------------------------------------------------------
self.corn_prices = deque(maxlen=self.window)
self.cattle_prices = deque(maxlen=self.window)
# 0 = no position
# 1 = long Corn / short Cattle
# -1 = short Corn / long Cattle
self.current_position = 0
self.last_trade_date = None
self.entry_gross_exposure = None
self.entry_portfolio_value = None
self.entry_date = None
self.entry_z_score = None
# Cash benchmark.
self.SetBenchmark(lambda time: self.initial_cash)
# Build rolling window live. This avoids continuous futures
# history-indexing errors during Initialize.
self.Schedule.On(
self.DateRules.EveryDay(),
self.TimeRules.At(10, 0),
self.TradeLogic
)
def TradeLogic(self):
# ------------------------------------------------------------
# 1. PREVENT MULTIPLE DECISIONS SAME DAY
# ------------------------------------------------------------
if self.last_trade_date == self.Time.date():
return
# ------------------------------------------------------------
# 2. HANDLE COOL-OFF RESTART
# ------------------------------------------------------------
if self.Time.date() <= self.cooloff_until:
self.cooloff_was_active = True
self.Debug(
"Cool-off active on "
+ str(self.Time.date())
+ ". No new trades until "
+ str(self.cooloff_until)
)
self.last_trade_date = self.Time.date()
return
# Once cool-off has expired, explicitly reset risk state.
if self.cooloff_was_active:
self.ResetTradeRiskState()
self.cooloff_was_active = False
self.Debug(
"Cool-off ended on "
+ str(self.Time.date())
+ ". Strategy restarted."
)
# ------------------------------------------------------------
# 3. GET CONTINUOUS FUTURES PRICES
# ------------------------------------------------------------
if not self.Securities.ContainsKey(self.corn_symbol):
return
if not self.Securities.ContainsKey(self.cattle_symbol):
return
corn_price = self.Securities[self.corn_symbol].Price
cattle_price = self.Securities[self.cattle_symbol].Price
if corn_price <= 0 or cattle_price <= 0:
return
self.corn_prices.append(float(corn_price))
self.cattle_prices.append(float(cattle_price))
# Wait until rolling window is full.
if len(self.corn_prices) < self.window:
self.Debug(
"Building rolling window: "
+ str(len(self.corn_prices))
+ " / "
+ str(self.window)
)
return
if len(self.cattle_prices) < self.window:
return
z_score, current_ratio = self.CalculateZScore(
corn_price,
cattle_price
)
if z_score is None:
return
# ------------------------------------------------------------
# 4. RISK CHECKS ONLY WHEN POSITION IS OPEN
# ------------------------------------------------------------
if self.current_position != 0:
risk_exit_reason = self.CheckRiskExits(z_score)
if risk_exit_reason is not None:
self.ExitSpread(risk_exit_reason)
self.cooloff_until = (
self.Time.date()
+ timedelta(days=self.cooloff_days)
)
self.cooloff_was_active = True
self.Debug(
"Risk exit: "
+ risk_exit_reason
+ " on "
+ str(self.Time.date())
+ ". Cooling off until "
+ str(self.cooloff_until)
)
self.last_trade_date = self.Time.date()
return
# ------------------------------------------------------------
# 5. TRADING RULES
# ------------------------------------------------------------
if self.current_position == 0:
if z_score > self.entry_threshold:
self.EnterShortSpread(z_score)
elif z_score < -self.entry_threshold:
self.EnterLongSpread(z_score)
elif self.current_position == 1:
# Long spread exits when ratio mean-reverts upward.
if z_score > -self.exit_threshold:
self.ExitSpread("Mean reversion exit")
elif self.current_position == -1:
# Short spread exits when ratio mean-reverts downward.
if z_score < self.exit_threshold:
self.ExitSpread("Mean reversion exit")
self.last_trade_date = self.Time.date()
# ------------------------------------------------------------
# 6. PLOTS
# ------------------------------------------------------------
self.Plot("Pair Signal", "Z Score", z_score)
self.Plot("Pair Signal", "Entry Upper", self.entry_threshold)
self.Plot("Pair Signal", "Entry Lower", -self.entry_threshold)
self.Plot("Pair Signal", "Exit Upper", self.exit_threshold)
self.Plot("Pair Signal", "Exit Lower", -self.exit_threshold)
self.Plot("Pair Signal", "Corn / Cattle Ratio", current_ratio)
def CalculateZScore(self, corn_price, cattle_price):
ratio_series = (
np.array(self.corn_prices)
/ np.array(self.cattle_prices)
)
ratio_mean = np.mean(ratio_series)
ratio_std = np.std(ratio_series)
if ratio_std <= 0:
return None, None
current_ratio = corn_price / cattle_price
z_score = (current_ratio - ratio_mean) / ratio_std
return z_score, current_ratio
def EnterLongSpread(self, z_score):
# Long spread:
# Long Corn, short Live Cattle.
corn_contract = self.corn.Mapped
cattle_contract = self.cattle.Mapped
if corn_contract is None or cattle_contract is None:
return
self.SetHoldings(corn_contract, self.leg_weight)
self.SetHoldings(cattle_contract, -self.leg_weight)
self.current_position = 1
self.entry_portfolio_value = self.Portfolio.TotalPortfolioValue
self.entry_gross_exposure = None
self.entry_date = self.Time.date()
self.entry_z_score = z_score
self.Debug(
"Entered long spread: long Corn, short Live Cattle on "
+ str(self.Time.date())
+ " z="
+ str(round(z_score, 2))
)
def EnterShortSpread(self, z_score):
# Short spread:
# Short Corn, long Live Cattle.
corn_contract = self.corn.Mapped
cattle_contract = self.cattle.Mapped
if corn_contract is None or cattle_contract is None:
return
self.SetHoldings(corn_contract, -self.leg_weight)
self.SetHoldings(cattle_contract, self.leg_weight)
self.current_position = -1
self.entry_portfolio_value = self.Portfolio.TotalPortfolioValue
self.entry_gross_exposure = None
self.entry_date = self.Time.date()
self.entry_z_score = z_score
self.Debug(
"Entered short spread: short Corn, long Live Cattle on "
+ str(self.Time.date())
+ " z="
+ str(round(z_score, 2))
)
def ExitSpread(self, reason):
# Liquidate all open futures positions.
# This is safer than liquidating only the currently mapped contracts
# because futures contracts can roll.
for holding in self.Portfolio.Values:
if (
holding.Invested
and holding.Symbol.SecurityType == SecurityType.Future
):
self.Liquidate(holding.Symbol)
self.current_position = 0
self.ResetTradeRiskState()
self.Debug(
"Exited spread on "
+ str(self.Time.date())
+ " | Reason: "
+ reason
)
def CheckRiskExits(self, z_score):
total_unrealized_pnl = 0.0
gross_exposure = 0.0
worst_leg_pnl = 0.0
for holding in self.Portfolio.Values:
if (
holding.Invested
and holding.Symbol.SecurityType == SecurityType.Future
):
total_unrealized_pnl += holding.UnrealizedProfit
gross_exposure += abs(holding.HoldingsValue)
if holding.UnrealizedProfit < worst_leg_pnl:
worst_leg_pnl = holding.UnrealizedProfit
if gross_exposure <= 0:
return None
if self.entry_gross_exposure is None:
self.entry_gross_exposure = gross_exposure
if self.entry_gross_exposure <= 0:
return None
spread_return = total_unrealized_pnl / self.entry_gross_exposure
worst_leg_return = worst_leg_pnl / self.entry_gross_exposure
self.Plot("Risk Management", "Spread Return", spread_return)
self.Plot("Risk Management", "Worst Leg Return", worst_leg_return)
self.Plot("Risk Management", "Spread Stop", -self.spread_stop_loss_pct)
self.Plot("Risk Management", "Leg Stop", -self.single_leg_stop_loss_pct)
# ------------------------------------------------------------
# 1. SPREAD STOP LOSS
# ------------------------------------------------------------
if spread_return <= -self.spread_stop_loss_pct:
return "Spread stop loss"
# ------------------------------------------------------------
# 2. SINGLE LEG STOP LOSS
# ------------------------------------------------------------
if worst_leg_return <= -self.single_leg_stop_loss_pct:
return "Single-leg stop loss"
# ------------------------------------------------------------
# 3. ADVERSE Z-SCORE STOP
# ------------------------------------------------------------
if self.current_position == 1:
if z_score <= -self.adverse_z_stop:
return "Adverse z-score stop"
if self.current_position == -1:
if z_score >= self.adverse_z_stop:
return "Adverse z-score stop"
# ------------------------------------------------------------
# 4. MAX HOLDING PERIOD
# ------------------------------------------------------------
if self.entry_date is not None:
days_held = (
self.Time.date()
- self.entry_date
).days
self.Plot("Risk Management", "Days Held", days_held)
if days_held >= self.max_holding_days:
return "Max holding period"
return None
def ResetTradeRiskState(self):
self.entry_gross_exposure = None
self.entry_portfolio_value = None
self.entry_date = None
self.entry_z_score = None
def OnData(self, data):
self.Plot(
"Strategy Equity",
"Portfolio Value",
self.Portfolio.TotalPortfolioValue
)
self.Plot(
"Strategy Equity",
"Cash Benchmark",
self.initial_cash
)
corn_price = self.Securities[self.corn_symbol].Price
cattle_price = self.Securities[self.cattle_symbol].Price
if corn_price > 0:
self.Plot(
"Continuous Futures Prices",
"Corn",
corn_price
)
if cattle_price > 0:
self.Plot(
"Continuous Futures Prices",
"Live Cattle",
cattle_price
)
self.Plot(
"Position State",
"Current Position",
self.current_position
)
gross_exposure = 0.0
for holding in self.Portfolio.Values:
if holding.Invested:
gross_exposure += abs(holding.HoldingsValue)
if self.Portfolio.TotalPortfolioValue > 0:
self.Plot(
"Position State",
"Gross Exposure",
gross_exposure / self.Portfolio.TotalPortfolioValue
)
cooloff_active = 0
if self.Time.date() <= self.cooloff_until:
cooloff_active = 1
self.Plot(
"Risk Management",
"Cool-Off Active",
cooloff_active
)