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:
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.
Benchmark return sensitivity: The model is sensitive to the choice of benchmark. A sector-neutral benchmark produces different attribution than a broad market benchmark.
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:
- Integrate the attribution module into your backtesting pipeline as a post-processing step.
- Run rolling 252-day regressions monthly to track how factor exposures evolve.
- Set an alpha threshold: If annualized alpha falls below 2% with a t-statistic below 1.96, the strategy's "alpha" may be noise.
- 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:
- Use point-in-time, survivor-biased-free data for all attribution inputs.
- Report both gross and net attribution — transaction costs can materially change the alpha story.
- Track attribution stability: A strategy that shows high alpha in 2022 but not 2023 is not a stable alpha generator.
- 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:
- Export your daily returns as a pandas Series with dates as index
- Map your portfolio holdings to sectors for Brinson analysis
- Run the
run_full_attribution()function with your data - 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.