6 Factor Models, the CAPM Decomposition, and Market Benchmarks
A PhD-level reference note, grounded in the SBFoundation platform.
- Version: 1.0 · Created: 2026-08-02
- Scope: factor loadings; factor returns; the CAPM regression
r = α + β·market + ε; systematic vs. idiosyncratic return; how factors become buy/sell signals; how a market benchmark is chosen; academic literature on benchmark construction; data sources. - Grounding: every concept is tied to the module, table, or config that implements it in this repository. Code references use
path:line. Academic citations resolve todocs/reference-papers/README.md(row numbers in brackets, e.g. [#116]).
Reading guide. §1–§4 are the theory (loadings, returns, CAPM, systematic/idiosyncratic). §5–§6 connect factors to tradable signals and give a worked example. §7–§9 cover benchmark choice, the literature, and data provenance. §10 is the canonical factor catalogue. A recurring “In this platform” callout maps each idea to real code.
6.1 0. Two regression geometries (read this first)
Almost every confusion about factor models dissolves once you separate the two orthogonal regression geometries the field uses. SBFoundation implements both, in different packages, and keeps them deliberately distinct.
| Time-series regression | Cross-sectional regression | |
|---|---|---|
| Runs over | dates, for one asset (or one portfolio) | assets, for one date |
| Estimates | loadings/betas β (asset’s sensitivity to a factor’s return) and alpha α |
factor returns F (the period’s payoff to a unit of exposure) |
| Factor is | an observable return series (e.g. the market excess return, HML) | a characteristic (e.g. book-to-market, momentum score) |
| Canonical estimator | OLS with HAC errors — CAPM, Fama-French time-series tests | Fama–MacBeth two-pass [#122] / Barra WLS |
| In this platform | sbattribution (return-based β to SPY/MTUM/FF5) |
sbriskmodel (daily WLS characteristic regression) |
The words loading, exposure, and beta all name the coefficient that multiplies a factor. Which geometry produced it determines what it means: a time-series β is a slope on a factor return; a cross-sectional exposure is a standardized characteristic that the cross-sectional regression will later price.
6.2 1. Factor loadings
6.2.1 1.1 Definition
A factor loading (equivalently exposure, beta) is the sensitivity of an asset’s return to a common factor. In the linear factor model
\[ r_{i,t} \;=\; \alpha_i \;+\; \sum_{k=1}^{K} \beta_{i,k}\, f_{k,t} \;+\; \varepsilon_{i,t}, \]
β_{i,k} is asset i’s loading on factor k. f_{k,t} is the factor’s realization at t, and ε_{i,t} is the asset-specific residual. Loadings are the bridge between an asset and the systematic forces that move it.
There are two operational definitions, matching the two geometries in §0:
(a) Time-series loading (a slope on a factor return). Regress the asset’s realized returns on the factor’s realized return series over a rolling window. The market beta is the archetype:
\[ \beta_i \;=\; \frac{\operatorname{Cov}(r_i,\, r_m)}{\operatorname{Var}(r_m)}. \]
(b) Cross-sectional exposure (a standardized characteristic). Take a raw characteristic — book-to-market, 12-1 momentum, log market cap — and standardize it across the cross-section on each date so exposures are comparable across factors and time. This is the Barra convention.
6.2.2 1.2 How loadings are calculated
Time-series market beta is computed directly from the covariance/variance identity over a trailing window. SBFoundation computes a 252-trading-day rolling market beta against SPY, persisted as beta_f in gold.fact_eod. Because DuckDB has no windowed REGR_SLOPE, it evaluates the identity with windowed aggregates (src/sbfactors/eod_feature_service.py:364-431):
COVAR_SAMP(stock_return, spy_return) OVER (
PARTITION BY instrument_sk ORDER BY date_sk
ROWS BETWEEN 251 PRECEDING AND CURRENT ROW
) / NULLIF(
VAR_SAMP(spy_return) OVER (
PARTITION BY instrument_sk ORDER BY date_sk
ROWS BETWEEN 251 PRECEDING AND CURRENT ROW), 0
) AS beta_fReturns are log returns ln(adj_close / lag(adj_close)); SPY is joined cross-instrument by date_sk. This is exactly estimator (a): a rolling Cov(r_i, r_SPY)/Var(r_SPY).
Cross-sectional exposures are standardized characteristics. The production risk model (sbriskmodel.FactorExposureService, src/sbriskmodel/factor_exposure_service.py:347-396) builds the exposure matrix X each day by:
- selecting the investable cross-section (require sector + all style sources non-null);
- applying a per-factor transform (
identity | log | negate); - robust z-scoring across the daily cross-section —
z = (x − median) / (1.4826 · MAD)(_robust_zscore,:502), which is outlier-resistant relative to a mean/σ z-score; - winsorizing to ±cap (
:515); - appending sector one-hot dummies (with
Industrialsdropped as the baseline), holding the column count stable at K = 20 (10 style + 10 non-baseline sector columns) so the downstream factor covariance sees a constant dimension day-to-day.
The result is written to ops.risk_factor_exposure (raw / z-scored / winsorized), keyed (date, instrument_sk, factor_id). Sign conventions are folded into the transform so that a positive exposure always means more of the priced attribute: low-vol = −volatility, size = −log(market_cap), liquidity = −log(ADV$), value = −fcf_yield (sbfactorrisk/factor_risk.py:703-714).
In this platform. Two exposure surfaces coexist. The point-in-time B-matrix in
sbriskmodel.factor_exposure_service→ops.risk_factor_exposureis the load-bearing one (it feeds the daily factor-return regression). A lighter cross-sectional z-score cube insbfactorrisk.factor_risk._build_exposure_cube(factor_risk.py:725-750) is used only for the exposure-spread / stress report. The time-seriesbeta_fis a third construct — a market loading, not a Barra style exposure — which the risk model then re-imports as themarket_betastyle (factor_risk.py:706).
6.2.3 1.3 Point-in-time discipline
Loadings must be knowable at the moment they are used. The risk model regresses yesterday’s exposures X_{t-1} on today’s return r_t (§2.2), annual fundamentals are joined with a conservative calendar_year = year(as_of) − 1 as-of rule, and FINRA short-interest exposures are snapped forward 18 calendar days for the disclosure lag (FactorValueLiftService, src/sbfactors/factor_value_lift_service.py). A loading computed with information the market did not yet have is look-ahead — the single most common way a backtest lies.
6.3 2. Factor returns
6.3.1 2.1 Definition
A factor return f_{k,t} is the payoff, in period t, to holding one unit of exposure to factor k while being neutral to everything else. Where a loading is a property of an asset, a factor return is a property of a factor — a time series you could, in principle, earn by holding the factor-mimicking portfolio. There are two standard constructions, and SBFoundation builds both.
6.3.2 2.2 Construction A — cross-sectional regression (Fama–MacBeth / Barra)
Estimate the factor returns as the slopes of a cross-sectional regression of realized returns on lagged exposures, one regression per date. This is the first pass of Fama–MacBeth [#122]; SBFoundation uses the Barra USE3 weighting (sbriskmodel.CrossSectionalRegressionService, src/sbriskmodel/cross_sectional_regression_service.py:135-170):
\[ r_{i,t} \;=\; \sum_{k} X_{i,k,t-1}\, F_{k,t} \;+\; \varepsilon_{i,t}, \qquad w_i = \sqrt{\text{market cap}_i}. \]
Solved as ridge-regularized weighted least squares:
w = np.sqrt(mc) # sqrt-cap weights (Barra USE3)
XtWX = X.T @ (w[:, None] * X)
XtWr = X.T @ (w * r)
F = np.linalg.solve(XtWX + ridge_lambda * np.eye(k), XtWr) # the K factor returns
residuals = r - X @ F # unweighted ε → idiosyncraticF is the vector of K factor returns for date t, written to ops.risk_factor_return (date, factor_id, factor_return, regression_r_squared, regression_n_obs). Cap-weighting makes the estimate approximate the return a real, cap-aware portfolio would have earned; the ridge term λ·I stabilizes the solve when exposures are collinear. Because X is X_{t-1}, the regression is point-in-time by construction.
The factor covariance Σ_F is then a rolling 252-day Ledoit–Wolf shrinkage of the factor-return panel [#23][#24] (factor_covariance_service.py), and the idiosyncratic variance D is a rolling 63-day residual variance with a 5th-percentile floor (idio_variance_service.py). Together (X, F, Σ_F, D) form the risk-model snapshot ops.risk_factor_* that portfolio construction and attribution read.
6.3.3 2.3 Construction B — long/short quantile spread
The alternative, common in academic factor libraries (Fama–French [#44][#112], AQR), is a portfolio spread return: sort the cross-section by the characteristic, go long the top quantile and short the bottom, and record the spread’s return each period. SBFoundation builds this as a top-decile-minus-bottom-decile, equal-weighted series (scripts/research/validate_diy_factors.py:117-188):
WITH factor_deciles AS ( -- cross-sectional NTILE(10) each formation date
SELECT date_sk, instrument_sk,
NTILE(10) OVER (PARTITION BY date_sk ORDER BY value) AS decile
FROM gold.fact_factor_value WHERE factor_id = ? AND value IS NOT NULL),
daily_returns AS ( -- close-to-close per instrument
SELECT e.instrument_sk, e.date_sk, d.full_date,
e.adj_close / LAG(e.adj_close) OVER (...) - 1.0 AS ret
FROM gold.fact_eod e JOIN gold.dim_date d ON ... AND d.is_us_market_day),
held AS ( -- ASOF: assign each day the most recent decile formed STRICTLY before it
SELECT dr.full_date, dr.ret, fd.decile FROM daily_returns dr
ASOF JOIN factor_deciles fd
ON dr.instrument_sk = fd.instrument_sk AND dr.date_sk > fd.date_sk),
legs AS (
SELECT full_date,
AVG(CASE WHEN decile=10 THEN ret END) AS long_ret,
AVG(CASE WHEN decile= 1 THEN ret END) AS short_ret
FROM held GROUP BY full_date)
SELECT full_date, long_ret - short_ret AS ls_ret FROM legs ORDER BY full_date;The ASOF JOIN … date_sk > enforces strict no-look-ahead (each trading day is scored by the most recent decile assignment formed before it). This series is what the platform cross-correlates against the Kenneth French and AQR published factor series to validate that its constructed factors behave like the academic ones (sbdiag.factor_series_correlation, tripwire SB_DIY_FACTOR_CORR_TRIPWIRE; F-316/F-318).
The two are not interchangeable. Construction A is the risk-model factor return (used for risk decomposition and attribution); Construction B is a validation/benchmark factor return (used to sanity-check construction against the literature). Keeping them separate is deliberate — see the summary table.
| Notion | Where | Method | Storage |
|---|---|---|---|
Risk-model factor return F_t |
sbriskmodel.cross_sectional_regression_service |
daily √cap WLS cross-sectional regression (ridge), X_{t-1} → r_t |
ops.risk_factor_return |
| Long/short quantile factor return | scripts/research/validate_diy_factors.py |
NTILE(10) top-minus-bottom decile, equal-weighted, ASOF-held | none (read-only report) |
6.4 3. The CAPM decomposition: r = α + β·market + ε
6.4.1 3.1 The model
The Capital Asset Pricing Model (Sharpe 1964 [#116], Lintner 1965 [#117], Mossin 1966 [#118], Treynor [#119]) states that in equilibrium the expected excess return of any asset is proportional to its market beta:
\[ \mathbb{E}[r_i] - r_f \;=\; \beta_i\,\big(\mathbb{E}[r_m] - r_f\big). \]
Its empirical, testable form is the time-series regression of realized excess returns:
\[ \underbrace{r_{i,t} - r_{f,t}}_{\text{asset excess return}} \;=\; \alpha_i \;+\; \beta_i\,\underbrace{(r_{m,t} - r_{f,t})}_{\text{market excess return}} \;+\; \varepsilon_{i,t}. \]
Writing market ≡ (r_m − r_f) and folding r_f into the left side, this is the requested form r = α + β·market + ε. Each term below is defined, then its estimator and its data source (FMP first, then free sources) are given.
6.4.2 3.2 Each term, and how to calculate it
r — the dependent variable (asset or portfolio excess return). The realized return of the thing being evaluated, in excess of the risk-free rate. For a single stock, r_{i,t} = adj_close_t / adj_close_{t-1} − 1. For a strategy, it is the daily NAV return of the book. In SBFoundation, the evaluated streams are NAV return series: ops.strategy_nav.daily_return (backtest books) and ops.signal_backtest_nav.ret_net (the walk-forward ML book) — see sbattribution/beta_attribution_service.py:340-378.
market — the market excess return (the single systematic factor). market_t = r_{m,t} − r_{f,t}, where r_m is the return of a broad market proxy. The choice of proxy is the subject of §7. In SBFoundation the proxy is SPY (the S&P 500 ETF), stored as a normalized NAV level in ops.benchmark_nav and differenced onto each stream’s own dates (attribution_kernel.py:136-149). The academic market factor mkt_rf (value-weighted CRSP-universe excess return) is additionally available from Kenneth French (§3.4, F-316).
β — the market loading (systematic sensitivity). The slope of r on market; β = Cov(r, market)/Var(market). β = 1 moves one-for-one with the market; β > 1 is amplified; β < 1 is defensive. Estimated by OLS (below). At the single-stock level the platform persists a 252-day rolling beta_f (§1.2); at the strategy level sbattribution estimates β per NAV stream.
α — the intercept (Jensen’s alpha, risk-adjusted excess return). α = 𝔼[r] − β·𝔼[market] — the average return not explained by market exposure (Jensen 1968 [#120]). A positive, statistically significant α is the empirical signature of skill (or of a missing risk factor). The platform’s central institutional question — does the book beat cheap beta? — is exactly “is α significantly positive after subtracting SPY (and MTUM, and FF5+UMD) exposure?” (sbattribution, F-294/IR-3). Alpha is reported both per-period (alpha_daily) and annualized by the stream’s own frequency — a monthly book is not inflated ×21 (settings.py:85-89).
ε — the residual (idiosyncratic / asset-specific return). ε_t = r_t − α − β·market_t — the part of the return orthogonal to the market. Its variance is the idiosyncratic (diversifiable) risk. See §4.
6.4.3 3.3 The estimator: OLS with Newey–West HAC errors
SBFoundation estimates α and β with ordinary least squares, but computes the standard errors with the Newey–West heteroskedasticity-and-autocorrelation-consistent (HAC) estimator [#123], because daily NAV returns are serially correlated and heteroskedastic — assuming i.i.d. Gaussian errors would understate the standard errors and overstate significance. The kernel is sbattribution.ols.ols_hac (src/sbattribution/ols.py:79-125):
- β by
np.linalg.lstsq; residualse = y − Xβ. - HAC covariance with Bartlett weights: \[ V = (X^\top X)^{-1}\Big( S_0 + \sum_{l=1}^{L} w_l\,(S_l + S_l^\top) \Big)(X^\top X)^{-1}, \quad w_l = 1 - \tfrac{l}{L+1}, \quad S_l = \sum_t e_t e_{t-l}\, x_t x_{t-l}^\top. \]
- Lag truncation
L = ⌊4·(n/100)^{2/9}⌋(Newey–West 1994 rule of thumb). - SE
= sqrt(diag(V)); t-stat= β/SE; p-value= erfc(|t|/√2)(a two-sided normal approximation, to avoid a hard scipy dependency); R²= 1 − SS_res/SS_tot.
The design matrix is [1, market] for the single-factor CAPM, extended to [1, r_SPY, r_MTUM] for the two-factor spanning regression the platform runs by default, and to [1, mkt_rf, smb, hml, rmw, cma, umd] for the FF5+UMD basis (§3.4). A |t| < 2 α is greyed out on the SPA as “≈ beta” — indistinguishable from cheap market exposure.
6.4.4 3.4 The data — FMP first, then free sources
This is the provenance the calculations actually use, ordered as requested (paid FMP first, then free, cheapest-relevant-source-first for anything FMP does not cover).
| Term | Primary data (FMP, paid) | Free / non-FMP data | Where it lands |
|---|---|---|---|
r (stock returns) |
FMP EOD bulk prices eod-bulk-price → silver.fmp_eod_bulk_price → gold.fact_eod.adj_close; split+dividend-adjusted via historical-price-eod/dividend-adjusted (the adj_close correction, B-147.6) |
— | gold.fact_eod |
r (strategy NAV) |
derived from FMP prices through the backtest | — | ops.strategy_nav, ops.signal_backtest_nav |
market (proxy return) |
FMP SPY (and MTUM) daily closes from silver.fmp_eod_bulk_price, normalized to NAV |
Kenneth French value-weighted market excess mkt_rf (free, Dartmouth) → silver.kenneth_french_factors |
ops.benchmark_nav; French table |
r_f (risk-free) |
— | FRED short-end T-bill fred-dgs1mo/fred-dgs3mo (free) → silver.fred_dgs1mo/3mo; French rf column (free) |
silver.fred_*, silver.kenneth_french_factors |
β, α (estimates) |
computed by sbattribution.ols_hac over the above |
FF5+UMD basis from French (free) for the richer spanning regression | beta_attribution_<run_id>.json |
Two honest caveats the platform documents:
- The risk-free rate is currently a benchmark-relative simplification. SBFoundation’s default “excess return” is excess of benchmark (SPY / MTUM / French
rf), not excess of a resolved short-end RF. FRED short-end tenors were ingested (F-317) precisely so the first excess-return-vs-RF consumer can wire the correct tenor; today no such consumer resolves an RF, and Sharpe/Sortino are computed withRF = 0(signal_backtest_stats.py). The only place a FRED yield is used as a rate today is WACC/cost-of-equity, which correctly uses the 10-yearfred-dgs10. - SPY vs. the academic market. SPY (S&P 500, ~500 large caps) is the investable proxy; the French
mkt_rf(the entire CRSP universe, value-weighted) is the academic proxy. They differ in small-cap coverage; F-316 added the French basis so attribution is not hostage to a single thin two-ETF basis.
6.5 4. Systematic vs. idiosyncratic returns
6.5.1 4.1 The decomposition
Given the factor model, every asset return splits into two orthogonal parts:
\[ r_{i,t} \;=\; \underbrace{\sum_k \beta_{i,k} f_{k,t}}_{\text{systematic (common)}} \;+\; \underbrace{\varepsilon_{i,t}}_{\text{idiosyncratic (specific)}}. \]
- Systematic return is the part explained by common factors — market, size, value, momentum, sector. It is undiversifiable: every asset shares it, so holding more names does not remove it. In equilibrium theory, only systematic risk is compensated with expected return.
- Idiosyncratic (specific) return is the residual
ε— the part unique to the asset (a product recall, an earnings surprise, a lawsuit). It is diversifiable: in a large portfolio theε’s partly cancel, so specific variance falls roughly as1/N.
The variance decomposition is Var(r_i) = β_i^T Σ_F β_i + Var(ε_i); the systematic share is the regression R², and 1 − R² is the idiosyncratic share.
6.5.2 4.2 How it is computed here
Three residualizations coexist, each answering a different question:
- Risk-model specific return (the canonical one). The daily cross-sectional regression (§2.2) returns
residuals = r − X·F̂; theseε_{i,t}are persisted toops.risk_residualand rolled intoresidual_var_63d(the specific varianceD) byIdiosyncraticVarianceService. The regression’s cap-weightedR²is the systematic-variance share (cross_sectional_regression_service.py:163-170). - Market-model residual for crowding analysis.
sbcrowdingcomputesresidual_return = stock_return − beta_f · spy_returnin SQL, reusing the persistedbeta_f(comomentum_crowding_service.py:223); abnormal correlation among these residuals within a momentum decile is the Lou–Polk [#74] comomentum crowding metric. - Residual momentum. A factor in its own right: momentum computed on the market-model residual return (regressed against an equal-weighted market proxy), so the trend signal is neutral to the market beta (Blitz–Huij–Martens [#50];
eod_feature_service.pyPass 3b →residual_momentum_252d_f).
Why it matters for the platform’s thesis. The whole point of
sbattribution(§3) is to ask whether a strategy’s return is systematic (cheap beta you could buy with an ETF) or genuinely idiosyncratic alpha. A book whose return is fully spanned by SPY+MTUM hasα ≈ 0,εsmall: it is repackaged beta. Persistent, significantαwith material residual variance is the only thing that justifies active fees.
6.6 5. From factors to buy/sell signals
A factor is a forecast of the cross-section of returns; a signal is the decision that forecast implies; a strategy is (factor_id, mechanic, params) — one forecast wired to a construction rule (config/strategies/<name>.yaml). The path from factor value to order is:
1. Score the cross-section. Each name gets a factor value gold.fact_factor_value.value (e.g. its momentum score), or an ML signal score fact_signal_score from a model trained on many factors (sbsignals.MLSignalService).
2. Rank and select (the mechanic). The mechanic turns scores into weights: * long_only_top_n — sort by score, buy the top N equal-weighted (e.g. clinic06-benchmark: top-20, equal-weight, monthly). Higher score → buy; falling out of the top-N → sell. * long_short / dollar-neutral — long the top quantile, short the bottom, long_ret − short_ret (the same spread as §2.3). * long_flat_trend — hold each name whose own time-series trend is positive, else flat (the TSMOM ETF sleeve, F-283). The sign convention matters: a factor with direction: negative (e.g. pe_ratio, volatility_30d) predicts high returns for low values, so the mechanic ranks ascending.
3. Validate the forecast (does the signal predict?). The information coefficient measures predictive power: the per-date Spearman rank correlation between factor value and forward return, aggregated to an IC-IR = mean(IC)/σ(IC) (sbic, ic_service.py:1297-1367). A factor only earns promotion to back a live strategy after clearing IC, permutation-significance (MCPT/HLZ), orthogonality, and overfitting gates — the lifecycle in sbpromotion.
4. Damp turnover (hysteresis). Re-ranking daily against a noisy signal churns the book. A hysteresis band only trades a name out once its rank drifts past a buffer, trading turnover (and cost) against tracking of the ideal book (sbportfolio.hysteresis; calibrated in F-310).
5. Size and route. Construction applies executability floors (price ≥ $5, $1M ADV — F-304), leverage/position caps, and optional vol-targeting, producing the target book; the settlement watch submits orders through gate checks (decay, PBO/DSR, plausibility, margin — F-307).
So “buy/sell” is not a threshold on one number; it is: score → rank → select-per-mechanic → (only if the factor has proven IC) → band → size → route. The factor supplies the ranking; the mechanic and gates supply the decision.
6.7 6. Worked example — vol-scaled 12-1 momentum
The factor. config/factors/momentum_12_1_vol_scaled.yaml — source_column: momentum_12_1_vol_scaled_f, source_table: fact_eod, style: momentum, expected_sign: positive, hypothesis_class: behavioral. Economic story: investors under-react to information, so past 12-month winners keep winning over the next month (Jegadeesh–Titman 1993 [#49]); scaling by realized volatility targets the same edge while damping the crash-prone tail (Barroso–Santa-Clara 2015 [#90], Daniel–Moskowitz 2016 [#21]).
The market data it consumes. Only gold.fact_eod.adj_close (split- and dividend-adjusted daily closes, sourced from FMP eod-bulk-price / historical-price-eod/dividend-adjusted). From that one column:
- Daily log returns:
g_t = ln(adj_close_t / adj_close_{t-1}). - The 12-1 trend (the “aspect” being measured): cumulative return from ~12 months ago to ~1 month ago — the window
[t−252, t−21]— skipping the most recent ~21 days to avoid the well-documented short-term reversal: \[ M_{i,t} \;=\; \prod_{s=t-252}^{t-21}\big(1 + \text{ret}_{i,s}\big) - 1. \] - Volatility scaling: divide the trend by the name’s trailing realized volatility (annualized σ of daily returns), so a unit of signal carries a comparable risk contribution across names: \[ \text{momentum\_12\_1\_vol\_scaled}_{i,t} \;=\; \frac{M_{i,t}}{\sigma_{i,t}}. \]
All of this is evaluated in DuckDB windowed SQL inside EodFeatureService (the same engine that produces the beta_f slope shown verbatim in §1.2), never in Python — per the platform’s “feature math in SQL” constraint.
From value to loading to signal. The wide momentum_12_1_vol_scaled_f column is lifted narrow into gold.fact_factor_value (FactorValueLiftService). From there: (a) the risk model standardizes it cross-sectionally into an exposure and prices it into a factor return (§1–§2); (b) sbic measures its IC-IR at horizon 21d to confirm it predicts; (c) a long_only_top_n mechanic turns the ranked scores into a buy list (top-20). The identical value also underlies the funnel-exempt tsmom_12_1_vol_scaled factor, where the selection differs (own-trend sign, not cross-sectional rank) — a clean illustration that a factor’s value and its use are separable.
6.8 7. Choosing a market benchmark
6.8.1 7.1 What a benchmark is for
The benchmark plays three distinct roles, and the “right” choice depends on which one you mean:
- The market factor in an asset-pricing model — the
marketterm in §3. Theory (CAPM) wants the true market portfolio: the cap-weighted portfolio of all risky assets. - The performance yardstick — what a strategy’s return is compared against to judge skill (α) and to compute active return / information ratio.
- The risk-neutral reference — the exposure a long-only book is implicitly benchmarked to when measuring active bets.
6.8.2 7.2 The theoretical problem: Roll’s critique
The true market portfolio is unobservable (it includes human capital, private assets, real estate, foreign equities). Roll (1977) [#124] showed that every empirical CAPM test is therefore a joint test of the model and of the benchmark proxy: a “failed” CAPM might just mean the proxy is inefficient. There is no way around this — only mitigation. It is why the platform reports attribution against multiple bases (SPY, then SPY+MTUM, then FF5+UMD): no single proxy is privileged, so robustness is shown across proxies.
6.8.3 7.3 The practical criteria
A usable benchmark should be, per the CFA/index-industry consensus and Grinold–Kahn:
- Unambiguous & investable — known constituents you could actually hold (SPY, not “the market”);
- Cap-weighted & broad — reflects the aggregate opportunity set and is low-turnover;
- Measurable, with priced total returns — dividends reinvested;
- Specified in advance — chosen before the evaluation period, not fitted to flatter the strategy;
- Appropriate to the strategy’s universe — a US large-cap momentum book is measured against a US large-cap index, not a global aggregate.
6.8.4 7.4 How SBFoundation chooses
- Market proxy = SPY. Broad, liquid, cap-weighted, investable, dividend-adjusted, near-zero cost to replicate — it satisfies §7.3 for a US-equity platform. It is the
marketterm in attribution and the dashed line on every equity-curve overlay. - Factor references = MTUM (+ the FF5+UMD basis). Because the platform trades momentum, a pure market proxy is not enough to prove skill — a momentum book should be measured against a momentum benchmark.
MTUM(MSCI USA Momentum ETF) is the investable one; the Kenneth Frenchumd/hml/rmw/… series are the academic ones. This is exactly the “does it beat cheap factor beta, not just market beta?” test. - A platform yardstick =
clinic06-benchmark. A long-only, top-20, equal-weight, monthly, vol-scaled-momentum replica run through the real pipeline (config/strategies/clinic06-benchmark.yaml). It is not a tradable alpha; where it diverges from the external reference, the divergence is the measurement of a platform behaviour (PIT-clean universe, broker-true costs) — a benchmark for the machinery, not for the market. - Categories are enforced. Each configured benchmark carries a
category ∈ {core_equity, bonds_volatility, sector_etf}(benchmark_metain the strategy YAML), so overlays can group like-for-like.
6.9 8. Academic studies on benchmark construction, and the calculation process
6.9.1 8.1 The literature
| Theme | Study | What it establishes |
|---|---|---|
| The market portfolio / CAPM benchmark | Sharpe (1964) [#116], Lintner (1965) [#117], Mossin (1966) [#118] | Equilibrium prices risk relative to the cap-weighted market portfolio — the benchmark is the market. |
| Benchmark unobservability | Roll (1977) [#124] | The true market is unobservable; every benchmark is a proxy, so tests are joint tests of model + proxy. |
| Zero-beta / flat SML | Black (1972) [#121] | Without riskless borrowing the benchmark-relative security market line is flatter than CAPM — motivates Betting-Against-Beta [#93]. |
| Multifactor benchmarks | Fama–French (1992/1993) [#55][#44][#112], Carhart (1997) [#57], Fama–French (2015) [#56] | The benchmark is multidimensional (market + size + value + profitability + investment + momentum); defines how the SMB/HML/RMW/CMA/UMD factor-benchmark portfolios are built. |
| Policy benchmark dominance | Brinson–Hood–Beebower (1986) [#70], Brinson–Fachler (1985) [#27] | The benchmark (asset-allocation policy) explains the bulk of return variation — attribution is defined relative to it. |
| Active-management benchmark theory | Grinold (1989) [#25], Grinold–Kahn (2000) | Formalizes active return / active risk / information ratio against a benchmark; the Fundamental Law. |
| Naive vs. optimized benchmark | DeMiguel–Garlappi–Uppal (2009) [#60] | 1/N is a hard benchmark to beat out-of-sample — a cautionary reference for “smart” benchmarks. |
6.9.2 8.2 The benchmark-calculation process (cap-weighted total-return index)
The canonical construction the literature and index providers use, and the one SPY/the S&P 500 follows:
- Define the universe & selection rules — eligibility (listing, liquidity, float, domicile). For factor benchmarks (French/AQR), rank the universe on the characteristic and take quantile breakpoints.
- Weight the constituents — float-adjusted market-capitalization weight
w_i = (price_i · float_shares_i) / Σ_j (price_j · float_shares_j). Factor benchmarks are typically equal- or value-weighted within the long and short legs (French uses value-weighted 2×3 sorts). - Compute the index return —
R_t = Σ_i w_{i,t-1} · r_{i,t}with total returnr_{i,t}(dividends reinvested). This is precisely the weighted-average-of-constituent-returns thatbenchmark_nav.pyreproduces by normalizing an ETF’s total-return NAV to 1.0. - Reconstitute & rebalance on a schedule (quarterly/annual), applying buffering to limit turnover — the analogue of the platform’s hysteresis.
- Excess-return form — for asset-pricing use, subtract the risk-free rate:
mkt_rf = R_m − r_f(French subtracts the 1-month T-bill; the platform’s FRED short-end tenors, F-317, are the resolved-RF equivalent).
SBFoundation does not re-derive an index from constituents; it consumes a provider’s already-computed total-return series (SPY/MTUM ETF closes) and normalizes it to a NAV level — the pragmatic equivalent of steps 2–3 above (benchmark_nav.py:1-27).
6.10 9. Data sources for benchmarks (FMP first, then free)
| Rank | Source | Cost | Series | How it enters the platform |
|---|---|---|---|---|
| 1 | FMP | Paid (primary vendor) | SPY, MTUM, QQQ, IWM, sector SPDRs, bond/vol ETFs (18 total) — daily closes | eod-bulk-price → silver.fmp_eod_bulk_price → normalized → ops.benchmark_nav |
| 2 | Kenneth French Data Library (Dartmouth) | Free | mkt_rf (academic market), smb, hml, rmw, cma, umd, rf — daily & monthly |
sbops.KennethFrenchFactorService (stdlib urllib/zipfile) → silver.kenneth_french_factors (F-316) |
| 3 | FRED (St. Louis Fed) | Free | Risk-free tenors DGS1MO/DGS3MO (short-end), DGS10 (WACC), plus term/credit/vol spreads |
keymap fred-* → silver.fred_* (F-317) |
| 4 | AQR Data Library | Free (manual) | Published QMJ, BAB, Value, TSMOM monthly factor returns |
committed CSV data/reference/aqr/aqr_factor_returns.csv (F-318 reference shelf) |
Guidance: for an investable benchmark and total-return NAVs, FMP ETF closes are primary. For an academic market/factor benchmark (and the risk-free rate), the free Kenneth French and FRED series are preferred over any paid equivalent — they are the field’s ground truth and cost nothing. AQR provides an independent free cross-check on constructed factors. The platform deliberately holds all three free series so its attribution and factor-validation are not captive to a single paid vendor.
6.11 10. A canonical factor catalogue (economic justification + style)
Thirteen well-established factors, each with its economic rationale, its style tag (the platform’s own taxonomy), the anchoring citation, and the SBFoundation factor_id that implements it. Styles in use across config/factors/*.yaml: momentum, value, quality, volatility, growth, liquidity, positioning, size, beta.
| # | Factor | Style | Economic justification (why a premium exists) | Anchor citation | Platform factor_id |
|---|---|---|---|---|---|
| 1 | Market | beta |
Compensation for bearing undiversifiable market risk — the one factor everyone must hold; the CAPM premium. | Sharpe 1964 [#116], Lintner 1965 [#117] | market_beta_252d (beta_f) |
| 2 | Size | size |
Small caps earn a premium for illiquidity, distress, and limited analyst coverage; partly a compensation, partly a limits-to-arbitrage effect. | Banz 1981 [#125], Fama–French 1992 [#55] | market_cap |
| 3 | Value | value |
Cheap (high E/P, B/M, FCF-yield) stocks out-earn expensive ones — risk premium for distressed/low-growth firms and mispricing from extrapolation. | Fama–French 1992/1993 [#55][#44] | earnings_yield, pe_ratio, value_composite, pb_ratio, ps_ratio, pfcf_ratio |
| 4 | Momentum | momentum |
Past 12-1 winners keep winning — under-reaction to news and delayed information diffusion (behavioral). | Jegadeesh–Titman 1993 [#49], Carhart 1997 [#57] | momentum_12m_1m, momentum_12_1_vol_scaled |
| 5 | Profitability / Quality | quality |
Profitable, safe, well-managed firms (high gross-profitability, ROIC, QMJ) out-earn junk — a premium for quality that markets under-price. | Novy-Marx 2013 [#92], Fama–French 2015 [#56], Asness-Frazzini-Pedersen 2019 [#113] | qmj_composite, roic_spread, gross_profitability, piotroski |
| 6 | Investment / Accruals | quality/growth |
Firms that invest conservatively and have low accruals out-earn aggressive investors — over-investment and earnings-quality mispricing. | Sloan 1996 [#83], Cooper-Gulen-Schill 2008 [#103], Fama–French 2015 [#56] | accruals, earnings_growth_qoq |
| 7 | Low volatility / Defensive | volatility |
Low-risk stocks earn higher risk-adjusted returns — leverage constraints and lottery-preference bid up high-vol names (the low-vol anomaly / BAB). | Ang et al. 2006 [#108], Frazzini–Pedersen 2014 [#93], Novy-Marx 2014 [#10] | volatility_30d |
| 8 | Liquidity / Illiquidity | liquidity |
Illiquid stocks (high Amihud price-impact, low $ADV) require a return premium for the cost and risk of trading them. | Amihud 2002 [#48] | amihud, adv_dollar_20d |
| 9 | Short interest / Positioning | positioning |
Heavily-shorted / high-days-to-cover names underperform — informed short sellers and short-sale-constraint overvaluation (divergence of opinion). | Boehmer-Jones-Zhang 2008 [#101], Rapach-Ringgenberg-Zhou 2016 [#100], Miller 1977 [#111] | days_to_cover, short_pct_float |
| 10 | Seasonality | momentum |
Same-calendar-month historical returns recur — persistent cross-sectional seasonalities from mood, liquidity, and information cycles. | Heston–Sadka 2008 [#37], Hirshleifer et al. 2020 [#40] | seasonality_same_month |
| 11 | Earnings momentum / PEAD | growth |
Prices under-react to earnings surprises (SUE), drifting for weeks after the announcement — the post-earnings-announcement drift. | Bernard–Thomas 1989 [#75] | sue |
| 12 | Time-series momentum / Trend | momentum |
An asset’s own past 12-1 return predicts its next-month return across asset classes — trend-following / slow-diffusion premium. | Moskowitz-Ooi-Pedersen 2012 [#115], Hurst-Ooi-Pedersen 2017 [#95] | tsmom_12_1_vol_scaled |
| 13 | Short-horizon reversal | volatility |
Very-short-horizon losers bounce (and winners fade) — liquidity provision / over-reaction correction (contrarian). | Lehmann 1990 [#96] | bb_pct |
Caveats the platform enforces. A factor’s premium may be risk compensation or mispricing; the
economic_rationale.hypothesis_classfield (risk_premium | behavioral | structural | data_mining) records which, and adata_miningstory is refused at the gate. Premia also decay after publication (McLean–Pontiff 2016 [#14]), so IC and significance are re-measured every run, not assumed. And the factor zoo is largely redundant (Feng–Giglio–Xiu 2020 [#13], Jensen–Kelly–Pedersen 2023 [#98]) — SBFoundation collapses near-duplicate factors to a single effective trial before counting significance (LH-6y / F-295).
6.12 11. References
All citations resolve to docs/reference-papers/README.md by the bracketed row number. The CAPM/estimation foundations (Sharpe, Lintner, Mossin, Treynor, Jensen, Black, Fama–MacBeth, Newey–West, Roll, Banz) are catalogued there under §16, added alongside this note. Foundational, benchmark, factor-zoo, and per-factor sources are catalogued under §§1–15.
Primary code touchpoints (for the reader who wants to trace any claim to source):
- Loadings / exposures —
src/sbriskmodel/factor_exposure_service.py;src/sbfactors/eod_feature_service.py:364-431(beta_f). - Factor returns —
src/sbriskmodel/cross_sectional_regression_service.py;scripts/research/validate_diy_factors.py(L/S spread). - CAPM / attribution —
src/sbattribution/ols.py,beta_attribution_service.py;src/sborchestration/tasks/benchmark_nav.py. - Systematic/idiosyncratic —
ops.risk_residual,IdiosyncraticVarianceService;src/sbcrowding/services/comomentum_crowding_service.py. - Factors & signals —
config/factors/*.yaml,src/sbcontracts/settings.py(ACTIVE_FACTOR_IDS);src/sbic/services/ic_service.py;src/sbsignals/,src/sbportfolio/. - Free reference data —
src/sbops/kenneth_french_service.py,silver.kenneth_french_factors,silver.fred_*,data/reference/aqr/.