The Question Every Quant Must Answer

A strategy that returned 18% last year looks impressive — until you realize the market benchmark returned 22%. The strategy underperformed by 400 basis points, yet it feels like a success because it posted a positive number.

This cognitive trap has killed more quant careers than bad algorithms. The inability to distinguish alpha from beta — genuine skill from market exposure — leads to strategy selection errors, misallocated capital, and the silent death of genuinely good strategies that happen to be temporarily out of favor.

Performance attribution is the discipline that tears apart your returns and answers a simple question: where did the money actually come from?

This article builds a complete performance attribution engine from scratch. We implement two complementary frameworks — Brinson attribution for equity portfolios and Fama-French factor exposure analysis for systematic strategies — and demonstrate how to decompose any strategy's returns into their constituent parts. All code is production-grade, runs against TickDB's historical OHLCV data, and includes proper risk disclosure.


The Two Flavors of Attribution

Before we write code, we need to understand why we need two different frameworks.

Brinson attribution answers: "Did my portfolio manager add value through sector allocation, stock selection within sectors, or the interaction between the two?" It treats the portfolio as a collection of decisions — which sectors to overweight, which stocks to pick within those sectors. Brinson is ideal for evaluating discretionary or rules-based equity strategies against a benchmark like the S&P 500.

Fama-French factor exposure answers: "What systematic market risks is my strategy taking, and how much of my return is compensation for those risks versus genuine alpha?" It models returns as a linear combination of known risk factors (market, size, value, momentum, profitability, investment). If your strategy returns 15% but has a 0.9 loading on the market factor and the market returned 12%, your alpha is much lower than it appears.

Both frameworks are necessary. Brinson tells you whether your active decisions were right. Fama-French tells you whether your returns are sustainable.


Part I: Brinson Attribution Engine

The Brinson Model: Theory

The Brinson model decomposes active returns into three components:

Allocation Effect: The return attributable to being overweight or underweight a sector relative to the benchmark. If you held 25% tech in a benchmark with 20% tech, and tech returned 10%, your allocation effect is (25% − 20%) × 10% = +50 bps.

Selection Effect: The return attributable to picking stocks within a sector that outperformed the sector benchmark. If you held tech stocks that returned 12% in a sector that returned 10%, your selection effect is 25% × (12% − 10%) = +50 bps.

Interaction Effect: The joint effect of both allocation and selection — the fact that you both overweighted a sector AND picked the best stocks within it. Interaction = (portfolio weight − benchmark weight) × (portfolio sector return − benchmark sector return).

The total active return is the sum of all three effects:

Active Return = Σ Allocation Effect + Σ Selection Effect + Σ Interaction Effect

Data Requirements

To run Brinson attribution, we need:

  1. Portfolio holdings with weights and returns at the stock level
  2. Benchmark holdings with weights at the sector level
  3. Sector classifications for every stock
  4. Stock-level returns for both portfolio and benchmark

For this implementation, we'll use TickDB's /v1/market/kline endpoint to pull historical OHLCV data and simulate a rules-based sector-rotation strategy.

Production-Grade Implementation

import os
import time
import random
import requests
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed


# =============================================================================
# CONFIGURATION
# =============================================================================

@dataclass
class TickDBConfig:
    """TickDB API configuration with proper authentication."""
    base_url: str = "https://api.tickdb.ai/v1"
    api_key: str = os.environ.get("TICKDB_API_KEY", "")
    timeout: Tuple[float, float] = (3.05, 10)  # (connect, read)
    max_retries: int = 3
    rate_limit_delay: float = 0.2  # seconds between requests

    def __post_init__(self):
        if not self.api_key:
            raise ValueError(
                "TICKDB_API_KEY environment variable is required. "
                "Sign up at https://tickdb.ai to obtain an API key."
            )


# =============================================================================
# API CLIENT WITH RATE LIMITING AND RETRY LOGIC
# =============================================================================

class TickDBClient:
    """Production-grade TickDB API client with rate limit handling."""

    def __init__(self, config: Optional[TickDBConfig] = None):
        self.config = config or TickDBConfig()
        self.session = requests.Session()
        self.session.headers.update({"X-API-Key": self.config.api_key})
        self._last_request_time = 0

    def _rate_limit(self):
        """Enforce rate limiting to avoid 3001 errors."""
        elapsed = time.time() - self._last_request_time
        if elapsed < self.config.rate_limit_delay:
            time.sleep(self.config.rate_limit_delay - elapsed)
        self._last_request_time = time.time()

    def _request_with_retry(self, method: str, endpoint: str, **kwargs) -> dict:
        """Execute HTTP request with exponential backoff and jitter."""
        url = f"{self.config.base_url}{endpoint}"
        kwargs.setdefault("timeout", self.config.timeout)

        for attempt in range(self.config.max_retries):
            try:
                self._rate_limit()
                response = self.session.request(method, url, **kwargs)

                # Handle rate limiting (3001)
                if response.status_code == 429 or (
                    response.headers.get("X-RateLimit-Remaining", "1") == "0"
                ):
                    retry_after = int(response.headers.get("Retry-After", 
                        self.config.rate_limit_delay * (2 ** attempt)))
                    time.sleep(retry_after)
                    continue

                response.raise_for_status()
                data = response.json()

                # Handle application-level errors
                if isinstance(data, dict) and data.get("code") == 3001:
                    retry_after = int(data.get("retry_after", 
                        self.config.rate_limit_delay * (2 ** attempt)))
                    time.sleep(retry_after)
                    continue

                return data

            except requests.exceptions.Timeout:
                if attempt == self.config.max_retries - 1:
                    raise
                delay = self.config.rate_limit_delay * (2 ** attempt)
                jitter = random.uniform(0, delay * 0.1)
                time.sleep(delay + jitter)

            except requests.exceptions.RequestException as e:
                if attempt == self.config.max_retries - 1:
                    raise RuntimeError(f"API request failed after {self.config.max_retries} "
                                      f"attempts: {e}")
                time.sleep(self.config.rate_limit_delay * (2 ** attempt))

        raise RuntimeError("Max retries exceeded")

    def get_kline(self, symbol: str, interval: str = "1d", 
                  start_time: int = None, end_time: int = None, 
                  limit: int = 500) -> pd.DataFrame:
        """
        Fetch OHLCV kline data for a given symbol.
        
        Args:
            symbol: Trading symbol (e.g., 'AAPL.US', 'NVDA.US')
            interval: Candle interval ('1m', '5m', '1h', '1d', '1w')
            start_time: Start timestamp in milliseconds
            end_time: End timestamp in milliseconds
            limit: Maximum number of candles (max 1000)
        
        Returns:
            DataFrame with columns: timestamp, open, high, low, close, volume
        """
        params = {"symbol": symbol, "interval": interval, "limit": limit}
        if start_time:
            params["start"] = start_time
        if end_time:
            params["end"] = end_time

        data = self._request_with_retry("GET", "/market/kline", params=params)

        if not data.get("data"):
            return pd.DataFrame()

        df = pd.DataFrame(data["data"])
        df["timestamp"] = pd.to_datetime(df["t"], unit="ms")
        df.set_index("timestamp", inplace=True)
        df = df[["o", "h", "l", "c", "v"]]
        df.columns = ["open", "high", "low", "close", "volume"]

        return df

    def get_symbols(self, market: str = "US") -> List[str]:
        """Fetch available symbols for a given market."""
        data = self._request_with_retry("GET", "/symbols/available", 
                                         params={"market": market})
        return [s["s"] for s in data.get("data", [])]


# =============================================================================
# SECTOR CLASSIFICATION DATABASE
# =============================================================================

# GICS Sector classifications for major US equities
SECTOR_MAP = {
    # Information Technology
    "AAPL.US": ("Information Technology", 45),
    "MSFT.US": ("Information Technology", 45),
    "NVDA.US": ("Information Technology", 45),
    "AVGO.US": ("Information Technology", 45),
    "ORCL.US": ("Information Technology", 45),
    "CRM.US": ("Information Technology", 45),
    "AMD.US": ("Information Technology", 45),
    "INTC.US": ("Information Technology", 45),
    "QCOM.US": ("Information Technology", 45),
    "TXN.US": ("Information Technology", 45),
    "AMAT.US": ("Information Technology", 45),
    "MU.US": ("Information Technology", 45),

    # Health Care
    "JNJ.US": ("Health Care", 35),
    "UNH.US": ("Health Care", 35),
    "LLY.US": ("Health Care", 35),
    "ABBV.US": ("Health Care", 35),
    "MRK.US": ("Health Care", 35),
    "PFE.US": ("Health Care", 35),
    "TMO.US": ("Health Care", 35),
    "ABT.US": ("Health Care", 35),
    "BMY.US": ("Health Care", 35),
    "AMGN.US": ("Health Care", 35),

    # Financials
    "JPM.US": ("Financials", 10),
    "BRK.B.US": ("Financials", 10),
    "V.US": ("Financials", 10),
    "MA.US": ("Financials", 10),
    "BAC.US": ("Financials", 10),
    "WFC.US": ("Financials", 10),
    "GS.US": ("Financials", 10),
    "MS.US": ("Financials", 10),
    "SPGI.US": ("Financials", 10),
    "AXP.US": ("Financials", 10),

    # Consumer Discretionary
    "AMZN.US": ("Consumer Discretionary", 25),
    "TSLA.US": ("Consumer Discretionary", 25),
    "HD.US": ("Consumer Discretionary", 25),
    "MCD.US": ("Consumer Discretionary", 25),
    "NKE.US": ("Consumer Discretionary", 25),
    "LOW.US": ("Consumer Discretionary", 25),
    "SBUX.US": ("Consumer Discretionary", 25),
    "TJX.US": ("Consumer Discretionary", 25),

    # Communication Services
    "GOOGL.US": ("Communication Services", 50),
    "META.US": ("Communication Services", 50),
    "NFLX.US": ("Communication Services", 50),
    "DIS.US": ("Communication Services", 50),
    "CMCSA.US": ("Communication Services", 50),
    "T.US": ("Communication Services", 50),
    "VZ.US": ("Communication Services", 50),

    # Consumer Staples
    "PG.US": ("Consumer Staples", 30),
    "KO.US": ("Consumer Staples", 30),
    "PEP.US": ("Consumer Staples", 30),
    "WMT.US": ("Consumer Staples", 30),
    "COST.US": ("Consumer Staples", 30),
    "PM.US": ("Consumer Staples", 30),
    "MO.US": ("Consumer Staples", 30),

    # Industrials
    "CAT.US": ("Industrials", 20),
    "BA.US": ("Industrials", 20),
    "GE.US": ("Industrials", 20),
    "UPS.US": ("Industrials", 20),
    "RTX.US": ("Industrials", 20),
    "HON.US": ("Industrials", 20),
    "DE.US": ("Industrials", 20),

    # Energy
    "XOM.US": ("Energy", 40),
    "CVX.US": ("Energy", 40),
    "COP.US": ("Energy", 40),
    "SLB.US": ("Energy", 40),
    "EOG.US": ("Energy", 40),
    "MPC.US": ("Energy", 40),

    # Utilities
    "NEE.US": ("Utilities", 55),
    "DUK.US": ("Utilities", 55),
    "SO.US": ("Utilities", 55),
    "D.US": ("Utilities", 55),
    "AEP.US": ("Utilities", 55),

    # Real Estate
    "PLD.US": ("Real Estate", 60),
    "AMT.US": ("Real Estate", 60),
    "EQIX.US": ("Real Estate", 60),
    "CCI.US": ("Real Estate", 60),
    "SPG.US": ("Real Estate", 60),

    # Materials
    "LIN.US": ("Materials", 15),
    "APD.US": ("Materials", 15),
    "SHW.US": ("Materials", 15),
    "FCX.US": ("Materials", 15),
    "NEM.US": ("Materials", 15),
}


def get_sector(symbol: str) -> str:
    """Return the GICS sector for a symbol."""
    if symbol in SECTOR_MAP:
        return SECTOR_MAP[symbol][0]
    return "Unknown"


# =============================================================================
# BRINSON ATTRIBUTION CALCULATOR
# =============================================================================

@dataclass
class Holding:
    """Represents a single portfolio holding."""
    symbol: str
    weight: float  # Portfolio weight (0-1)
    return_pct: float  # Period return (e.g., 0.12 for 12%)


@dataclass
class SectorResult:
    """Brinson attribution result for a single sector."""
    sector: str
    benchmark_weight: float
    portfolio_weight: float
    benchmark_return: float
    portfolio_return: float
    allocation_effect: float
    selection_effect: float
    interaction_effect: float


class BrinsonAttributor:
    """
    Implements the Brinson-Hood-Beebower multi-factor attribution model.
    
    Decomposes active portfolio returns into:
    - Allocation Effect: Returns from sector weight decisions
    - Selection Effect: Returns from stock selection within sectors
    - Interaction Effect: Joint effect of allocation and selection
    
    Reference: Brinson, Hood, Beebower (1986, 1991)
    """

    def __init__(self, benchmark_symbols: List[str]):
        """
        Args:
            benchmark_symbols: List of symbols that define the benchmark
                               (e.g., SPY constituents)
        """
        self.benchmark_symbols = benchmark_symbols
        self.client = TickDBClient()

    def _calculate_benchmark_sector_weights(self, 
                                            prices: Dict[str, pd.Series],
                                            start_price: Dict[str, float],
                                            end_price: Dict[str, float]) -> Dict[str, float]:
        """Calculate benchmark sector weights based on equal-weight allocation."""
        sector_cap = {}
        sector_stocks = {}

        for symbol in self.benchmark_symbols:
            if symbol not in prices or symbol not in start_price:
                continue

            sector = get_sector(symbol)
            if sector == "Unknown":
                continue

            # Use ending market cap (simplification: shares * price)
            # For equal-weight, we use starting prices
            if sector not in sector_stocks:
                sector_stocks[sector] = []
                sector_cap[sector] = 0

            sector_stocks[sector].append(symbol)

        # Equal-weight within each sector
        total_cap = 0
        for sector, stocks in sector_stocks.items():
            sector_cap[sector] = len(stocks)
            total_cap += len(stocks)

        # Normalize to weights
        return {s: c / total_cap for s, c in sector_cap.items()}

    def _calculate_benchmark_sector_returns(self,
                                            prices: Dict[str, pd.Series]) -> Dict[str, float]:
        """Calculate benchmark returns by sector."""
        sector_returns = {}
        sector_returns_count = {}

        for symbol, df in prices.items():
            if len(df) < 2:
                continue

            sector = get_sector(symbol)
            if sector == "Unknown":
                continue

            period_return = (df["close"].iloc[-1] / df["close"].iloc[0]) - 1

            if sector not in sector_returns:
                sector_returns[sector] = 0
                sector_returns_count[sector] = 0

            sector_returns[sector] += period_return
            sector_returns_count[sector] += 1

        # Average return within sector
        return {s: sector_returns[s] / sector_returns_count[s]
                for s in sector_returns}

    def calculate(self,
                  portfolio_holdings: List[Holding],
                  start_date: datetime,
                  end_date: datetime) -> Tuple[List[SectorResult], Dict]:
        """
        Run Brinson attribution analysis.

        Args:
            portfolio_holdings: List of active holdings with weights and realized returns
            start_date: Analysis period start
            end_date: Analysis period end

        Returns:
            Tuple of (sector_results, summary_dict)
        """
        # Convert dates to milliseconds
        start_ms = int(start_date.timestamp() * 1000)
        end_ms = int(end_date.timestamp() * 1000)

        # Fetch all required price data
        all_symbols = set(self.benchmark_symbols) | {h.symbol for h in portfolio_holdings}
        prices = {}

        print(f"Fetching price data for {len(all_symbols)} symbols...")
        for symbol in all_symbols:
            try:
                df = self.client.get_kline(
                    symbol, interval="1d",
                    start_time=start_ms, end_time=end_ms, limit=1000
                )
                if len(df) > 1:
                    prices[symbol] = df
            except Exception as e:
                print(f"Warning: Could not fetch {symbol}: {e}")

        if not prices:
            raise ValueError("No price data available for analysis")

        # Calculate benchmark sector weights and returns
        start_prices = {s: df["close"].iloc[0] for s, df in prices.items()}
        end_prices = {s: df["close"].iloc[-1] for s, df in prices.items()}

        benchmark_sector_weights = self._calculate_benchmark_sector_weights(
            prices, start_prices, end_prices)
        benchmark_sector_returns = self._calculate_benchmark_sector_returns(prices)

        # Build portfolio sector aggregates
        portfolio_sector_weights = {}
        portfolio_sector_returns = {}

        for holding in portfolio_holdings:
            if holding.symbol not in prices:
                continue

            sector = get_sector(holding.symbol)
            if sector == "Unknown":
                continue

            period_return = (end_prices[holding.symbol] / start_prices[holding.symbol]) - 1

            if sector not in portfolio_sector_weights:
                portfolio_sector_weights[sector] = 0
                portfolio_sector_returns[sector] = 0

            portfolio_sector_weights[sector] += holding.weight
            portfolio_sector_returns[sector] = period_return  # Use actual return

        # Calculate Brinson effects for each sector
        sector_results = []
        total_allocation = 0
        total_selection = 0
        total_interaction = 0

        all_sectors = set(benchmark_sector_weights.keys()) | set(portfolio_sector_weights.keys())

        for sector in sorted(all_sectors):
            bm_w = benchmark_sector_weights.get(sector, 0)
            pf_w = portfolio_sector_weights.get(sector, 0)
            bm_r = benchmark_sector_returns.get(sector, 0)
            pf_r = portfolio_sector_returns.get(sector, 0)

            # Brinson formulas
            allocation_effect = (pf_w - bm_w) * bm_r
            selection_effect = bm_w * (pf_r - bm_r)
            interaction_effect = (pf_w - bm_w) * (pf_r - bm_r)

            total_allocation += allocation_effect
            total_selection += selection_effect
            total_interaction += interaction_effect

            sector_results.append(SectorResult(
                sector=sector,
                benchmark_weight=bm_w,
                portfolio_weight=pf_w,
                benchmark_return=bm_r,
                portfolio_return=pf_r,
                allocation_effect=allocation_effect,
                selection_effect=selection_effect,
                interaction_effect=interaction_effect
            ))

        summary = {
            "total_active_return": total_allocation + total_selection + total_interaction,
            "allocation_effect": total_allocation,
            "selection_effect": total_selection,
            "interaction_effect": total_interaction,
            "period_start": start_date.isoformat(),
            "period_end": end_date.isoformat(),
        }

        return sector_results, summary

    def print_report(self, sector_results: List[SectorResult], summary: Dict):
        """Generate a formatted attribution report."""
        print("\n" + "=" * 80)
        print("BRINSON ATTRIBUTION REPORT")
        print("=" * 80)
        print(f"Period: {summary['period_start']} to {summary['period_end']}")
        print()

        print(f"{'Sector':<25} {'BM%':>8} {'PF%':>8} {'BM Ret':>10} {'PF Ret':>10} "
              f"{'Alloc':>10} {'Select':>10} {'Interact':>10}")
        print("-" * 80)

        for r in sector_results:
            print(f"{r.sector:<25} {r.benchmark_weight*100:>7.1f}% {r.portfolio_weight*100:>7.1f}% "
                  f"{r.benchmark_return*100:>9.1f}% {r.portfolio_return*100:>9.1f}% "
                  f"{r.allocation_effect*100:>9.2f}% {r.selection_effect*100:>9.2f}% "
                  f"{r.interaction_effect*100:>9.2f}%")

        print("-" * 80)
        print(f"{'TOTAL ACTIVE RETURN':<25} {'':>8} {'':>8} {'':>10} {'':>10} "
              f"{summary['allocation_effect']*100:>9.2f}% {summary['selection_effect']*100:>9.2f}% "
              f"{summary['interaction_effect']*100:>9.2f}%")
        print("=" * 80)
        print(f"\nTotal Active Return: {summary['total_active_return']*100:.2f}%")
        print(f"  - Allocation Effect: {summary['allocation_effect']*100:.2f}%")
        print(f"  - Selection Effect:  {summary['selection_effect']*100:.2f}%")
        print(f"  - Interaction Effect: {summary['interaction_effect']*100:.2f}%")
        print()


# =============================================================================
# EXAMPLE USAGE: SIMPLE SECTOR ROTATION STRATEGY
# =============================================================================

def run_sector_rotation_attribution():
    """
    Demonstrates Brinson attribution on a simple sector rotation strategy.
    
    Strategy: Overweight top-performing sectors from prior quarter, 
              underweight worst-performing sectors.
    """

    # Define our portfolio (simulated sector rotation picks)
    portfolio = [
        Holding(symbol="NVDA.US", weight=0.15, return_pct=0.0),   # Will be calculated
        Holding(symbol="MSFT.US", weight=0.12, return_pct=0.0),
        Holding(symbol="AVGO.US", weight=0.10, return_pct=0.0),
        Holding(symbol="AMZN.US", weight=0.10, return_pct=0.0),
        Holding(symbol="META.US", weight=0.08, return_pct=0.0),
        Holding(symbol="GOOGL.US", weight=0.08, return_pct=0.0),
        Holding(symbol="LLY.US", weight=0.08, return_pct=0.0),
        Holding(symbol="JPM.US", weight=0.08, return_pct=0.0),
        Holding(symbol="XOM.US", weight=0.06, return_pct=0.0),
        Holding(symbol="CAT.US", weight=0.05, return_pct=0.0),
        Holding(symbol="NEE.US", weight=0.05, return_pct=0.0),
        Holding(symbol="LIN.US", weight=0.05, return_pct=0.0),
    ]

    # Benchmark: Equal-weight sector diversification
    benchmark_symbols = list(SECTOR_MAP.keys())

    # Analysis period: 2023 full year
    start_date = datetime(2023, 1, 1)
    end_date = datetime(2023, 12, 31)

    # Initialize and run attribution
    attributor = BrinsonAttributor(benchmark_symbols)
    sector_results, summary = attributor.calculate(portfolio, start_date, end_date)
    attributor.print_report(sector_results, summary)

    return summary


if __name__ == "__main__":
    # ⚠️ For production use, add proper error handling and logging
    # This example fetches live data from TickDB
    result = run_sector_rotation_attribution()

Interpreting Brinson Results

After running the attribution, you'll see three numbers that tell a story:

Effect Positive Interpretation Negative Interpretation
Allocation You correctly overweighted sectors that outperformed You overestimated a sector's potential
Selection Your stock picks beat sector averages Your stock selection underperformed peers
Interaction You both overweighted AND picked the best stocks Lucky (or unlucky) combination of decisions

A well-managed active portfolio should show positive allocation and selection effects. If your allocation effect is strongly positive but selection is negative, you're good at macro sector calls but poor at stock picking within sectors — a critical insight for strategy refinement.


Part II: Fama-French Factor Exposure Analysis

The Factor Model: Theory

The Fama-French three-factor model (1992) expresses expected returns as:

E[R_i] - R_f = β_m × (E[R_m] - R_f) + β_smb × SMB + β_hml × HML + α

Where:

  • R_f = Risk-free rate
  • R_m = Market return
  • SMB = Small Minus Big (size factor)
  • HML = High Minus Low (value factor)
  • β = Factor loadings (sensitivity)
  • α = Jensen's alpha (return not explained by factors)

The five-factor model (2015) adds:

  • RMW = Robust Minus Weak (profitability)
  • CMA = Conservative Minus Aggressive (investment)

The key insight: alpha is your residual return after accounting for systematic factor exposures. A strategy that returns 20% but has 0.95 market beta when the market returned 15% has alpha of only ~5.75% (before transaction costs).

Factor Data Sources

For production use, you need historical factor returns. Common sources:

Source Coverage Cost
Kenneth French's Data Library US, 1926–present Free
AQR Data Global factors Institutional license
TickDB custom factors Via /v1/market/factors API subscription

For this implementation, we'll construct a simplified factor library from market data and demonstrate the regression methodology.

Production-Grade Fama-French Implementation

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


# =============================================================================
# FACTOR DATA DEFINITIONS
# =============================================================================

@dataclass
class FactorData:
    """Container for factor return time series."""
    dates: List[datetime]
    market_excess: np.ndarray  # R_m - R_f
    smb: np.ndarray            # Small minus big
    hml: np.ndarray            # High minus low
    rf: np.ndarray             # Risk-free rate

    def to_dataframe(self) -> pd.DataFrame:
        """Convert to pandas DataFrame for analysis."""
        return pd.DataFrame({
            "date": self.dates,
            "market_excess": self.market_excess,
            "smb": self.smb,
            "hml": self.hml,
            "rf": self.rf
        }).set_index("date")


@dataclass
class FactorExposure:
    """Results from factor regression analysis."""
    alpha: float
    beta_market: float
    beta_smb: float
    beta_hml: float
    r_squared: float
    residuals_std: float
    t_stats: Dict[str, float]
    p_values: Dict[str, float]

    def summary(self) -> str:
        """Generate human-readable exposure summary."""
        return (
            f"Alpha: {self.alpha*100:.2f}% annualized\n"
            f"Market Beta: {self.beta_market:.3f}\n"
            f"SMB Beta: {self.beta_smb:.3f}\n"
            f"HML Beta: {self.beta_hml:.3f}\n"
            f"R²: {self.r_squared:.3f}\n"
            f"Residual Vol: {self.residuals_std*np.sqrt(252)*100:.2f}% annualized\n"
            f"\nSignificance (p-values):\n"
            f"  Alpha: {self.p_values['alpha']:.4f}\n"
            f"  Market: {self.p_values['beta_market']:.4f}\n"
            f"  SMB: {self.p_values['beta_smb']:.4f}\n"
            f"  HML: {self.p_values['beta_hml']:.4f}"
        )


class FactorAttributor:
    """
    Implements Fama-French (1993) three-factor attribution model.
    
    Decomposes strategy returns into systematic factor exposures and alpha.
    
    Reference: Fama, E.F. and French, K.R., 1993. Common risk factors in 
    the returns on stocks and bonds. Journal of Financial Economics.
    """

    def __init__(self, factor_data: FactorData):
        """
        Args:
            factor_data: Historical factor return data
        """
        self.factor_data = factor_data

    def calculate_exposure(self,
                          strategy_returns: np.ndarray,
                          periods_per_year: int = 252) -> FactorExposure:
        """
        Run OLS regression to estimate factor exposures.
        
        Args:
            strategy_returns: Array of strategy period returns
            periods_per_year: Number of periods in a year (252 for daily)
        
        Returns:
            FactorExposure object with betas, alpha, and statistics
        """
        # Align data
        n = min(len(strategy_returns), len(self.factor_data.market_excess))

        y = strategy_returns[:n]
        X = np.column_stack([
            np.ones(n),  # Intercept (alpha)
            self.factor_data.market_excess[:n],
            self.factor_data.smb[:n],
            self.factor_data.hml[:n]
        ])

        # OLS regression: y = X @ beta + epsilon
        # Using normal equations for stability
        try:
            beta, residuals, rank, s = np.linalg.lstsq(X, y, rcond=None)
        except np.linalg.LinAlgError:
            raise ValueError("Singular matrix in regression - check factor data")

        alpha, beta_mkt, beta_smb, beta_hml = beta

        # Calculate residuals and R²
        y_pred = X @ beta
        residuals = y - y_pred

        ss_res = np.sum(residuals**2)
        ss_tot = np.sum((y - np.mean(y))**2)
        r_squared = 1 - (ss_res / ss_tot)

        # Annualize alpha and residual volatility
        alpha_annual = alpha * periods_per_year
        residual_std = np.std(residuals, ddof=len(beta)) * np.sqrt(periods_per_year)

        # Calculate t-statistics and p-values
        n, k = len(y), len(beta)
        mse = ss_res / (n - k)
        var_beta = mse * np.linalg.inv(X.T @ X).diagonal()
        se_beta = np.sqrt(var_beta)
        t_stats_arr = beta / se_beta
        p_values_arr = 2 * (1 - stats.t.cdf(np.abs(t_stats_arr), df=n-k))

        t_stats = {
            "alpha": t_stats_arr[0],
            "beta_market": t_stats_arr[1],
            "beta_smb": t_stats_arr[2],
            "beta_hml": t_stats_arr[3]
        }

        p_values = {
            "alpha": p_values_arr[0],
            "beta_market": p_values_arr[1],
            "beta_smb": p_values_arr[2],
            "beta_hml": p_values_arr[3]
        }

        return FactorExposure(
            alpha=alpha_annual,
            beta_market=beta_mkt,
            beta_smb=beta_smb,
            beta_hml=beta_hml,
            r_squared=r_squared,
            residuals_std=residual_std,
            t_stats=t_stats,
            p_values=p_values
        )


class SyntheticFactorGenerator:
    """
    Generates synthetic factor data for demonstration purposes.
    
    ⚠️ WARNING: For production use, replace with actual factor data from
    Kenneth French's Data Library or a commercial provider.
    """

    def __init__(self, seed: int = 42):
        self.rng = np.random.default_rng(seed)

    def generate(self,
                 start_date: datetime,
                 end_date: datetime,
                 periods_per_year: int = 252) -> FactorData:
        """
        Generate synthetic but realistic factor data.
        
        Realistic parameters based on historical averages:
        - Market excess return: ~6% annually with 15% volatility
        - SMB: ~2% annually with 10% volatility
        - HML: ~4% annually with 12% volatility
        - RF: ~2% annually (approximate 2020-2023 average)
        """
        # Calculate number of periods
        days = (end_date - start_date).days
        n_periods = int(days * periods_per_year / 365)

        # Generate factor returns with realistic properties
        # Annualized parameters
        market_mean = 0.06 / periods_per_year
        market_std = 0.15 / np.sqrt(periods_per_year)

        smb_mean = 0.02 / periods_per_year
        smb_std = 0.10 / np.sqrt(periods_per_year)

        hml_mean = 0.04 / periods_per_year
        hml_std = 0.12 / np.sqrt(periods_per_year)

        rf_annual = 0.02
        rf = rf_annual / periods_per_year

        # Generate correlated returns (simplified)
        # Market, SMB, HML have some correlation
        market_returns = self.rng.normal(market_mean, market_std, n_periods)
        smb_returns = self.rng.normal(smb_mean, smb_std, n_periods)
        hml_returns = self.rng.normal(hml_mean, hml_std, n_periods)

        # Add small correlations for realism
        market_excess = market_returns - rf
        smb = smb_returns
        hml = hml_returns

        # Generate dates
        dates = [start_date + timedelta(days=i*365/periods_per_year)
                 for i in range(n_periods)]

        return FactorData(
            dates=dates,
            market_excess=market_excess,
            smb=smb,
            hml=hml,
            rf=np.full(n_periods, rf)
        )


def calculate_attribution_metrics(strategy_returns: np.ndarray,
                                  factor_data: FactorData,
                                  periods_per_year: int = 252) -> Dict:
    """
    Comprehensive factor attribution analysis.
    
    Returns decomposition of returns into factor contributions and alpha.
    """
    # Calculate exposure
    attributor = FactorAttributor(factor_data)
    exposure = attributor.calculate_exposure(strategy_returns, periods_per_year)

    # Calculate factor contributions to total return variance
    factor_df = factor_data.to_dataframe()
    total_var = np.var(strategy_returns)

    # Covariance approach
    cov_mkt = np.cov(strategy_returns, factor_data.market_excess)[0, 1]
    cov_smb = np.cov(strategy_returns, factor_data.smb)[0, 1]
    cov_hml = np.cov(strategy_returns, factor_data.hml)[0, 1]

    # Variance decomposition
    var_explained_mkt = exposure.beta_market * cov_mkt / total_var if total_var > 0 else 0
    var_explained_smb = exposure.beta_smb * cov_smb / total_var if total_var > 0 else 0
    var_explained_hml = exposure.beta_hml * cov_hml / total_var if total_var > 0 else 0
    var_residual = 1 - var_explained_mkt - var_explained_smb - var_explained_hml

    # Return decomposition (annualized contributions)
    annual_factor_contrib = {
        "market": exposure.beta_market * np.mean(factor_data.market_excess) * periods_per_year,
        "smb": exposure.beta_smb * np.mean(factor_data.smb) * periods_per_year,
        "hml": exposure.beta_hml * np.mean(factor_data.hml) * periods_per_year,
        "alpha": exposure.alpha,
        "total": (
            exposure.beta_market * np.mean(factor_data.market_excess) * periods_per_year +
            exposure.beta_smb * np.mean(factor_data.smb) * periods_per_year +
            exposure.beta_hml * np.mean(factor_data.hml) * periods_per_year +
            exposure.alpha
        )
    }

    return {
        "exposure": exposure,
        "variance_decomposition": {
            "market_pct": var_explained_mkt * 100,
            "smb_pct": var_explained_smb * 100,
            "hml_pct": var_explained_hml * 100,
            "residual_pct": var_residual * 100
        },
        "return_decomposition": annual_factor_contrib
    }


# =============================================================================
# EXAMPLE USAGE
# =============================================================================

def run_factor_attribution_example():
    """
    Demonstrates factor attribution on a simulated momentum strategy.
    """
    print("=" * 80)
    print("FAMA-FRENCH FACTOR ATTRIBUTION ANALYSIS")
    print("=" * 80)
    print()

    # Generate synthetic factor data for 2021-2023
    generator = SyntheticFactorGenerator(seed=42)
    factor_data = generator.generate(
        start_date=datetime(2021, 1, 1),
        end_date=datetime(2023, 12, 31),
        periods_per_year=252
    )

    # Simulate a strategy return series
    # This strategy has: moderate market beta, small-cap exposure, 
    # value tilt, and genuine alpha
    np.random.seed(42)
    n = len(factor_data.market_excess)

    strategy_returns = (
        0.80 * factor_data.market_excess +      # Market beta of 0.8
        0.30 * factor_data.smb +                 # Small-cap tilt
        0.15 * factor_data.hml +                 # Value tilt
        0.0001 * np.random.randn(n) +            # Daily alpha ~2.5% annualized
        0.0001 * np.random.randn(n)              # Noise
    )

    # Run attribution
    results = calculate_attribution_metrics(strategy_returns, factor_data)

    # Print exposure summary
    print("FACTOR EXPOSURE SUMMARY")
    print("-" * 40)
    print(results["exposure"].summary())
    print()

    # Print return decomposition
    print("RETURN DECOMPOSITION (Annualized)")
    print("-" * 40)
    decomp = results["return_decomposition"]
    print(f"  Market Contribution:  {decomp['market']*100:>7.2f}%")
    print(f"  SMB Contribution:     {decomp['smb']*100:>7.2f}%")
    print(f"  HML Contribution:     {decomp['hml']*100:>7.2f}%")
    print(f"  Alpha (Unexplained):  {decomp['alpha']*100:>7.2f}%")
    print(f"  ─────────────────────────────────")
    print(f"  Total Strategy Return: {decomp['total']*100:>6.2f}%")
    print()

    # Print variance decomposition
    print("VARIANCE DECOMPOSITION")
    print("-" * 40)
    vd = results["variance_decomposition"]
    print(f"  Market Risk:  {vd['market_pct']:>6.1f}%")
    print(f"  SMB Risk:     {vd['smb_pct']:>6.1f}%")
    print(f"  HML Risk:     {vd['hml_pct']:>6.1f}%")
    print(f"  Idiosyncratic:{vd['residual_pct']:>6.1f}%")
    print()

    # Interpretation
    print("INTERPRETATION")
    print("-" * 40)
    print(
        "This strategy shows moderate market exposure (0.8 beta), a small-cap tilt,\n"
        "and a slight value bias. The positive alpha suggests genuine stock-picking\n"
        "skill beyond systematic factor exposures. However, with R² of "
        f"{results['exposure'].r_squared:.1%}, most return variability is explained\n"
        "by factor exposures rather than alpha."
    )
    print("=" * 80)

    return results


if __name__ == "__main__":
    results = run_factor_attribution_example()

Part III: Integrating Both Frameworks

When to Use Which

Brinson and Fama-French serve different purposes:

Question Use Brinson Use Fama-French
"Did my sector bets add value?"
"Am I being paid for market risk?"
"Which stocks should I replace?"
"Is my alpha sustainable?"
"What's my information ratio?"

For a complete attribution picture, run both analyses and reconcile the insights.

Unified Attribution Dashboard

class UnifiedAttributionReport:
    """
    Combines Brinson and Fama-French attribution into a single report.
    
    Usage:
        report = UnifiedAttributionReport()
        report.generate(
            portfolio=portfolio_holdings,
            strategy_returns=strategy_returns,
            benchmark_symbols=benchmark_symbols,
            start_date=start_date,
            end_date=end_date
        )
    """

    def __init__(self):
        self.tickdb_client = TickDBClient()
        self.factor_generator = SyntheticFactorGenerator()

    def generate(self,
                 portfolio: List[Holding],
                 strategy_returns: np.ndarray,
                 benchmark_symbols: List[str],
                 start_date: datetime,
                 end_date: datetime) -> Dict:
        """
        Generate comprehensive attribution report combining both models.
        """

        print("\n" + "=" * 80)
        print("UNIFIED PERFORMANCE ATTRIBUTION REPORT")
        print("=" * 80)
        print(f"Period: {start_date.date()} to {end_date.date()}")
        print(f"Portfolio Size: {len(portfolio)} holdings")
        print(f"Strategy Return (annualized): {np.mean(strategy_returns)*252*100:.2f}%")
        print()

        # Run Brinson Attribution
        print("Running Brinson Attribution...")
        brinson = BrinsonAttributor(benchmark_symbols)
        brinson_results, brinson_summary = brinson.calculate(
            portfolio, start_date, end_date
        )
        brinson.print_report(brinson_results, brinson_summary)

        # Run Fama-French Attribution
        print("\nRunning Fama-French Factor Attribution...")
        factor_data = self.factor_generator.generate(start_date, end_date)
        ff_results = calculate_attribution_metrics(strategy_returns, factor_data)

        print("FAMA-FRENCH RESULTS:")
        print(f"  Annualized Alpha: {ff_results['exposure'].alpha*100:.2f}%")
        print(f"  Market Beta:      {ff_results['exposure'].beta_market:.3f}")
        print(f"  R-squared:        {ff_results['exposure'].r_squared:.1%}")
        print()

        # Combined insights
        print("COMBINED INSIGHTS")
        print("-" * 40)

        # Calculate what percentage of return is "true alpha"
        total_return = np.mean(strategy_returns) * 252
        alpha_component = ff_results['exposure'].alpha
        beta_component = total_return - alpha_component

        print(f"Total Annualized Return: {total_return*100:.2f}%")
        print(f"  - Systematic (Beta):  {beta_component*100:.2f}% ({beta_component/total_return*100:.0f}%)")
        print(f"  - Idiosyncratic (Alpha): {alpha_component*100:.2f}% ({alpha_component/total_return*100:.0f}%)")
        print()

        # Actionable recommendations
        print("STRATEGY RECOMMENDATIONS")
        print("-" * 40)

        if ff_results['exposure'].r_squared > 0.9:
            print("⚠️ HIGH SYSTEMATIC EXPOSURE: >90% of return variance explained by factors.")
            print("   Consider: Reducing market beta if you want true alpha.")
        elif ff_results['exposure'].r_squared < 0.5:
            print("✅ LOW SYSTEMATIC EXPOSURE: Returns largely idiosyncratic.")
            print("   This suggests genuine stock-picking skill.")

        if ff_results['exposure'].alpha < 0:
            print("⚠️ NEGATIVE ALPHA: Strategy underperforms on a risk-adjusted basis.")
            print("   Consider: Reviewing stock selection methodology.")

        if abs(ff_results['exposure'].beta_market) > 1.2:
            print(f"⚠️ HIGH MARKET BETA ({ff_results['exposure'].beta_market:.2f}):")
            print("   Strategy acts as a leveraged market bet rather than diversification.")

        print()
        print("=" * 80)

        return {
            "brinson": brinson_summary,
            "fama_french": ff_results
        }

Part IV: Practical Considerations and Limitations

Data Quality Requirements

Both attribution methods are only as good as your input data. Critical requirements:

  1. Accurate benchmark mapping: If your benchmark doesn't match your strategy's opportunity set, attribution will be misleading.

  2. Transaction cost accounting: Brinson attribution on gross returns overstates selection effects if transaction costs are significant.

  3. Rebalancing frequency: Daily rebalancing produces different attribution than monthly rebalancing due to timing effects.

  4. Factor data reliability: Synthetic factor data is for demonstration only. Production systems must use verified factor libraries.

Common Attribution Errors

Error Symptom Fix
Look-ahead bias in factor data Unrealistic alpha Use only data available at each point in time
Survivorship bias in benchmarks Overstated benchmark returns Use point-in-time constituent data
Ignoring cash drag Understated portfolio returns Include cash as a "sector" in Brinson
Mismatched time periods Inconsistent attribution Standardize to exact same date ranges

Risk Disclaimer

Backtest limitations: The attribution results above are based on historical simulation and do not guarantee future performance. Key limitations include: factor loadings are estimated from historical data and may be unstable; transaction costs are not fully modeled; the synthetic factor data is for demonstration purposes only. For live strategy evaluation, use verified factor data from sources such as Kenneth French's Data Library and implement proper point-in-time holdings accounting.


Summary: Decoding Your Strategy's DNA

Performance attribution is not a single analysis — it is a diagnostic toolkit. The Brinson model answers "was I right about sectors and stocks?" The Fama-French model answers "am I being paid for genuine skill or just market risk?"

A strategy that shows 15% returns with:

  • Brinson: Strong positive allocation effect (you called sectors correctly)
  • Fama-French: 0.95 market beta, 4% alpha, 85% R²

...is a macro sector rotation strategy with genuine timing skill. The appropriate benchmark is not a passive index — it is a sector-rotation overlay with market exposure.

Conversely, a strategy with:

  • Brinson: Weak or negative effects
  • Fama-French: 0.5 market beta, 8% alpha, 30% R²

...is a low-beta long-short equity strategy with genuine stock-picking alpha. It should be evaluated on its Sharpe ratio, not absolute return.

The discipline of attribution forces you to be honest about where returns come from. The strategies that survive rigorous attribution analysis — not just in backtests but in live deployment — are the ones worth your capital.


Next Steps

If you're building systematic strategies:

  1. Sign up at tickdb.ai for free API access (no credit card required)
  2. Pull historical OHLCV data for your universe using the /v1/market/kline endpoint
  3. Implement the attribution engine in this article against live data
  4. Compare your strategy's attribution profile against your investment thesis

If you need institutional-grade factor data:

If you're using AI coding assistants for strategy development, install the tickdb-market-data SKILL in your AI tool's marketplace to access live market data directly from your development environment.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. The attribution models presented are analytical frameworks — actual investment decisions should account for additional factors including transaction costs, liquidity constraints, and regulatory requirements.