Skip to content

Style and Factor Exposures ​

Part 1 examined portfolio returns, risk, allocation constraints, and liabilities. We now ask a different question: what exposures help explain a portfolio's returns? A portfolio can outperform a broad index because it takes different risks, rather than because its manager selects better securities.

We begin with factor models and a hand-checkable CAPM calculation. We then estimate Berkshire Hathaway's exposures, distinguish factor regression from constrained style replication, and investigate changes in estimated style and portfolio weighting. These are models of returns; they do not reveal a portfolio's actual holdings.

Run the examples in order. Returns are decimals, and the historical regressions use monthly observations. This setup provides the shared numerical tools and chart output:

python
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok

Analysis of Factor Investing ​

A factor summarizes a common source of return variation. An exposure, or loading, measures how a portfolio's return responds to that factor in a specified model. A factor premium is an expected reward for exposure, not the factor's realized return in every period. Neither a positive historical average nor an economic risk story guarantees a positive future premium.

Categories of Factors ​

  • Macroeconomic factors describe economic conditions or innovations: inflation surprises, growth shocks, or changes in interest rates. An oil-price change can benefit producers and hurt consumers, so exposures can have opposite signs.
  • Statistical factors summarize shared variation extracted from data, for example principal components. Their economic interpretation is a separate question.
  • Style factors are associated with characteristics such as market capitalization, book-to-market, momentum, or profitability. They are often represented by the return spread between portfolios sorted on those characteristics.

A macroeconomic measurement need not be a traded return. When we use a tradable factor portfolio, its return has a direct investment interpretation; otherwise, the units of the exposure depend on the units of the explanatory variable.

Factor Models ​

Let Ri,t be a portfolio's simple return and Rf,t the matched cash return. A time-series factor model is:

Ri,t−Rf,t=αi+∑k=1Kβi,kFk,t+εi,t.

Here Fk,t is a realized factor observation, βi,k its loading, αi an intercept, and εi,t the unexplained return. All observations must refer to the same period. For return factors expressed as decimals, the loadings are dimensionless and alpha is a return per observation period.

Under the model's zero-mean residual condition, taking expectations gives:

E[Ri−Rf]=αi+∑k=1Kβi,kE[Fk].

This separates realized attribution from expected premiums. A fitted model is descriptive; using it to forecast requires assumptions about future exposures and factor expectations. An equilibrium pricing model imposes additional restrictions, such as zero alpha, which an arbitrary regression does not establish.

The Capital Asset Pricing Model (CAPM) ​

CAPM is a single-period equilibrium model with a common risk-free opportunity, mean–variance investors, homogeneous beliefs, and frictionless trading under its standard assumptions. It prices exposure to the aggregate market portfolio. Its Security Market Line (SML) is:

E[Ri]=rf+βi(E[RM]−rf),βi=Cov(Ri,RM)Var(RM).

The risk-free return rf is known for this investment horizon, and market variance is positive. The SML relates expected return to beta. It differs from the capital allocation lines in Part 1, which relate expected return to total volatility for mixtures of cash and a risky fund.

Beta Is Not Correlation ​

For positive volatilities:

βi=ρi,MσiσM.

Correlation measures standardized co-movement; beta also reflects the asset's volatility relative to the market. An asset with correlation 0.6 and twice market volatility has beta 1.2. An asset with correlation 0.3 and four times market volatility has the same beta. Their idiosyncratic risk can be very different.

Hand check. If the one-year cash return is 2%, expected market return is 8%, and beta is 1.2, CAPM implies 2%+1.2(8%−2%)=9.2%. This is a model-implied expected return, not a promised outcome.

python
cash_return, expected_market_return = 0.02, 0.08
market_volatility = 0.15
asset_volatilities = np.array([0.30, 0.60])
correlations = np.array([0.60, 0.30])
betas = correlations * asset_volatilities / market_volatility
capm_expected_returns = cash_return + betas * (expected_market_return - cash_return)
print(pd.DataFrame({
    "Correlation": correlations,
    "Beta": betas,
    "CAPM expected return (%)": capm_expected_returns * 100,
}, index=["Asset A", "Asset B"]).to_string(float_format=lambda x: f"{x:.3f}"))

Try it: halve Asset B's correlation while retaining its volatility. Its beta falls to 0.6 and its model-implied expected return to 5.6%. A historical arithmetic mean can estimate an expected market return, but its sampling uncertainty remains substantial.

Estimating a Market Regression ​

For historical observations, define yt=Ri,t−Rf,t and xt=RM,t−Rf,t. Estimate:

yt=α+βxt+εt,minα,β∑t(yt−α−βxt)2.

The first-order conditions set the residual sum and its cross-product with x to zero. Subtracting the sample means gives:

β^=∑t(xt−x¯)(yt−y¯)∑t(xt−x¯)2,α^=y¯−β^x¯.

When cash varies across historical months, use the matched excess-return series in this formula. Beta estimated from raw returns need not be identical.

Consider four monthly market excess returns: −3%, −1%, 1%, and 3%. Construct asset excess returns as 0.1%+1.2xt+εt, with residuals 0.2%, −0.2%, −0.2%, and 0.2%. The residuals sum to zero and have zero cross-product with x, so OLS must recover alpha 0.1% and beta 1.2 exactly.

python
market_excess = pd.Series([-0.03, -0.01, 0.01, 0.03], name="Market")
residuals = np.array([0.002, -0.002, -0.002, 0.002])
asset_excess = 0.001 + 1.2 * market_excess + residuals
beta_by_hand = market_excess.cov(asset_excess) / market_excess.var()
alpha_by_hand = asset_excess.mean() - beta_by_hand * market_excess.mean()
worked_fit = pok.linear_regression(asset_excess, market_excess.to_frame())
print(f"Hand calculation: beta {beta_by_hand:.4f}; monthly alpha {alpha_by_hand:.2%}")
print(worked_fit.params.to_string(float_format=lambda x: f"{x:.4f}"))
print(f"Residual sum: {worked_fit.resid.sum():.2e}")

Four artificial observations verify the algebra, not statistical reliability. In a real sample, alpha, beta, and their uncertainty depend on the sample window, explanatory factors, and assumptions about the errors.

Value, Growth, and the Fama–French Factors ​

Price-to-Book and Book-to-Market ​

For a firm with positive common book equity, market-to-book compares the market value of common equity with its accounting book value:

P/B=market capitalizationcommon book equity,B/P=1P/B.

Book equity is an accounting residual after liabilities and relevant non-common-equity claims; it is not the book value of one asset. For market capitalization of $200 million and common book equity of $100 million, P/B=2 and B/P=0.5.

Value stocks have relatively high book-to-market ratios; growth stocks have relatively low ratios within a stated sorting universe. These classifications do not prove undervaluation or overvaluation. Differences can reflect growth expectations, profitability, distress, accounting practices, or intangible assets. There is no universal 15% ROE threshold defining growth stocks, and negative book equity requires separate treatment.

Fama–French Three-Factor Model ​

The three-factor time-series specification adds two long–short return spreads to the market excess return:

Ri,t−Rf,t=αi+βi,M(RM,t−Rf,t)+βi,SSMBt+βi,HHMLt+εi,t.

In the standard US construction, stocks are assigned to six value-weighted portfolios from two size groups (Small, Big) and three book-to-market groups (Low, Neutral, High). The sorts use NYSE-based breakpoints; these factors are not simply bottom-decile minus top-decile returns. With RS,H denoting the small/high portfolio return:

SMB=RS,L+RS,N+RS,H3−RB,L+RB,N+RB,H3,HML=RS,H+RB,H2−RS,L+RB,L2.

SMB means Small Minus Big; HML means High Minus Low book-to-market. Both are return spreads whose component weights sum to zero. Do not subtract cash again from these supplied spreads or from the supplied Mkt-RF column. Cash is subtracted from the dependent asset's total return.

Hand check. Suppose the six portfolios return 1%, 2%, 4% for Small and 0%, 1%, 3% for Big in one month. SMB is 1%; HML is 3%. For market excess return 2%, loadings (0.8,−0.2,0.4), and cash return 0.1%, the zero-alpha factor benchmark return is 0.1%+0.8(2%)−0.2(1%)+0.4(3%)=2.7%.

python
small = np.array([0.01, 0.02, 0.04])
big = np.array([0.00, 0.01, 0.03])
smb = small.mean() - big.mean()
hml = (small[2] + big[2] - small[0] - big[0]) / 2
factor_benchmark = 0.001 + 0.8 * 0.02 - 0.2 * smb + 0.4 * hml
print(f"SMB: {smb:.2%}; HML: {hml:.2%}")
print(f"One-month factor benchmark: {factor_benchmark:.2%}")

The Carhart four-factor model adds a momentum spread. A common construction ranks past performance over months 2 through 12 before the holding month, omitting the most recent month. The exact sorting, weighting, and rebalancing rules must accompany a chosen dataset. Momentum is not a guarantee that recent winners continue winning; we do not estimate that fourth factor from this three-factor file.

Factor definitions and formation rules are documented in the Kenneth French Data Library. Our bundled CSV is a fixed historical snapshot, not a live update.

Factor Benchmarks and the Meaning of Alpha ​

For the market model, the zero-alpha benchmark return is:

RB,t=Rf,t+β(RM,t−Rf,t)=βRM,t+(1−β)Rf,t.

For each dollar of equity, beta 1.3 means $1.30 of market exposure financed with $0.30 cash borrowing. Beta 0.55 means $0.55 in the market and $0.45 in cash. This interpretation assumes the stated financing rate and feasible positions. Multifactor long–short spreads add zero-net-investment exposures; their loadings are not ordinary long-only allocation weights.

With a constant exposure estimated in-sample, OLS with an intercept makes the sample mean of asset return minus benchmark return equal to fitted alpha. It does not make every month's difference equal to alpha. An estimated positive alpha can reflect skill, omitted risks, chance, or measurement choices; it is not proof of manager value added. Berkshire is also a company return, not a fee-adjusted mutual-fund mandate.

Monthly alpha multiplied by 12 is an annualized arithmetic intercept. Compounding it as (1+α)12−1 would describe a hypothetical constant return process, not the observed annual excess performance of the asset over its benchmark.

Factor Analysis of Warren Buffett's Berkshire Hathaway ​

Load and Align the Historical Observations ​

The bundled Berkshire file contains daily decimal returns from January 1990 through December 2018. Compound the observed daily returns within each calendar month:

Ri,m=∏d∈m(1+Ri,d)−1.

The factor CSV contains monthly percent observations from July 1926 through December 2018. Its loader divides by 100 and creates a monthly PeriodIndex. The Berkshire loader creates the same index when aggregating. It rejects missing observations and empty calendar months; neither loader reconstructs missing trading days or establishes the original vendor's adjustment conventions from the CSV alone.

python
brka_monthly = pok.get_brka_rets(monthly=True)["BRKA"]
factors_monthly = pok.get_fff_returns()
print(f"Berkshire: {brka_monthly.index[0]} to {brka_monthly.index[-1]}, {len(brka_monthly)} months")
print(f"Factors: {factors_monthly.index[0]} to {factors_monthly.index[-1]}, {len(factors_monthly)} months")
print(pd.concat([brka_monthly, factors_monthly], axis=1, join="inner").head().to_string())

For continuity with the original case study, start with January 1990–May 2015, 305 months. Join by month labels before subtraction or fitting. Subtracting raw .values from independently sliced frames can silently pair different dates if one series has a gap.

python
sample_start, sample_end = "1990-01", "2015-05"
factor_sample = pd.concat([brka_monthly.rename("BRKA"), factors_monthly], axis=1, join="inner")
factor_sample = factor_sample.loc[sample_start:sample_end]
print(f"Aligned observations: {len(factor_sample)}")
print("Missing values by column:")
print(factor_sample.isna().sum().to_string())
brka_excess = factor_sample["BRKA"] - factor_sample["RF"]

An inner join chooses common dates; it does not prove the history has no gaps. Inspect the coverage and missing counts. The regression helper rejects incomplete observations rather than silently selecting a different sample for each model.

CAPM Regression and Statistical Uncertainty ​

Fit the market regression to monthly excess returns. The helper adds an Alpha intercept and preserves coefficient names. Default OLS standard errors assume homoskedastic, serially uncorrelated errors. Below we also report heteroskedasticity and autocorrelation consistent (HAC) standard errors with six monthly lags, a finite-sample correction, and asymptotic normal reference inference. HAC changes estimated uncertainty, not the fitted coefficients; the lag choice is an assumption to examine.

python
capm_fit = pok.linear_regression(brka_excess, factor_sample[["Mkt-RF"]])
hac_lags = 6
capm_hac = capm_fit.get_robustcov_results(cov_type="HAC", maxlags=hac_lags,
                                       use_correction=True, use_t=False)
capm_table = pd.DataFrame({
    "Coefficient": capm_fit.params,
    "OLS standard error": capm_fit.bse,
    "HAC standard error": capm_hac.bse,
    "HAC p-value": capm_hac.pvalues,
})
print(capm_table.to_string(float_format=lambda x: f"{x:.5f}"))
print(f"Monthly alpha: {capm_fit.params['Alpha']:.3%}")
print(f"12 × monthly alpha: {12 * capm_fit.params['Alpha']:.3%}")
print(f"In-sample R-squared: {capm_fit.rsquared:.3f}")

Interpret beta as sensitivity per unit of market excess return. Interpret alpha in monthly return units. The p-value concerns the zero-coefficient null under the specified inference assumptions; it is not the probability that a manager has skill. A confidence interval and the size of the estimated return are more informative than a significance label alone.

For this snapshot and window, market beta is approximately 0.555, monthly alpha 0.634%, and R2 0.163. The market-only benchmark therefore combines about 55.5% market exposure with 44.5% cash. Its modest explanatory power leaves substantial monthly variation outside the market factor.

Try it: change hac_lags to 3 or 12 and rerun this cell. The coefficient estimates stay fixed while their estimated uncertainty changes.

Add Size and Value Exposures ​

Use the same dependent returns and months for the three-factor model. A negative SMB loading is a large-company tilt relative to this factor definition; a positive HML loading is a value tilt. These are partial regression loadings conditional on the other factors, not the fractions of Berkshire's accounting assets invested in those categories.

python
factor_names = ["Mkt-RF", "SMB", "HML"]
ff_fit = pok.linear_regression(brka_excess, factor_sample[factor_names])
ff_hac = ff_fit.get_robustcov_results(cov_type="HAC", maxlags=hac_lags,
                                   use_correction=True, use_t=False)
ff_intervals = pd.DataFrame(ff_hac.conf_int(), index=ff_fit.params.index, columns=["Lower 95%", "Upper 95%"])
ff_table = pd.concat([ff_fit.params.rename("Coefficient"), ff_intervals], axis=1)
print(ff_table.to_string(float_format=lambda x: f"{x:.5f}"))
model_comparison = pd.DataFrame({
    "CAPM": [capm_fit.params["Alpha"] * 100, capm_fit.rsquared, capm_fit.rsquared_adj],
    "Three factor": [ff_fit.params["Alpha"] * 100, ff_fit.rsquared, ff_fit.rsquared_adj],
}, index=["Monthly alpha (%)", "R-squared", "Adjusted R-squared"])
print(model_comparison.to_string(float_format=lambda x: f"{x:.4f}"))
factor_plot = {
    "factorLoadings": {
        "type": "bar", "title": "Berkshire: Three-Factor Exposures",
        "yAxisName": "Loading (return per unit factor return)",
        "xAxis": {"type": "category", "data": factor_names},
        "series": {"Loading": ff_fit.params[factor_names].tolist()},
    },
}
print("\n<ECHARTS_DATA>" + json.dumps(factor_plot, allow_nan=False))

Adding regressors cannot reduce ordinary in-sample R2 on a common sample. That mechanical improvement does not establish better forecasting. Alpha can move in either direction when factors are added, and adjusted R2 applies a parameter-count penalty. The table supplies confidence intervals; the bar chart shows exposures only, keeping monthly alpha separate from dimensionless loadings.

Here the fitted loadings are approximately 0.688 market, −0.489 SMB, and 0.392 HML. Monthly alpha falls to 0.542%, and R2 rises to 0.298. Under the stated six-lag HAC calculation, the 95% interval for monthly alpha is approximately −0.003% to 1.087%, including zero. The evidence supports describing large-company and value exposures; it does not establish a precisely known positive residual premium.

Reconstruct the Benchmark and Attribute the Mean ​

For the fitted three-factor exposures, construct a zero-alpha benchmark from cash plus factor returns. Keep the distinction between fitted return, which includes the intercept, and factor benchmark return, which excludes it:

R^i,t=RB,t+α^,Ri,t−RB,t=α^+ε^t.
python
factor_contributions = factor_sample[factor_names].mul(ff_fit.params[factor_names], axis=1)
factor_benchmark_returns = factor_sample["RF"] + factor_contributions.sum(axis=1)
active_returns = factor_sample["BRKA"] - factor_benchmark_returns
mean_attribution = factor_contributions.mean()
mean_attribution["Cash"] = factor_sample["RF"].mean()
mean_attribution["Alpha"] = ff_fit.params["Alpha"]
print("Arithmetic mean monthly contributions (%):")
print((mean_attribution * 100).to_string(float_format=lambda x: f"{x:.4f}"))
print(f"Sum of contributions: {mean_attribution.sum():.4%}")
print(f"Observed Berkshire mean: {factor_sample['BRKA'].mean():.4%}")
print(f"Mean return minus benchmark: {active_returns.mean():.4%}")
factor_wealth = 100 * (1 + pd.DataFrame({
    "Berkshire": factor_sample["BRKA"], "Zero-alpha benchmark": factor_benchmark_returns,
})).cumprod()
factor_wealth.loc[factor_wealth.index[0] - 1] = 100.0
factor_wealth = factor_wealth.sort_index()
benchmark_plot = {
    "dates": factor_wealth.index.astype(str).tolist(),
    "factorWealth": {
        "title": "In-Sample Factor Benchmark: Growth of 100",
        "yAxis": {"type": "log", "name": "Wealth per 100 initial units"},
        "series": {name: factor_wealth[name].tolist() for name in factor_wealth},
    },
}
print("\n<ECHARTS_DATA>" + json.dumps(benchmark_plot, allow_nan=False))

The arithmetic mean identity is exact up to numerical precision. Compounded wealth differences are path-dependent and do not equal compounded alpha. The benchmark uses exposures estimated over the entire displayed sample: it is an in-sample explanatory comparison, not a strategy that knew those weights beforehand. It also omits financing spreads, trading costs, and restrictions on implementing factor legs.

Try it: change sample_end to "2018-12" in the alignment cell and rerun the regressions and benchmark. The sample becomes 348 months. Compare exposures and confidence intervals before interpreting the change in alpha.

Sharpe Returns-Based Style Analysis ​

Here replication means approximating observed returns with a style portfolio and measuring the remaining error. It differs from the contractual replication in Options and Replication and Deriving and Using Black–Scholes. For example, a style fit with 1% monthly residual volatility still leaves uncertainty; a two-state payoff match must reproduce both specified payments exactly. A high regression fit alone does not establish a no-arbitrage price.

Factor regression permits signed loadings and an intercept. Returns-based style analysis instead asks which mix of chosen benchmark indices best replicates a manager's returns under allocation constraints. For building-block returns Bt,j, we use a long-only, fully invested portfolio:

minw1T∑t=1T(Rm,t−∑j=1NwjBt,j)2,wj≥0,∑jwj=1.

This chapter's implementation has no intercept. We choose to minimize the squared level of the active return, penalizing both mean difference and variability. A specification with a freely fitted intercept would instead absorb the mean difference and minimize demeaned residual variability. Those are different objectives and can give different weights.

Fit Distance Versus Tracking-Error Volatility ​

Let at=Rm,t−wTBt. Three quantities must be distinguished:

fit distance=∑tat2,RMSE=1T∑tat2,TEmonthly=∑t(at−a¯)2T−1.

The toolkit's historical name tracking_error() denotes the first quantity, retained for compatibility. Conventional tracking-error volatility is the third. Their relationship is:

RMSE2=a¯2+T−1TTEmonthly2.

For four active returns all equal to 1%, fit distance is 2%, RMSE is 1%, and tracking-error volatility is zero. A stable performance shortfall can therefore have zero tracking-error volatility.

python
constant_active = pd.Series([0.01] * 4)
fit_distance = pok.tracking_error(constant_active, constant_active * 0)
rmse = np.sqrt((constant_active ** 2).mean())
tracking_volatility = constant_active.std(ddof=1)
print(f"Fit distance: {fit_distance:.2%}; RMSE: {rmse:.2%}; tracking volatility: {tracking_volatility:.2%}")

Recover a Known Allocation First ​

Begin with three benchmark assets and a manager who holds 30%, 50%, and 20%, rebalanced at each period's start. The supplied observations are deliberately small and identifiable. Because the manager is exactly a feasible combination, minimum squared error is zero.

python
toy_blocks = pd.DataFrame({
    "A": [0.02, -0.01, 0.03, 0.00],
    "B": [0.00, 0.02, -0.01, 0.01],
    "C": [-0.01, 0.00, 0.02, 0.03],
})
known_weights = pd.Series([0.30, 0.50, 0.20], index=toy_blocks.columns)
toy_manager = toy_blocks @ known_weights
recovered_weights = pok.style_analysis(toy_manager, toy_blocks)
print(pd.DataFrame({"Known (%)": known_weights * 100, "Recovered (%)": recovered_weights * 100}).to_string())
print(f"RMSE: {np.sqrt(((toy_manager - toy_blocks @ recovered_weights) ** 2).mean()):.8%}")

The first-period manager return is 0.3(2%)+0.5(0%)+0.2(−1%)=0.4%. Try it: change the known allocation to [0.0, 1.0, 0.0]. The optimizer should recover the boundary solution. A constrained optimum may have zero weights; “long-only” means nonnegative, not strictly positive.

Industry-Based Replication and Unconstrained Regression ​

Now use the 30 value-weighted industry portfolios from 2000 through 2018. Build a synthetic manager with 30% Beer, 50% Smoke, and 20% Finance exposure, plus a small seeded residual. The residual is a modeling disturbance, not an investable fourth allocation. The known benchmark weights already sum to one.

python
industry_vw = pok.get_ind_file(filetype="rets", nind=30, ew=False)
industry_sample = industry_vw.loc["2000":"2018"]
true_style = pd.Series(0.0, index=industry_sample.columns)
true_style.loc[["Beer", "Smoke", "Fin"]] = [0.30, 0.50, 0.20]
rng = np.random.default_rng(42)
synthetic_manager = industry_sample @ true_style + rng.normal(0, 0.002, len(industry_sample))
style_weights = pok.style_analysis(synthetic_manager, industry_sample)
style_fit = industry_sample @ style_weights
unconstrained_fit = pok.linear_regression(synthetic_manager, industry_sample)
style_comparison = pd.DataFrame({
    "Known weight": true_style,
    "Constrained weight": style_weights,
    "OLS loading": unconstrained_fit.params.drop("Alpha"),
})
print(style_comparison.sort_values("Constrained weight", ascending=False).to_string(float_format=lambda x: f"{x:.4f}"))
print(f"Constrained RMSE: {np.sqrt(((synthetic_manager - style_fit) ** 2).mean()):.3%}")
print(f"OLS monthly alpha: {unconstrained_fit.params['Alpha']:.3%}")
top_styles = style_weights.nlargest(8).index
style_plot = {
    "styleWeights": {
        "type": "bar", "title": "Synthetic Manager: Eight Largest Fitted Style Weights",
        "yAxisName": "Portfolio weight (%)",
        "xAxis": {"type": "category", "data": top_styles.tolist()},
        "series": {
            "Known": (true_style[top_styles] * 100).tolist(),
            "Constrained fit": (style_weights[top_styles] * 100).tolist(),
        },
    },
}
print("\n<ECHARTS_DATA>" + json.dumps(style_plot, allow_nan=False))

The table includes every industry; the chart selects the eight largest fitted weights for readability. Some small weights can absorb the residual rather than represent true holdings. Unconstrained OLS can have negative loadings and a non-unit sum, and also fits an intercept. Its larger feasible set can improve in-sample fit without creating an implementable long-only portfolio.

Try it: set the residual standard deviation from 0.002 to 0.0. The synthetic manager becomes exactly replicable. Then increase it and observe how inference becomes less precise. Highly correlated benchmark returns can produce similar fitted returns with different weights; weight stability matters alongside fit quality.

Berkshire: In-Sample Style Versus Held-Out Replication ​

Apply the same constrained model to Berkshire, recognizing that an industry mix omits its cash, insurance liabilities, financing, private operations, and changing holdings. Fit weights on 2000–2008, freeze them, and evaluate 2009–2018. Separately refit on the later period to see how hindsight changes the result.

python
style_sample = pd.concat([brka_monthly.rename("BRKA"), industry_vw], axis=1, join="inner").loc["2000":"2018"]
training = style_sample.loc[:"2008"]
held_out = style_sample.loc["2009":]
industry_names = industry_vw.columns
training_weights = pok.style_analysis(training["BRKA"], training[industry_names])
hindsight_weights = pok.style_analysis(held_out["BRKA"], held_out[industry_names])
style_diagnostics = {}
for name, sample, weights in [
    ("Training fit", training, training_weights),
    ("Held-out frozen weights", held_out, training_weights),
    ("Held-out hindsight refit", held_out, hindsight_weights),
]:
    active = sample["BRKA"] - sample[industry_names] @ weights
    style_diagnostics[name] = [active.mean() * 100, np.sqrt((active ** 2).mean()) * 100,
                               active.std(ddof=1) * np.sqrt(12) * 100]
print(pd.DataFrame(style_diagnostics, index=["Mean active monthly (%)", "RMSE monthly (%)", "Annualized tracking vol (%)"]).T.to_string(float_format=lambda x: f"{x:.3f}"))
print("Largest training-window weights (%):")
print((training_weights.nlargest(8) * 100).to_string(float_format=lambda x: f"{x:.2f}"))

The frozen weights use only the training sample. The later refit uses the evaluation period itself and is not an out-of-sample result. Annualizing tracking volatility by 12 assumes zero serial covariances and stable period variance, as in Part 1. The mean active return here is a replication gap, not regression alpha estimated with a free intercept.

python
holdout_returns = pd.DataFrame({
    "Berkshire": held_out["BRKA"],
    "Frozen training mix": held_out[industry_names] @ training_weights,
})
holdout_wealth = 100 * (1 + holdout_returns).cumprod()
holdout_wealth.loc[holdout_wealth.index[0] - 1] = 100.0
holdout_wealth = holdout_wealth.sort_index()
print("\n<ECHARTS_DATA>" + json.dumps({
    "dates": holdout_wealth.index.astype(str).tolist(),
    "heldOutReplication": {
        "title": "2009–2018: Berkshire and a Frozen Style Mix",
        "yAxisName": "Wealth per 100 initial units",
        "series": {name: holdout_wealth[name].tolist() for name in holdout_wealth},
    },
}, allow_nan=False))

“Frozen” means constant target weights, rebalanced monthly, not buy-and-hold quantities. This retrospective holdout demonstrates the timing distinction. The selected assets, dates, and bundled data still do not establish a fully point-in-time investment experiment.

Style Drift: Rolling Exposures ​

A rolling estimate fits only the most recent window. Apparent style drift can reflect actual changes in exposure, sampling noise, correlated benchmarks, or omitted risks. Short windows adapt faster but generally produce noisier estimates; long windows smooth changes and can mix different regimes.

Use a controlled experiment with two industries. The manager's Beer weight changes from 80% to 20% in January 2009, with the rest in Smoke. For each month t, estimate weights using the previous 60 months, then apply them to month t. The chart labels the month in which the estimate is used, not the final month of its training window.

python
drift_blocks = industry_sample[["Beer", "Smoke"]]
actual_beer_weight = pd.Series(np.where(drift_blocks.index < pd.Period("2009-01", freq="M"), 0.8, 0.2), index=drift_blocks.index)
drift_manager = actual_beer_weight * drift_blocks["Beer"] + (1 - actual_beer_weight) * drift_blocks["Smoke"]
window_months = 60
rolling_rows = []
for t in range(window_months, len(drift_blocks)):
    rolling_rows.append(pok.style_analysis(drift_manager.iloc[t-window_months:t],
                                          drift_blocks.iloc[t-window_months:t]))
rolling_styles = pd.DataFrame(rolling_rows, index=drift_blocks.index[window_months:])
rolling_replication = (rolling_styles * drift_blocks.loc[rolling_styles.index]).sum(axis=1)
print(f"Forecast months: {rolling_styles.index[0]} to {rolling_styles.index[-1]}")
print(f"One-month-ahead replication RMSE: {np.sqrt(((drift_manager.loc[rolling_styles.index] - rolling_replication) ** 2).mean()):.3%}")
print("\n<ECHARTS_DATA>" + json.dumps({
    "dates": rolling_styles.index.astype(str).tolist(),
    "rollingStyle": {
        "title": "Rolling Style: Known Change and Lagged Estimate",
        "yAxisName": "Beer portfolio weight (%)",
        "series": {"Actual": (actual_beer_weight.loc[rolling_styles.index] * 100).tolist(),
                   "Prior-window estimate": (rolling_styles["Beer"] * 100).tolist()},
        "seriesOptions": {"Actual": {"step": "start"}},
    },
}, allow_nan=False))

The January 2009 estimate cannot know the new allocation: its training data end in December 2008. Estimates adjust as post-change observations enter the window. Try it: replace 60 with 24 months. Compare adaptation speed; this noiseless two-industry example isolates timing, while real managers also introduce estimation noise and model mismatch.

Weighting Schemes as Sources of Style Exposure ​

Equally Weighted Versus Value-Weighted Industry Portfolios ​

“Value-weighted” here means market-capitalization-weighted, not value-stock-weighted. Equal weighting within an industry generally gives smaller firms more weight relative to capitalization weighting. The return difference can therefore reflect size exposure, rebalancing, and implementation costs, rather than unexplained skill.

Compare the bundled EW and VW versions of each of the 30 industries over 1945–2018. Use the observed monthly RF series for both. Compute each industry's conventional annualized arithmetic Sharpe from its monthly excess returns:

SR^ann=12R−Rf―s(R−Rf).
python
industry_ew = pok.get_ind_file(filetype="rets", nind=30, ew=True)
comparison_dates = industry_vw.loc["1945":"2018"].index.intersection(industry_ew.index).intersection(factors_monthly.index)
cash_history = factors_monthly.loc[comparison_dates, "RF"]
vw_excess = industry_vw.loc[comparison_dates].sub(cash_history, axis=0)
ew_excess = industry_ew.loc[comparison_dates].sub(cash_history, axis=0)
industry_sharpes = pd.DataFrame({
    "VW": np.sqrt(12) * vw_excess.mean() / vw_excess.std(ddof=1),
    "EW": np.sqrt(12) * ew_excess.mean() / ew_excess.std(ddof=1),
})
fraction_ew_higher = (industry_sharpes["EW"] > industry_sharpes["VW"]).mean()
sharpe_axis_max = float(np.ceil(industry_sharpes.to_numpy().max() * 10) / 10)
print(industry_sharpes.to_string(float_format=lambda x: f"{x:.3f}"))
print(f"Industries with higher EW Sharpe: {fraction_ew_higher:.1%}")
print("\n<ECHARTS_DATA>" + json.dumps({
    "industrySharpeComparison": {
        "type": "scatter", "title": "Industry Sharpe Ratios: EW Versus VW",
        "xAxis": {"type": "value", "name": "VW Sharpe ratio", "min": 0, "max": sharpe_axis_max},
        "yAxis": {"type": "value", "name": "EW Sharpe ratio", "min": 0, "max": sharpe_axis_max},
        "series": {**{name: [[float(row["VW"]), float(row["EW"])]] for name, row in industry_sharpes.iterrows()},
                   "Equal Sharpe": [[0, 0], [sharpe_axis_max, sharpe_axis_max]]},
        "seriesOptions": {"Equal Sharpe": {"type": "line", "lineStyle": {"type": "dashed"}}},
    },
}, allow_nan=False))

Points above the y=x comparison have higher EW Sharpe. The reported fraction counts industries, not months or the probability EW will win. Each point summarizes its entire common sample. These are portfolios weighted within industries; the next example allocates across industries, a different investment decision.

Equal Weighting with Capitalization Limits ​

A portfolio can limit small-component exposure by requiring wi≤mci, where ci is its capitalization fraction and m is a chosen multiple. If an eligibility threshold excludes a component, its upper bound becomes zero. Full investment is feasible only if the remaining upper bounds sum to at least one.

Clipping equal weights and then renormalizing is not sufficient: renormalization can lift capped weights back above their limits. Instead, retain equal allocations among unconstrained components and redistribute only to components with remaining capacity. This produces:

wi=min(λ,ui),∑iwi=1,

where ui is the eligible upper bound and λ is chosen to satisfy the budget.

Hand check. For cap weights (0.05,0.15,0.80) and m=2, the limits are (0.10,0.30,1.60). Capped equal weighting gives (0.10,0.30,0.60). Clipping one-third weights then renormalizing would violate the first two caps.

python
cap_example = pd.DataFrame([[0.05, 0.15, 0.80]], columns=["Small", "Medium", "Large"],
                           index=pd.period_range("2024-01", periods=1, freq="M"))
return_window = pd.DataFrame([[0.0, 0.0, 0.0]], columns=cap_example.columns, index=cap_example.index)
capped_example = pok.weight_ew(return_window, cap_ws=cap_example, max_cw_mult=2)
print(pd.DataFrame({"Cap fraction": cap_example.iloc[0], "Capped equal weight": capped_example}).to_string())

A Lagged Industry Allocation Comparison ​

Use the 49-industry return and capitalization metadata. Estimate each month's industry capitalization as firm count times average firm size, then normalize across industries. For return month t, the weighting rule uses capitalization at t−1. This is why the return input includes December 1973 before the evaluation starts in January 1974.

These EW and cap-weight rules do not estimate a covariance matrix, so a one-row training window is sufficient. A 52-row window on monthly data would mean 52 months, not a year. Using its first capitalization row would introduce a stale allocation rather than a current lagged one.

python
industry49 = pok.get_ind_file(filetype="rets", nind=49, ew=False).loc["1973-12":"2018"]
firm_counts49 = pok.get_ind_file(filetype="nfirms", nind=49).loc[industry49.index]
firm_sizes49 = pok.get_ind_file(filetype="size", nind=49).loc[industry49.index]
capitalization49 = firm_counts49 * firm_sizes49
cap49 = capitalization49.div(capitalization49.sum(axis=1), axis=0)
allocation_returns = pd.DataFrame({
    "Equal industry weights": pok.backtest_weight_scheme(industry49, window=1, weight_scheme=pok.weight_ew),
    "Lagged capitalization": pok.backtest_weight_scheme(industry49, window=1, weight_scheme=pok.weight_cw, cap_ws=cap49),
    "Capped equal weights": pok.backtest_weight_scheme(industry49, window=1, weight_scheme=pok.weight_ew,
                                                       cap_ws=cap49, max_cw_mult=5, microcap_thr=0.005),
}).iloc[1:]
allocation_excess = allocation_returns.sub(factors_monthly.loc[allocation_returns.index, "RF"], axis=0)
allocation_statistics = pd.DataFrame({
    "CAGR (%)": pok.annualize_rets(allocation_returns, 12) * 100,
    "Annualized volatility (%)": pok.annualize_vol(allocation_returns, 12) * 100,
    "Sharpe": np.sqrt(12) * allocation_excess.mean() / allocation_excess.std(ddof=1),
})
print(f"Evaluation: {allocation_returns.index[0]} to {allocation_returns.index[-1]}, {len(allocation_returns)} months")
print(allocation_statistics.to_string(float_format=lambda x: f"{x:.3f}"))
allocation_wealth = 100 * (1 + allocation_returns).cumprod()
allocation_wealth.loc[allocation_returns.index[0] - 1] = 100.0
allocation_wealth = allocation_wealth.sort_index()
print("\n<ECHARTS_DATA>" + json.dumps({
    "dates": allocation_wealth.index.astype(str).tolist(),
    "industryAllocation": {
        "title": "Across-Industry Weighting: Lagged Monthly Allocations",
        "yAxis": {"type": "log", "name": "Wealth per 100 initial units"},
        "series": {name: allocation_wealth[name].tolist() for name in allocation_wealth},
    },
}, allow_nan=False))

The legacy parameter name microcap_thr applies here to an industry's share of aggregate capitalization, not to the size of individual stocks. The 0.5% threshold excludes small industries; the five-times cap limits remaining industry allocations. All three strategies rebalance monthly and omit trading costs. Lagging avoids using the return month's capitalization, but these files do not establish when the metadata were published or how historical constituents were revised.

Try it: remove the exclusion by setting microcap_thr=0.0, or tighten max_cw_mult. Some combinations are infeasible; the toolkit reports that instead of returning weights that violate the budget or caps. Compare return, volatility, and exposures rather than choosing the strategy with the highest ending wealth alone.

Chapter Summary ​

  • Factor loadings measure modeled sensitivity; expected premiums, realized factor returns, and portfolio weights are distinct quantities.
  • CAPM beta is covariance divided by market variance, not correlation. The SML uses beta rather than total volatility.
  • Monthly factor regressions require aligned dates, consistent units, and matched cash subtraction. Alpha is a model- and sample-dependent intercept.
  • Constrained style replication can produce an investable benchmark mix, but a good return fit does not identify actual holdings or establish skill.
  • Frozen-sample and rolling evaluations must respect the information available before each evaluated return.
  • Equal weighting, capitalization weighting, and exposure caps change style as well as performance. Benchmark design and implementation assumptions belong in the interpretation.

The next chapter, Covariance Matrix Estimation, studies how to estimate the risk inputs that allocation methods require.

Independent study notes and Python examples by Amine El Qazoui.