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:
- Portfolio holdings with weights and returns at the stock level
- Benchmark holdings with weights at the sector level
- Sector classifications for every stock
- 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 rateR_m= Market returnSMB= 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:
Accurate benchmark mapping: If your benchmark doesn't match your strategy's opportunity set, attribution will be misleading.
Transaction cost accounting: Brinson attribution on gross returns overstates selection effects if transaction costs are significant.
Rebalancing frequency: Daily rebalancing produces different attribution than monthly rebalancing due to timing effects.
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:
- Sign up at tickdb.ai for free API access (no credit card required)
- Pull historical OHLCV data for your universe using the
/v1/market/klineendpoint - Implement the attribution engine in this article against live data
- Compare your strategy's attribution profile against your investment thesis
If you need institutional-grade factor data:
- Kenneth French's Data Library: mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html
- For TickDB enterprise plans covering extended historical data, contact enterprise@tickdb.ai
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.