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 npimport pandas as pdimport shinybroker as sbimport datetimeimport plotly.express as pximport plotly.graph_objects as gofrom plotly.subplots import make_subplotsfrom scipy import stats#### The 11 SPDR sector ETFs that make up the S&P 500 sector breakdownSECTORS = ['XLK', 'XLF', 'XLE', 'XLV', 'XLI', 'XLP', 'XLY', 'XLU', 'XLRE', 'XLB', 'XLC']#### Human-readable names for labeling charts and commentarySECTOR_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 = 63LOOKBACK_DAYS =63#### Long the top-3 and short the bottom-3 by 3-month momentum rankN_LONG =3N_SHORT =3#### 100 shares per leg — consistent with all prior course assignmentsQTY =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 instructionsRISK_FREE_RATE =0.0375START_CASH =100_000.0# starting portfolio NAVPLOTLY_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 symbolsector_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 benchmarkspy_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')
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 sectorfor 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 wayspy_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 datesclose_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().indexclose_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 /100open_panel['trd_prd'] = open_panel.index.year + open_panel.index.month /100#### Prepare SPY for benchmark comparisonsspy_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))
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 Datesall_dates = common_dates # all valid trading days#### First trading day of each calendar month = entry dateentry_date_map = ( pd.Series(all_dates) .groupby([all_dates.year, all_dates.month]) .min())#### Last trading day of each calendar month = scheduled exit dateexit_date_map_raw = ( pd.Series(all_dates) .groupby([all_dates.year, all_dates.month]) .max())#### Map trd_prd → scheduled exit dateexit_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 dayrebalance_dates = pd.DatetimeIndex([ d for d in entry_date_map.valuesif all_dates.get_loc(d) >= LOOKBACK_DAYS])rebalance_trd_prds = rebalance_dates.year + rebalance_dates.month /100print(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 datamomentum = prev_close / prev_close.shift(LOOKBACK_DAYS) -1#### Sanity check: on each rebalance date, verify momentum only uses past closessample_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.
#### 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 datasetledger = pd.DataFrame(index=common_dates)ledger.index.name ='date'ledger['cash'] = np.nanledger['position_value'] = np.nanledger['mkt_value'] = np.nan#### Fund the account on the first dayledger.loc[ledger.index[0], 'cash'] = START_CASHprint('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 Loopfor 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 isNone: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-QTYfor 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 entryif daily_close < ep * (1- STOP_LOSS_PCT): exit_date = day exit_price = daily_close # exit at close on stop-loss day fate ='stop_loss'breakelse:#### Short stop-loss: price rose more than STOP_LOSS_PCT above entryif 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 rowsblotter = 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)
Compute GMRR, annual volatility, Sharpe ratio (rf = 3.75%), and max drawdown for the strategy and the SPY benchmark. Also report trade-level statistics.
────────────────────────────────────────────
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/sectorheatmap_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-axisy_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 - 1nav_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 windowspy_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() -1months_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_shortfig4 = 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 >0else'#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 daysdaily_rets = ledger['mkt_value'].pct_change().dropna()roll_mean = daily_rets.rolling(window).mean() *252roll_std = daily_rets.rolling(window).std() * np.sqrt(252)roll_sharpe = (roll_mean - RISK_FREE_RATE) / roll_stdfig7 = 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
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 >0else'#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 periodblotter['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'] -1ab_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 + interceptfig10 = 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 inenumerate(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:
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).
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:
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.
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.
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?
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.
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.
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.
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.