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:
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
Here
Under the model's zero-mean residual condition, taking expectations gives:
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:
The risk-free return
Beta Is Not Correlation
For positive volatilities:
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
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
The first-order conditions set the residual sum and its cross-product with
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
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:
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,
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:
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
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
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:
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
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:
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.
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.
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.
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
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.
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
Here the fitted loadings are approximately 0.688 market, −0.489 SMB, and 0.392 HML. Monthly alpha falls to 0.542%, and
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:
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
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
The toolkit's historical name tracking_error() denotes the first quantity, retained for compatibility. Conventional tracking-error volatility is the third. Their relationship is:
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.
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.
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.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.
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.
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
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
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:
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
Equal Weighting with Capitalization Limits
A portfolio can limit small-component exposure by requiring
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:
where
Hand check. For cap weights
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
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.
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.
