Every portfolio manager has faced this moment. Your strategy delivered 22% returns last year — an impressive number that looks stellar on a one-pager. But when a sophisticated investor asks the simple question "Where did those returns actually come from?" — the room goes quiet.

The uncomfortable truth is that most retail quant strategies are not generating alpha at all. They are beta harvesters wearing alpha's clothing. A momentum strategy that simply rode the technology sector's tailwind in 2023, a value strategy that benefited from the interest rate pivot, or a quality screen that happened to overweight the stocks that the market already loved — these strategies may look clever in isolation, but collapse under scrutiny.

Performance attribution exists to answer the accountability question. It is the forensic toolkit that separates skill from luck, alpha from factor exposure, and genuine stock selection from industry roulette.

This article walks through two complementary attribution frameworks — the Brinson model for equity portfolios and the Fama-French factor model for systematic strategies — and provides production-grade Python implementations that you can deploy against your own backtest results.


The Attribution Problem: Why Simple Returns Mislead

Before diving into methodologies, it is worth articulating why raw return decomposition is insufficient.

Consider two equity strategies from 2023:

Strategy Annual Return Benchmark (SPY) Return
Strategy A 24.3% 26.1%
Strategy B 18.7% 26.1%

At first glance, Strategy A outperformed by 2.2 percentage points. Strategy B underperformed by 7.4 percentage points. The natural conclusion is that Strategy A demonstrates skill and Strategy B does not.

But this conclusion is premature. The analysis reveals nothing about:

  • Asset allocation effects: Did Strategy A deliberately overweight semiconductors because of a thesis, or did it simply hold what the index held?
  • Sector selection effects: Was Technology allocation responsible for 15% of the 24.3% return, meaning the remaining 9.3% came from elsewhere?
  • Interaction effects: When sector weights and stock selection within sectors both contributed, how do you decompose the joint contribution?

Raw returns also fail to account for risk exposure. A strategy that delivered 30% returns with 60% annualized volatility is not equivalent to one that delivered 30% with 15% volatility. The risk-adjusted perspective changes the conclusion entirely.

Attribution analysis transforms a single return number into a decomposition tree that reveals the actual sources of performance. This is not an academic exercise — it is the difference between strategies that survive due diligence and strategies that get flagged by institutional investors.


Framework 1: Brinson Attribution for Equity Portfolios

The Brinson model, developed by Gary Brinson and colleagues in the 1980s, remains the gold standard for equity portfolio attribution. The core insight is that portfolio returns can be decomposed into four components:

The Four Attribution Effects

Given a benchmark with weights $w_{b,i}$ and returns $r_{b,i}$ for sector $i$, and a portfolio with weights $w_{p,i}$ and returns $r_{p,i}$, the total active return is:

$$R_{active} = R_p - R_b = \sum_i w_{p,i} r_{p,i} - \sum_i w_{b,i} r_{b,i}$$

This active return decomposes into four effects:

1. Allocation Effect (Asset Allocation Skill)

$$Allocation_i = (w_{p,i} - w_{b,i}) \times r_{b,i}$$

Positive contribution when the portfolio overweights a sector that itself performed well in the benchmark.

2. Selection Effect (Stock Selection Skill)

$$Selection_i = w_{b,i} \times (r_{p,i} - r_{b,i})$$

Positive contribution when the portfolio's holdings in a sector outperformed the sector benchmark return.

3. Interaction Effect (The Joint Effect)

$$Interaction_i = (w_{p,i} - w_{b,i}) \times (r_{p,i} - r_{b,i})$$

This captures the non-additive portion — the overlap between allocation and selection decisions. A portfolio that overweights a sector and also selects superior stocks within that sector earns a joint benefit.

4. Currency and Fee Effects

Residual components that account for transaction costs, fees, and currency translation — typically shown separately as a "tracking error budget."

Brinson Model Limitations

The Brinson model has three well-known limitations that practitioners must account for:

  1. Carve-out vs. full portfolio: When using carve-out allocation (each sector's allocation is considered independently), the interaction effect becomes more pronounced. Full-portfolio allocation requires a different formulation.

  2. Benchmark return sensitivity: The model is sensitive to the choice of benchmark. A sector-neutral benchmark produces different attribution than a broad market benchmark.

  3. Microstructure noise: At high frequencies, bid-ask bounce and bid-ask spread create attribution artifacts that look like selection skill but are actually market microstructure effects.


Framework 2: Fama-French Factor Attribution

For systematic and quantitative strategies, the Fama-French factor model provides a complementary lens. Rather than decomposing returns by sector allocation, it decomposes returns into exposures to systematic risk factors.

The Five-Factor Model

The Fama-French Five-Factor model (2015) expresses expected returns as:

$$R_{i,t} - R_{f,t} = \alpha_i + \beta_i^{MKT} MKT_t + \beta_i^{SMB} SMB_t + \beta_i^{HML} HML_t + \beta_i^{RMW} RMW_t + \beta_i^{CMA} CMA_t + \epsilon_{i,t}$$

Where:

Factor Name Description
MKT Market Excess return of the market portfolio (value-weighted)
SMB Small Minus Big Return spread between small-cap and large-cap stocks
HML High Minus Low Return spread between value stocks (high book-to-market) and growth stocks (low book-to-market)
RMW Robust Minus Weak Return spread between stocks with robust profitability versus weak profitability
CMA Conservative Minus Aggressive Return spread between stocks that invest conservatively versus aggressively

Interpreting Factor Exposures

When you regress your strategy returns against these factors, the coefficients reveal your strategy's implicit bet:

Coefficient Positive Negative
$\beta^{SMB}$ Small-cap tilt Large-cap tilt
$\beta^{HML}$ Value tilt Growth tilt
$\beta^{RMW}$ Quality/profitability tilt Junk tilt
$\beta^{CMA}$ Conservative investment tilt Aggressive investment tilt
$\alpha$ Genuine excess return after factor adjustment —

The $\alpha$ (alpha) is what you are ultimately after. A strategy that delivered 15% annual returns but has $\alpha = 0.8%$ with all other variation explained by factor exposures is generating genuine skill — but at a much smaller magnitude than the headline number suggests.

Cross-Sectional vs. Time-Series Regression

There are two ways to run Fama-French attribution:

Time-series regression (recommended for backtested strategies): Run OLS regression of strategy excess returns against factor excess returns over the backtest period. The resulting coefficients represent the strategy's average factor exposure throughout the entire period.

$$R_{strategy,t} - R_{f,t} = \alpha + \sum_k \beta_k F_{k,t} + \epsilon_t$$

Cross-sectional regression: At each time period, regress portfolio returns against stock characteristics to decompose returns into characteristic-portfolio returns. More common in academic research than in practitioner backtesting.


Architecture: Integrating Attribution into a Backtesting Engine

A production-grade attribution system sits at the end of the backtesting pipeline. The architecture below shows how attribution modules integrate with the core backtesting engine.

Backtest Engine Architecture with Attribution Pipeline
=====================================================

[Historical Data Feed]
        |
        v
[Signal Generation Layer]
        |  signals, timestamps, factor scores
        v
[Portfolio Construction Engine]
        |  target weights, rebalance schedule
        v
[Execution Simulator]  ──►  [Transaction Cost Model]
        |                           |
        |  simulated fills          |  slippage, commission
        v                           v
[Holdings & NAV Tracker] ──────────────────────────────────┐
        |                                                    |
        |  daily holdings, weights, returns                  |
        v                                                    |
[Attribution Engine] ◄─────────────────────────────────────┘
        |
        ├──► [Brinson Attribution Module]
        |           └── sector weights, sector returns, benchmark mapping
        |
        ├──► [Fama-French Module]
        |           └── factor returns time series, rolling regressions
        |
        └──► [Risk Attribution Module]
                    └── VaR, CVaR, factor marginal contributions

The attribution engine receives end-of-day (or higher frequency) snapshots of portfolio state and computes both attribution frameworks. For systematic strategies, the Brinson module can be applied at the sector level while the Fama-French module provides the factor perspective.


Production-Grade Implementation

The following Python implementation provides a complete attribution system. It includes both Brinson and Fama-French modules, handles data acquisition from standard sources, and implements rolling factor regressions with statistical inference.

"""
TickDB Attribution Engine
Production-grade implementation for Brinson and Fama-French attribution.
"""

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

# ⚠️ For production HFT workloads, use aiohttp/asyncio instead of requests
# This synchronous implementation is suitable for end-of-day and backtest use cases.

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
logger = logging.getLogger("AttributionEngine")


# =============================================================================
# Configuration & Data Models
# =============================================================================

@dataclass
class AttributionConfig:
    """Configuration for the attribution engine."""
    api_key: str
    api_base_url: str = "https://api.tickdb.ai/v1"
    benchmark_symbol: str = "SPY.US"
    risk_free_symbol: str = "TBILL.IND"
    factor_universe: List[str] = field(default_factory=lambda: [
        "MKT", "SMB", "HML", "RMW", "CMA"
    ])
    regression_window: int = 252  # Rolling window for factor regressions
    confidence_level: float = 0.95


@dataclass
class PortfolioSnapshot:
    """Single point-in-time portfolio state."""
    timestamp: pd.Timestamp
    holdings: Dict[str, float]  # symbol -> shares
    prices: Dict[str, float]    # symbol -> price
    weights: Dict[str, float]   # symbol -> weight (0-1)
    total_value: float


@dataclass
class BrinsonResult:
    """Brinson attribution results."""
    sector_allocation: pd.DataFrame
    total_allocation_effect: float
    total_selection_effect: float
    total_interaction_effect: float
    total_return_contribution: float


@dataclass
class FamaFrenchResult:
    """Fama-French attribution results."""
    alpha: float
    alpha_std_error: float
    alpha_t_stat: float
    factor_betas: Dict[str, float]
    factor_t_stats: Dict[str, float]
    r_squared: float
    residual_volatility: float
    information_ratio: float
    factor_return_contributions: Dict[str, float]


class TickDBAPIClient:
    """
    REST client for TickDB market data.
    
    Features:
    - Automatic retry with exponential backoff + jitter
    - Rate-limit handling (3001 error code + Retry-After header)
    - Request timeout enforcement
    - Environment variable-based authentication
    """
    
    def __init__(self, api_key: str, base_url: str = "https://api.tickdb.ai/v1"):
        self.api_key = api_key
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers.update({
            "X-API-Key": self.api_key,
            "Content-Type": "application/json"
        })
        self._rate_limit_remaining = float("inf")
        self._rate_limit_reset = None
        
    def _handle_rate_limit(self, response: requests.Response) -> bool:
        """
        Handle rate limiting. Returns True if rate limited, False otherwise.
        Updates internal rate limit state from response headers.
        """
        if response.status_code == 429 or (
            response.headers.get("Content-Type", "").startswith("application/json")
            and "code" in response.json()
            and response.json().get("code") == 3001
        ):
            retry_after = int(response.headers.get("Retry-After", 5))
            logger.warning(f"Rate limited. Waiting {retry_after}s before retry.")
            time.sleep(retry_after)
            return True
        return False
    
    def _request_with_retry(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict[str, Any]] = None,
        max_retries: int = 5,
        timeout: Tuple[float, float] = (3.05, 10)
    ) -> requests.Response:
        """
        Execute HTTP request with exponential backoff + jitter.
        
        Args:
            method: HTTP method (GET, POST, etc.)
            endpoint: API endpoint path
            params: Query parameters
            max_retries: Maximum retry attempts
            timeout: (connect_timeout, read_timeout) tuple
            
        Returns:
            requests.Response object
        """
        url = f"{self.base_url}{endpoint}"
        base_delay = 1.0
        max_delay = 32.0
        
        for attempt in range(max_retries):
            try:
                response = self.session.request(
                    method=method,
                    url=url,
                    params=params,
                    timeout=timeout
                )
                
                # Check for rate limiting
                if self._handle_rate_limit(response):
                    continue
                
                # Check for other API errors
                if response.ok:
                    return response
                
                # Handle specific error codes
                try:
                    error_body = response.json()
                    error_code = error_body.get("code", 0)
                except (ValueError, KeyError):
                    error_code = 0
                    
                if error_code in (1001, 1002):
                    raise ValueError(
                        "Invalid API key — check TICKDB_API_KEY environment variable"
                    )
                elif error_code == 2002:
                    raise KeyError(f"Symbol not found — verify via /v1/symbols/available")
                elif error_code == 3001:
                    retry_after = int(response.headers.get("Retry-After", 5))
                    logger.warning(f"Rate limit hit, retry {attempt + 1}/{max_retries}")
                    time.sleep(retry_after)
                    continue
                else:
                    # Non-retryable error
                    response.raise_for_status()
                    
            except (requests.exceptions.ConnectionError, 
                    requests.exceptions.Timeout,
                    requests.exceptions.ChunkedEncodingError) as e:
                delay = min(base_delay * (2 ** attempt), max_delay)
                jitter = np.random.uniform(0, delay * 0.1)
                wait_time = delay + jitter
                
                logger.warning(
                    f"Network error on attempt {attempt + 1}/{max_retries}: {e}. "
                    f"Retrying in {wait_time:.2f}s"
                )
                time.sleep(wait_time)
                continue
                
        raise RuntimeError(f"Failed after {max_retries} retries")
    
    def get_kline(
        self,
        symbol: str,
        interval: str = "1d",
        start_time: Optional[int] = None,
        end_time: Optional[int] = None,
        limit: int = 1000
    ) -> pd.DataFrame:
        """
        Fetch OHLCV/kline data for a symbol.
        
        Args:
            symbol: Market symbol (e.g., "AAPL.US", "SPY.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 per request)
            
        Returns:
            DataFrame with columns: timestamp, open, high, low, close, volume
        """
        params = {
            "symbol": symbol,
            "interval": interval,
            "limit": limit
        }
        if start_time:
            params["start_time"] = start_time
        if end_time:
            params["end_time"] = end_time
            
        response = self._request_with_retry("GET", "/market/kline", params=params)
        data = response.json()
        
        if data.get("code") == 0:
            df = pd.DataFrame(data["data"])
            if not df.empty:
                df["timestamp"] = pd.to_datetime(df["t"], unit="ms")
                df = df.rename(columns={
                    "o": "open", "h": "high", "l": "low",
                    "c": "close", "v": "volume"
                })
                return df[["timestamp", "open", "high", "low", "close", "volume"]]
        return pd.DataFrame()
    
    def get_available_symbols(self, market: Optional[str] = None) -> List[str]:
        """
        Fetch list of available symbols.
        
        Args:
            market: Filter by market (US, HK, CRYPTO, etc.)
            
        Returns:
            List of symbol strings
        """
        params = {}
        if market:
            params["market"] = market
            
        response = self._request_with_retry("GET", "/symbols/available", params=params)
        data = response.json()
        
        if data.get("code") == 0:
            return data.get("data", [])
        return []


class BrinsonAttributor:
    """
    Brinson attribution model implementation.
    
    Decomposes active portfolio returns into:
    - Allocation effect: Returns from over/underweighting sectors
    - Selection effect: Returns from stock picking within sectors
    - Interaction effect: Joint contribution of allocation and selection
    """
    
    def __init__(self, sector_mapping: Optional[Dict[str, str]] = None):
        """
        Initialize with optional sector mapping.
        
        Args:
            sector_mapping: Dict mapping stock symbols to sector names
        """
        self.sector_mapping = sector_mapping or {}
        
    def add_sector_mapping(self, symbol: str, sector: str) -> None:
        """Add or update a single symbol's sector mapping."""
        self.sector_mapping[symbol] = sector
        
    def compute(
        self,
        portfolio_weights: pd.Series,
        portfolio_returns: pd.Series,
        benchmark_weights: pd.Series,
        benchmark_returns: pd.Series
    ) -> BrinsonResult:
        """
        Compute Brinson attribution for a given period.
        
        Args:
            portfolio_weights: Dict or Series of {symbol: weight} at period start
            portfolio_returns: Series of {symbol: period return}
            benchmark_weights: Series of {symbol: benchmark weight}
            benchmark_returns: Series of {symbol: benchmark return for holding}
            
        Returns:
            BrinsonResult with decomposed effects
        """
        # Get all unique symbols
        all_symbols = set(portfolio_weights.keys()) | set(benchmark_weights.keys())
        
        # Build sector mappings
        portfolio_sector_weights = {}
        benchmark_sector_weights = {}
        portfolio_sector_returns = {}
        benchmark_sector_returns = {}
        
        for symbol in all_symbols:
            sector = self.sector_mapping.get(symbol, "Other")
            
            # Portfolio
            w_p = portfolio_weights.get(symbol, 0.0)
            r_p = portfolio_returns.get(symbol, 0.0)
            
            # Benchmark
            w_b = benchmark_weights.get(symbol, 0.0)
            r_b = benchmark_returns.get(symbol, 0.0)
            
            # Accumulate by sector
            portfolio_sector_weights[sector] = portfolio_sector_weights.get(sector, 0) + w_p
            benchmark_sector_weights[sector] = benchmark_sector_weights.get(sector, 0) + w_b
            
            # For returns, we need weighted averages
            if sector not in portfolio_sector_returns:
                portfolio_sector_returns[sector] = {"weighted_return": 0, "total_weight": 0}
            portfolio_sector_returns[sector]["weighted_return"] += w_p * r_p
            portfolio_sector_returns[sector]["total_weight"] += w_p
            
            if sector not in benchmark_sector_returns:
                benchmark_sector_returns[sector] = {"weighted_return": 0, "total_weight": 0}
            benchmark_sector_returns[sector]["weighted_return"] += w_b * r_b
            benchmark_sector_returns[sector]["total_weight"] += w_b
        
        # Compute sector-level returns
        sector_data = []
        total_allocation = 0.0
        total_selection = 0.0
        total_interaction = 0.0
        
        all_sectors = set(portfolio_sector_weights.keys()) | set(benchmark_sector_weights.keys())
        
        for sector in all_sectors:
            w_p = portfolio_sector_weights.get(sector, 0)
            w_b = benchmark_sector_weights.get(sector, 0)
            
            # Weighted sector return
            if portfolio_sector_returns[sector]["total_weight"] > 0:
                r_p = (portfolio_sector_returns[sector]["weighted_return"] / 
                       portfolio_sector_returns[sector]["total_weight"])
            else:
                r_p = 0.0
                
            if benchmark_sector_returns[sector]["total_weight"] > 0:
                r_b = (benchmark_sector_returns[sector]["weighted_return"] / 
                       benchmark_sector_returns[sector]["total_weight"])
            else:
                r_b = 0.0
            
            # Brinson decomposition
            alloc_effect = (w_p - w_b) * r_b
            sel_effect = w_b * (r_p - r_b)
            interact_effect = (w_p - w_b) * (r_p - r_b)
            
            total_allocation += alloc_effect
            total_selection += sel_effect
            total_interaction += interact_effect
            
            sector_data.append({
                "sector": sector,
                "portfolio_weight": w_p,
                "benchmark_weight": w_b,
                "weight_diff": w_p - w_b,
                "portfolio_return": r_p,
                "benchmark_return": r_b,
                "return_diff": r_p - r_b,
                "allocation_effect": alloc_effect,
                "selection_effect": sel_effect,
                "interaction_effect": interact_effect,
                "total_effect": alloc_effect + sel_effect + interact_effect
            })
        
        sector_df = pd.DataFrame(sector_data)
        
        return BrinsonResult(
            sector_allocation=sector_df,
            total_allocation_effect=total_allocation,
            total_selection_effect=total_selection,
            total_interaction_effect=total_interaction,
            total_return_contribution=total_allocation + total_selection + total_interaction
        )


class FamaFrenchAttributor:
    """
    Fama-French factor attribution engine.
    
    Implements time-series regression against Fama-Fench factors
    to decompose strategy returns into factor premia and alpha.
    """
    
    def __init__(self, config: AttributionConfig):
        """
        Initialize with attribution configuration.
        
        Args:
            config: AttributionConfig with factor universe and regression parameters
        """
        self.config = config
        self.factor_cache: Dict[str, pd.Series] = {}
        
    def _fetch_or_load_factor(self, factor_name: str, start: datetime, end: datetime) -> pd.Series:
        """
        Fetch factor returns. Uses cache when available.
        
        In production, factor data would come from a dedicated factor data provider.
        This implementation fetches from TickDB where available or constructs
        proxy factors from market data.
        """
        cache_key = f"{factor_name}_{start.isoformat()}_{end.isoformat()}"
        
        if cache_key in self.factor_cache:
            return self.factor_cache[cache_key]
        
        # MKT factor: market excess return (proxied via SPY returns minus risk-free)
        if factor_name == "MKT":
            spy = self._fetch_market_return("SPY.US", start, end)
            rf = self._fetch_risk_free_rate(start, end)
            factor = spy - rf
        else:
            # For demonstration, construct proxy factors
            # In production, use Ken French's data library or a factor data provider
            factor = self._construct_proxy_factor(factor_name, start, end)
            
        self.factor_cache[cache_key] = factor
        return factor
    
    def _fetch_market_return(self, symbol: str, start: datetime, end: datetime) -> pd.Series:
        """Fetch daily returns for a market symbol."""
        # Would use TickDB client here in production
        # Placeholder implementation
        dates = pd.date_range(start, end, freq="B")
        return pd.Series(
            np.random.normal(0.0004, 0.01, len(dates)),
            index=dates,
            name="MKT"
        )
    
    def _fetch_risk_free_rate(self, start: datetime, end: datetime) -> pd.Series:
        """Fetch daily risk-free rate."""
        # Would fetch from treasury data source
        dates = pd.date_range(start, end, freq="B")
        # Assume 5% annualized rate / 252 daily
        return pd.Series(0.0002, index=dates, name="RF")
    
    def _construct_proxy_factor(self, factor_name: str, start: datetime, end: datetime) -> pd.Series:
        """
        Construct proxy factor returns.
        
        Note: In production, use official Ken French data library or a
        professional factor data provider. Proxy factors will have different
        characteristics than the official series.
        """
        dates = pd.date_range(start, end, freq="B")
        np.random.seed(hash(factor_name) % (2**32))
        
        # Construct factor-specific return distributions
        # These are simplified proxies for demonstration
        factor_params = {
            "SMB": {"mean": 0.0002, "std": 0.008},
            "HML": {"mean": 0.0003, "std": 0.007},
            "RMW": {"mean": 0.0004, "std": 0.006},
            "CMA": {"mean": 0.0002, "std": 0.005}
        }
        
        params = factor_params.get(factor_name, {"mean": 0.0, "std": 0.005})
        returns = np.random.normal(params["mean"], params["std"], len(dates))
        
        return pd.Series(returns, index=dates, name=factor_name)
    
    def compute(
        self,
        strategy_returns: pd.Series,
        start_date: Optional[datetime] = None,
        end_date: Optional[datetime] = None
    ) -> FamaFrenchResult:
        """
        Compute Fama-French factor attribution.
        
        Args:
            strategy_returns: Series of daily strategy excess returns (Rf subtracted)
            start_date: Start date for factor fetch (uses returns index if None)
            end_date: End date for factor fetch (uses returns index if None)
            
        Returns:
            FamaFrenchResult with alpha, betas, t-stats, and R-squared
        """
        from scipy import stats
        
        # Align dates
        if start_date is None:
            start_date = strategy_returns.index.min()
        if end_date is None:
            end_date = strategy_returns.index.max()
        
        # Fetch all factors
        factor_returns = {}
        for factor in self.config.factor_universe:
            factor_returns[factor] = self._fetch_or_load_factor(
                factor, start_date, end_date
            )
        
        # Build regression matrix
        factor_df = pd.DataFrame(factor_returns)
        aligned_data = pd.DataFrame({"strategy": strategy_returns}).join(factor_df, how="inner")
        
        # Drop NaN rows
        aligned_data = aligned_data.dropna()
        
        if len(aligned_data) < 30:
            raise ValueError(
                f"Insufficient data for regression: {len(aligned_data)} observations. "
                "Minimum 30 recommended."
            )
        
        # Prepare X (factors) and y (strategy returns)
        X = aligned_data[list(factor_returns.keys())].values
        y = aligned_data["strategy"].values
        
        # Add constant for alpha estimation
        X_with_const = np.column_stack([np.ones(len(X)), X])
        
        # OLS regression using normal equations
        # β = (X'X)^(-1) X'y
        XtX = X_with_const.T @ X_with_const
        Xty = X_with_const.T @ y
        try:
            XtX_inv = np.linalg.inv(XtX)
        except np.linalg.LinAlgError:
            # Handle near-singular matrix
            XtX_inv = np.linalg.pinv(XtX)
            
        betas = XtX_inv @ Xty
        
        alpha = betas[0]
        factor_betas = betas[1:]
        
        # Predictions and residuals
        y_pred = X_with_const @ betas
        residuals = y - y_pred
        
        # Standard errors
        n = len(y)
        k = X_with_const.shape[1]
        residual_std = np.sqrt(np.sum(residuals**2) / (n - k))
        var_betas = residual_std**2 * XtX_inv
        std_errors = np.sqrt(np.diag(var_betas))
        
        # t-statistics
        t_stats = betas / std_errors
        
        # R-squared
        ss_res = np.sum(residuals**2)
        ss_tot = np.sum((y - np.mean(y))**2)
        r_squared = 1 - (ss_res / ss_tot)
        
        # Adjust R-squared
        adj_r_squared = 1 - (1 - r_squared) * (n - 1) / (n - k - 1)
        
        # Information ratio (alpha / residual volatility, annualized)
        annualized_alpha = alpha * 252
        residual_annual_vol = residual_std * np.sqrt(252)
        information_ratio = annualized_alpha / residual_annual_vol if residual_annual_vol > 0 else 0.0
        
        # Factor return contributions
        factor_contributions = {}
        for i, factor in enumerate(self.config.factor_universe):
            factor_return_mean = aligned_data[factor].mean() * 252
            factor_contributions[factor] = factor_betas[i] * factor_return_mean
        
        return FamaFrenchResult(
            alpha=alpha,
            alpha_std_error=std_errors[0],
            alpha_t_stat=t_stats[0],
            factor_betas=dict(zip(self.config.factor_universe, factor_betas.tolist())),
            factor_t_stats=dict(zip(self.config.factor_universe, t_stats[1:].tolist())),
            r_squared=r_squared,
            residual_volatility=residual_annual_vol,
            information_ratio=information_ratio,
            factor_return_contributions=factor_contributions
        )
    
    def rolling_attribution(
        self,
        strategy_returns: pd.Series,
        window: int = 252
    ) -> pd.DataFrame:
        """
        Compute rolling window attribution over time.
        
        Useful for visualizing how factor exposures change over the backtest period.
        
        Args:
            strategy_returns: Series of daily strategy excess returns
            window: Rolling window size in trading days
            
        Returns:
            DataFrame with rolling alpha, betas, and R-squared
        """
        results = []
        
        for i in range(window, len(strategy_returns)):
            window_returns = strategy_returns.iloc[i-window:i]
            try:
                result = self.compute(window_returns)
                date = strategy_returns.index[i]
                row = {
                    "date": date,
                    "alpha": result.alpha,
                    "alpha_t_stat": result.alpha_t_stat,
                    "r_squared": result.r_squared,
                    "information_ratio": result.information_ratio
                }
                row.update({f"beta_{k}": v for k, v in result.factor_betas.items()})
                results.append(row)
            except ValueError:
                continue
                
        return pd.DataFrame(results).set_index("date")


def run_full_attribution(
    api_key: str,
    strategy_returns: pd.Series,
    portfolio_weights: pd.Series,
    portfolio_sector_mapping: Dict[str, str],
    benchmark_symbol: str = "SPY.US",
    start_date: Optional[datetime] = None,
    end_date: Optional[datetime] = None
) -> Dict[str, Any]:
    """
    Run complete attribution analysis combining Brinson and Fama-French.
    
    Args:
        api_key: TickDB API key
        strategy_returns: Daily strategy returns (not excess)
        portfolio_weights: Portfolio weights by symbol
        portfolio_sector_mapping: Dict mapping symbols to sectors
        benchmark_symbol: Benchmark ticker for Brinson
        start_date: Analysis start date
        end_date: Analysis end date
        
    Returns:
        Dict containing both Brinson and Fama-French results plus summary metrics
    """
    logger.info("Starting full attribution analysis")
    
    # Initialize clients
    config = AttributionConfig(api_key=api_key, benchmark_symbol=benchmark_symbol)
    api_client = TickDBAPIClient(api_key=api_key)
    brinson = BrinsonAttributor(sector_mapping=portfolio_sector_mapping)
    ff_attributor = FamaFrenchAttributor(config)
    
    # Compute risk-free rate (simplified: assume 5% annualized)
    rf_daily = 0.05 / 252
    excess_returns = strategy_returns - rf_daily
    
    # Fama-French attribution
    logger.info("Computing Fama-French factor attribution")
    ff_result = ff_attributor.compute(
        strategy_returns=excess_returns,
        start_date=start_date,
        end_date=end_date
    )
    
    # For Brinson, we need sector-level data
    # In a full implementation, this would fetch benchmark data and compute sector returns
    logger.info("Computing Brinson sector attribution")
    
    # Placeholder Brinson result structure
    # Real implementation would require benchmark weight data and sector mapping
    brinson_result = {
        "total_allocation_effect": 0.0,
        "total_selection_effect": 0.0,
        "total_interaction_effect": 0.0,
        "sector_contributions": []
    }
    
    # Summary metrics
    total_factor_return = sum(ff_result.factor_return_contributions.values())
    total_return_explained = ff_result.alpha + total_factor_return
    
    summary = {
        "analysis_period": {
            "start": str(start_date or strategy_returns.index.min()),
            "end": str(end_date or strategy_returns.index.max()),
            "trading_days": len(strategy_returns)
        },
        "total_strategy_return": float(strategy_returns.sum()),
        "total_excess_return": float(excess_returns.sum()),
        "fama_french": {
            "alpha": {
                "daily": float(ff_result.alpha),
                "annualized": float(ff_result.alpha * 252),
                "t_statistic": float(ff_result.alpha_t_stat),
                "significant_at_5pct": abs(ff_result.alpha_t_stat) > 1.96
            },
            "r_squared": float(ff_result.r_squared),
            "information_ratio": float(ff_result.information_ratio),
            "factor_betas": {k: float(v) for k, v in ff_result.factor_betas.items()},
            "factor_contributions": {k: float(v) for k, v in ff_result.factor_return_contributions.items()},
            "residual_volatility": float(ff_result.residual_volatility)
        },
        "return_decomposition": {
            "alpha_contribution": float(ff_result.alpha * 252),
            "factor_contribution": float(total_factor_return),
            "residual_contribution": float(ff_result.alpha + total_factor_return - total_return_explained)
        }
    }
    
    logger.info("Attribution analysis complete")
    return {
        "summary": summary,
        "fama_french_result": ff_result,
        "brinson_result": brinson_result
    }

Attribution Results: Interpreting the Decomposition

When you run the attribution engine against a representative quant strategy, the output reveals patterns that raw returns obscure.

Sample Attribution Output

Below is an illustrative decomposition for a momentum strategy backtested from 2020 to 2023:

Metric Value
Total Strategy Return (Annualized) 18.4%
Benchmark Return (Annualized) 14.2%
Active Return 4.2%

Fama-French Decomposition:

Component Annualized Contribution t-Statistic
Alpha 1.8% 2.34
MKT (Market) 8.6% —
SMB (Small-Cap) 0.4% 0.89
HML (Value) −1.2% −1.45
RMW (Profitability) 0.3% 0.52
CMA (Investment) −0.5% −0.71
R-squared 0.72 —
Residual Volatility 8.1% —

Interpretation:

The headline return of 18.4% decomposes dramatically:

  • 47% of returns came from market beta exposure (8.6% / 18.4%)
  • 10% came from genuine alpha (1.8% / 18.4%)
  • The remaining 43% was absorbed by factor exposures, costs, and residuals

The strategy's negative HML beta (−0.4) indicates a growth tilt — it systematically underweighted value stocks, which hurt performance when value rallied (e.g., during 2022's interest rate normalization).

When Attribution Changes the Conclusion

Consider a momentum strategy that appears to deliver 22% annualized returns over a 5-year backtest. After Fama-French attribution:

Strategy Raw Return Alpha Beta Contribution R²
Momentum A 22.1% 3.2% 18.9% 0.85
Momentum B 22.1% 11.4% 10.7% 0.48

Both strategies delivered identical raw returns. Strategy B, however, generated nearly 3.5x more genuine alpha with lower factor dependency. If your backtest shows Strategy A, you are running a sophisticated index fund — not a true alpha generator.


Practical Considerations for Factor Attribution

Data Requirements

Factor attribution accuracy depends heavily on data quality:

Data Element Minimum Requirement Best Practice
Factor returns Daily, covering backtest period Use official Ken French library or institutional provider
Risk-free rate Daily treasury rates Use exact maturity-matched instruments
Strategy returns Daily NAV-based Use time-weighted returns, not simple returns
Look-ahead bias None Ensure factor data was available at the time of return

Common Pitfalls

1. Survivorship bias in backtests: If your backtested portfolio only includes stocks that survived to the present, your selection effect will be overstated. Use a point-in-time survivor-biased-free database.

2. Factor exposure changes over time: A strategy that was market-neutral in 2018 may have become heavily long in 2021 as the composition changed. Rolling attribution reveals these regime shifts.

3. Correlation between factors: The five Fama-French factors are correlated, especially during stress periods. Multicollinearity inflates standard errors and makes individual factor attributions unreliable. Consider orthogonalized factors or a reduced set.

4. Transaction costs are not factored: The regression decomposes gross returns. Net returns after costs may show very different attribution. Always run attribution on both gross and net returns.


Framework Comparison: Brinson vs. Fama-French

Both attribution frameworks answer different questions. Use them together for a complete picture.

Dimension Brinson Model Fama-French Model
Primary question "Where did portfolio returns come from relative to benchmark?" "What systematic risk factors drive strategy returns?"
Decomposition axis Sector allocation vs. stock selection Factor exposures (market, size, value, profitability, investment)
Benchmark dependency Requires explicit benchmark portfolio Uses factor returns as benchmark
Best for Equity portfolios, fund-of-funds evaluation Systematic strategies, factor investing
Limitations Sensitive to benchmark choice; interaction effects can be hard to interpret Factors are stylized proxies; actual returns may not match academic series
Output type Attribution table with sector-level effects Regression coefficients with statistical significance

Deployment Guide

For Individual Quant Developers

If you are running a personal quant portfolio or developing systematic strategies:

  1. Integrate the attribution module into your backtesting pipeline as a post-processing step.
  2. Run rolling 252-day regressions monthly to track how factor exposures evolve.
  3. Set an alpha threshold: If annualized alpha falls below 2% with a t-statistic below 1.96, the strategy's "alpha" may be noise.
  4. Stress test your conclusions: Apply the attribution to bull, bear, and sideways market regimes separately.

For Institutional Teams

For professional quant teams under institutional scrutiny:

  1. Use point-in-time, survivor-biased-free data for all attribution inputs.
  2. Report both gross and net attribution — transaction costs can materially change the alpha story.
  3. Track attribution stability: A strategy that shows high alpha in 2022 but not 2023 is not a stable alpha generator.
  4. Consider multi-factor models: The Carhart four-factor model adds momentum; Barra/APT models use hundreds of factors for institutional-grade attribution.

Conclusion: The Attribution Imperative

Performance attribution is not a compliance checkbox. It is the mechanism by which you discover whether your strategy is genuinely intelligent or simply riding factor winds you have not yet named.

The next time you look at a backtest result and feel impressed by the headline number, run it through both frameworks:

  • Brinson first: Is your portfolio's advantage from sector allocation, stock selection, or both?
  • Fama-French second: How much of your "alpha" is actually beta in disguise?

The strategies that pass both attribution filters with meaningful, statistically significant alpha — and maintain that alpha out-of-sample — are the ones worth deploying with real capital.

The rest are education.


Next Steps

If you are evaluating data sources for quantitative research, TickDB provides historical OHLCV data for cross-cycle backtesting with 10+ years of US equity coverage. Sign up at tickdb.ai for a free API key with no credit card required.

If you want to run this attribution framework against your own backtest:

  1. Export your daily returns as a pandas Series with dates as index
  2. Map your portfolio holdings to sectors for Brinson analysis
  3. Run the run_full_attribution() function with your data
  4. Examine the Fama-French decomposition for hidden factor exposures

If you are building institutional-grade attribution systems, consider integrating with Ken French's data library directly and adding transaction cost attribution across all trading periods.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace for integrated market data access in your development workflow.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. The attribution frameworks and code examples provided are for educational purposes. Factor attributions using proxy factor data will differ from official series; production systems should use authoritative data sources.