Overall Statistics
Total Orders
264
Average Win
8.32%
Average Loss
-2.49%
Compounding Annual Return
15.194%
Drawdown
44.500%
Expectancy
0.398
Start Equity
100000
End Equity
211804.78
Net Profit
111.805%
Sharpe Ratio
0.393
Sortino Ratio
0.399
Probabilistic Sharpe Ratio
7.173%
Loss Rate
68%
Win Rate
32%
Profit-Loss Ratio
3.34
Alpha
0.131
Beta
-0.053
Annual Standard Deviation
0.323
Annual Variance
0.104
Information Ratio
0.154
Tracking Error
0.355
Treynor Ratio
-2.388
Total Fees
$540.40
Estimated Strategy Capacity
$1200000000.00
Lowest Capacity Asset
TSLA UNU3P8Y3WFAD
Portfolio Turnover
7.99%
Drawdown Recovery
506
#region imports
from AlgorithmImports import *
#endregion

import numpy as np


"""
DYNAMIC BREAKOUT STRATEGY WITH FLEXIBLE ENTRY AND LOOP CONTROL

This strategy trades TSLA using a dynamic breakout model. The lookback window
adjusts with recent volatility. When volatility rises, the lookback can expand.
When volatility falls, the lookback can contract. The lookback is kept between
a minimum floor and a maximum ceiling.

The model calculates breakout levels using previous completed daily bars only.
This is important. If the current day's high is included in the breakout level,
the current price will almost never be above the highest high, so the strategy may
not trade.

The strategy uses three signals:

1. Dynamic breakout:
   Price breaks above the prior lookback high or below the prior lookback low.

2. Dynamic band:
   Price moves above the upper dynamic band or below the lower dynamic band.

3. Trend confirmation:
   The short moving average must be above the long moving average for long trades,
   and below the long moving average for short trades.

Trading logic:
- Go long when price shows upside breakout or upper-band strength, with positive trend.
- Go short when price shows downside breakout or lower-band weakness, with negative trend.
- Stay in cash when signals are mixed.

Risk management:
- Exit a long position if price falls below the long liquidation level.
- Exit a short position if price rises above the short liquidation level.
- After a risk exit, wait a few days before re-entering.

The model avoids repeated orders by trading only when the target weight changes
meaningfully and by allowing only one trading decision per day.
"""


class DynamicBreakoutAlgorithm(QCAlgorithm):

    def Initialize(self):

        # ------------------------------------------------------------
        # 1. BACKTEST SETTINGS
        # ------------------------------------------------------------
        self.SetStartDate(2021, 1, 15)
        self.SetEndDate(2026, 5, 5)

        self.initial_cash = 100000
        self.SetCash(self.initial_cash)

        # ------------------------------------------------------------
        # 2. SECURITIES
        # ------------------------------------------------------------
        self.symbol = self.AddEquity("TSLA", Resolution.Daily, Market.USA).Symbol
        self.spy = self.AddEquity("SPY", Resolution.Daily, Market.USA).Symbol

        self.SetBenchmark(self.spy)

        # ------------------------------------------------------------
        # 3. PARAMETERS
        # ------------------------------------------------------------
        self.numdays = 20
        self.ceiling = 60
        self.floor = 10

        # Wider band = fewer trades. Smaller band = more trades.
        self.band_width = 0.40

        self.long_weight = 1.00
        self.short_weight = -0.50
        self.cash_weight = 0.00

        # Trend confirmation.
        self.fast_ma_period = 10
        self.slow_ma_period = 30

        # Loop-control parameters.
        self.rebalance_threshold = 0.05
        self.cooldown_days = 3
        self.cooldown_until = datetime.min.date()
        self.last_decision_date = None

        # ------------------------------------------------------------
        # 4. INDICATORS
        # ------------------------------------------------------------
        self.fast_ma = self.SMA(self.symbol, self.fast_ma_period, Resolution.Daily)
        self.slow_ma = self.SMA(self.symbol, self.slow_ma_period, Resolution.Daily)

        self.SetWarmUp(max(self.ceiling, self.slow_ma_period) + 5, Resolution.Daily)

        # ------------------------------------------------------------
        # 5. LEVELS AND STATE
        # ------------------------------------------------------------
        self.buy_point = None
        self.sell_point = None
        self.long_liq_point = None
        self.short_liq_point = None
        self.upper_band = None
        self.lower_band = None
        self.middle_band = None

        self.current_target_weight = 0.0

        self.initial_tsla_price = None
        self.initial_spy_price = None

        # ------------------------------------------------------------
        # 6. SCHEDULED SIGNAL
        # ------------------------------------------------------------
        self.Schedule.On(
            self.DateRules.EveryDay(self.symbol),
            self.TimeRules.BeforeMarketClose(self.symbol, 5),
            self.SetSignal
        )

    def SetSignal(self):

        # ------------------------------------------------------------
        # 1. LOOP CONTROL
        # ------------------------------------------------------------
        if self.IsWarmingUp:
            return

        if self.Time.date() <= self.cooldown_until:
            return

        if self.last_decision_date == self.Time.date():
            return

        if not self.fast_ma.IsReady or not self.slow_ma.IsReady:
            return

        # ------------------------------------------------------------
        # 2. HISTORY
        # ------------------------------------------------------------
        history_length = max(self.ceiling + 5, 70)

        history = self.History(
            self.symbol,
            history_length,
            Resolution.Daily
        )

        if history.empty:
            return

        closes = history["close"]
        highs = history["high"]
        lows = history["low"]
        opens = history["open"]

        if len(closes) < 35:
            return

        # Use previous completed bars only.
        # This avoids comparing current price to today's own high/low.
        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

        # ------------------------------------------------------------
        # 3. DYNAMIC LOOKBACK
        # ------------------------------------------------------------
        today_vol = np.std(closes_prev.iloc[-30:])
        yesterday_vol = np.std(closes_prev.iloc[-31:-1])

        if today_vol <= 0:
            return

        delta_vol = (today_vol - yesterday_vol) / today_vol
        new_lookback = int(round(self.numdays * (1 + delta_vol)))

        self.numdays = max(self.floor, min(self.ceiling, new_lookback))

        if len(closes_prev) < self.numdays:
            return

        lookback_highs = highs_prev.iloc[-self.numdays:]
        lookback_lows = lows_prev.iloc[-self.numdays:]
        lookback_closes = closes_prev.iloc[-self.numdays:]
        lookback_opens = opens_prev.iloc[-self.numdays:]

        self.buy_point = max(lookback_highs)
        self.sell_point = min(lookback_lows)

        self.middle_band = np.mean(lookback_closes)
        band_std = np.std(lookback_closes)

        self.upper_band = self.middle_band + self.band_width * band_std
        self.lower_band = self.middle_band - self.band_width * band_std

        self.long_liq_point = np.mean(lookback_closes)
        self.short_liq_point = np.mean(lookback_opens)

        price = self.Securities[self.symbol].Price

        if price <= 0:
            return

        # ------------------------------------------------------------
        # 4. SIGNAL LOGIC
        # ------------------------------------------------------------
        current_weight = self.GetCurrentWeight(self.symbol)
        target_weight = self.cash_weight

        uptrend = self.fast_ma.Current.Value > self.slow_ma.Current.Value
        downtrend = self.fast_ma.Current.Value < self.slow_ma.Current.Value

        upside_strength = (
            price > self.upper_band
            or price > self.buy_point
        )

        downside_weakness = (
            price < self.lower_band
            or price < self.sell_point
        )

        # Risk exits first.
        if current_weight > 0 and price <= self.long_liq_point:

            target_weight = 0.0
            self.cooldown_until = self.Time.date() + timedelta(days=self.cooldown_days)

        elif current_weight < 0 and price >= self.short_liq_point:

            target_weight = 0.0
            self.cooldown_until = self.Time.date() + timedelta(days=self.cooldown_days)

        else:

            if upside_strength and uptrend:
                target_weight = self.long_weight

            elif downside_weakness and downtrend:
                target_weight = self.short_weight

            else:
                target_weight = self.cash_weight

        # ------------------------------------------------------------
        # 5. EXECUTION
        # ------------------------------------------------------------
        if abs(target_weight - current_weight) < self.rebalance_threshold:
            self.last_decision_date = self.Time.date()
            return

        self.SetHoldings(self.symbol, target_weight)

        self.current_target_weight = target_weight
        self.last_decision_date = self.Time.date()

        self.Debug(
            "Trade "
            + str(self.Time.date())
            + " price="
            + str(round(price, 2))
            + " lookback="
            + str(self.numdays)
            + " target="
            + str(round(target_weight, 2))
            + " uptrend="
            + str(uptrend)
            + " downtrend="
            + str(downtrend)
        )

    def OnData(self, data):

        if self.symbol not in data or data[self.symbol] is None:
            return

        if self.spy not in data or data[self.spy] is None:
            return

        tsla_price = self.Securities[self.symbol].Price
        spy_price = self.Securities[self.spy].Price

        if tsla_price <= 0 or spy_price <= 0:
            return

        if self.initial_tsla_price is None:
            self.initial_tsla_price = tsla_price

        if self.initial_spy_price is None:
            self.initial_spy_price = spy_price

        # ------------------------------------------------------------
        # 1. EQUITY CURVE
        # ------------------------------------------------------------
        self.Plot(
            "Strategy Equity",
            "Portfolio Value",
            self.Portfolio.TotalPortfolioValue
        )

        self.Plot(
            "Strategy Equity",
            "Buy Hold TSLA",
            self.initial_cash * tsla_price / self.initial_tsla_price
        )

        self.Plot(
            "Strategy Equity",
            "Buy Hold SPY",
            self.initial_cash * spy_price / self.initial_spy_price
        )

        # ------------------------------------------------------------
        # 2. LEVELS
        # ------------------------------------------------------------
        self.Plot(
            "Breakout Levels",
            "TSLA Price",
            tsla_price
        )

        if self.buy_point is not None:
            self.Plot("Breakout Levels", "Buy Point", self.buy_point)

        if self.sell_point is not None:
            self.Plot("Breakout Levels", "Sell Point", self.sell_point)

        if self.upper_band is not None:
            self.Plot("Dynamic Bands", "Upper Band", self.upper_band)

        if self.middle_band is not None:
            self.Plot("Dynamic Bands", "Middle Band", self.middle_band)

        if self.lower_band is not None:
            self.Plot("Dynamic Bands", "Lower Band", self.lower_band)

        if self.long_liq_point is not None:
            self.Plot("Risk Levels", "Long Liquidation", self.long_liq_point)

        if self.short_liq_point is not None:
            self.Plot("Risk Levels", "Short Liquidation", self.short_liq_point)

        # ------------------------------------------------------------
        # 3. DIAGNOSTICS
        # ------------------------------------------------------------
        self.Plot("Diagnostics", "Dynamic Lookback", self.numdays)
        self.Plot("Diagnostics", "Target Weight", self.current_target_weight)

        if self.fast_ma.IsReady:
            self.Plot("Trend", "Fast MA", self.fast_ma.Current.Value)

        if self.slow_ma.IsReady:
            self.Plot("Trend", "Slow MA", self.slow_ma.Current.Value)

    def GetCurrentWeight(self, symbol):

        if self.Portfolio.TotalPortfolioValue <= 0:
            return 0.0

        return (
            self.Portfolio[symbol].HoldingsValue
            / self.Portfolio.TotalPortfolioValue
        )