| Overall Statistics |
|
Total Orders 5065 Average Win 0.20% Average Loss -0.23% Compounding Annual Return 10.558% Drawdown 19.100% Expectancy 0.119 Start Equity 100000 End Equity 208246.89 Net Profit 108.247% Sharpe Ratio 0.424 Sortino Ratio 0.442 Probabilistic Sharpe Ratio 16.896% Loss Rate 40% Win Rate 60% Profit-Loss Ratio 0.87 Alpha -0.006 Beta 0.531 Annual Standard Deviation 0.106 Annual Variance 0.011 Information Ratio -0.515 Tracking Error 0.098 Treynor Ratio 0.085 Total Fees $8633.09 Estimated Strategy Capacity $0 Lowest Capacity Asset QUAL VIBZ5HTB7N8L Portfolio Turnover 32.26% Drawdown Recovery 765 |
#region imports
from AlgorithmImports import *
#endregion
import numpy as np
"""
MULTI-ASSET DYNAMIC BREAKOUT STRATEGY WITH LOOP CONTROL
This strategy applies a dynamic breakout model to a basket of U.S. factor ETFs.
The universe includes SPY, USMV, QUAL, DGRO, DVY, MTUM, VLUE, EFAV, SIZE, INTF,
IQLT, and LRGF.
For each ETF, the algorithm calculates a volatility-adjusted breakout window.
When realized volatility rises, the lookback window can expand. When volatility
falls, the lookback window can contract. The lookback is kept between a floor and
a ceiling.
For each ETF, the model calculates:
1. Buy point:
The highest high over the prior completed lookback window.
2. Sell point:
The lowest low over the prior completed lookback window.
3. Dynamic upper and lower bands:
A Bollinger-style band calculated from the mean and standard deviation of
closing prices over the dynamic lookback.
4. Liquidation levels:
Average close is used as a long liquidation reference.
Average open is used as a short liquidation reference.
Trading logic:
- If an ETF breaks above its upper band or prior buy point, the model assigns it
a positive score.
- If an ETF breaks below its lower band or prior sell point, the model assigns it
a negative score.
- The portfolio invests equally across positive breakout ETFs.
- The model does not short by default. This keeps the example more stable and
pedagogical.
- If no ETF has a positive breakout, the portfolio holds SPY as a defensive core.
Loop control:
- The model evaluates signals once per day after the market opens.
- It only trades when the target weight changes meaningfully.
- It uses previous completed bars only, avoiding look-ahead-like comparisons to
the current day's own high or low.
- It does not create indicators inside the trading loop.
"""
class DynamicBreakoutAlgorithm(QCAlgorithm):
def Initialize(self):
# ------------------------------------------------------------
# 1. BACKTEST SETTINGS
# ------------------------------------------------------------
self.SetStartDate(2019, 1, 15)
self.SetEndDate(2026, 5, 5)
self.initial_cash = 100000
self.SetCash(self.initial_cash)
# ------------------------------------------------------------
# 2. UNIVERSE
# ------------------------------------------------------------
self.tickers = [
"SPY",
"USMV",
"QUAL",
"DGRO",
"DVY",
"MTUM",
"VLUE",
"EFAV",
"SIZE",
"INTF",
"IQLT",
"LRGF"
]
self.symbols = []
for ticker in self.tickers:
symbol = self.AddEquity(
ticker,
Resolution.Daily,
Market.USA
).Symbol
self.symbols.append(symbol)
self.spy = self.symbols[0]
self.SetBenchmark(self.spy)
# ------------------------------------------------------------
# 3. PARAMETERS
# ------------------------------------------------------------
self.initial_lookback = self.GetIntParameter("initial-lookback", 20)
self.lookback_floor = self.GetIntParameter("lookback-floor", 10)
self.lookback_ceiling = self.GetIntParameter("lookback-ceiling", 60)
self.band_width = self.GetFloatParameter("band-width", 0.50)
self.max_invested_weight = self.GetFloatParameter("max-invested-weight", 1.00)
self.core_spy_weight = self.GetFloatParameter("core-spy-weight", 0.50)
self.rebalance_threshold = self.GetFloatParameter("rebalance-threshold", 0.03)
# Safety checks
self.lookback_floor = max(2, self.lookback_floor)
if self.lookback_ceiling <= self.lookback_floor:
self.lookback_ceiling = self.lookback_floor + 1
if self.initial_lookback < self.lookback_floor:
self.initial_lookback = self.lookback_floor
if self.initial_lookback > self.lookback_ceiling:
self.initial_lookback = self.lookback_ceiling
self.max_invested_weight = max(0.0, min(1.0, self.max_invested_weight))
self.core_spy_weight = max(0.0, min(1.0, self.core_spy_weight))
# ------------------------------------------------------------
# 4. STATE BY SYMBOL
# ------------------------------------------------------------
self.lookback_by_symbol = {}
self.buy_point_by_symbol = {}
self.sell_point_by_symbol = {}
self.upper_band_by_symbol = {}
self.lower_band_by_symbol = {}
self.middle_band_by_symbol = {}
self.long_liq_by_symbol = {}
for symbol in self.symbols:
self.lookback_by_symbol[symbol] = self.initial_lookback
self.buy_point_by_symbol[symbol] = None
self.sell_point_by_symbol[symbol] = None
self.upper_band_by_symbol[symbol] = None
self.lower_band_by_symbol[symbol] = None
self.middle_band_by_symbol[symbol] = None
self.long_liq_by_symbol[symbol] = None
self.current_targets = {}
for symbol in self.symbols:
self.current_targets[symbol] = 0.0
self.last_decision_date = None
self.initial_spy_price = None
self.strategy_peak = self.initial_cash
self.benchmark_peak = self.initial_cash
self.SetWarmUp(
self.lookback_ceiling + 5,
Resolution.Daily
)
# ------------------------------------------------------------
# 5. SCHEDULED SIGNAL
# ------------------------------------------------------------
self.Schedule.On(
self.DateRules.EveryDay(self.spy),
self.TimeRules.AfterMarketOpen(self.spy, 30),
self.SetCombinedSignal
)
def SetCombinedSignal(self):
# ------------------------------------------------------------
# 1. LOOP CONTROL
# ------------------------------------------------------------
if self.IsWarmingUp:
return
if self.last_decision_date == self.Time.date():
return
positive_symbols = []
for symbol in self.symbols:
signal = self.CalculateSymbolSignal(symbol)
if signal > 0:
positive_symbols.append(symbol)
# ------------------------------------------------------------
# 2. TARGET PORTFOLIO
# ------------------------------------------------------------
target_weights = {}
for symbol in self.symbols:
target_weights[symbol] = 0.0
if len(positive_symbols) > 0:
equal_weight = self.max_invested_weight / len(positive_symbols)
for symbol in positive_symbols:
target_weights[symbol] = equal_weight
else:
# Defensive fallback:
# If no ETF has a positive breakout, hold a partial SPY core.
target_weights[self.spy] = self.core_spy_weight
# ------------------------------------------------------------
# 3. RISK EXIT CHECK
# ------------------------------------------------------------
for symbol in self.symbols:
current_weight = self.GetCurrentWeight(symbol)
current_price = self.Securities[symbol].Price
long_liq = self.long_liq_by_symbol[symbol]
if (
current_weight > 0
and long_liq is not None
and current_price <= long_liq
):
target_weights[symbol] = 0.0
# ------------------------------------------------------------
# 4. APPLY TARGETS ONLY WHEN THEY CHANGE
# ------------------------------------------------------------
for symbol, target_weight in target_weights.items():
current_weight = self.GetCurrentWeight(symbol)
if abs(target_weight - current_weight) > self.rebalance_threshold:
self.SetHoldings(symbol, target_weight)
self.current_targets[symbol] = target_weight
self.last_decision_date = self.Time.date()
self.Debug(
"Rebalance "
+ str(self.Time.date())
+ " positive symbols="
+ str([x.Value for x in positive_symbols])
)
def CalculateSymbolSignal(self, symbol):
# ------------------------------------------------------------
# 1. HISTORY
# ------------------------------------------------------------
history_length = max(self.lookback_ceiling + 5, 70)
history = self.History(
symbol,
history_length,
Resolution.Daily
)
if history.empty:
return 0
closes = history["close"]
highs = history["high"]
lows = history["low"]
opens = history["open"]
if len(closes) < 35:
return 0
# Use previous completed bars only.
closes_prev = closes.iloc[:-1]
highs_prev = highs.iloc[:-1]
lows_prev = lows.iloc[:-1]
opens_prev = opens.iloc[:-1]
if len(closes_prev) < 31:
return 0
# ------------------------------------------------------------
# 2. DYNAMIC LOOKBACK
# ------------------------------------------------------------
today_vol = np.std(closes_prev.iloc[-30:])
yesterday_vol = np.std(closes_prev.iloc[-31:-1])
if today_vol <= 0:
return 0
delta_vol = (today_vol - yesterday_vol) / today_vol
old_lookback = self.lookback_by_symbol[symbol]
new_lookback = int(round(old_lookback * (1 + delta_vol)))
new_lookback = max(
self.lookback_floor,
min(self.lookback_ceiling, new_lookback)
)
self.lookback_by_symbol[symbol] = new_lookback
if len(closes_prev) < new_lookback:
return 0
lookback_highs = highs_prev.iloc[-new_lookback:]
lookback_lows = lows_prev.iloc[-new_lookback:]
lookback_closes = closes_prev.iloc[-new_lookback:]
self.buy_point_by_symbol[symbol] = max(lookback_highs)
self.sell_point_by_symbol[symbol] = min(lookback_lows)
middle_band = np.mean(lookback_closes)
band_std = np.std(lookback_closes)
upper_band = middle_band + self.band_width * band_std
lower_band = middle_band - self.band_width * band_std
self.middle_band_by_symbol[symbol] = middle_band
self.upper_band_by_symbol[symbol] = upper_band
self.lower_band_by_symbol[symbol] = lower_band
self.long_liq_by_symbol[symbol] = np.mean(lookback_closes)
price = self.Securities[symbol].Price
if price <= 0:
return 0
# ------------------------------------------------------------
# 3. FLEXIBLE BREAKOUT SIGNAL
# ------------------------------------------------------------
upside_breakout = (
price > upper_band
or price > self.buy_point_by_symbol[symbol]
)
downside_breakout = (
price < lower_band
or price < self.sell_point_by_symbol[symbol]
)
if upside_breakout:
return 1
if downside_breakout:
return -1
return 0
def OnData(self, data):
if self.spy not in data or data[self.spy] is None:
return
spy_price = self.Securities[self.spy].Price
if spy_price <= 0:
return
if self.initial_spy_price is None:
self.initial_spy_price = spy_price
benchmark_value = (
self.initial_cash
* spy_price
/ self.initial_spy_price
)
# ------------------------------------------------------------
# 1. STRATEGY EQUITY
# ------------------------------------------------------------
self.Plot(
"Strategy Equity",
"Portfolio Value",
self.Portfolio.TotalPortfolioValue
)
self.Plot(
"Strategy Equity",
"Buy Hold SPY",
benchmark_value
)
# ------------------------------------------------------------
# 2. PORTFOLIO STATE
# ------------------------------------------------------------
invested_value = 0
active_holdings = 0
for holding in self.Portfolio.Values:
if holding.Invested:
invested_value += abs(holding.HoldingsValue)
active_holdings += 1
if self.Portfolio.TotalPortfolioValue > 0:
invested_weight = invested_value / self.Portfolio.TotalPortfolioValue
cash_weight = 1 - invested_weight
self.Plot("Portfolio State", "Invested Weight", invested_weight)
self.Plot("Portfolio State", "Cash Weight", cash_weight)
self.Plot("Portfolio Diagnostics", "Active Holdings", active_holdings)
# ------------------------------------------------------------
# 3. SPY LEVEL DIAGNOSTICS
# ------------------------------------------------------------
self.Plot("SPY Levels", "SPY Price", spy_price)
if self.buy_point_by_symbol[self.spy] is not None:
self.Plot(
"SPY Levels",
"Buy Point",
self.buy_point_by_symbol[self.spy]
)
if self.sell_point_by_symbol[self.spy] is not None:
self.Plot(
"SPY Levels",
"Sell Point",
self.sell_point_by_symbol[self.spy]
)
if self.upper_band_by_symbol[self.spy] is not None:
self.Plot(
"SPY Bands",
"Upper Band",
self.upper_band_by_symbol[self.spy]
)
if self.lower_band_by_symbol[self.spy] is not None:
self.Plot(
"SPY Bands",
"Lower Band",
self.lower_band_by_symbol[self.spy]
)
self.Plot(
"Diagnostics",
"SPY Dynamic Lookback",
self.lookback_by_symbol[self.spy]
)
# ------------------------------------------------------------
# 4. DRAWDOWN
# ------------------------------------------------------------
self.strategy_peak = max(
self.strategy_peak,
self.Portfolio.TotalPortfolioValue
)
self.benchmark_peak = max(
self.benchmark_peak,
benchmark_value
)
strategy_drawdown = (
self.Portfolio.TotalPortfolioValue
/ self.strategy_peak
- 1
)
benchmark_drawdown = (
benchmark_value
/ self.benchmark_peak
- 1
)
self.Plot("Drawdown", "Strategy Drawdown", strategy_drawdown)
self.Plot("Drawdown", "Benchmark Drawdown", benchmark_drawdown)
def GetCurrentWeight(self, symbol):
if self.Portfolio.TotalPortfolioValue <= 0:
return 0.0
return (
self.Portfolio[symbol].HoldingsValue
/ self.Portfolio.TotalPortfolioValue
)
def GetIntParameter(self, name, default_value):
value = self.GetParameter(name)
if value is None or value == "":
return default_value
return int(value)
def GetFloatParameter(self, name, default_value):
value = self.GetParameter(name)
if value is None or value == "":
return default_value
return float(value)