Overall Statistics
Total Orders
285
Average Win
1.03%
Average Loss
-0.97%
Compounding Annual Return
7.130%
Drawdown
29.900%
Expectancy
-0.047
Start Equity
100000
End Equity
134879.14
Net Profit
34.879%
Sharpe Ratio
0.102
Sortino Ratio
0.115
Probabilistic Sharpe Ratio
5.909%
Loss Rate
54%
Win Rate
46%
Profit-Loss Ratio
1.07
Alpha
-0.024
Beta
0.895
Annual Standard Deviation
0.161
Annual Variance
0.026
Information Ratio
-0.304
Tracking Error
0.094
Treynor Ratio
0.018
Total Fees
$1076.19
Estimated Strategy Capacity
$390000000.00
Lowest Capacity Asset
TQQQ UK280CGTCB51
Portfolio Turnover
4.30%
Drawdown Recovery
787
#region imports
from AlgorithmImports import *
#endregion


"""
CORE-TILT MODEL WITH PARAMETERIZED TACTICAL ETF OVERLAY

This strategy uses a core-satellite structure.

The core position is SPY. This gives the portfolio stable broad-market exposure.
The tactical tilt is smaller and can rotate between a levered long ETF and an
inverse ETF depending on the trend signal.

The default setup is:

Core:
    SPY

Bullish tilt:
    TQQQ

Bearish tilt:
    SH

The model uses SPY as the signal asset. If SPY is in an uptrend, the strategy
adds the bullish tilt ETF. If SPY is in a downtrend and RSI confirms weakness, the
strategy adds the inverse ETF. If signals are mixed, the tilt sleeve stays in cash.

The model is parameterized so it can be optimized from the QuantConnect interface.
It uses the same parameter style as the prior examples.

Parameters:

core-weight:
    Permanent SPY allocation, expressed as a percentage.

tilt-weight:
    Maximum tactical sleeve allocation, expressed as a percentage.

ema-slow:
    Slow EMA period.

ema-spread:
    Difference between slow EMA and fast EMA.
    fast EMA = ema-slow - ema-spread.

period_rsi:
    RSI period.

low_rsi:
    Lower RSI threshold used to confirm bearish conditions.

high_rsi:
    Upper RSI threshold used to confirm bullish strength.

tilt-long:
    Levered or aggressive ETF used for bullish tilt.

tilt-short:
    Inverse ETF used for bearish tilt.

Important risk note:
Levered and inverse ETFs are tactical instruments. Their long-term behavior can
differ materially from the simple multiple of the underlying index because of
daily compounding and path dependency. The tilt sleeve should therefore remain
smaller than the core position.
"""


class CoreTiltParameterizedAlgorithm(QCAlgorithm):

    def Initialize(self):

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

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

        # ------------------------------------------------------------
        # 2. PARAMETERS
        # ------------------------------------------------------------
        self.core_weight = self.GetFloatParameter("core-weight", 70) / 100.0
        self.tilt_weight = self.GetFloatParameter("tilt-weight", 30) / 100.0

        self.slow = self.GetIntParameter("ema-slow", 50)
        self.spread_ema = self.GetIntParameter("ema-spread", 30)

        self.fast = self.slow - self.spread_ema

        if self.fast < 1:
            self.fast = 1

        if self.slow <= self.fast:
            self.slow = self.fast + 1

        self.rsi_period = self.GetIntParameter("period_rsi", 14)
        self.low_rsi = self.GetIntParameter("low_rsi", 45)
        self.high_rsi = self.GetIntParameter("high_rsi", 55)

        if self.low_rsi < 0:
            self.low_rsi = 0

        if self.high_rsi > 100:
            self.high_rsi = 100

        if self.high_rsi <= self.low_rsi:
            self.high_rsi = self.low_rsi + 1

        self.long_tilt_ticker = self.GetStringParameter("tilt-long", "TQQQ")
        self.short_tilt_ticker = self.GetStringParameter("tilt-short", "SH")

        # Make sure portfolio weights do not exceed 100%.
        total_weight = self.core_weight + self.tilt_weight

        if total_weight > 1.0:
            scale = 1.0 / total_weight
            self.core_weight = self.core_weight * scale
            self.tilt_weight = self.tilt_weight * scale

        # ------------------------------------------------------------
        # 3. SECURITIES
        # ------------------------------------------------------------
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol

        self.long_tilt = self.AddEquity(
            self.long_tilt_ticker,
            Resolution.Daily
        ).Symbol

        self.short_tilt = self.AddEquity(
            self.short_tilt_ticker,
            Resolution.Daily
        ).Symbol

        self.SetBenchmark(self.spy)

        # ------------------------------------------------------------
        # 4. INDICATORS ON SPY
        # ------------------------------------------------------------
        self.ema_fast = self.EMA(
            self.spy,
            self.fast,
            Resolution.Daily
        )

        self.ema_slow = self.EMA(
            self.spy,
            self.slow,
            Resolution.Daily
        )

        self.rsi = self.RSI(
            self.spy,
            self.rsi_period,
            MovingAverageType.Wilders,
            Resolution.Daily
        )

        warmup_period = max(
            self.slow,
            self.rsi_period
        )

        self.SetWarmUp(
            warmup_period,
            Resolution.Daily
        )

        # ------------------------------------------------------------
        # 5. BENCHMARK VARIABLES
        # ------------------------------------------------------------
        self.initial_spy_price = None

        # ------------------------------------------------------------
        # 6. TRADE CONTROL
        # ------------------------------------------------------------
        self.current_core_weight = 0
        self.current_long_tilt_weight = 0
        self.current_short_tilt_weight = 0

        self.rebalance_threshold = 0.02

        self.Debug(
            "Parameters: "
            + "core="
            + str(round(self.core_weight, 2))
            + ", tilt="
            + str(round(self.tilt_weight, 2))
            + ", fast EMA="
            + str(self.fast)
            + ", slow EMA="
            + str(self.slow)
            + ", RSI period="
            + str(self.rsi_period)
            + ", low RSI="
            + str(self.low_rsi)
            + ", high RSI="
            + str(self.high_rsi)
            + ", long tilt="
            + self.long_tilt_ticker
            + ", short tilt="
            + self.short_tilt_ticker
        )

    def OnData(self, data):

        # ------------------------------------------------------------
        # 1. CHECK DATA
        # ------------------------------------------------------------
        if self.spy not in data or data[self.spy] is None:
            return

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

        if self.short_tilt not in data or data[self.short_tilt] 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

        # ------------------------------------------------------------
        # 2. WAIT FOR INDICATORS
        # ------------------------------------------------------------
        if self.IsWarmingUp:
            return

        if not self.ema_fast.IsReady:
            return

        if not self.ema_slow.IsReady:
            return

        if not self.rsi.IsReady:
            return

        # ------------------------------------------------------------
        # 3. SIGNALS
        # ------------------------------------------------------------
        bullish_trend = self.ema_fast.Current.Value > self.ema_slow.Current.Value
        bearish_trend = self.ema_fast.Current.Value < self.ema_slow.Current.Value

        rsi_value = self.rsi.Current.Value

        bullish_confirmation = rsi_value > self.high_rsi
        bearish_confirmation = rsi_value < self.low_rsi

        # ------------------------------------------------------------
        # 4. TARGET WEIGHTS
        # ------------------------------------------------------------
        target_spy_weight = self.core_weight
        target_long_tilt_weight = 0.0
        target_short_tilt_weight = 0.0

        if bullish_trend and bullish_confirmation:

            target_long_tilt_weight = self.tilt_weight
            target_short_tilt_weight = 0.0

        elif bearish_trend and bearish_confirmation:

            target_long_tilt_weight = 0.0
            target_short_tilt_weight = self.tilt_weight

        else:

            target_long_tilt_weight = 0.0
            target_short_tilt_weight = 0.0

        # ------------------------------------------------------------
        # 5. EXECUTION
        # ------------------------------------------------------------
        self.ApplyTargetWeight(
            self.spy,
            target_spy_weight,
            "SPY Core"
        )

        self.ApplyTargetWeight(
            self.long_tilt,
            target_long_tilt_weight,
            "Long Tilt"
        )

        self.ApplyTargetWeight(
            self.short_tilt,
            target_short_tilt_weight,
            "Short Tilt"
        )

        # ------------------------------------------------------------
        # 6. PLOTS
        # ------------------------------------------------------------
        spy_benchmark_value = (
            self.initial_cash
            * spy_price
            / self.initial_spy_price
        )

        self.Plot(
            "Strategy Equity",
            "Portfolio Value",
            self.Portfolio.TotalPortfolioValue
        )

        self.Plot(
            "Strategy Equity",
            "Buy Hold SPY",
            spy_benchmark_value
        )

        core_cash_benchmark = (
            self.initial_cash
            * (
                (1.0 - self.core_weight)
                +
                self.core_weight
                * spy_price
                / self.initial_spy_price
            )
        )

        self.Plot(
            "Strategy Equity",
            "Core SPY Cash Benchmark",
            core_cash_benchmark
        )

        self.Plot(
            "Signal",
            "EMA Fast",
            self.ema_fast.Current.Value
        )

        self.Plot(
            "Signal",
            "EMA Slow",
            self.ema_slow.Current.Value
        )

        self.Plot(
            "RSI",
            "RSI",
            rsi_value
        )

        self.Plot(
            "RSI",
            "Low RSI",
            self.low_rsi
        )

        self.Plot(
            "RSI",
            "High RSI",
            self.high_rsi
        )

        self.Plot(
            "Target Weights",
            "SPY Core",
            target_spy_weight
        )

        self.Plot(
            "Target Weights",
            "Long Tilt",
            target_long_tilt_weight
        )

        self.Plot(
            "Target Weights",
            "Inverse Tilt",
            target_short_tilt_weight
        )

    # ------------------------------------------------------------
    # EXECUTION HELPER
    # ------------------------------------------------------------
    def ApplyTargetWeight(self, symbol, target_weight, label):

        current_weight = 0.0

        if self.Portfolio.TotalPortfolioValue > 0:

            current_weight = (
                self.Portfolio[symbol].HoldingsValue
                / self.Portfolio.TotalPortfolioValue
            )

        if abs(target_weight - current_weight) > self.rebalance_threshold:

            self.SetHoldings(
                symbol,
                target_weight
            )

            self.Debug(
                label
                + " target "
                + str(round(target_weight, 2))
                + " at "
                + str(self.Time.date())
            )

    # ------------------------------------------------------------
    # PARAMETER HELPERS
    # ------------------------------------------------------------
    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)

    def GetStringParameter(self, name, default_value):

        value = self.GetParameter(name)

        if value is None or value == "":
            return default_value

        return value