Overall Statistics
Total Orders
578
Average Win
0.86%
Average Loss
-1.02%
Compounding Annual Return
15.479%
Drawdown
28.300%
Expectancy
0.261
Start Equity
100000
End Equity
205415.98
Net Profit
105.416%
Sharpe Ratio
0.434
Sortino Ratio
0.442
Probabilistic Sharpe Ratio
5.330%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
0.84
Alpha
0.021
Beta
1.035
Annual Standard Deviation
0.187
Annual Variance
0.035
Information Ratio
0.202
Tracking Error
0.115
Treynor Ratio
0.078
Total Fees
$751.08
Estimated Strategy Capacity
$2400000.00
Lowest Capacity Asset
VIXY UT076X30D0MD
Portfolio Turnover
4.50%
Drawdown Recovery
513
# region imports
from AlgorithmImports import *

import numpy as np
# endregion

# Implementation of Strategy 4 ("eVRP + BoC + Sizing") from:
#   Zarattini, C., Mele, A., & Aziz, A. (2025). "The Volatility Edge: A Dual Approach
#   for VIX ETNs Trading." Swiss Finance Institute.
#
# Signal (paper, Table 2):
#   eVRP > 0  and VIX < VIX3M -> short vol at VIX%
#   eVRP <= 0 and VIX < VIX3M -> short vol at 0.5 x VIX%
#   eVRP <= 0 and VIX > VIX3M -> long vol at VIX%
#   otherwise                 -> cash
# where eVRP = VIX - std(last 10 SPY daily returns) * sqrt(252) * 100, the final return
# running from yesterday's close to the current intraday price.
#
# Deviations from the paper, and why:
#   - Default reality models only. The paper nets a flat 5 bps per trade; overriding
#     LEAN's fee model to reproduce that is not permitted, so absolute returns here will
#     differ from Table 3.
#   - Signal fires 16 minutes before the close rather than the paper's 15. LEAN's
#     default market-on-close submission buffer is 15m30s and rejects anything later.
#   - Backtest covers the trailing 5 years, so the paper's 2008 (+86.9%) and 2020
#     (+42.3%) years are outside the sample.
#   - The paper trades synthetic proxies: VIXLONG (SPVXSTR less 50bp/yr) and VIXSHORT
#     (the inverse index, XIV-like -1x, less 80bp). "Long VIXSHORT at VIX%" is -VIX% of
#     1x futures exposure, so any listed expression with matching exposure is
#     equivalent. Legs are therefore defined as (ticker, beta): short VIXY at 1x, long
#     SVXY at 2x, or long SVIX at 1x.
#   - The paper's Strategy 4 rule box writes "eVRP < 0" while its Table 2 summary writes
#     "eVRP <= 0" for the same strategy. Table 2 and the reference notebook agree, so
#     <= is used here.


class VIXDualStrategy(QCAlgorithm):
    """Dual-signal VIX ETN strategy with VIX-scaled position sizing."""

    def initialize(self) -> None:
        # self.set_end_date(2026, 7, 22)
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self.initial_cash = 100000
        self.set_cash(self.initial_cash)
        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.settings.minimum_order_margin_portfolio_percentage = 0

        lookback = int(self.get_parameter("lookback", 10))
        minutes_before_close = int(self.get_parameter("minutes_before_close", 16))
        maximum_exposure = float(self.get_parameter("maximum_exposure", 1.0))
        sizing_divisor = float(self.get_parameter("sizing_divisor", 100.0))
        # "dynamic" = Strategy 4 (eVRP + BoC + Sizing); "static" = Strategy 3 (eVRP + BoC).
        sizing_mode = str(self.get_parameter("sizing_mode", "dynamic"))
        static_size = float(self.get_parameter("static_size", 0.20))
        # Band is quoted in vol-exposure terms and converted per instrument below.
        exposure_band = float(self.get_parameter("exposure_band", 0.02))
        # The paper holds unused capital in cash and assumes no interest on it. Its SPY
        # blend is a separate month-end-rebalanced sleeve, not a daily gross-100% overlay.
        spy_overlay = bool(int(self.get_parameter("spy_overlay", 1)))

        # Leg definitions. Beta is exposure to 1x short-term VIX futures: VIXY/VXX = +1.0,
        # SVXY = -0.5 (it was -1.0 before Feb 2018), SVIX = -1.0.
        long_ticker = self.get_parameter("long_vol_etn", "VIXY")
        long_beta = float(self.get_parameter("long_vol_beta", 1.0))
        # Leave short_vol_etn equal to long_vol_etn to express short vol by shorting the
        # +1x ETN; set it to SVXY or SVIX to go long an inverse ETN instead.
        short_ticker = self.get_parameter("short_vol_etn", long_ticker)
        default_short_beta = 1.0 if short_ticker == long_ticker else -0.5
        short_beta = float(self.get_parameter("short_vol_beta", default_short_beta))

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)

        # Signal inputs. SPY is a data feed unless the overlay parameter is switched on.
        # It doubles as the benchmark, so it is NOT added a second time at daily
        # resolution: the signal needs the minute bar at 3:44pm to build today's return.
        self.benchmark_symbol = "SPY"
        self.spy_prices = []
        equity = self.add_equity(self.benchmark_symbol, Resolution.MINUTE)
        self.set_benchmark(equity.symbol)
        spy = equity.symbol
        vix = self.add_index("VIX", Resolution.MINUTE).symbol
        vix3m = self.add_index("VIX3M", Resolution.MINUTE).symbol

        long_symbol = Symbol.create(long_ticker, SecurityType.EQUITY, Market.USA)
        short_symbol = Symbol.create(short_ticker, SecurityType.EQUITY, Market.USA)
        tradables = [long_symbol] if short_symbol == long_symbol else [long_symbol, short_symbol]

        self.universe_settings.resolution = Resolution.MINUTE
        self.set_universe_selection(ManualUniverseSelectionModel(tradables))

        self.set_alpha(
            VIXDualSignalAlphaModel(
                (long_symbol, long_beta),
                (short_symbol, short_beta),
                spy,
                vix,
                vix3m,
                lookback,
                minutes_before_close,
                sizing_divisor,
                sizing_mode,
                static_size,
                spy_overlay,
            )
        )

        # A leg held at 1/|beta| notional needs a 1/|beta| wider notional band to keep the
        # same exposure band, otherwise an inverse leg churns twice as often.
        bands = {
            long_symbol: exposure_band / abs(long_beta),
            short_symbol: exposure_band / abs(short_beta),
            spy: exposure_band / abs(long_beta),
        }
        self.set_portfolio_construction(BandedWeightPortfolioConstructionModel(bands, exposure_band))
        self.set_execution(MarketOnCloseExecutionModel())
        self.set_risk_management(MaximumGrossExposureRiskModel(maximum_exposure))

        # Warm up historical data before placing initial trades. The 5-day boilerplate
        # floor is not enough here: 10 daily returns need 11 daily bars, and a short
        # warm-up would silently compute the vol estimate on fewer samples.
        self.set_warm_up(lookback + 1, Resolution.DAILY)


class VIXDualSignalAlphaModel(AlphaModel):
    """Generates the dual (eVRP + term-structure) volatility signal once per day.

    The signal is produced as a vol exposure in units of 1x short-term VIX futures. Each
    leg converts that exposure into a notional weight by dividing by the instrument's
    beta, so long SVXY (beta -0.5) is sized at twice the notional of a short VIXY
    position (beta +1.0) for identical exposure.
    """

    def __init__(
        self,
        long_vol: Tuple[Symbol, float],
        short_vol: Tuple[Symbol, float],
        spy: Symbol,
        vix: Symbol,
        vix3m: Symbol,
        lookback: int = 10,
        minutes_before_close: int = 16,
        sizing_divisor: float = 100.0,
        sizing_mode: str = "dynamic",
        static_size: float = 0.20,
        spy_overlay: bool = False,
    ) -> None:
        self.name = "VIXDualSignalAlphaModel"
        self._long_vol_symbol, self._long_vol_beta = long_vol
        self._short_vol_symbol, self._short_vol_beta = short_vol
        self._spy = spy
        self._vix = vix
        self._vix3m = vix3m
        self._lookback = lookback
        self._minutes_before_close = minutes_before_close
        self._sizing_divisor = sizing_divisor
        self._sizing_mode = sizing_mode.lower()
        self._static_size = static_size
        self._spy_overlay = spy_overlay

        self._daily_returns: RateOfChange = None
        self._previous_close: Identity = None
        self._last_signal_date: date = None

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        self._ensure_indicators(algorithm)
        if algorithm.is_warming_up or not self._is_signal_time(algorithm):
            return []

        exposure = self._vol_exposure(algorithm)
        self._last_signal_date = algorithm.time.date()
        if exposure is None:
            return []

        # Route the exposure to whichever leg expresses it, and flatten the other.
        separate_short_leg = self._short_vol_symbol != self._long_vol_symbol
        if exposure < 0 and separate_short_leg:
            long_weight = 0.0
            short_weight = exposure / self._short_vol_beta
        else:
            long_weight = exposure / self._long_vol_beta
            short_weight = 0.0

        # If the leg carrying the exposure has no price, the vol trade cannot be placed.
        # Emitting the SPY overlay anyway would leave a naked long-equity book
        # masquerading as a vol strategy, which is what happens when the backtest starts
        # before the ETN's inception date.
        carrier = self._short_vol_symbol if (exposure < 0 and separate_short_leg) else self._long_vol_symbol
        if exposure != 0 and algorithm.securities[carrier].price == 0:
            algorithm.log(f"{carrier.value} has no price; suppressing insights (before inception?)")
            return []

        insights = [self._make_insight(self._long_vol_symbol, long_weight)]
        if separate_short_leg:
            insights.append(self._make_insight(self._short_vol_symbol, short_weight))

        # Optional overlay: park the capital not used by the short-vol leg in SPY so gross
        # exposure stays at 100%. Not in the paper; it stacks two risks that draw down
        # together. Off by default.
        if self._spy_overlay:
            used = abs(long_weight) + abs(short_weight)
            spy_weight = max(0.0, 1 - used) if exposure < 0 else 0.0
            insights.append(self._make_insight(self._spy, spy_weight))

        return insights

    def _ensure_indicators(self, algorithm: QCAlgorithm) -> None:
        """Register the SPY indicators on first use."""
        if self._daily_returns is not None:
            return
        self._daily_returns = algorithm.roc(self._spy, 1, Resolution.DAILY)
        # The window holds the last (lookback - 1) completed daily returns; the final
        # return of the sample is today's partial close-to-now return.
        self._daily_returns.window.size = self._lookback - 1
        self._previous_close = algorithm.identity(self._spy, Resolution.DAILY)

    def _is_signal_time(self, algorithm: QCAlgorithm) -> bool:
        """True once per trading day, `minutes_before_close` before the SPY close.

        Derived from exchange hours rather than a fixed clock time so that half-days
        (1:00pm closes) are handled correctly, per the paper's footnote 18.
        """
        if self._last_signal_date == algorithm.time.date():
            return False
        exchange = algorithm.securities[self._spy].exchange
        if not exchange.date_time_is_open(algorithm.time):
            return False
        market_close = exchange.hours.get_next_market_close(algorithm.time, False)
        seconds_to_close = (market_close - algorithm.time).total_seconds()
        return 0 < seconds_to_close <= self._minutes_before_close * 60

    def _vol_exposure(self, algorithm: QCAlgorithm) -> float:
        """Signed target exposure in 1x VIX-futures units, or None if inputs are stale."""
        if self._daily_returns.window.count < self._lookback - 1:
            return None
        if not self._previous_close.is_ready or self._previous_close.current.value == 0:
            return None

        spy_price = algorithm.securities[self._spy].price
        vix = algorithm.securities[self._vix].price
        vix3m = algorithm.securities[self._vix3m].price
        if spy_price == 0 or vix == 0 or vix3m == 0:
            return None

        # Trailing daily returns, with today's partial return as the most recent sample.
        returns = [x.value for x in self._daily_returns.window]
        returns.append(spy_price / self._previous_close.current.value - 1)

        # Expected realised vol over the next 30 days, annualised in VIX points.
        e_rv30 = float(np.std(returns, ddof=1) * np.sqrt(252) * 100)
        e_vrp = vix - e_rv30
        contango = vix < vix3m
        backwardation = vix > vix3m

        # Strategy 4 scales with the VIX level; Strategy 3 uses a fixed 20%/10%. The
        # signal logic is identical either way, only the size responds.
        base = vix / self._sizing_divisor if self._sizing_mode == "dynamic" else self._static_size

        if e_vrp > 0 and contango:
            exposure = -base          # Case 1: full short-vol conviction
        elif e_vrp <= 0 and contango:
            exposure = -0.5 * base    # Case 2: half-size short-vol conviction
        elif e_vrp <= 0 and backwardation:
            exposure = base           # Case 3: full long-vol conviction
        else:
            exposure = 0.0            # Case 4: conflicting signals -> cash

        algorithm.log(
            f"eRV30={e_rv30:.2f} VIX={vix:.2f} VIX3M={vix3m:.2f} "
            f"eVRP={e_vrp:.2f} mode={self._sizing_mode} exposure={exposure:.4f}"
        )
        return exposure

    def _make_insight(self, symbol: Symbol, weight: float) -> Insight:
        if weight > 0:
            direction = InsightDirection.UP
        elif weight < 0:
            direction = InsightDirection.DOWN
        else:
            direction = InsightDirection.FLAT

        # Sign travels in the direction, magnitude in the weight.
        return Insight.price(symbol, timedelta(days=1), direction, None, None, self.name, abs(weight))


class BandedWeightPortfolioConstructionModel(PortfolioConstructionModel):
    """Turns a signed insight weight into an absolute share target, with a churn band.

    A new target is only issued when the target weight changes sign or moves further than
    that symbol's band away from the current weight. Bands are supplied per symbol so a
    2x-sized inverse leg is not rebalanced twice as often as a 1x leg.
    """

    def __init__(self, bands: Dict[Symbol, float], default_band: float = 0.02) -> None:
        self._bands = bands
        self._default_band = default_band

    def create_targets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
        targets: List[PortfolioTarget] = []
        total_value = algorithm.portfolio.total_portfolio_value
        if total_value <= 0:
            return targets

        for insight in insights:
            symbol = insight.symbol
            weight = insight.weight if insight.weight is not None else 0.0
            target_weight = self._sign_of(insight.direction) * float(weight)
            current_weight = algorithm.portfolio[symbol].holdings_value / total_value
            band = self._bands.get(symbol, self._default_band)

            # Skip only when we are already on the right side and close enough.
            same_side = np.sign(current_weight) == np.sign(target_weight)
            if same_side and abs(current_weight - target_weight) <= band:
                continue

            target = PortfolioTarget.percent(algorithm, symbol, target_weight)
            if target is None:
                continue
            targets.append(target)
        return targets

    @staticmethod
    def _sign_of(direction: InsightDirection) -> float:
        """InsightDirection is a .NET enum and is not castable to float in Python."""
        if direction == InsightDirection.UP:
            return 1.0
        if direction == InsightDirection.DOWN:
            return -1.0
        return 0.0


class MarketOnCloseExecutionModel(ExecutionModel):
    """Fills portfolio targets with MOC orders, netting against existing holdings."""

    def execute(self, algorithm: QCAlgorithm, targets: List[PortfolioTarget]) -> None:
        if algorithm.is_warming_up:
            return
        for target in targets:
            symbol = target.symbol
            open_quantity = sum(
                ticket.quantity
                for ticket in algorithm.transactions.get_open_order_tickets(lambda t, s=symbol: t.symbol == s)
            )
            delta = target.quantity - algorithm.portfolio[symbol].quantity - open_quantity
            if delta != 0 and algorithm.securities[symbol].is_tradable:
                algorithm.market_on_close_order(symbol, delta)


class MaximumGrossExposureRiskModel(RiskManagementModel):
    """Hard cap on absolute exposure to any single security."""

    def __init__(self, maximum_exposure: float = 1.0) -> None:
        self._maximum_exposure = abs(maximum_exposure)

    def manage_risk(self, algorithm: QCAlgorithm, targets: List[PortfolioTarget]) -> List[PortfolioTarget]:
        risk_targets: List[PortfolioTarget] = []
        total_value = algorithm.portfolio.total_portfolio_value
        if total_value <= 0:
            return risk_targets

        for kvp in algorithm.portfolio:
            holding = kvp.Value
            if not holding.invested:
                continue
            weight = holding.holdings_value / total_value
            if abs(weight) > self._maximum_exposure:
                capped = np.sign(weight) * self._maximum_exposure
                algorithm.debug(f"Risk cap hit on {holding.symbol}: {weight:.2f} -> {capped:.2f}")
                risk_targets.append(PortfolioTarget.percent(algorithm, holding.symbol, capped))
        return risk_targets