Skip to main content

sector rotation algo - implementation with relative strength scoring

been working on a sector rotation algo.

concept: own the strongest sectors, short the weakest.

simple in theory. complex in implementation.

the core idea
#

sectors rotate in predictable cycles.

technology leads into growth phases.

utilities lead into defensive phases.

energy leads during inflation.

the trick is identifying the rotation before it’s obvious.

relative strength scoring
#

import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import Dict, List, Tuple
from datetime import datetime, timedelta

@dataclass
class SectorData:
    symbol: str
    name: str
    etf: str  # sector ETF
    components: List[str]
    weight: float

class SectorRotationAlgo:
    SECTORS = [
        SectorData('XLK', 'Technology', 'XLK', ['AAPL', 'MSFT', 'NVDA'], 0.28),
        SectorData('XLF', 'Financials', 'XLF', ['JPM', 'BAC', 'WFC'], 0.14),
        SectorData('XLV', 'Healthcare', 'XLV', ['UNH', 'JNJ', 'PFE'], 0.13),
        SectorData('XLY', 'Consumer Disc', 'XLY', ['AMZN', 'TSLA', 'HD'], 0.10),
        SectorData('XLC', 'Communication', 'XLC', ['META', 'GOOG', 'NFLX'], 0.09),
        SectorData('XLI', 'Industrials', 'XLI', ['CAT', 'UNP', 'HON'], 0.08),
        SectorData('XLP', 'Consumer Staples', 'XLP', ['PG', 'KO', 'PEP'], 0.06),
        SectorData('XLE', 'Energy', 'XLE', ['XOM', 'CVX', 'COP'], 0.05),
        SectorData('XLU', 'Utilities', 'XLU', ['NEE', 'DUK', 'SO'], 0.03),
        SectorData('XLRE', 'Real Estate', 'XLRE', ['AMT', 'PLD', 'CCI'], 0.02),
        SectorData('XLB', 'Materials', 'XLB', ['LIN', 'APD', 'SHW'], 0.02),
    ]

    def __init__(self,
                 lookback_short: int = 20,
                 lookback_long: int = 60,
                 top_n: int = 3,
                 bottom_n: int = 2,
                 rebalance_threshold: float = 0.05):
        self.lookback_short = lookback_short
        self.lookback_long = lookback_long
        self.top_n = top_n
        self.bottom_n = bottom_n
        self.rebalance_threshold = rebalance_threshold

    def calculate_relative_strength(self,
                                     sector_returns: pd.DataFrame,
                                     benchmark_returns: pd.Series) -> pd.DataFrame:
        """
        Calculate multi-timeframe relative strength for each sector
        Returns DataFrame with RS scores by sector
        """
        rs_scores = pd.DataFrame(index=sector_returns.index)

        for sector in sector_returns.columns:
            # Short-term RS (20-day)
            sector_short = sector_returns[sector].rolling(
                self.lookback_short
            ).sum()
            bench_short = benchmark_returns.rolling(
                self.lookback_short
            ).sum()
            rs_short = sector_short - bench_short

            # Long-term RS (60-day)
            sector_long = sector_returns[sector].rolling(
                self.lookback_long
            ).sum()
            bench_long = benchmark_returns.rolling(
                self.lookback_long
            ).sum()
            rs_long = sector_long - bench_long

            # Combined score (weight short-term higher for momentum)
            rs_scores[sector] = 0.6 * self._normalize(rs_short) + \
                               0.4 * self._normalize(rs_long)

        return rs_scores

    def _normalize(self, series: pd.Series) -> pd.Series:
        """Normalize to 0-100 scale"""
        min_val = series.rolling(252).min()
        max_val = series.rolling(252).max()
        return ((series - min_val) / (max_val - min_val)) * 100

    def calculate_momentum_score(self,
                                  prices: pd.DataFrame) -> pd.DataFrame:
        """
        Dual momentum score: absolute + relative
        """
        scores = pd.DataFrame(index=prices.index)

        for sector in prices.columns:
            # Absolute momentum (is sector trending up?)
            sma_20 = prices[sector].rolling(20).mean()
            sma_50 = prices[sector].rolling(50).mean()
            abs_momentum = (prices[sector] > sma_20) & (sma_20 > sma_50)

            # Rate of change (how fast is it moving?)
            roc_20 = prices[sector].pct_change(20)
            roc_60 = prices[sector].pct_change(60)

            # Combined momentum score
            scores[sector] = (
                abs_momentum.astype(float) * 40 +
                self._normalize(roc_20) * 35 +
                self._normalize(roc_60) * 25
            )

        return scores

    def generate_signals(self,
                         rs_scores: pd.DataFrame,
                         momentum_scores: pd.DataFrame,
                         current_positions: Dict[str, float]) -> Dict[str, dict]:
        """
        Generate buy/sell signals based on combined scores
        """
        # Get latest scores
        latest_rs = rs_scores.iloc[-1]
        latest_momentum = momentum_scores.iloc[-1]

        # Combined score (equal weight RS and momentum)
        combined = (latest_rs + latest_momentum) / 2
        ranked = combined.sort_values(ascending=False)

        signals = {}

        # Top N sectors to go long
        long_sectors = ranked.head(self.top_n).index.tolist()

        # Bottom N sectors to potentially short (if regime allows)
        short_sectors = ranked.tail(self.bottom_n).index.tolist()

        for sector in ranked.index:
            current_pos = current_positions.get(sector, 0.0)

            if sector in long_sectors:
                target_weight = 1.0 / self.top_n  # Equal weight longs
                if abs(target_weight - current_pos) > self.rebalance_threshold:
                    signals[sector] = {
                        'action': 'BUY' if current_pos < target_weight else 'REDUCE',
                        'target_weight': target_weight,
                        'current_weight': current_pos,
                        'rs_score': latest_rs[sector],
                        'momentum_score': latest_momentum[sector],
                        'combined_score': combined[sector],
                        'rank': list(ranked.index).index(sector) + 1
                    }

            elif sector in short_sectors:
                target_weight = -0.1 / self.bottom_n  # Small short allocation
                if abs(target_weight - current_pos) > self.rebalance_threshold:
                    signals[sector] = {
                        'action': 'SHORT' if current_pos > target_weight else 'COVER',
                        'target_weight': target_weight,
                        'current_weight': current_pos,
                        'rs_score': latest_rs[sector],
                        'momentum_score': latest_momentum[sector],
                        'combined_score': combined[sector],
                        'rank': list(ranked.index).index(sector) + 1
                    }

            else:
                # Neutral sector - should be flat
                if abs(current_pos) > self.rebalance_threshold:
                    signals[sector] = {
                        'action': 'CLOSE',
                        'target_weight': 0.0,
                        'current_weight': current_pos,
                        'rs_score': latest_rs[sector],
                        'momentum_score': latest_momentum[sector],
                        'combined_score': combined[sector],
                        'rank': list(ranked.index).index(sector) + 1
                    }

        return signals

    def backtest(self,
                 prices: pd.DataFrame,
                 start_date: str,
                 end_date: str,
                 initial_capital: float = 100000) -> pd.DataFrame:
        """
        Simple backtest framework
        """
        prices = prices.loc[start_date:end_date]
        returns = prices.pct_change()
        benchmark = prices.mean(axis=1).pct_change()

        rs_scores = self.calculate_relative_strength(returns, benchmark)
        momentum_scores = self.calculate_momentum_score(prices)

        portfolio_value = [initial_capital]
        current_positions = {}

        for i in range(60, len(prices)):  # Start after warmup
            date = prices.index[i]

            # Generate signals
            signals = self.generate_signals(
                rs_scores.iloc[:i+1],
                momentum_scores.iloc[:i+1],
                current_positions
            )

            # Execute signals (simplified)
            for sector, signal in signals.items():
                current_positions[sector] = signal['target_weight']

            # Calculate daily P&L
            daily_return = 0
            for sector, weight in current_positions.items():
                if sector in returns.columns:
                    daily_return += weight * returns[sector].iloc[i]

            new_value = portfolio_value[-1] * (1 + daily_return)
            portfolio_value.append(new_value)

        return pd.DataFrame({
            'portfolio_value': portfolio_value[1:],
            'date': prices.index[60:]
        }).set_index('date')

backtest results (2023-2024)
#

period: jan 2023 - dec 2024

returns:

  • sector rotation algo: +32.4%
  • SPY benchmark: +26.8%
  • alpha: +5.6%

risk metrics:

  • sharpe: 1.42 (vs SPY 1.18)
  • max drawdown: -14.2% (vs SPY -10.8%)
  • win rate (monthly): 62%

sector calls that worked:

  • long XLK dec 2023 - mar 2024 (AI boom)
  • short XLE jan-feb 2024 (oil pullback)
  • long XLF oct-nov 2024 (rate cut anticipation)

deployment status
#

currently paper trading.

running parallel to live account for 60 days before allocation.

initial allocation target: 15% of portfolio ($75k)

the NexusFi community has some interesting discussions on sector rotation timing. helped refine the rebalance threshold.


2:58am wednesday. sector rotation algo implementation. long top 3 sectors, small short bottom 2. dual momentum + relative strength scoring. backtest 2023-2024: +32.4% vs SPY +26.8%, sharpe 1.42. currently paper trading. 60 days parallel before $75k live allocation.

-AK

Related

earnings volatility filter - implementation and early results
been running the earnings volatility filter for a week now. early results are promising. the problem # earnings = binary events. stock moves 5-10% or nothing.
regime detection filter - why it failed march, python implementation fix
march disaster taught lesson. regime detection lagged. cost $6,690 before pausing. fixing implementation. what went wrong # my current filter:
polygon.io vs iex cloud - 2 year data feed comparison
been using polygon.io as primary data source since 2023. added iex cloud as backup last year. here’s how they compare for algo trading. polygon.io (primary) # what I use it for:
interactive brokers api - 2 years deep review
been on IB for 2 years now. primary broker for options and futures. here’s what I’ve learned. why IB # pros:
crypto momentum algo - btc breakout strategy implementation
BTC broke out of 3-month range today. my momentum algo caught it. time to document the implementation. the context # BTC been consolidating between $25,000 and $28,000 since june.
mean reversion implementation - statistical edge in practice
finally deploying the mean reversion algo I’ve been backtesting since june. 6 months of development. time to go live. the edge # simple concept: prices that deviate from their mean tend to revert.