Final Project: Cross-Sectional Sector Momentum Rotation Strategy

Strategy Overview

This strategy exploits cross-sectional momentum across the 11 SPDR sector ETFs. Each month, we rank all 11 sectors by their trailing 3-month price return. We go long the top 3 (strongest momentum) and short the bottom 3 (weakest momentum), holding for one calendar month before rebalancing.

Market phenomenon being captured: Sector momentum — institutional capital rotates slowly and persistently across economic sectors in response to macro cycle shifts. Sectors that have been winning recently tend to continue winning in the near term because institutional flows are slow to adjust (Jegadeesh & Titman, 1993).

Strategy parameters (frozen before any backtest results were observed): - Lookback: 63 trading days (~3 calendar months) - Universe: 11 SPDR sector ETFs - Positions: Long top 3, Short bottom 3, 100 shares each leg - Hold: one calendar month - Stop-loss: 8% per leg - Risk-free rate: 3.75% (course specification)

1: Imports & Strategy Definitions

Import all required libraries and freeze all strategy parameters up front. Parameters are defined here — before any data is fetched or results observed — to eliminate any possibility of data snooping.

import numpy as np
import pandas as pd
import shinybroker as sb
import datetime
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from scipy import stats

#### The 11 SPDR sector ETFs that make up the S&P 500 sector breakdown
SECTORS = ['XLK', 'XLF', 'XLE', 'XLV', 'XLI', 'XLP', 'XLY', 'XLU', 'XLRE', 'XLB', 'XLC']

#### Human-readable names for labeling charts and commentary
SECTOR_NAMES = {
    'XLK':  'Technology',
    'XLF':  'Financials',
    'XLE':  'Energy',
    'XLV':  'Healthcare',
    'XLI':  'Industrials',
    'XLP':  'Consumer Staples',
    'XLY':  'Consumer Discretionary',
    'XLU':  'Utilities',
    'XLRE': 'Real Estate',
    'XLB':  'Materials',
    'XLC':  'Comm. Services',
}

#### ── STRATEGY PARAMETERS (frozen before viewing any results) ──────────────
#### Lookback = 63 trading days: 21 trading days/month × 3 months = 63
LOOKBACK_DAYS  = 63

#### Long the top-3 and short the bottom-3 by 3-month momentum rank
N_LONG  = 3
N_SHORT = 3

#### 100 shares per leg — consistent with all prior course assignments
QTY = 100

#### Stop-loss: exit a leg early if it moves 8% against our position
#### Rationale: sector ETFs rarely move >8% intra-month without a structural shift;
#### a loss of this magnitude signals that our momentum thesis has broken down for
#### this leg and we should cut exposure rather than hold to month-end.
STOP_LOSS_PCT = 0.08

#### Risk-free rate = 3.75% as specified in course instructions
RISK_FREE_RATE = 0.0375

START_CASH     = 100_000.0   # starting portfolio NAV
PLOTLY_THEME   = 'plotly_dark'
THICK          = 3           # line width for primary chart traces
╭───────────────────────────────────────── ShinyBroker Usage and License ─────────────────────────────────────────╮
│ ShinyBroker is an ongoing project that is developed and maintained by volunteers at the FinTech Master's        │
│ Program at Duke University, and is made available to the public for use on paper accounts. ANY USE OF           │
│ SHINYBROKER IS AT THE USER'S SOLE RISK. ShinyBroker authors, contributors and copyright holders, and Duke       │
│ University do not sponsor, endorse, or recommend ShinyBroker in any manner and shall not be liable for any      │
│ direct, indirect, incidental, special, consequential, or punitive damages, or any loss of profits, data, use,   │
│ goodwill, or other intangible losses. For more information, review the LICENSE agreement included with the      │
│ package and posted online at the link below.                                                                    │
╰────────────────────────────── Read License: https://shinybroker.com/LICENSE.html ───────────────────────────────╯

2: Data Fetching via IBKR / ShinyBroker

We fetch 2 years of daily OHLCV data for all 11 sector ETFs plus SPY.

Why daily (not hourly)? This strategy holds positions for a full calendar month. Intraday resolution adds no signal value and limits how far back IBKR will go. Daily bars allow a 2-year lookback (~24 rebalance periods) — far more than the 6-month / ~26-week windows used in prior weekly assignments.

Note: IBKR classifies ETFs under secType='STK' in its API — same as equities.

#### Fetch 2 years of daily data for each sector ETF
#### We store each sector's raw DataFrame in a dict keyed by ticker symbol
sector_data = {}

for sym in SECTORS:
    contract = sb.Contract({
        'symbol':   sym,
        'secType':  'STK',   # IBKR uses 'STK' for both stocks and ETFs
        'exchange': 'SMART',
        'currency': 'USD',
    })
    raw = sb.fetch_historical_data(
        contract,
        endDateTime='',
        durationStr='2 Y',
        barSizeSetting='1 day',
        whatToShow='Trades',
        useRTH=True,
        host='127.0.0.1',
        port=7497,
        client_id=9999,
        timeout=3,
    )
    sector_data[sym] = raw['hst_dta']
    print(f'Fetched {sym} ({SECTOR_NAMES[sym]}): {len(raw["hst_dta"])} daily bars')

#### Fetch SPY as our market benchmark
spy_contract = sb.Contract({
    'symbol':   'SPY',
    'secType':  'STK',
    'exchange': 'SMART',
    'currency': 'USD',
})
spy_raw = sb.fetch_historical_data(
    spy_contract,
    endDateTime='',
    durationStr='2 Y',
    barSizeSetting='1 day',
    whatToShow='Trades',
    useRTH=True,
    host='127.0.0.1',
    port=7497,
    client_id=9999,
    timeout=3,
)
spy_data = spy_raw['hst_dta']
print(f'Fetched SPY: {len(spy_data)} daily bars')
Fetched XLK (Technology): 501 daily bars
Fetched XLF (Financials): 501 daily bars
Fetched XLE (Energy): 501 daily bars
Fetched XLV (Healthcare): 501 daily bars
Fetched XLI (Industrials): 501 daily bars
Fetched XLP (Consumer Staples): 501 daily bars
Fetched XLY (Consumer Discretionary): 501 daily bars
Fetched XLU (Utilities): 501 daily bars
Fetched XLRE (Real Estate): 501 daily bars
Fetched XLB (Materials): 501 daily bars
Fetched XLC (Comm. Services): 501 daily bars
Fetched SPY: 501 daily bars

3: Data Preparation

Build two price panels (close and open) with one column per sector. We also add a trd_prd column using the monthly convention:

trd_prd = year + month / 100   → e.g., May 2024 = 2024.05

This is the monthly equivalent of the weekly trd_prd format from prior assignments.

#### Prepare Data

#### Convert timestamps and set index for every sector
for sym in SECTORS:
    df = sector_data[sym].copy()
    df['timestamp'] = pd.to_datetime(df['timestamp'])
    df = df.set_index('timestamp')
    sector_data[sym] = df

#### Prepare SPY in the same way
spy_data['timestamp'] = pd.to_datetime(spy_data['timestamp'])
spy_data = spy_data.set_index('timestamp')

#### Build close-price and open-price panels
#### close_panel is used for: momentum signal, stop-loss checks, mark-to-market
#### open_panel  is used for: entry prices on rebalance dates
close_panel = pd.DataFrame({sym: sector_data[sym]['close'] for sym in SECTORS})
open_panel  = pd.DataFrame({sym: sector_data[sym]['open']  for sym in SECTORS})

#### Keep only dates where ALL sectors have data (drops early thin-trading days)
common_dates = close_panel.dropna().index
close_panel  = close_panel.loc[common_dates]
open_panel   = open_panel.loc[common_dates]

#### Add trd_prd: year + month/100 (e.g., 2024.05 for May 2024)
close_panel['trd_prd'] = close_panel.index.year + close_panel.index.month / 100
open_panel['trd_prd']  = open_panel.index.year  + open_panel.index.month / 100

#### Prepare SPY for benchmark comparisons
spy_close = spy_data['close'].reindex(common_dates).ffill()

print(f'Price panel: {close_panel.shape[0]} trading days across {len(SECTORS)} sectors')
print(f'Date range : {common_dates[0].date()}{common_dates[-1].date()}')
print(close_panel[SECTORS].head(3))
Price panel: 501 trading days across 11 sectors
Date range : 2024-05-02 → 2026-05-01
               XLK    XLF    XLE     XLV     XLI    XLP    XLY    XLU   XLRE  \
timestamp                                                                      
2024-05-02   98.53  40.46  46.28  140.48  121.92  75.65  88.36  33.94  36.73   
2024-05-03  101.28  40.55  46.29  140.83  122.77  75.88  88.96  34.21  37.02   
2024-05-06  102.47  41.07  46.65  141.40  124.01  75.92  89.91  34.38  37.03   

              XLB    XLC  
timestamp                 
2024-05-02  44.32  79.37  
2024-05-03  44.77  80.21  
2024-05-06  45.04  81.30  

4: Rebalance Dates

Define all monthly rebalance dates: - Entry date: first actual trading day of each calendar month (open of that day) - Exit date: last actual trading day of each calendar month (close of that day)

We drop any month where we do not yet have 63 days of historical closes — those months cannot produce a valid momentum signal.

#### Rebalance Dates

all_dates = common_dates  # all valid trading days

#### First trading day of each calendar month = entry date
entry_date_map = (
    pd.Series(all_dates)
    .groupby([all_dates.year, all_dates.month])
    .min()
)

#### Last trading day of each calendar month = scheduled exit date
exit_date_map_raw = (
    pd.Series(all_dates)
    .groupby([all_dates.year, all_dates.month])
    .max()
)

#### Map trd_prd → scheduled exit date
exit_date_map = {}
for (yr, mo), last_day in exit_date_map_raw.items():
    exit_date_map[yr + mo / 100] = last_day

#### Convert entry dates to DatetimeIndex and filter out months with insufficient history
#### A month needs LOOKBACK_DAYS of prior data before its first trading day
rebalance_dates = pd.DatetimeIndex([
    d for d in entry_date_map.values
    if all_dates.get_loc(d) >= LOOKBACK_DAYS
])

rebalance_trd_prds = rebalance_dates.year + rebalance_dates.month / 100

print(f'Total rebalance periods : {len(rebalance_dates)}')
print(f'First rebalance date    : {rebalance_dates[0].date()}')
print(f'Last  rebalance date    : {rebalance_dates[-1].date()}')
print(f'Approx trading days/period: {len(all_dates) / len(rebalance_dates):.1f}')
Total rebalance periods : 21
First rebalance date    : 2024-09-03
Last  rebalance date    : 2026-05-01
Approx trading days/period: 23.9

5: Momentum Signal

Anti-Lookahead Design — Critical

The momentum signal is computed as:

momentum[t] = close[t-1] / close[t-1-63] - 1

The .shift(1) ensures we use yesterday’s close, not today’s. This means: - On rebalance date d (when we enter at the open), the signal uses close[d-1] - close[d-1] is always available before the market opens on d — zero lookahead - We never use the open or close of the entry day itself to rank sectors

This is the same principle as the entry logic in HW2 (comparing opening price to the previous period’s close to decide direction).

#### Momentum Signal (no lookahead)

#### Work only with sector close prices (drop trd_prd helper column)
closes = close_panel[SECTORS].copy()

#### shift(1): use yesterday's close so signal is fully formed before today's open
#### This is the anti-lookahead shift — without it we'd be using information
#### not yet available at the moment of trade entry.
prev_close = closes.shift(1)

#### 3-month trailing momentum for each sector on each calendar day
#### momentum[d] = prev_close[d] / prev_close[d-63] - 1
####             = close[d-1]    / close[d-1-63]    - 1   ← all pre-entry data
momentum = prev_close / prev_close.shift(LOOKBACK_DAYS) - 1

#### Sanity check: on each rebalance date, verify momentum only uses past closes
sample_date = rebalance_dates[5]
print(f'Momentum signal on {sample_date.date()} (enters at market open):')
print(momentum.loc[sample_date].sort_values(ascending=False).round(4))
print(f'\nSignal uses closes from {(sample_date - pd.tseries.offsets.BDay(64)).date()}'
      f' to {(sample_date - pd.tseries.offsets.BDay(1)).date()} — all pre-open data ✓')
Momentum signal on 2025-02-03 (enters at market open):
XLY     0.1595
XLC     0.1093
XLF     0.0970
XLI     0.0201
XLE    -0.0023
XLV    -0.0103
XLK    -0.0107
XLU    -0.0177
XLP    -0.0178
XLRE   -0.0546
XLB    -0.0567
Name: 2025-02-03 00:00:00, dtype: float64

Signal uses closes from 2024-11-05 to 2025-01-31 — all pre-open data ✓

6: Initialize Blotter & Ledger

Blotter: one row per position leg per month. Because we hold 6 legs simultaneously (3 long + 3 short), we use a plain integer index and carry trd_prd as a column — unlike prior assignments where one trade per week allowed trd_prd as the index.

Ledger: one row per trading day, tracks daily portfolio NAV.

Blotter columns: - trd_prd, sector, direction, momentum_score, momentum_rank - qty (+100 long / -100 short) - entry_date, entry_price, exit_date, exit_price - fate (‘rebalance’ or ‘stop_loss’) - trade_return, pnl

#### Initialize Blotter & Ledger

#### Blotter will be built as a list of dicts and converted to DataFrame after the loop
#### (faster than repeatedly appending to a DataFrame)
blotter_rows = []

#### Ledger: daily rows for every trading day in our dataset
ledger = pd.DataFrame(index=common_dates)
ledger.index.name = 'date'
ledger['cash']           = np.nan
ledger['position_value'] = np.nan
ledger['mkt_value']      = np.nan

#### Fund the account on the first day
ledger.loc[ledger.index[0], 'cash'] = START_CASH

print('Blotter initialized (empty, will fill in backtest loop)')
print(f'Ledger initialized: {len(ledger)} trading days')
print(ledger.head(3))
Blotter initialized (empty, will fill in backtest loop)
Ledger initialized: 501 trading days
                cash  position_value  mkt_value
date                                           
2024-05-02  100000.0             NaN        NaN
2024-05-03       NaN             NaN        NaN
2024-05-06       NaN             NaN        NaN

7: Backtest Loop

For each monthly rebalance date, the loop: 1. Reads the momentum signal (pre-computed, no lookahead) 2. Ranks all 11 sectors by 3-month return 3. Assigns Long to top 3, Short to bottom 3 4. Enters at the open on the first trading day of the month 5. Checks daily closes for stop-loss triggers (8% adverse move) 6. Exits at the close of the last trading day OR on the stop-loss day

Why open for entry? We observe yesterday’s close before the market opens. The open is the first tradeable price after the signal is formed — no lookahead.

Stop-loss implementation: because we use daily (not hourly) data, the stop-loss fires at the closing price on the day the threshold is breached. This slightly overstates the loss versus an intraday trigger, which is the conservative and correct assumption in a daily-bar backtest.

#### Backtest Loop

for entry_date in rebalance_dates:
    trd_prd = entry_date.year + entry_date.month / 100

    #### Read the pre-computed momentum signal for this rebalance date
    #### shift(1) was already applied — this is entirely backward-looking data
    mom_today = momentum.loc[entry_date]

    #### Skip any month where momentum could not be computed (insufficient history)
    if mom_today.isna().any():
        continue

    #### Rank sectors: rank 1 = highest 3-month momentum
    #### ascending=False so the best-performing sector gets rank 1
    mom_ranked = mom_today.rank(ascending=False).astype(int)

    #### Select which sectors to trade this month
    long_sectors  = mom_ranked[mom_ranked <= N_LONG].index.tolist()
    short_sectors = mom_ranked[mom_ranked > (len(SECTORS) - N_SHORT)].index.tolist()

    #### Entry prices: open of the first trading day of the month
    entry_prices = open_panel.loc[entry_date, SECTORS]

    #### Get the scheduled exit date (last trading day of this calendar month)
    scheduled_exit = exit_date_map.get(trd_prd)
    if scheduled_exit is None:
        continue

    #### All trading days in this month (for stop-loss scanning)
    month_dates = common_dates[
        (common_dates >= entry_date) & (common_dates <= scheduled_exit)
    ]

    #### ── Build each position leg ──────────────────────────────────────────
    for direction, sectors in [('long', long_sectors), ('short', short_sectors)]:
        qty = QTY if direction == 'long' else -QTY

        for sym in sectors:
            ep = float(entry_prices[sym])   # entry at today's open

            #### Default exit: hold until month-end
            exit_date  = scheduled_exit
            exit_price = float(close_panel.loc[scheduled_exit, sym])
            fate       = 'rebalance'

            #### Scan each trading day in the month for a stop-loss breach
            #### We skip the entry date itself (the loss threshold applies to
            #### subsequent days — we need at least one close to evaluate)
            for day in month_dates[1:]:   # skip entry_date
                daily_close = float(close_panel.loc[day, sym])

                if direction == 'long':
                    #### Long stop-loss: price fell more than STOP_LOSS_PCT below entry
                    if daily_close < ep * (1 - STOP_LOSS_PCT):
                        exit_date  = day
                        exit_price = daily_close   # exit at close on stop-loss day
                        fate       = 'stop_loss'
                        break
                else:
                    #### Short stop-loss: price rose more than STOP_LOSS_PCT above entry
                    if daily_close > ep * (1 + STOP_LOSS_PCT):
                        exit_date  = day
                        exit_price = daily_close
                        fate       = 'stop_loss'
                        break

            #### #returns part
            #### trade_return is sign-adjusted: positive means the trade made money
            trade_return = (exit_price - ep) / ep * np.sign(qty)
            pnl          = qty * (exit_price - ep)   # dollar P&L

            blotter_rows.append({
                'trd_prd':        trd_prd,
                'sector':         sym,
                'direction':      direction,
                'momentum_score': float(mom_today[sym]),
                'momentum_rank':  int(mom_ranked[sym]),
                'qty':            qty,
                'entry_date':     entry_date,
                'entry_price':    ep,
                'exit_date':      exit_date,
                'exit_price':     exit_price,
                'fate':           fate,
                'trade_return':   trade_return,
                'pnl':            pnl,
            })

#### Assemble blotter DataFrame from the collected rows
blotter = pd.DataFrame(blotter_rows)
blotter['entry_date'] = pd.to_datetime(blotter['entry_date'])
blotter['exit_date']  = pd.to_datetime(blotter['exit_date'])

print(f'Blotter: {len(blotter)} legs, {blotter["trd_prd"].nunique()} rebalance periods')
print(f'Rebalance exits : {(blotter["fate"]=="rebalance").sum()}')
print(f'Stop-loss exits : {(blotter["fate"]=="stop_loss").sum()}')
print()
print(blotter.head(12).to_string())
blotter.to_csv("data/blotter.csv", index=False)
Blotter: 126 legs, 21 rebalance periods
Rebalance exits : 113
Stop-loss exits : 13

    trd_prd sector direction  momentum_score  momentum_rank  qty entry_date  entry_price  exit_date  exit_price       fate  trade_return    pnl
0   2024.09    XLF      long        0.098463              2  100 2024-09-03        45.49 2024-09-30       45.32  rebalance     -0.003737  -17.0
1   2024.09    XLV      long        0.093946              3  100 2024-09-03       156.88 2024-09-30      154.02  rebalance     -0.018230 -286.0
2   2024.09   XLRE      long        0.144547              1  100 2024-09-03        43.21 2024-09-30       44.67  rebalance      0.033788  146.0
3   2024.09    XLK     short        0.048439              9 -100 2024-09-03       109.00 2024-09-30      112.88  rebalance     -0.035596 -388.0
4   2024.09    XLE     short       -0.020601             11 -100 2024-09-03        44.90 2024-09-30       43.90  rebalance      0.022272  100.0
5   2024.09    XLB     short        0.029932             10 -100 2024-09-03        46.46 2024-09-30       48.19  rebalance     -0.037236 -173.0
6   2024.10    XLI      long        0.123424              3  100 2024-10-01       135.37 2024-10-31      133.83  rebalance     -0.011376 -154.0
7   2024.10    XLU      long        0.193558              1  100 2024-10-01        40.36 2024-10-31       39.96  rebalance     -0.009911  -40.0
8   2024.10   XLRE      long        0.174290              2  100 2024-10-01        44.84 2024-10-31       43.20  rebalance     -0.036574 -164.0
9   2024.10    XLK     short       -0.009564             10 -100 2024-10-01       112.55 2024-10-31      111.12  rebalance      0.012705  143.0
10  2024.10    XLE     short       -0.037281             11 -100 2024-10-01        43.52 2024-10-31       44.30  rebalance     -0.017923  -78.0
11  2024.10    XLC     short        0.059789              9 -100 2024-10-01        90.72 2024-10-31       92.04  rebalance     -0.014550 -132.0

8: Ledger Construction

Build daily portfolio NAV from the completed blotter.

Cash accounting (unified formula for long and short): - Entry: cash -= qty × entry_price
(buying 100 shares reduces cash; shorting 100 shares increases cash because we receive proceeds) - Exit: cash += qty × exit_price
(selling increases cash; covering a short decreases cash)

Position value on day t = Σ qty_i × close_i_t for all legs where entry_date ≤ t < exit_date

NAV = cash + position_value

#### Ledger Construction

#### #calculations

#### Step 1: Daily cash flows from trade entries and exits
#### Entry cash flow: -qty * entry_price (neg for long, pos for short)
#### Exit  cash flow: +qty * exit_price  (pos for long, neg for short)
daily_entry_cf = (
    blotter
    .groupby('entry_date')
    .apply(lambda x: -(x['qty'] * x['entry_price']).sum())
    .reindex(ledger.index, fill_value=0.0)
)

daily_exit_cf = (
    blotter
    .groupby('exit_date')
    .apply(lambda x: (x['qty'] * x['exit_price']).sum())
    .reindex(ledger.index, fill_value=0.0)
)

daily_cashflow = daily_entry_cf + daily_exit_cf

#### Step 2: Cumulative cash = starting cash + all net cash flows up to each day
ledger['cash'] = START_CASH + daily_cashflow.cumsum()

#### Step 3: Mark-to-market of open positions
#### A leg is 'open' on day t if: entry_date <= t < exit_date
#### We use < exit_date because cash already reflects exit proceeds on the exit day
position_value = pd.Series(0.0, index=ledger.index)

for _, leg in blotter.iterrows():
    mask = (ledger.index >= leg['entry_date']) & (ledger.index < leg['exit_date'])
    if mask.any():
        sector_closes = close_panel.loc[mask, leg['sector']]
        position_value[mask] += leg['qty'] * sector_closes.values

ledger['position_value'] = position_value

#### Step 4: Total portfolio NAV
ledger['mkt_value'] = ledger['cash'] + ledger['position_value']

#### Step 5: Normalize SPY to the same starting value for fair comparison
first_trade_date = blotter['entry_date'].min()
spy_normalized = spy_close / spy_close.loc[first_trade_date] * START_CASH

print('Ledger:')
print(ledger.head(10).to_string())
print('...')
print(ledger.tail(5).to_string())
ledger.to_csv("data/ledger.csv")
Ledger:
                cash  position_value  mkt_value
date                                           
2024-05-02  100000.0             0.0   100000.0
2024-05-03  100000.0             0.0   100000.0
2024-05-06  100000.0             0.0   100000.0
2024-05-07  100000.0             0.0   100000.0
2024-05-08  100000.0             0.0   100000.0
2024-05-09  100000.0             0.0   100000.0
2024-05-10  100000.0             0.0   100000.0
2024-05-13  100000.0             0.0   100000.0
2024-05-14  100000.0             0.0   100000.0
2024-05-15  100000.0             0.0   100000.0
...
                cash  position_value  mkt_value
date                                           
2026-04-27   90029.0         10293.0   100322.0
2026-04-28   90029.0         10351.0   100380.0
2026-04-29   90029.0         10375.0   100404.0
2026-04-30  100613.0             0.0   100613.0
2026-05-01  100891.0             0.0   100891.0

9: Performance Summary

Compute GMRR, annual volatility, Sharpe ratio (rf = 3.75%), and max drawdown for the strategy and the SPY benchmark. Also report trade-level statistics.

#### #calculations - performance metrics

def compute_metrics(nav_series, label):
    """Compute annualized GMRR, vol, Sharpe, and max drawdown from a daily NAV series."""
    nav    = nav_series.dropna()
    r      = nav.pct_change().dropna()
    n_days = len(r)
    total_return = nav.iloc[-1] / nav.iloc[0] - 1
    gmrr   = (1 + total_return) ** (252 / n_days) - 1
    vol    = r.std() * np.sqrt(252)
    sharpe = (gmrr - RISK_FREE_RATE) / vol
    max_dd = ((nav / nav.cummax()) - 1).min()
    print(f"\n{'─' * 44}")
    print(f"  {label}")
    print(f"{'─' * 44}")
    print(f"  Total Return   : {total_return:>9.2%}")
    print(f"  Annual GMRR    : {gmrr:>9.2%}")
    print(f"  Annual Vol     : {vol:>9.2%}")
    print(f"  Sharpe Ratio   : {sharpe:>9.2f}")
    print(f"  Max Drawdown   : {max_dd:>9.2%}")
    return dict(total_return=total_return, gmrr=gmrr, vol=vol, sharpe=sharpe, max_dd=max_dd)

strat_metrics = compute_metrics(ledger['mkt_value'],        'SECTOR ROTATION STRATEGY')
spy_metrics   = compute_metrics(spy_normalized.reindex(ledger.index).ffill(), 'SPY BENCHMARK (buy & hold)')

#### #output - trade-level statistics
print(f"\n{'─' * 44}")
print(f"  TRADE STATISTICS")
print(f"{'─' * 44}")
print(f"  Total legs       : {len(blotter)}")
print(f"  Rebalance exits  : {(blotter['fate'] == 'rebalance').sum()}")
print(f"  Stop-loss exits  : {(blotter['fate'] == 'stop_loss').sum()}")
print(f"  Overall win rate : {(blotter['trade_return'] > 0).mean():.1%}")
print(f"  Avg return/leg   : {blotter['trade_return'].mean():.3%}")
print(f"  Long  win rate   : {(blotter[blotter['direction']=='long']['trade_return'] > 0).mean():.1%}")
print(f"  Short win rate   : {(blotter[blotter['direction']=='short']['trade_return'] > 0).mean():.1%}")
avg_hold = (blotter['exit_date'] - blotter['entry_date']).dt.days.mean()
print(f"  Avg hold (days)  : {avg_hold:.1f}")
print(f"  Expected P&L/leg : ${blotter['pnl'].mean():.2f}")

────────────────────────────────────────────
  SECTOR ROTATION STRATEGY
────────────────────────────────────────────
  Total Return   :     0.89%
  Annual GMRR    :     0.45%
  Annual Vol     :     4.57%
  Sharpe Ratio   :     -0.72
  Max Drawdown   :    -6.46%

────────────────────────────────────────────
  SPY BENCHMARK (buy & hold)
────────────────────────────────────────────
  Total Return   :    42.69%
  Annual GMRR    :    19.62%
  Annual Vol     :    16.69%
  Sharpe Ratio   :      0.95
  Max Drawdown   :   -19.00%

────────────────────────────────────────────
  TRADE STATISTICS
────────────────────────────────────────────
  Total legs       : 126
  Rebalance exits  : 113
  Stop-loss exits  : 13
  Overall win rate : 49.2%
  Avg return/leg   : -0.054%
  Long  win rate   : 58.7%
  Short win rate   : 39.7%
  Avg hold (days)  : 25.4
  Expected P&L/leg : $7.07

10: Visualizations

Eleven purposeful charts, each answering a specific analytical question: 1. Portfolio NAV vs SPY — overall performance 2. Sector Rotation Heatmap — where capital was deployed each month 3. Monthly Returns bar chart — month-by-month win/loss 4. Long vs Short P&L decomposition — which leg drove returns? 5. Trade return distribution — shape of the return profile 6. Return by sector — which ETFs contributed most? 7. Rolling 6-month Sharpe — strategy stability over time 8. Drawdown chart — risk profile 9. Momentum spread — signal strength over time 10. Alpha/Beta scatter vs SPY — market neutrality test 11. Monthly sector exposure — long vs short stacked bars

fig1 = go.Figure()

# Trim strategy NAV to start on first actual trade date (fair comparison)
first_trade_date = blotter['entry_date'].min()
strat_nav_trimmed = ledger['mkt_value'].loc[ledger.index >= first_trade_date]

fig1.add_trace(go.Scatter(      # ← strategy trace now uses trimmed series
    x=strat_nav_trimmed.index,
    y=strat_nav_trimmed,
    mode='lines',
    name='Sector Rotation Strategy',
    line=dict(color='#00CC96', width=THICK),
))
fig1.add_trace(go.Scatter(      # ← SPY trace, unchanged
    x=spy_normalized.index,
    y=spy_normalized,
    mode='lines',
    name='SPY (Buy & Hold)',
    line=dict(color='#EF553B', width=THICK),
))
fig1.add_hline(
    y=START_CASH, line_dash='dot', line_color='gray',
    annotation_text='Starting NAV $100,000',
)
fig1.update_layout(
    template=PLOTLY_THEME,
    title='Portfolio NAV vs SPY — Starting Value $100,000',
    xaxis_title='Date',
    yaxis_title='Portfolio Value ($)',
    legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1),
    height=500,
)
fig1.show()

fig1.write_html("charts/nav_vs_spy.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 2: Sector Rotation Heatmap
#### Purpose: Visual 'fingerprint' of the strategy — which sectors were long/short each month?
#### Color = momentum score (green = strong momentum, red = weak/negative)
#### Arrows show position direction: ▲ long, ▼ short

#### Pivot the blotter to get momentum scores and directions per month/sector
heatmap_scores = blotter.pivot_table(
    index='trd_prd', columns='sector', values='momentum_score', aggfunc='first'
)
heatmap_dirs = blotter.pivot_table(
    index='trd_prd', columns='sector', values='direction', aggfunc='first'
).map(lambda x: '▲' if x == 'long' else ('▼' if x == 'short' else ''))

#### Format trd_prd as readable month labels for the y-axis
y_labels = [
    f"{int(tp // 1)}-{int(round((tp % 1) * 100)):02d}"
    for tp in heatmap_scores.index
]
x_labels = [SECTOR_NAMES[s] for s in heatmap_scores.columns]

fig2 = go.Figure(data=go.Heatmap(
    z=heatmap_scores.values,
    x=x_labels,
    y=y_labels,
    colorscale='RdYlGn',
    zmid=0,
    text=heatmap_dirs.values,
    texttemplate='%{text}',
    textfont=dict(size=11, color='white'),
    colorbar=dict(title='3M Momentum'),
))
fig2.update_layout(
    template=PLOTLY_THEME,
    title='Sector Rotation Heatmap — ▲ Long  |  ▼ Short  |  Color = 3-Month Momentum Score',
    xaxis_title='Sector',
    yaxis_title='Month',
    height=700,
)
fig2.show()
fig2.write_html("charts/heatmap.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 3: Monthly Returns — Strategy vs SPY
#### Purpose: Month-by-month attribution — identifies which periods drove outperformance

#### Strategy monthly returns: last NAV / first NAV of each month - 1
nav_monthly = ledger['mkt_value'].dropna().copy()
nav_monthly.index = pd.to_datetime(nav_monthly.index)
monthly_strat = nav_monthly.resample('ME').last() / nav_monthly.resample('ME').first() - 1

#### SPY monthly returns over the same window
spy_mo = spy_normalized.reindex(ledger.index).dropna()
spy_mo.index = pd.to_datetime(spy_mo.index)
monthly_spy = spy_mo.resample('ME').last() / spy_mo.resample('ME').first() - 1

months_common = monthly_strat.index.intersection(monthly_spy.index)
x_labs = [d.strftime('%Y-%m') for d in months_common]

fig3 = go.Figure()
fig3.add_trace(go.Bar(
    x=x_labs,
    y=monthly_strat.loc[months_common].values,
    name='Strategy',
    marker_color='#00CC96',
    opacity=0.85,
))
fig3.add_trace(go.Bar(
    x=x_labs,
    y=monthly_spy.loc[months_common].values,
    name='SPY',
    marker_color='#EF553B',
    opacity=0.85,
))
fig3.add_hline(y=0, line_dash='dot', line_color='gray')
fig3.update_layout(
    template=PLOTLY_THEME,
    title='Monthly Returns — Strategy vs SPY',
    xaxis_title='Month',
    yaxis_title='Return',
    yaxis=dict(tickformat='.1%'),
    barmode='group',
    height=450,
)
fig3.show()
fig3.write_html("charts/monthly_returns.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 4: Cumulative P&L — Long Legs vs Short Legs
#### Purpose: Decomposes returns by trade direction.
#### If longs profit but shorts don't, momentum works in one direction only.
#### If shorts profit but longs don't, we may be capturing a different effect.

long_legs  = blotter[blotter['direction'] == 'long'].copy()
short_legs = blotter[blotter['direction'] == 'short'].copy()

#### Cumulative P&L summed by entry date (approximation for daily resolution)
cum_long  = long_legs.groupby('entry_date')['pnl'].sum().reindex(ledger.index, fill_value=0).cumsum()
cum_short = short_legs.groupby('entry_date')['pnl'].sum().reindex(ledger.index, fill_value=0).cumsum()
cum_total = cum_long + cum_short

fig4 = go.Figure()
fig4.add_trace(go.Scatter(
    x=ledger.index, y=cum_long,
    mode='lines', name='Long Legs',
    line=dict(color='#00CC96', width=THICK),
))
fig4.add_trace(go.Scatter(
    x=ledger.index, y=cum_short,
    mode='lines', name='Short Legs',
    line=dict(color='#EF553B', width=THICK),
))
fig4.add_trace(go.Scatter(
    x=ledger.index, y=cum_total,
    mode='lines', name='Total Strategy P&L',
    line=dict(color='#AB63FA', width=THICK, dash='dash'),
))
fig4.add_hline(y=0, line_dash='dot', line_color='gray')
fig4.update_layout(
    template=PLOTLY_THEME,
    title='Cumulative P&L Decomposition — Long vs Short Legs',
    xaxis_title='Date',
    yaxis_title='Cumulative P&L ($)',
    height=450,
)
fig4.show()
fig4.write_html("charts/long_short_pnl.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 5: Trade Return Distribution — Long vs Short
#### Purpose: Shows the shape of the P&L distribution.
#### We want right-skewed longs (cut losers, let winners run) and
#### right-skewed shorts (symmetric profit from falling losers).

fig5 = go.Figure()
fig5.add_trace(go.Histogram(
    x=long_legs['trade_return'],
    name='Long Legs',
    marker_color='#00CC96',
    opacity=0.75,
    nbinsx=30,
))
fig5.add_trace(go.Histogram(
    x=short_legs['trade_return'],
    name='Short Legs',
    marker_color='#EF553B',
    opacity=0.75,
    nbinsx=30,
))
fig5.add_vline(x=0, line_dash='dash', line_color='white', annotation_text='Break-even')
fig5.update_layout(
    template=PLOTLY_THEME,
    title='Trade Return Distribution — Long vs Short Legs',
    xaxis_title='Trade Return',
    xaxis=dict(tickformat='.1%'),
    yaxis_title='Count',
    barmode='overlay',
    height=420,
)
fig5.show()
fig5.write_html("charts/return_distribution.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 6: Average Trade Return by Sector
#### Purpose: Identifies which ETFs generated alpha and which were consistent losers.
#### If one sector (e.g., XLK) dominates returns, the strategy may be degenerate.

sec_ret = (
    blotter.groupby('sector')['trade_return']
    .mean()
    .sort_values()
    .reset_index()
)
sec_ret['color']       = sec_ret['trade_return'].apply(lambda x: '#00CC96' if x > 0 else '#EF553B')
sec_ret['sector_name'] = sec_ret['sector'].map(SECTOR_NAMES)

fig6 = go.Figure(go.Bar(
    x=sec_ret['trade_return'],
    y=sec_ret['sector_name'],
    orientation='h',
    marker_color=sec_ret['color'],
    text=sec_ret['trade_return'].map('{:.2%}'.format),
    textposition='outside',
))
fig6.add_vline(x=0, line_dash='dot', line_color='white')
fig6.update_layout(
    template=PLOTLY_THEME,
    title='Average Trade Return by Sector (long and short legs combined)',
    xaxis_title='Avg Trade Return',
    xaxis=dict(tickformat='.2%'),
    yaxis_title='',
    height=460,
)
fig6.show()
fig6.write_html("charts/return_by_sector.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 7: Rolling 6-Month Sharpe Ratio
#### Purpose: Measures strategy CONSISTENCY — is performance stable or lumpy?
#### A Sharpe that collapses toward 0 signals the momentum effect is weakening.
#### Our live monitoring threshold is 0.5 sustained for 3+ months.

window = 126   # ~6 calendar months in trading days

daily_rets = ledger['mkt_value'].pct_change().dropna()
roll_mean  = daily_rets.rolling(window).mean() * 252
roll_std   = daily_rets.rolling(window).std()  * np.sqrt(252)
roll_sharpe = (roll_mean - RISK_FREE_RATE) / roll_std

fig7 = go.Figure()
fig7.add_trace(go.Scatter(
    x=roll_sharpe.index, y=roll_sharpe,
    mode='lines', name='6-Month Rolling Sharpe',
    line=dict(color='#FFA15A', width=THICK),
))
fig7.add_hline(y=0,   line_dash='dot',  line_color='gray',    annotation_text='0.0')
fig7.add_hline(y=0.5, line_dash='dash', line_color='#00CC96', annotation_text='0.5 — monitoring threshold')
fig7.add_hline(y=1.0, line_dash='dash', line_color='#AB63FA', annotation_text='1.0')
fig7.update_layout(
    template=PLOTLY_THEME,
    title='Rolling 6-Month Sharpe Ratio (risk-free rate = 3.75%)',
    xaxis_title='Date',
    yaxis_title='Sharpe Ratio',
    height=400,
)
fig7.show()
fig7.write_html("charts/rolling_sharpe.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 8: Portfolio Drawdown
#### Purpose: Quantifies the depth and duration of losing streaks.
#### A drawdown exceeding -20% would breach our pre-defined risk limit.

nav     = ledger['mkt_value'].dropna()
cum_max = nav.cummax()
drawdown = (nav / cum_max) - 1

fig8 = go.Figure()
fig8.add_trace(go.Scatter(
    x=drawdown.index, y=drawdown,
    mode='lines',
    name='Drawdown',
    fill='tozeroy',
    line=dict(color='#EF553B', width=2),
    fillcolor='rgba(239, 85, 59, 0.3)',
))
fig8.add_hline(y=-0.20, line_dash='dash', line_color='yellow',
               annotation_text='-20% risk limit')
fig8.update_layout(
    template=PLOTLY_THEME,
    title=f'Portfolio Drawdown  |  Maximum: {drawdown.min():.2%}',
    xaxis_title='Date',
    yaxis_title='Drawdown',
    yaxis=dict(tickformat='.1%'),
    height=380,
)
fig8.show()
fig8.write_html("charts/drawdown.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 9: Monthly Momentum Spread
#### Purpose: Measures how STRONG the cross-sectional signal was each month.
#### Spread = avg momentum of top-3 long sectors MINUS avg momentum of bottom-3 short sectors.
#### When spread collapses toward zero, sectors are all moving together and the
#### rotation signal is meaningless — strategy performance should suffer.
#### This is our second live monitoring metric alongside rolling Sharpe.

spread_rows = []
for trd_prd in blotter['trd_prd'].unique():
    month_legs = blotter[blotter['trd_prd'] == trd_prd]
    long_mom   = month_legs[month_legs['direction'] == 'long']['momentum_score'].mean()
    short_mom  = month_legs[month_legs['direction'] == 'short']['momentum_score'].mean()
    entry_dt   = month_legs['entry_date'].iloc[0]
    spread_rows.append({'date': entry_dt, 'spread': long_mom - short_mom,
                        'long_avg': long_mom, 'short_avg': short_mom})

spread_df = pd.DataFrame(spread_rows).set_index('date').sort_index()

fig9 = go.Figure()
fig9.add_trace(go.Bar(
    x=spread_df.index,
    y=spread_df['spread'],
    name='Momentum Spread (Long − Short)',
    marker_color=spread_df['spread'].apply(lambda x: '#00CC96' if x > 0 else '#EF553B'),
))
fig9.add_hline(y=0,    line_dash='dot',  line_color='white')
fig9.add_hline(y=0.02, line_dash='dash', line_color='yellow',
               annotation_text='2% warning threshold')
fig9.update_layout(
    template=PLOTLY_THEME,
    title='Monthly Momentum Spread: Avg(Top-3 Momentum) − Avg(Bottom-3 Momentum)',
    xaxis_title='Rebalance Month',
    yaxis_title='Momentum Spread',
    yaxis=dict(tickformat='.1%'),
    height=400,
)
fig9.show()
fig9.write_html("charts/momentum_spread.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 10: Trade Return vs SPY Return (Alpha/Beta Analysis)
#### Purpose: Tests whether strategy returns are driven by market beta or true alpha.
#### For a long-short portfolio, we expect low beta (≈0) and positive alpha.
#### If beta is high, the strategy is just leveraged market exposure — not a real edge.

#### Compute SPY return over each leg's holding period
blotter['spy_entry'] = blotter['entry_date'].map(lambda d: spy_close.get(d, np.nan))
blotter['spy_exit']  = blotter['exit_date'].map(lambda d: spy_close.get(d, np.nan))
blotter['spy_return'] = blotter['spy_exit'] / blotter['spy_entry'] - 1

ab_data = blotter.dropna(subset=['spy_return', 'trade_return'])

slope, intercept, r_val, _, _ = stats.linregress(
    ab_data['spy_return'], ab_data['trade_return']
)
x_line = np.linspace(ab_data['spy_return'].min(), ab_data['spy_return'].max(), 60)
y_line = slope * x_line + intercept

fig10 = go.Figure()
fig10.add_trace(go.Scatter(
    x=ab_data['spy_return'],
    y=ab_data['trade_return'],
    mode='markers',
    name='Trade Legs',
    marker=dict(
        color=ab_data['trade_return'],
        colorscale='RdYlGn',
        size=9,
        opacity=0.8,
        colorbar=dict(title='Leg Return'),
    ),
    text=ab_data['sector'] + ' (' + ab_data['direction'] + ')',
    hovertemplate='%{text}<br>SPY: %{x:.2%}<br>Leg Return: %{y:.2%}',
))
fig10.add_trace(go.Scatter(
    x=x_line, y=y_line,
    mode='lines',
    name=f'OLS (β={slope:.2f}, α={intercept:.4f}, R²={r_val**2:.2f})',
    line=dict(color='white', width=2, dash='dash'),
))
fig10.add_hline(y=0, line_dash='dot', line_color='gray')
fig10.add_vline(x=0, line_dash='dot', line_color='gray')
print(f'Beta  = {slope:.3f}')
print(f'Alpha = {intercept:.4f} per trade period')
print(f'R²    = {r_val**2:.3f}')
fig10.update_layout(
    template=PLOTLY_THEME,
    title=f'Trade Return vs SPY Return  |  β = {slope:.2f}  |  α = {intercept:.4f}  |  R² = {r_val**2:.2f}',
    xaxis_title='SPY Return (over same holding period)',
    yaxis_title='Strategy Leg Return',
    xaxis=dict(tickformat='.1%'),
    yaxis=dict(tickformat='.1%'),
    height=520,
)
fig10.show()
fig10.write_html("charts/alpha_beta.html", include_plotlyjs="cdn")
Beta  = 0.196
Alpha = -0.0028 per trade period
R²    = 0.021
Unable to display output for mime type(s): application/vnd.plotly.v1+json
#### Plot 11: Monthly Sector Exposure — Long vs Short
#### Purpose: Timeline of capital allocation across sectors.
#### Reveals whether the strategy is diversified or if one sector dominates.
#### A healthy rotation strategy should show different sectors in the top/bottom
#### positions across months — not the same ETF always occupying rank 1.

colors11 = px.colors.qualitative.Plotly

#### Build monthly long/short exposure (shares held per sector)
long_exp  = blotter[blotter['direction'] == 'long'].pivot_table(
    index='trd_prd', columns='sector', values='qty', aggfunc='sum'
).fillna(0)
short_exp = blotter[blotter['direction'] == 'short'].pivot_table(
    index='trd_prd', columns='sector', values='qty', aggfunc='sum'
).abs().fillna(0)

mo_labels = [
    f"{int(tp // 1)}-{int(round((tp % 1) * 100)):02d}"
    for tp in long_exp.index
]

fig11 = make_subplots(
    rows=2, cols=1, shared_xaxes=True,
    subplot_titles=['Long Exposure (shares)', 'Short Exposure (shares)'],
    vertical_spacing=0.08,
)

for j, sym in enumerate(SECTORS):
    color = colors11[j % len(colors11)]
    name  = SECTOR_NAMES[sym]
    if sym in long_exp.columns:
        fig11.add_trace(go.Bar(
            x=mo_labels, y=long_exp[sym],
            name=name, marker_color=color, legendgroup=sym, showlegend=True,
        ), row=1, col=1)
    if sym in short_exp.columns:
        fig11.add_trace(go.Bar(
            x=mo_labels, y=short_exp[sym],
            name=name, marker_color=color, legendgroup=sym, showlegend=False,
        ), row=2, col=1)

fig11.update_layout(
    template=PLOTLY_THEME,
    title='Monthly Sector Exposure — Long (top) vs Short (bottom)',
    barmode='stack',
    height=620,
)
fig11.show()
fig11.write_html("charts/sector_exposure.html", include_plotlyjs="cdn")
Unable to display output for mime type(s): application/vnd.plotly.v1+json

11: Forward-Looking Strategy Monitoring

The rubric requires us to answer two questions explicitly.

Q1: How will we know the strategy is performing in line with expectations?

We monitor two real-time metrics at each monthly rebalance:

  1. Rolling 6-Month Sharpe Ratio (see Plot 7): if it drops below 0.5 for three consecutive months, the strategy is underperforming its historical risk-adjusted baseline and requires a full review (not automatic shutdown — macro regime may explain it).

  2. Momentum Spread (see Plot 9): the average 3-month return of the long basket minus the average 3-month return of the short basket. If this spread compresses below 2% for three consecutive months, sectors are moving together — the cross-sectional momentum signal has effectively disappeared, and we should reduce position size.

Q2: How will we know when the strategy stops working?

We declare the strategy non-functional and halt trading if any one of the following triggers fires:

  1. Momentum reversal: the long portfolio underperforms the short portfolio (i.e., the momentum effect has reversed) for 4 consecutive months. This means the market has shifted to a mean-reversion regime where recent winners become losers — the opposite of our thesis.

  2. Win rate collapse: leg win rate drops below 40% for 3 consecutive months. If fewer than 2 of 6 legs are profitable in a given month, the classifier is no longer predictive and the signal has degraded below random.

  3. Drawdown breach: portfolio drawdown exceeds -20% at any point. This is a hard risk limit — at this level of loss, the strategy’s expected recovery time exceeds what a rational investor should tolerate.

12: Commentary

Did Filtering (Stop-Losses) Improve Performance?

Discuss how many legs were stopped out vs. held to month-end, and whether the stop-loss improved or hurt overall returns. A stop-loss that fires frequently suggests the ETFs are choppy intra-month and the 8% threshold may need adjustment. A stop-loss that almost never fires suggests it provides tail insurance at no cost.

What Did the Long/Short Decomposition Reveal?

Refer to Plot 4. If the long book drove all returns and the short book was a drag, the strategy may be more of a ‘quality long’ strategy in disguise rather than a genuine cross-sectional momentum strategy. If both contribute, the market-neutral long-short thesis is confirmed.

Which Sectors Were Most Useful?

Refer to Plot 6. Sectors with consistently positive average returns contributed alpha. Sectors with consistently negative average returns are potential candidates for removal from the universe in a future version, though this should only be done with out-of-sample validation to avoid data snooping.

How Would We Improve the Model?

  1. Inverse-volatility position sizing: instead of 100 shares per leg, allocate more capital to lower-volatility sectors so that each leg contributes equal risk. This avoids the current implicit over-weight toward high-volatility sectors like XLE.

  2. Extended lookback: combine the 3-month signal with a 12-month signal and skip the most recent month (the standard Fama-French momentum factor construction). This may reduce noise while preserving the longer-term trend signal.

  3. Macro regime filter: reduce position size or go flat when VIX is above a threshold (e.g., 30). During high-fear regimes, sectors converge and the rotation signal becomes less reliable — this was validated in our Choppiness Index work during the Donchian assignment.

  4. Transaction cost modeling: add a realistic cost per share (e.g., $0.005) to each leg to assess whether the strategy survives after execution friction.