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)