In this project we move from asset-only allocation to factor-aware allocation. Instead of only asking which sector or country ETF has been going up, we ask which economic return premia are currently strong, which assets are exposed to those premia, and whether that exposure has enough evidence to justify active rotation.
In this project, we will cover the following:
build and inspect U.S. Fama-French style factor data and industry/ETF factor exposures,
estimate full-sample and rolling factor regressions,
convert factor performance into a real-time factor-state signal,
map factor states into sector scores using rolling betas,
add residual strength, trend strength, and risk penalty signals,
validate the signals using rank IC, top-minus-bottom payoff, and signal decay,
construct soft active sector weights and compare the strategies,
repeat the workflow on developed ex-U.S. country ETFs through the library.
1) Factor Investing as a Language for Explaining Returns
A sector return can be decomposed into two parts: the part explained by common return drivers, and the part left over after those common drivers are removed. Factor investing starts from the idea that returns aren’t random ticker-by-ticker events. Many assets move together because they share exposure to the same underlying economic forces.
For example, a technology ETF and a consumer discretionary ETF can both have high exposure to the broad market factor, but they can behave differently when value stocks are strong, when small caps outperform, or when profitability and investment factors change. In this project we treat those shared forces as factor premia.
A factor premium is a portfolio return that represents a systematic style or risk characteristic. The factor can be academic, like the Fama-French size factor, or tradable, like a small-cap ETF spread versus a broad market ETF. The academic factor is cleaner because it’s constructed directly from sorted portfolios. The tradable proxy is less clean but closer to what a portfolio can actually hold.
The workflow is:
Measure factor states: which premia are currently strong or weak.
Measure asset exposures: which sectors or countries load on those premia.
Score assets: reward assets that have exposure to strong premia and penalize exposure to weak premia.
Allocate softly: tilt away from equal weight without making extreme concentrated bets.
We use the regressions to explain sector behavior, build signals, and test whether those signals are useful for portfolio rotation. The estimated exposure and the current factor state play different roles: the exposure describes sensitivity, while the state describes recent factor behavior.
Show code
import matplotlib.pyplot as pltimport numpy as npimport pandas as pdfrom IPython.display import displayimport warningsfrom quantfinlab.backtest.portfolio import run_many_weights_backtestsfrom quantfinlab.dataio import load_yfinance_panelfrom quantfinlab.plotting.curves import set_plot_stylefrom quantfinlab.portfolio.selection import build_strategy_summary, build_trade_table, calc_drawdownwarnings.filterwarnings("ignore")set_plot_style()pd.options.display.float_format ="{:.4f}".format
2) Data Sources and Return Panel
We use sector ETF prices together with the factor layer: the factor series give us common return drivers, and the ETF returns let us estimate how each sector is exposed to those drivers.
The source data and preparation steps are documented in the repository’s data folders:
Each folder contains the source description and the steps for rebuilding its prepared file. The Fama-French files used below already contain decimal returns, so we can align them directly with monthly ETF returns.
The same prepared monthly files are used throughout the regression and signal calculations.
We begin with U.S. sector ETFs, SHY as the cash-like defensive asset, and SPY as the broad equity benchmark. The sector universe includes materials, energy, financials, industrials, technology, staples, utilities, health care, discretionary, real estate, and telecom/communication exposure through IYR and IYZ.
Show code
tickers = sector_tickers + [cash_ticker, benchmark_ticker]prices = load_yfinance_panel(["../data/sector_etfs.csv", "../data/core_cross_asset_etfs.csv"], fields=("close",), tickers=tickers, start="1999-01-01")["close"].ffill(limit=3)prices = prices.reindex(columns=tickers)coverage = pd.DataFrame( {"ticker": prices.columns, "first date": [prices[c].first_valid_index() for c in prices.columns],"last date": [prices[c].last_valid_index() for c in prices.columns], "observations": prices.notna().sum().values})display(coverage)
ticker
first date
last date
observations
0
XLB
1999-01-04
2026-06-23
6909
1
XLE
1999-01-04
2026-06-23
6909
2
XLF
1999-01-04
2026-06-23
6909
3
XLI
1999-01-04
2026-06-23
6909
4
XLK
1999-01-04
2026-06-23
6909
5
XLP
1999-01-04
2026-06-23
6909
6
XLU
1999-01-04
2026-06-23
6909
7
XLV
1999-01-04
2026-06-23
6909
8
XLY
1999-01-04
2026-06-23
6909
9
IYR
2000-06-19
2026-09-30
6610
10
IYZ
2000-05-26
2026-06-23
6556
11
SHY
2002-07-30
2026-09-30
6082
12
SPY
1999-01-04
2026-09-30
6978
Most sector ETFs in the coverage output begin in 1999 and run through June 2026. IYR and IYZ begin in 2000, and SHY starts in 2002. IYR, SHY, and SPY continue through September 2026 in the prepared files. That matters because later calculations require overlapping observations across sectors, cash, benchmark, and factors. Once we move to monthly returns and align the factor data, the sample becomes February 1999 to June 2026 for the main sector/factor work.
This is a good sample for factor investing because it contains several distinct regimes:
the dot-com crash and recovery,
the 2008 credit crisis,
the post-GFC low-rate expansion,
the COVID crash and reopening rally,
the 2022 inflation/rates shock,
the 2023-2025 AI/growth-led market.
A factor model is more interesting across this kind of sample than in a single quiet bull market. The sector exposures have had to survive growth crashes, value rebounds, energy shocks, rate shocks, and defensive rotations.
The alignment table below gives the dates actually used by the factor regressions.
2.2 Monthly Returns and Timing
We convert daily adjusted close prices to month-end prices and then compute monthly returns:
\[
R_{t,i} = \frac{P_{t,i}}{P_{t-1,i}} - 1
\]
where \(P_{t,i}\) is the month-end price of asset \(i\) in month \(t\). Monthly frequency is a good fit for this project because Fama-French factor files are monthly and sector rotation doesn’t need daily rebalancing. It also reduces noise. Daily sector returns are dominated by broad market shocks, while monthly returns give factor relationships more room to show up.
The timing convention later is important: scores are formed using information available at month end, and portfolio returns are realized after that. This keeps the factor strategy from accidentally using future returns.
For example, if an ETF’s month-end price moves from \(100\) to \(103\), its monthly simple return is \(103/100-1=0.03\). We align that return with the factor observation for the same calendar month before estimating a regression.
The first displayed monthly summary has 332 monthly index rows from February 1999 through September 2026 before factor alignment. Most sector price histories stop in June, while IYR, SHY, and SPY extend further. After aligning with factor dates, we have 329 rows through June 2026. This is still enough for rolling 60-month regressions and a meaningful out-of-sample-style strategy test.
3) Fama-French Factors and Long-Run Premia
The core factor set is the U.S. Fama-French five-factor model plus momentum:
The risk-free rate is kept separately as \(RF_t\). For any sector return \(R_{t,i}\), we use the excess return:
\[
r_{t,i}^{excess} = R_{t,i} - RF_t
\]
This matters because the factor model explains returns above the cash rate. If the risk-free rate changes a lot, especially after 2022, using raw returns would mix equity premia with cash-rate shifts. Excess returns keep the focus on compensation beyond the safe rate.
The factor returns and \(RF_t\) are measured over the same monthly period. Subtracting \(RF_t\) from the asset return puts the dependent variable on the same excess-return basis as the market factor, while the other long-short factor series enter as their monthly returns.
3.1 Economic Reading of the Six Factors
The six factors have different economic meanings, so their states shouldn’t be interpreted the same way.
Market excess return is the broad risk appetite factor. When \(Mkt-RF\) is strong, equity risk is being rewarded relative to cash. Sectors with high market beta usually benefit, but this factor alone doesn’t tell us which sector is best. It mostly tells us whether equity exposure itself has been favorable.
SMB captures the small-cap premium. A positive \(SMB\) state often means smaller or less mega-cap-dominated stocks are participating. In sector terms, this can favor more cyclical or equal-weight-like exposures and penalize mega-cap-heavy sectors if the market is being led by broad participation rather than only the largest firms.
HML captures value versus growth. A positive \(HML\) state means value-like firms are outperforming growth-like firms. This often favors energy, financials, materials, and some industrials, while it can penalize technology and other long-duration growth sectors.
RMW captures profitability. A positive profitability state rewards firms with robust operating profitability. This can support quality-like sectors, but it doesn’t always line up with momentum or value. A negative \(RMW\) state means the market is not rewarding the profitability style at that moment.
CMA captures conservative investment. A positive \(CMA\) state means firms that invest conservatively are outperforming aggressive-investment firms. This can be related to quality, balance-sheet discipline, and lower capital intensity. It can also behave defensively in some regimes.
MOM captures continuation in recent winners. Momentum often works across assets, but it is vulnerable to sharp reversals. A strong \(MOM\) state can support sectors that have been leading, but if the market suddenly rotates, momentum can lose quickly.
This factor interpretation is the bridge from regression coefficients to sector allocation. A beta has no active meaning by itself. It becomes actionable only when the corresponding factor state is strong or weak.
3.2 Industry Portfolios as a Factor Exposure Test Bed
Before applying factor scores to sector ETFs, we first use long-history Fama-French 12-industry portfolios. These industry portfolios go back to 1963, much earlier than the ETF data. They act like a diagnostic laboratory: if factor regressions can’t explain broad industry portfolios over a long sample, we shouldn’t expect them to explain sector ETFs reliably.
We can compare annualized means and volatilities across the industry portfolios. Durable goods and business equipment have high volatility, while utilities and non-durables are much more defensive. Energy has high volatility and a very different cycle from technology or health care. These differences are exactly what make sector factor analysis useful.
A sector isn’t only an economic label. It has a factor signature. Business equipment usually has strong market and growth exposure. Utilities tend to have lower market beta and more defensive behavior. Energy is tied to commodity cycles and often has lower explanatory power from standard equity factors. Financials connect to value, credit, rates, and market beta. The factor regression turns these qualitative descriptions into estimated numbers.
We can begin with the long-run mean, volatility, and extremes of each factor premium. Annualized market excess return is about 7.2% with volatility around 15.5%, giving the strongest intuitive anchor: equity risk has been rewarded but with large drawdowns. Momentum has a similar annualized mean around 7.4% and volatility around 14.5%, with the highest Sharpe-like ratio in the table.
Several factors have smaller but still positive long-run means:
\(SMB\) has the weakest long-run Sharpe-like value among the listed factors.
\(HML\) has positive average return but large regime dependence.
\(RMW\) and \(CMA\) have lower volatility and relatively attractive Sharpe-like ratios.
\(MOM\) has strong long-run performance but very deep crash risk, visible later in the drawdown plot.
The minimum and maximum monthly values also show the tail behavior. Momentum has a minimum around -34%, which is much larger in magnitude than the other factor losses. That kind of loss often happens during violent reversals, when previous losers rebound quickly and previous winners lag. This is the main risk of momentum as a portfolio signal: it can work for long stretches and still suffer sharp reversal months.
3.3 Factor Premia Across Regimes
Factor investing becomes much more intuitive when we connect the premia to market regimes.
During growth-led disinflationary regimes, technology and growth sectors often benefit. In those periods, value can lag, small-cap participation can be weak, and momentum may concentrate in a narrow group of large winners. The sector model can underweight technology if value and size states are strong but growth still dominates. This is one of the central risks in this project.
During reflationary or inflationary regimes, value, energy, financials, and materials often become more attractive. Higher nominal growth, commodity strength, and rising rates can hurt long-duration growth assets while supporting real-economy sectors. This kind of regime was visible in 2021-2022.
During stress regimes, market beta explains a lot because correlations rise. But factor exposure doesn’t disappear. Defensive sectors, profitability, quality, low volatility, and residual sector strength can all matter. The model doesn’t have a dedicated macro stress factor here, so the risk penalty and SHY allocation help control this part.
During broad recovery regimes, small-cap and cyclical exposure can rebound strongly. \(SMB\) and \(HML\) often improve together when the market moves from narrow defensive/growth leadership into broader risk appetite.
This is why we don’t simply rank factors by long-run mean. The current state of each factor tells us which regime is being rewarded now, and rolling sector betas tell us which sectors are positioned for that regime.
Starting with $1 invested in each factor portfolio, we can see that the cumulative factor premia do not compound smoothly. Market and momentum create strong long-term growth, but both experience painful drawdowns. Value has long flat or weak stretches, especially during growth-dominated regimes. Profitability and conservative investment are steadier than the headline market and momentum premia, but they still aren’t risk-free.
The drawdown panel is more informative than the cumulative growth panel for portfolio design. A factor can have attractive long-run performance and still be hard to use if its drawdowns are deep, persistent, or clustered exactly when the portfolio is already under pressure. Momentum is the obvious example: it has a strong long-run average, but its crash risk means we shouldn’t simply maximize exposure to it.
The correlation matrix helps explain diversification among factors. Factors aren’t independent. Value and conservative investment often have a meaningful relationship because value firms and conservative-investment firms can overlap. Market is connected to many factors because almost every equity-style factor carries some market beta unless constructed very carefully. Low or moderate factor correlations are useful because they create the possibility of factor rotation: one premium can be weak while another is strong.
Each curve compounds a different monthly factor-return series over the available history.
Financially, \(\beta_i^\top f_t\) is the explained return from common factors. \(\alpha_i\) is the average return left after controlling for those factors. A positive alpha doesn’t automatically mean skill. It can also mean the factor model is missing an important sector-specific driver.
The first column of \(X\) is the intercept and each remaining column contains one factor’s monthly observations. The least-squares fit chooses the coefficient vector that makes the squared distance between observed and fitted excess returns as small as possible. The displayed inverse is the full-rank expression; the implementation uses a least-squares solver rather than forming the inverse directly.
4.1 Statistical Details Behind the Regression Output
Before using the regression results, we can connect the fitted values and residuals to the reported statistics.
\(R^2\) close to 1 means the factor model explains most of the return variation. \(R^2\) close to 0 means the factor model doesn’t explain much beyond the mean.
Alpha is annualized as:
\[
\alpha_i^{ann} = 12\hat{\alpha}_i
\]
because the regression is monthly. A monthly alpha of 0.004 becomes about 4.8% annualized before compounding. We still have to be careful with alpha interpretation. If a sector has positive alpha, it can mean genuine unexplained outperformance, but it can also mean omitted factors, nonlinear behavior, sector-specific macro exposure, or sample-specific luck.
The regression also assumes a linear relationship between sector returns and factor returns. That is a simplification. Energy doesn’t respond linearly to oil shocks through the Fama-French factor set, and utilities can respond to rates in a way that isn’t fully captured by equity factors. That is why residual diagnostics are part of the strategy rather than a secondary afterthought.
The numerator of \(R^2\) is the residual sum of squares and the denominator is variation around the observed mean. With an intercept, a smaller numerator means the factors account for more of the sample’s month-to-month variation. The alpha calculation multiplies the monthly intercept by 12; it is an annualized arithmetic rate, not a compounded annual return.
The industry betas let us compare how each industry responds to the same set of factor returns. Business equipment is the clearest positive-alpha industry in the full sample, with annualized alpha around 5.3% after controlling for the six factors. That likely reflects the long-run technology/software-related premium that isn’t fully captured by the traditional factor set.
Energy has a much lower \(R^2\) than many other industries, around 0.46. That makes sense. Energy returns are heavily tied to oil, gas, geopolitics, supply shocks, and inflation cycles. A U.S. equity factor set can capture broad equity beta and some value exposure, but it can’t fully explain commodity-linked sector behavior.
Utilities also have low explanatory power compared with industries like manufacturing, money/financials, or shops. Utilities are rate-sensitive and defensive, so their returns respond to yield changes, regulated cash-flow expectations, and dividend discounting. Those forces aren’t directly in the factor set.
Several industries show negative annualized alpha after factor adjustment, such as chemicals, other, energy, manufacturing, and money. This doesn’t mean those industries were bad investments. It means their realized returns were less than what their factor exposures would predict over the full sample. The regression separates performance into rewarded style exposure and residual sector-specific performance.
The beta estimates and alpha statistics use the aligned industry and factor observations.
4.2 Tradable Factor ETF Proxies
Academic factors are clean but not directly tradable in a simple ETF portfolio. We add factor proxy ETFs to bridge this gap:
Proxy
Intended exposure
MTUM
momentum tilt
VLUE
value tilt
QUAL
quality/profitability tilt
USMV
minimum volatility/defensive tilt
RSP
equal-weight market, often more mid/small-cap than cap-weighted SPY
IWF
growth
IWD
value
IWM
small caps
These aren’t perfect replicas of academic factors. For example, MTUM isn’t the same as the Fama-French momentum factor. It has sector constraints, holdings turnover rules, and a live ETF construction process. VLUE isn’t exactly \(HML\), and QUAL isn’t exactly \(RMW\). But they help us check whether the academic factor state is also visible in tradable instruments.
An academic factor can show strong performance while its tradable proxy points in a different direction, so we account for that disagreement in the blended state. That is why we later blend academic factor states with tradable proxy states and shrink the signal when they disagree.
Each proxy is interpreted using its own construction.
Show code
factor_etfs = ["MTUM", "VLUE", "QUAL", "USMV", "RSP", "IWF", "IWD", "IWM"]proxy_prices = load_yfinance_panel(["../data/core_cross_asset_etfs.csv", "../data/factor_proxy_etfs.csv"], fields=("close",), tickers=factor_etfs + [cash_ticker, benchmark_ticker], start="1999-01-01")["close"].ffill(limit=3)proxy_available = [t for t in factor_etfs if t in proxy_prices.columns and proxy_prices[t].notna().any()]proxy_monthly_returns = proxy_prices.resample("ME").last().pct_change().dropna(how="all")proxy_returns = proxy_monthly_returns[proxy_available]proxy_dates = proxy_returns.index.intersection(factors.dropna(subset=factor_cols).index).intersection(rf.index)proxy_excess = proxy_returns.loc[proxy_dates].subtract(rf.loc[proxy_dates], axis=0)proxy_beta, proxy_stats = full_sample_factor_regression(proxy_excess, factors.loc[proxy_dates, factor_cols])display(pd.DataFrame({"available factor ETF proxies": proxy_available}))
The factor ETF proxy regression behaves as expected in one important way: the \(R^2\) values are very high for most proxies. IWM has \(R^2\) around 0.985, IWF around 0.978, QUAL and RSP around 0.966, and IWD around 0.962. This means the factor model explains most of the variation in these ETFs.
USMV has the lowest \(R^2\) among the proxy ETFs, around 0.80. That makes sense because minimum-volatility strategies are partly driven by optimization rules, sector constraints, and low-beta defensive selection. A simple linear factor model captures some of that, but not all of it.
The proxy alpha table is also useful. Most proxy ETFs have slightly negative annualized alpha after controlling for the factor set. This is normal for factor ETFs because ETF expense ratios, turnover, implementation constraints, and imperfect factor construction can reduce realized performance relative to academic factors. IWF is the only listed proxy with small positive alpha. That fits the sample, where large-cap growth performed very strongly.
5) Rolling Exposures and Factor States
We align the sector and factor observations on the same monthly dates before estimating exposures. If factor returns and sector returns have different missing dates, regressions can accidentally use inconsistent rows. A factor model is only valid when all variables in the regression refer to the same month.
For month \(t\), the regression uses:
\[
\left(r_{t,i}^{excess}, f_t\right)
\]
for each sector \(i\). The sector return and factor returns are simultaneous monthly observations. Later, when we validate signals, we shift future returns forward:
\[
R_{t+1,i}
\]
so the score at time \(t\) is tested against the next month’s sector return. That separation between estimation period and future payoff is what makes the signal validation meaningful.
After aligning sector returns, cash returns, SPY, the factor matrix, and the risk-free rate, the main sample runs from February 1999 to June 2026 with 329 aligned monthly observations.
5.1 Rolling Factor Regression
Full-sample betas are useful for broad interpretation, but factor exposures change through time. Technology in 2002 isn’t technology in 2025. Energy in 2014 isn’t energy in 2022. Financials after the GFC don’t behave like financials before the GFC. So we estimate rolling 60-month factor regressions.
The time index on \(\beta_{t,i}\) is important. It means each month has its own estimated factor exposure vector. The rolling estimate answers: based on the last five years, how has this sector been behaving relative to the factor set?
The 60-month window is a compromise:
long enough to estimate six factor loadings plus an intercept,
short enough to let exposures adapt,
slow enough to avoid treating every short-term shock as a permanent beta change.
Rolling regressions are noisy, especially when factors are correlated. But for sector-level analysis, the rolling beta path is often more informative than one full-sample number.
For a date at the end of month \(t\), the window contains the current aligned observation and the preceding 59 months. Moving to \(t+1\) drops the oldest observation and adds the new one, so both the fitted exposure and the residual at the latest month can change.
5.2 Rolling Regression Stability and Estimation Risk
Rolling regressions give time-varying betas, but they also introduce estimation noise. With a 60-month window and six factors plus an intercept, each regression estimates seven coefficients from 60 observations. That is workable, but not huge.
where \(X_t\) is the rolling factor matrix for the 60-month window. If two factors are highly correlated inside that window, \(X_t^\top X_t\) becomes less stable. Then the beta estimates can move for statistical reasons, not only economic reasons.
This is especially relevant for style factors. Value, profitability, conservative investment, and size can overlap in some regimes. A sector’s exposure might look like “value” in one rolling window and “conservative investment” in another because the factors themselves are moving together.
We use rolling betas as signals that summarize the relationship over the available estimation window. The factor score doesn’t say the sector permanently belongs to a specific style. It says that based on the recent relationship between sector returns and factors, this sector currently behaves as if it has certain exposures.
A strong strategy shouldn’t require every beta estimate to be exact. It should still work if the broad exposure ranking is approximately right.
The seven coefficients include an intercept and six factor loadings. Correlation between factor columns makes it harder to separate their individual contributions even when the combined fitted return remains informative. The least-squares solution is estimated separately for each sector and each rolling window.
alpha, beta, r2, eps = rolling_factor_fit(sector_excess, factors, window=60)latest_beta = beta.dropna(how="all").iloc[-1].unstack("factor")fig, axes = plt.subplots(1, 3, figsize=(20, 4.8))im = axes[0].imshow(latest_beta, cmap="coolwarm", aspect="auto")axes[0].set_title("latest sector factor betas")axes[0].set_xticks(range(len(latest_beta.columns)))axes[0].set_xticklabels(latest_beta.columns, rotation=45, ha="right")axes[0].set_yticks(range(len(latest_beta.index)))axes[0].set_yticklabels(latest_beta.index)fig.colorbar(im, ax=axes[0], fraction=0.046, pad=0.04)r2.mean(axis=1).dropna().plot(ax=axes[1], lw=1.4)axes[1].set_title("rolling average sector R²")axes[1].set_ylabel("R²")axes[1].set_ylim(0, 1)main_factor ="Mkt-RF"if"Mkt-RF"in factor_cols else factor_cols[0]for ticker in [t for t in ["XLK", "XLE", "XLU", "XLF"] if t in sector_tickers]: beta[(ticker, main_factor)].dropna().plot(ax=axes[2], lw=1.2, label=ticker)axes[2].set_title(f"rolling {main_factor} beta")axes[2].axhline(1.0, color="black", lw=0.8)axes[2].legend()fig.tight_layout()plt.show()
The latest rolling betas give us a factor signature for each sector in the current estimation window. The rolling average \(R^2\) stays fairly high, which means the Fama-French factor set explains a meaningful portion of sector excess returns. But it doesn’t explain everything, and the unexplained residual later becomes part of the signal design.
The rolling market beta panel is especially useful. Sectors like XLK and XLF tend to have strong market exposure, while defensive sectors such as utilities can have lower or more unstable market beta. Energy can move through very different regimes because oil and inflation shocks sometimes dominate broad equity beta.
The rolling average \(R^2\) also tells us when factor explanation becomes stronger or weaker. During broad market crises, correlations rise and common factors explain more of the cross-section. During idiosyncratic sector shocks, residuals become larger and \(R^2\) can fall. That is one reason we don’t rely only on factor betas. We later add residual strength, trend strength, and risk penalties.
5.3 Factor State Construction
A factor exposure is only useful if we also know whether the factor itself is currently attractive. A sector can have high value beta, but that only helps if value is in a strong state. So we build a factor-state score for each factor.
The raw state combines recent factor performance over two horizons:
\(R_{t,k}^{short}\) is the recent short-horizon factor return,
\(R_{t,k}^{long}\) is the longer momentum return with a one-month skip,
\(\sigma_{t,k}\) is factor volatility over the volatility window.
The denominator matters because a 5% move in a low-volatility factor is more significant than a 5% move in a very volatile factor. This is similar to a Sharpe-like normalization.
Then we standardize the raw factor state through a rolling z-score:
where \(\bar{S}_{t,k}\) and \(s_{t,k}\) are the rolling mean and standard deviation of the raw state. Finally, we clip the result to \([-2,2]\) to prevent one extreme observation from dominating all sector scores.
A positive \(z_{t,k}\) means factor \(k\) is strong relative to its own recent history. A negative value means the factor is weak.
In this implementation the short return compounds the most recent six months, while the long component compounds eleven months after shifting by one month. Volatility uses a 36-month sample standard deviation multiplied by \(\sqrt{12}\), and the raw score is standardized against its own rolling 36-month mean and standard deviation before clipping.
5.4 Interpreting a Factor State Numerically
A factor state is easier to understand with a small numerical intuition. Suppose value has strong recent returns and moderate volatility, so its raw state is high. If the raw state is also high relative to its own last 36 months, the z-score becomes positive. If it is unusually high, it can hit the clip value of \(+2\).
A clipped value of \(+2\) doesn’t mean the factor will definitely keep working. It means the factor is in the strongest state we allow the model to express. The cap prevents the score from exploding.
If a sector has value beta \(\beta_{HML}=0.6\) and the current \(HML\) state is \(z_{HML}=1.7\), the value component of the sector score is:
\[
0.6 \times 1.7 = 1.02
\]
If another sector has value beta \(-0.4\), its value component is:
\[
-0.4 \times 1.7 = -0.68
\]
So the same factor state rewards one sector and penalizes the other. This is the whole idea of factor rotation: we don’t directly buy the factor, we tilt toward assets whose exposures match the current factor environment.
The same logic applies to every factor, and the final factor score sums all components. A sector can be helped by value but hurt by profitability or momentum at the same time.
Show code
def factor_state(factors, short_window=6, long_window=12, vol_window=36, skip=1, clip=2.0): f = pd.DataFrame(factors).astype(float) short = (1.0+ f).rolling(short_window).apply(np.prod, raw=True) -1.0 long_len =max(long_window - skip, 1)long= (1.0+ f.shift(skip)).rolling(long_len).apply(np.prod, raw=True) -1.0 vol = f.rolling(vol_window).std().replace(0.0, np.nan) * np.sqrt(12.0) raw = (0.45* short +0.55*long) / vol mean = raw.rolling(vol_window).mean() sd = raw.rolling(vol_window).std().replace(0.0, np.nan)return ((raw - mean) / sd).clip(-clip, clip)def factor_proxy_spreads(returns, benchmark_ticker="SPY", cash_ticker="SHY"): r = pd.DataFrame(returns).astype(float) out = {}if benchmark_ticker in r.columns and cash_ticker in r.columns: out["Mkt-RF"] = r[benchmark_ticker] - r[cash_ticker]elif benchmark_ticker in r.columns: out["Mkt-RF"] = r[benchmark_ticker]if"IWM"in r.columns and benchmark_ticker in r.columns: out["SMB"] = r["IWM"] - r[benchmark_ticker]elif"RSP"in r.columns and benchmark_ticker in r.columns: out["SMB"] = r["RSP"] - r[benchmark_ticker]if"IWD"in r.columns and"IWF"in r.columns: out["HML"] = r["IWD"] - r["IWF"]elif"VLUE"in r.columns and benchmark_ticker in r.columns: out["HML"] = r["VLUE"] - r[benchmark_ticker]if"QUAL"in r.columns and benchmark_ticker in r.columns: out["RMW"] = r["QUAL"] - r[benchmark_ticker]if"USMV"in r.columns and benchmark_ticker in r.columns: out["CMA"] = r["USMV"] - r[benchmark_ticker]if"MTUM"in r.columns and benchmark_ticker in r.columns: out["MOM"] = r["MTUM"] - r[benchmark_ticker]return pd.DataFrame(out).sort_index()def blend_factor_states(academic_state, tradable_state, academic_weight=0.70, disagreement_scale=0.50): a = pd.DataFrame(academic_state).astype(float) t = pd.DataFrame(tradable_state).astype(float) idx = a.index.intersection(t.index) out = a.copy() cols = a.columns.intersection(t.columns)iflen(idx) ==0orlen(cols) ==0:return out blended = academic_weight * a.loc[idx, cols] + (1.0- academic_weight) * t.loc[idx, cols] agree = np.sign(a.loc[idx, cols]) * np.sign(t.loc[idx, cols]) >=0 out.loc[idx, cols] = blended.where(agree, blended * disagreement_scale)return out
5.5 Academic and Tradable Factor-State Blending
We compute factor states in two ways:
Academic state from the Fama-French factor returns.
Tradable state from ETF proxy spreads such as IWM vs SPY, IWD vs IWF, MTUM vs SPY, and QUAL vs SPY.
with \(w_a = 0.70\). Academic factors get more weight because they are cleaner and longer-established. Tradable proxies get 30% because they tell us whether the academic premium is showing up in live ETF behavior.
When academic and tradable states disagree in sign, the blended signal is cut by a disagreement scale:
\[
z_{t,k}^{final} = d \cdot z_{t,k}^{blend}, \qquad d = 0.50
\]
This is a useful risk control. If the academic value factor is strong but value ETFs aren’t confirming it, the model still listens to the academic signal, but with less conviction.
For example, if the academic state is \(1.0\) and the tradable state is \(0.4\), their weighted blend is \(0.70(1.0)+0.30(0.4)=0.82\). If the proxy state were \(-0.4\), the blend would be \(0.58\) before the disagreement multiplier and \(0.29\) afterwards. The sign check affects the confidence in the blend, not the sign of the underlying factor return.
The latest factor states include very strong \(SMB\) at the cap of 2.000, strong \(HML\) at 1.991 and \(MOM\) at 2.000, a positive market state around 0.998 and conservative-investment state around 0.280, and a negative profitability state around -1.528. This says the current style environment is tilted toward small-cap and value-like behavior, with weaker support for profitability/quality.
Each value is standardized relative to that factor’s own recent history.
6) Sector Scores and Signal Components
Once we have rolling betas and current factor states, the factor score is a dot product:
The dot product connects the current factor states with the estimated exposures of each sector. If \(SMB\) and \(HML\) are strong, sectors with positive small/value exposure get rewarded. If \(RMW\) is weak, sectors with strong profitability exposure get penalized.
After computing the raw score, we cross-sectionally standardize it across sectors:
This makes scores comparable across dates. A score of 1.5 means the sector is high relative to other sectors at that month, not necessarily high in absolute return terms.
For each sector, the dot product sums one contribution per available factor. A positive loading multiplied by a positive state contributes positively, while a negative loading on the same factor contributes negatively. Cross-sectional standardization then measures the sector’s position relative to its peers in that month.
Show code
def cross_section_z(values): v = pd.DataFrame(values).astype(float) mean = v.mean(axis=1) sd = v.std(axis=1, ddof=0).replace(0.0, np.nan)return v.subtract(mean, axis=0).divide(sd, axis=0)def factor_scores(beta, z_factor): z = pd.DataFrame(z_factor).astype(float) dates = beta.index.intersection(z.index) assets = beta.columns.get_level_values("asset").unique() out = pd.DataFrame(np.nan, index=dates, columns=assets)for date in dates: b = beta.loc[date].unstack("factor") cols = b.columns.intersection(z.columns)iflen(cols): out.loc[date, b.index] = b[cols].dot(z.loc[date, cols])return out
We rank XLE highest at 1.763, followed by XLF at 1.729, XLI at 0.929, and XLB at 0.140. XLK is the lowest at -1.232, with utilities and staples also negative.
This ranking is economically coherent given the latest factor states. Strong \(SMB\) and \(HML\) favor sectors that behave more cyclically and value-like. Energy, financials, industrials, and materials fit that environment better than technology. XLK gets penalized because it is more growth-like and less aligned with a value/small-cap style state.
The score gives us a conditional ranking: given the current factor environment and the sector’s estimated factor loadings, energy is the most aligned sector in the latest cross-section. The ranking depends on both the current factor state and the estimated exposure. If the factor states change, the ranking can change quickly.
The heatmap history also shows that factor scores rotate across time. A factor model that always likes the same sectors would be suspicious. We expect sector scores to shift because factor premia shift through regimes.
At each date, every sector is evaluated against the same factor-state vector, while its own rolling betas determine how strongly each factor enters the score.
6.1 Residual Strength, Trend Strength, and Risk Penalty
The factor score explains sectors through common premia, but factor models always leave residuals. We use three extra components:
Residual strength
The residual from the rolling factor regression is:
Positive residual strength means the sector has been outperforming what its factor exposure predicts. That can capture sector-specific news, earnings revisions, commodity shocks, regulatory changes, or investor flows that aren’t captured by the factor set.
Trend strength
Trend strength is a 12-month return signal with a one-month skip:
Higher values mean the sector is riskier. Later, risk enters as a negative component in the dynamic score.
The residual sum and the skipped trend use different inputs: residual strength accumulates the part left after the contemporaneous factor fit, while trend compounds the sector’s own preceding monthly returns. The risk measure adds annualized 36-month volatility to the magnitude of the drawdown from a 12-month rolling NAV peak.
Recent residual strength adds the sector-specific behavior left outside the factor score. For example, a sector can have mediocre factor alignment but strong positive residuals, meaning it has been outperforming its expected factor return. IYZ is a good example in the latest table: factor score is near neutral, but residual and trend are strong, so its final dynamic score becomes high.
The latest trend score rewards sectors with stronger recent price persistence. IYZ and XLK show strong trend readings, while IYR, XLP, XLF, and XLV are weaker. This helps separate style alignment from actual price behavior. XLK has very poor factor score, but its trend is still positive. The final dynamic score has to decide how much to trust each component.
The risk penalty panel shows which sectors carry elevated volatility/drawdown risk. XLE and XLK have high risk penalty values in the latest snapshot, while XLF has a lower relative risk reading. Risk penalty isn’t always active in the final validation weights, but it still matters as a cash trigger inside the portfolio construction. If broad sector risk is elevated, the strategy can allocate part of the portfolio to SHY.
6.2 Validation-Weighted Dynamic Score
We don’t fix the signal weights forever. We let recent validation evidence decide how much weight to give to factor, residual, and trend components.
The weights \(w_t\) are based on recent predictive evidence. The validation window looks back over prior months and checks whether each signal actually ranked future returns well. The main metric is rank information coefficient:
The validation weight rewards signals that have positive rank IC and positive active payoff. It also has caps so one noisy component can’t dominate.
At date \(t\), the validation history ends before the current score date. For each component, we take the positive part of its historical mean rank IC and the positive part of its top-versus-average active return, then multiply them to form an evidence score. Those scores are normalized, blended with base weights, and capped before the current component scores are combined.
Show code
def rank_ic_series(score, future_returns): vals = []for date in score.index.intersection(future_returns.index): s = score.loc[date] r = future_returns.loc[date].reindex(s.index) mask = s.notna() & r.notna() vals.append(s[mask].rank().corr(r[mask].rank()) if mask.sum() >=3else np.nan)return pd.Series(vals, index=score.index.intersection(future_returns.index), name="rank_ic")def top_active_series(score, future_returns, top_n=4): vals = []for date in score.index.intersection(future_returns.index): s = score.loc[date].dropna().sort_values(ascending=False) r = future_returns.loc[date].reindex(s.index)iflen(s) < top_n: vals.append((date, np.nan))continue top = r.reindex(s.head(top_n).index).mean() avg = r.mean() vals.append((date, top - avg if np.isfinite(top) and np.isfinite(avg) else np.nan))return pd.Series(dict(vals)).sort_index()def top_return_series(score, future_returns, top_n=4): vals = []for date in score.index.intersection(future_returns.index): s = score.loc[date].dropna().sort_values(ascending=False) r = future_returns.loc[date].reindex(s.index)iflen(s) < top_n: vals.append((date, np.nan))continue vals.append((date, r.reindex(s.head(top_n).index).mean()))return pd.Series(dict(vals)).sort_index()def max_drawdown_from_returns(returns): r = pd.Series(returns, dtype=float).dropna()if r.empty:return np.nan nav = (1.0+ r).cumprod()return (nav / nav.cummax() -1.0).min()def component_weights(raw_weights, base_weights=None, validation_strength=1.0, max_component_weights=None): vals = pd.Series(raw_weights, dtype=float).replace([np.inf, -np.inf], np.nan).fillna(0.0).clip(lower=0.0)if vals.sum() <=1e-12: vals.loc[:] =1.0/len(vals)else: vals = vals / vals.sum()if base_weights isnotNone: base = pd.Series(base_weights, dtype=float).reindex(vals.index).fillna(0.0).clip(lower=0.0)if base.sum() >1e-12: base = base / base.sum() strength = np.clip(validation_strength, 0.0, 1.0) vals = (1.0- strength) * base + strength * vals vals = vals / vals.sum()if max_component_weights isnotNone: caps = pd.Series(max_component_weights, dtype=float).reindex(vals.index).fillna(1.0).clip(lower=0.0, upper=1.0)for _ inrange(len(vals) +2): vals = vals.clip(upper=caps) deficit =1.0- vals.sum()if deficit <=1e-12:break room = (caps - vals).clip(lower=0.0)if room.sum() <=1e-12:break vals = vals + deficit * room / room.sum()if vals.sum() >1e-12: vals = vals / vals.sum()return vals.to_dict()def validation_weighted_score(scores, future_returns, risk=None, window=72, min_periods=36, top_n=4, risk_weight=0.20, base_weights=None, validation_strength=1.0, max_component_weights=None): frames = {name: pd.DataFrame(value).astype(float) for name, value in scores.items()} future = pd.DataFrame(future_returns).astype(float) idx = future.index cols = future.columnsfor frame in frames.values(): idx = idx.intersection(frame.index) cols = cols.intersection(frame.columns) risk_frame = pd.DataFrame(risk).astype(float) if risk isnotNoneelseNoneif risk_frame isnotNone: idx = idx.intersection(risk_frame.index) cols = cols.intersection(risk_frame.columns) idx = pd.DatetimeIndex(idx).sort_values() out = pd.DataFrame(np.nan, index=idx, columns=cols) score_weights = pd.DataFrame(np.nan, index=idx, columns=list(frames.keys()) + ["risk"]) ic = {name: rank_ic_series(frame.reindex(index=idx, columns=cols), future.reindex(index=idx, columns=cols)) for name, frame in frames.items()} active = {name: top_active_series(frame.reindex(index=idx, columns=cols), future.reindex(index=idx, columns=cols), top_n=top_n) for name, frame in frames.items()}for i, date inenumerate(idx): hist = idx[max(0, i - window):i]iflen(hist) < min_periods:continue edges = {}for name in frames: ic_mean = ic[name].reindex(hist).mean() active_mean = active[name].reindex(hist).mean() edges[name] =max(0.0, ic_mean if np.isfinite(ic_mean) else0.0) *max(0.0, active_mean if np.isfinite(active_mean) else0.0) edge_sum =sum(edges.values()) raw_weights = {name: edge / edge_sum for name, edge in edges.items()} if edge_sum >1e-12else {name: 1.0/len(frames) for name in frames} weights_now = component_weights(raw_weights, base_weights=base_weights, validation_strength=validation_strength, max_component_weights=max_component_weights) base_score = pd.Series(0.0, index=cols, dtype=float) hist_score = pd.DataFrame(0.0, index=hist, columns=cols)for name, frame in frames.items(): base_score = base_score + weights_now[name] * frame.loc[date, cols] hist_score = hist_score + weights_now[name] * frame.loc[hist, cols] risk_use =0.0if risk_frame isnotNone: candidate_hist = hist_score - risk_weight * risk_frame.loc[hist, cols] base_active = top_active_series(hist_score, future.reindex(index=hist, columns=cols), top_n=top_n).mean() candidate_active = top_active_series(candidate_hist, future.reindex(index=hist, columns=cols), top_n=top_n).mean() base_dd = max_drawdown_from_returns(top_return_series(hist_score, future.reindex(index=hist, columns=cols), top_n=top_n)) candidate_dd = max_drawdown_from_returns(top_return_series(candidate_hist, future.reindex(index=hist, columns=cols), top_n=top_n)) active_ok = np.isfinite(candidate_active) and np.isfinite(base_active) and candidate_active >= base_active drawdown_ok = np.isfinite(candidate_dd) and np.isfinite(base_dd) and candidate_dd >= base_ddif active_ok or drawdown_ok: risk_use = risk_weight base_score = base_score - risk_use * risk_frame.loc[date, cols] out.loc[date] = base_scorefor name, value in weights_now.items(): score_weights.loc[date, name] = value score_weights.loc[date, "risk"] = risk_usereturn out, score_weightsnext_returns = sector_returns.shift(-1)s_total, validation_weights = validation_weighted_score({"factor": s_factor, "residual": s_resid, "trend": s_trend}, next_returns, risk=s_risk, window=72, min_periods=36, top_n=4, risk_weight=0.20, base_weights={"factor": 0.60, "residual": 0.25, "trend": 0.15}, validation_strength=0.50, max_component_weights={"residual": 0.30, "trend": 0.25})fig, axes = plt.subplots(1, 2, figsize=(16, 4.8))total_plot = s_total.dropna(how="all").tail(84).Tim = axes[0].imshow(total_plot, cmap="coolwarm", aspect="auto")axes[0].set_title("validation-weighted sector score history")axes[0].set_yticks(range(len(total_plot.index)))axes[0].set_yticklabels(total_plot.index)step =max(1, len(total_plot.columns) //8)locs =list(range(0, len(total_plot.columns), step))axes[0].set_xticks(locs)axes[0].set_xticklabels([total_plot.columns[i].strftime("%Y-%m") for i in locs], rotation=45, ha="right")fig.colorbar(im, ax=axes[0], fraction=0.046, pad=0.04)latest_total_score = s_total.dropna(how="all").iloc[-1].sort_values(ascending=False)display(latest_total_score.to_frame("s_dynamic").round(3))display(validation_weights.dropna(how="all").tail(12).round(3))latest_total_score.sort_values().plot(kind="barh", ax=axes[1])axes[1].axvline(0, color="black", lw=0.8)axes[1].set_title("latest validation-weighted score")fig.tight_layout()plt.show()
s_dynamic
XLE
0.7440
XLI
0.6920
XLF
0.5830
IYZ
0.5120
XLB
0.3160
IYR
0.0980
XLK
-0.2350
XLV
-0.5780
XLP
-0.6400
XLY
-0.7400
XLU
-0.7530
factor
residual
trend
risk
date
2025-07-31
0.4780
0.3000
0.2220
0.2000
2025-08-31
0.5320
0.3000
0.1680
0.0000
2025-09-30
0.5010
0.3000
0.1990
0.2000
2025-10-31
0.4970
0.3000
0.2030
0.2000
2025-11-30
0.5030
0.3000
0.1970
0.0000
2025-12-31
0.4990
0.3000
0.2010
0.2000
2026-01-31
0.5960
0.2330
0.1710
0.0000
2026-02-28
0.5230
0.2760
0.2000
0.2000
2026-03-31
0.5160
0.2340
0.2500
0.2000
2026-04-30
0.5680
0.1820
0.2500
0.2000
2026-05-31
0.5610
0.1890
0.2500
0.2000
2026-06-30
0.5690
0.1810
0.2500
0.2000
In the latest months, the factor component carries the largest weight, trend receives 0.250, residual receives 0.181, and the separate risk component is 0.200 in the latest month. The weights summarize the recent validation history used in the combination.
The latest dynamic score ranks XLE first at 0.744, XLI second at 0.692, XLF third at 0.583, and IYZ fourth at 0.512. XLU is last at -0.753, while XLK has a negative dynamic score despite its strong trend.
This is a useful example of a multi-component score working as intended:
XLE has a very strong factor score and acceptable trend, so it becomes the top sector.
IYZ has a slightly negative factor score but strong residual and trend readings, so it ranks fourth.
XLI has positive factor alignment and a favorable relative risk reading, placing it second despite a slightly negative trend score.
XLK has strong residual and trend readings, but negative factor alignment and a positive risk penalty leave its combined score below zero.
XLF has very strong factor alignment and a favorable relative risk reading, though its residual and trend components are weak; it ranks third overall.
The validation-weight table shows factor at 0.569, residual at 0.181, trend at 0.250, and risk at 0.200 in the latest month. That says the recent validation evidence trusts factor alignment most, but still keeps residual and trend in the model.
7) Signal Validation
The validation metrics measure different things.
Rank IC measures whether the ordering of scores matches the ordering of future returns. It ignores magnitude and focuses only on rank. This is useful because sector rotation is mostly a ranking problem: we want high-score sectors to do better than low-score sectors.
\[
IC_t = Corr(rank(s_t), rank(R_{t+1}))
\]
If \(IC_t > 0\), the score is directionally useful that month. If \(IC_t < 0\), the ranking is backward.
Top-minus-bottom payoff tests a simple long-short spread. It asks whether the top-ranked sectors beat the bottom-ranked sectors:
A signal can have positive top-minus-bottom but weak top-versus-average if it mainly identifies losers rather than winners. For a long-only sector strategy, top-versus-average matters a lot.
Hit rate measures the fraction of periods where the spread is positive. A signal with 50% hit rate can still be useful if its winning months are larger than its losing months. So we don’t judge the signal only by hit rate.
For a small example, if the top four sectors average \(2.0\%\) next month and the bottom four average \(0.5\%\), the top-minus-bottom payoff is \(1.5\%\). If the average across all sectors is \(1.2\%\), the top-versus-average payoff is \(0.8\%\). Both use the ranking formed before those subsequent returns are realized.
Show code
def signal_validation(scores, future_returns, top_n=4, bottom_n=4): rows = [] spreads = {} top_active = {} ic_series = {} avg = future_returns.mean(axis=1)for name, score in scores.items(): ic = rank_ic_series(score, future_returns) ic_series[name] = ic spread_vals = [] active_vals = []for date in score.index.intersection(future_returns.index): s = score.loc[date].dropna().sort_values(ascending=False)iflen(s) <max(top_n, bottom_n):continue r = future_returns.loc[date].reindex(s.index) top = r.reindex(s.head(top_n).index).mean() bottom = r.reindex(s.tail(bottom_n).index).mean()if np.isfinite(top) and np.isfinite(bottom): spread_vals.append(top - bottom) active_vals.append(top - avg.loc[date]) spread_s = pd.Series(spread_vals, dtype=float) active_s = pd.Series(active_vals, dtype=float) spreads[name] = spread_s top_active[name] = active_s rows.append( {"signal": name, "rank IC": ic.mean(), "top-minus-bottom": spread_s.mean(),"top-4 vs average": active_s.mean(), "hit rate": (spread_s >0).mean(), "n": spread_s.count()} )return pd.DataFrame(rows).set_index("signal"), pd.DataFrame(ic_series), spreads, top_active
We can compare the signals by how their rankings and top-minus-bottom portfolios relate to subsequent sector returns.
The dynamic score has the highest rank IC at 0.0456, compared with 0.0255 for factor score, -0.0126 for residual, and 0.0185 for trend. A rank IC around 0.046 isn’t huge, but in monthly cross-sectional sector data it is meaningful. We only have around 11 sectors, so signal measurement is noisy.
The top-minus-bottom payoff is strongest for the dynamic score at 0.0044 per month. Factor score is also positive at 0.0033. Residual is positive but small at 0.0004, and trend is close to flat at 0.0006. The dynamic score also has the best top-4 vs average active return at 0.0037 per month.
The hit rates are close to 50%. That means the signal doesn’t win every month. Its value comes from average payoff magnitude, not consistent monthly wins. This is realistic for factor rotation. Sector factor signals can be right over a cycle and still fail in many individual months.
Each statistic evaluates a different feature of the sector ranking.
7.1 Signal Decay Across Horizons
We now compare each score against compounded returns over one, three, and six months. This helps us see how the ranking behaves at different holding horizons.
Show code
def signal_decay(scores, returns, horizons=(1, 3, 6), top_n=4): rows = []for h in horizons: future = (1.0+ returns).rolling(h).apply(np.prod, raw=True).shift(-h) -1.0 avg = future.mean(axis=1)for name, score in scores.items(): vals = [] active = []for date in score.index.intersection(future.index): s = score.loc[date].dropna().sort_values(ascending=False)iflen(s) < top_n:continue ret = future.loc[date, s.head(top_n).index].mean() base = avg.loc[date]if np.isfinite(ret) and np.isfinite(base): vals.append(ret) active.append(ret - base) active_s = pd.Series(active, dtype=float) rows.append( {"signal": name, "horizon": h, "top return": np.mean(vals) if vals else np.nan,"active return": active_s.mean(), "hit rate": (active_s >0).mean(), "n": active_s.count()} )return pd.DataFrame(rows).set_index(["signal", "horizon"])decay_table = signal_decay(signals, sector_returns, horizons=(1, 3, 6), top_n=4)display(decay_table.round(4))
The hit rate is 0.5357 at one month, 0.5610 at three months, and 0.5443 at six months. This is encouraging because the signal isn’t disappearing immediately. It has some persistence.
The factor score also improves over longer horizons, with 6-month active return around 0.0082 and a 0.5696 hit rate. This matches the economic nature of factor premia. Factor rotations usually don’t work like overnight news. They tend to persist over several months when style leadership changes.
Residual strength is less stable. It has mild 1-month value but weak or inconsistent longer-horizon hit rates. That fits the interpretation: residual strength captures sector-specific outperformance, which can be shorter-lived than factor alignment.
Across the horizons, the dynamic and factor scores have the clearest positive active returns. Trend and residual provide additional information, although their longer-horizon payoffs are smaller in this sample.
8) Portfolio Construction
We translate scores into portfolio weights through a soft active allocation rule. The starting point is equal weight across sectors:
\[
w_i^{base} = \frac{1}{N}
\]
Scores are demeaned so they represent relative active preference:
\[
\tilde{s}_{t,i} = s_{t,i} - \bar{s}_t
\]
Then active weights are scaled by an active budget:
\[
w_{t,i}^{target} = w_i^{base} + B \cdot \frac{\tilde{s}_{t,i}}{\sum_j |\tilde{s}_{t,j}|}
\]
where \(B = 0.35\) is the active budget. This means the portfolio can tilt meaningfully but not recklessly. A sector with high score gets more than equal weight; a low-score sector gets less.
We also impose:
minimum sector weight of 2%,
maximum sector weight of 25%,
monthly turnover limit of 25%,
optional SHY allocation when broad risk is high,
next-close timing and 10 bps transaction cost in the backtest.
The soft active rule starts with every sector in the portfolio and tilts around that base. We can then compare the allocation with the equally weighted sector benchmark while retaining exposure across the universe.
The demeaned deviations sum to zero, so before bounds the active tilts also sum to zero and the sector target sums to one. The denominator is the sum of absolute deviations, making the sum of absolute active tilts equal to \(B\) when the scores are not all equal. The target is clipped and normalized before any cash allocation or turnover adjustment.
8.1 Portfolio Controls and Signal Translation
We translate the score into a controlled active tilt around equal weight, with separate limits on the sector target and turnover.
Raw scores can be unstable because factor states, rolling betas, residuals, and trends all contain estimation noise. If we translated scores directly into unconstrained weights, the portfolio could jump into extreme sector bets every month. The soft active rule prevents that.
The active part is normalized by the total absolute score deviation:
\[
a_{t,i} = B \cdot \frac{\tilde{s}_{t,i}}{\sum_j |\tilde{s}_{t,j}|}
\]
When the deviations are nonzero, their absolute values sum to \(B\): multiplying every deviation by the same positive number leaves the active tilt unchanged. If all deviations are zero, we use the equal-weight base. The sector bounds and subsequent normalization control the resulting target.
The min/max constraints also matter. A 2% minimum keeps every sector represented, which reduces the chance of completely missing a sudden reversal. A 25% maximum prevents one sector from dominating. The turnover limit makes the strategy more realistic because it doesn’t let weights instantly jump from one month to the next.
The SHY risk overlay is conservative. It doesn’t try to time the entire equity market. It only allows cash-like exposure when broad sector risk is elevated relative to its own history.
The 2% and 25% bounds are applied to the initial sector target before it is renormalized. Scaling the sector sleeve for SHY and limiting the change from the previous allocation can alter the final sector weights, so those figures describe the target-building step rather than a guarantee that every final sector weight stays inside both bounds.
latest_weights = pd.concat({name: w.iloc[-1] for name, w in weights.items()}, axis=1).fillna(0.0)display(latest_weights.loc[latest_weights.abs().sum(axis=1).gt(0)].round(3))fig, ax = plt.subplots(figsize=(12, 5))weight_plot = w_final.tail(60).loc[:, w_final.tail(60).mean().sort_values(ascending=False).index].Tim = ax.imshow(weight_plot, cmap="Blues", aspect="auto", vmin=0.0)ax.set_title("final strategy weights, last 60 months")ax.set_yticks(range(len(weight_plot.index)))ax.set_yticklabels(weight_plot.index)step =max(1, len(weight_plot.columns) //8)locs =list(range(0, len(weight_plot.columns), step))ax.set_xticks(locs)ax.set_xticklabels([weight_plot.columns[i].strftime("%Y-%m") for i in locs], rotation=45, ha="right")fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)fig.tight_layout()plt.show()
Sector Equal Weight
SPY
Sector Momentum
Factor Score
Factor + Residual
Factor + Residual + Trend - Risk
XLB
0.0910
0.0000
0.0860
0.0960
0.1180
0.1100
XLE
0.0910
0.0000
0.1250
0.1590
0.1530
0.1350
XLF
0.0910
0.0000
0.0440
0.1570
0.1080
0.1260
XLI
0.0910
0.0000
0.0870
0.1270
0.1220
0.1320
XLK
0.0910
0.0000
0.1600
0.0440
0.0800
0.0770
XLP
0.0910
0.0000
0.0550
0.0540
0.0630
0.0530
XLU
0.0910
0.0000
0.0700
0.0560
0.0490
0.0460
XLV
0.0910
0.0000
0.0730
0.0680
0.0440
0.0570
XLY
0.0910
0.0000
0.0720
0.0690
0.0430
0.0470
IYR
0.0910
0.0000
0.0650
0.0910
0.1040
0.0970
IYZ
0.0910
0.0000
0.1620
0.0800
0.1150
0.1210
SPY
0.0000
1.0000
0.0000
0.0000
0.0000
0.0000
We can compare the latest sector weights across the different allocation strategies. The final dynamic strategy overweights the top-ranked sectors but stays diversified.
The final strategy puts the largest weights in XLE, XLI, XLF, IYZ, and XLB, matching the latest dynamic score. XLK receives a weight around 7.7%, which is a modest underweight versus the equal-weight sector portfolio. That underweight comes from weak factor alignment and high risk despite positive trend.
The weight heatmap for the last 60 months shows the portfolio rotating through sectors rather than locking into one permanent allocation. Energy and financials receive larger weights during value/cyclical regimes. Technology exposure changes depending on whether growth/momentum is supported by the factor environment. Defensive sectors like staples, utilities, and health care rise during periods where risk or residual conditions favor them.
This is exactly the behavior we want from a factor-rotation model: it shouldn’t act like a static sector benchmark, but it also shouldn’t jump into extreme all-in positions.
9) Backtest Results and Portfolio Evaluation
We now evaluate the factor strategies with transaction costs, the turnover rule, next-close timing, and the same sector universe used in the score construction.
The comparisons use the same aligned return panel for each strategy.
Across the strategy comparison, we can compare compound return, volatility, and drawdown together. SPY has the highest CAGR at 15.97%, but the final dynamic strategy has the highest Sharpe ratio among the sector rotation strategies at 0.9240, with volatility only 13.09% and max drawdown around -16.20%.
Compared with sector equal weight:
sector equal weight CAGR: 12.27%,
final dynamic CAGR: 12.02%,
sector equal weight volatility: 15.93%,
final dynamic volatility: 13.09%,
sector equal weight max drawdown: -23.00%,
final dynamic max drawdown: -16.20%.
So the final strategy doesn’t beat SPY on raw return, but it improves the sector portfolio’s risk profile substantially. It produces similar CAGR to equal-weight sectors with much lower volatility and much lower drawdown. That is the main contribution of the model.
Among active sector models, adding residual strength improves performance, and the full factor + residual + trend - risk version is the best on Sharpe, Sortino, Calmar, and drawdown. The improvement is not dramatic but it is consistent.
The trading results let us compare the turnover and costs of each allocation rule. Sector equal weight has average turnover around 0.019 per month, while active models are around 0.10 to 0.11. The final strategy has average turnover around 0.0996 and total turnover around 8.36 over the sample.
At 10 bps cost, the cost drag is about 0.0054 annualized for the final strategy. That is small enough for the signal to survive, but it isn’t free. If trading costs were much higher, the advantage would shrink.
Effective N is around 9.0 for the active sector models, compared with 11 for equal weight and 1 for SPY. This tells us the strategy remains diversified. It isn’t simply making one or two large sector bets. That matters because sector rotation can easily overfit if it concentrates too aggressively in recent winners.
The combination of lower volatility, lower drawdown, and effective N around 9 suggests the model is acting like a controlled tilt engine rather than a high-conviction timing system.
Show code
nav = pd.DataFrame({name: result.net_values for name, result in results.items()})net_returns = pd.DataFrame({name: result.net_returns for name, result in results.items()})turnover = pd.DataFrame({name: result.turnover for name, result in results.items()})nav = nav.divide(nav.dropna().iloc[0])rolling_12 = (1.0+ net_returns).rolling(12).apply(np.prod, raw=True) -1.0rolling_active = rolling_12.subtract(rolling_12["SPY"], axis=0)fig, axes = plt.subplots(3, 2, figsize=(18, 13))axes = axes.ravel()nav.plot(ax=axes[0], lw=1.2)axes[0].set_title("costed strategy NAV")axes[0].set_ylabel("growth of $1")nav.apply(calc_drawdown).plot(ax=axes[1], lw=1.1)axes[1].set_title("strategy drawdowns")axes[1].set_ylabel("drawdown")rolling_12.plot(ax=axes[2], lw=1.1)axes[2].axhline(0, color="black", lw=0.8)axes[2].set_title("rolling 12-month return")rolling_active.drop(columns="SPY").plot(ax=axes[3], lw=1.1)axes[3].axhline(0, color="black", lw=0.8)axes[3].set_title("rolling active return versus SPY")turnover.plot(ax=axes[4], lw=1.1)axes[4].set_title("strategy turnover")axes[4].set_ylabel("turnover")axes[5].axis("off")fig.tight_layout()plt.show()
SPY compounds the most strongly in the NAV comparison, especially during the technology-led post-2020 and 2023-2025 periods. The sector strategies grow more slowly but with smoother drawdowns. This is consistent with the table: the sector rotation models are better risk-adjusted than sector equal weight, but they don’t fully capture the cap-weighted growth dominance embedded in SPY.
The drawdown plot is one of the strongest visual arguments for the factor strategy. Active sector models have shallower drawdowns than equal weight and SPY during several stress windows. The final dynamic strategy avoids some of the deepest sector equal-weight pain.
The rolling 12-month return plot shows that SPY dominates during strong growth-led markets. The rolling active return versus SPY is often negative during those periods, especially when the model underweights technology. That is a real limitation. A factor rotation model that penalizes growth-style exposure can lag badly when mega-cap growth leads the entire market.
The turnover panel confirms that active strategies trade regularly but not explosively. Turnover spikes around regime shifts, which is expected because factor states and sector ranks change fastest during stress and recovery periods.
The portfolio-level factor exposure is computed as the weighted average of sector betas:
\[
\beta_{t,p,k} = \sum_i w_{t,i}\beta_{t,i,k}
\]
In vector form:
\[
\beta_{t,p} = W_t^\top B_t
\]
where \(W_t\) is the portfolio weight vector and \(B_t\) is the matrix of sector betas. This tells us what kind of factor portfolio the final strategy has become after selecting sector weights.
The product adds each sector’s exposure to factor \(k\) weighted by its portfolio allocation. For instance, weights of \(0.6\) and \(0.4\) with factor betas \(1.2\) and \(0.8\) give a portfolio beta of \(0.6(1.2)+0.4(0.8)=1.04\). Cash has no sector-beta row in this calculation, so its weight is not added as an equity-factor exposure.
Show code
def portfolio_factor_exposure(weights, beta): W = pd.DataFrame(weights).astype(float) assets = beta.columns.get_level_values("asset").unique() factor_names = beta.columns.get_level_values("factor").unique() out = pd.DataFrame(np.nan, index=W.index, columns=factor_names)for date in W.index: hist = beta.loc[:date].dropna(how="all")if hist.empty:continue b = hist.iloc[-1].unstack("factor") w = W.loc[date].reindex(assets).fillna(0.0) common = b.index.intersection(w.index)iflen(common): out.loc[date] = w.reindex(common).dot(b.reindex(common))return out
We can read the final strategy through its sector-level components and the validation weights assigned to them.
When factor score dominates, the model is saying style premia are the most useful explanation of future sector leadership. In the latest snapshot, this is exactly the case: factor score receives more than half of the validation weight.
When residual strength matters, the model is saying some sector-specific behavior isn’t fully captured by the common factor set. This can be earnings revisions, commodity shocks, regulation, AI-related narratives, banking stress, or sector-level flows.
When trend strength matters, the model is saying price persistence itself has had useful predictive power. Trend can capture investor herding and slow information diffusion, but it can also lag at regime turning points.
When risk penalty matters, the model is saying elevated volatility and drawdown are informative enough to penalize risky sectors or trigger SHY. In the latest month, validation gives the risk penalty a 0.200 component weight, and risk also enters the cash overlay logic.
We can use the component values to explain why a sector is overweight: factor alignment, residual outperformance, price trend, and risk control each enter the score or allocation through a different rule.
Stress Window Interpretation
We can compare the portfolio returns across the selected stress and recovery windows.
During the COVID crash, SPY loses about -19.42% and sector equal weight loses -22.16%. The final dynamic strategy loses -16.20%, which is materially better. The model likely benefits from diversified sector weights and some defensive/cash behavior.
During the 2022 rates shock, SPY loses -17.74%, sector equal weight loses -10.39%, and the final dynamic strategy loses only -5.05%. This is where factor rotation is most useful. 2022 was a regime where value, energy, commodities, and some defensive/cyclical exposures outperformed long-duration growth. A cap-weighted SPY portfolio was heavily exposed to technology and rate-sensitive growth stocks, while the factor strategy rotated away from that pressure.
The trade-off appears in 2023 and 2024-2025. SPY strongly outperforms during the growth rebound, returning 26.18% in 2023 and 47.02% in 2024-2025. The final strategy returns 7.94% and 28.53% in those windows. The model protected in stress but lagged when mega-cap growth dominated.
This is an honest result: the model improves risk-adjusted sector allocation, not market-cap growth chasing.
Average Exposure, Contribution, and Best/Worst Months
The average weights indicate that the final strategy stays broadly diversified across sectors. Average weights range from about 7.76% to 9.10%, with SHY around 7.50%. That means most of the performance difference comes from moderate rotations through time, not huge average tilts.
The contribution table is more revealing. XLK contributes the most at 0.1652, followed by XLE at 0.1541. This is important because the model’s current underweight to XLK doesn’t mean technology wasn’t historically valuable. XLK remains the highest contributor over the whole backtest, while XLE also contributes strongly as the strategy holds energy exposure during the inflation/rates regime.
Financials, discretionary, industrials, and materials also contribute meaningfully. SHY contributes very little to return, as expected, but it can still help reduce risk during high-risk periods.
The best months include April 2020, November 2020, October 2022, November 2024, and November 2023. The worst months are February/March 2020, June/September 2022, and September 2023. The worst-month list confirms that the strategy still carries equity-sector risk. It reduces drawdowns, but it doesn’t remove crash exposure.
Average weight tells us how much capital we held in a sector, while contribution summarizes how much that sector added to the portfolio return over time.
The latest estimated portfolio factor exposures are:
market beta around 0.908,
positive \(CMA\) exposure around 0.221,
positive \(HML\) exposure around 0.243,
positive \(RMW\) exposure around 0.174,
small positive \(SMB\) exposure around 0.092,
slightly negative \(MOM\) exposure around -0.050.
This is economically consistent with the current weights. The portfolio is less than full market beta because it isn’t SPY and includes sector diversification. It has clear value/conservative-investment tilt, matching the strong latest \(HML\) and positive \(CMA\) states. Momentum exposure is near zero, which means the latest allocation isn’t simply chasing recent winners.
Latest Final Diagnostics
We can connect the latest factor states, sector scores, validation weights, and final allocation.
The factor states show strong \(SMB\), \(HML\), and \(MOM\), positive market and \(CMA\) states, and weak \(RMW\). Sector scores then translate those states into energy, industrials, financials, telecom, and materials preference. The validation weights are 56.9% for factor score, 18.1% for residual strength, and 25.0% for trend. The separate risk penalty coefficient is 0.20.
The final weights are:
overweight XLE, XLI, XLF, IYZ, and XLB,
slightly overweight IYR,
underweight XLK, XLP, XLY, XLU, and XLV relative to equal weight.
Active weight versus equal weight is strongest in XLE (+4.4%) and weakest in XLU (-4.5%). This is a clear style view: the current allocation favors value/cyclical/real-economy sectors and avoids mega-cap growth exposure.
The result is internally coherent. The latest factor environment says small/value-style premia are strong; sector betas say energy and financial/cyclical sectors benefit; validation says factor score has had the best recent evidence; the final portfolio tilts accordingly.
10) Developed ex-U.S. Country ETF Extension
The secondary implementation repeats the same workflow on developed ex-U.S. country ETFs using the library. The tradable universe includes country ETFs such as Japan, UK, Germany, France, Switzerland, Australia, Canada, Netherlands, Sweden, Italy, Spain, Hong Kong, and Singapore. EFA is the benchmark.
This secondary application is economically different from sector rotation. In the U.S. sector case, every ETF is inside the same country and currency regime. In the country ETF case, each country has local equity-market structure, sector composition, currency exposure, and regional macro sensitivity. Japan, Switzerland, Australia, Hong Kong, and Germany don’t just differ by style factor beta; they also differ by currency, export exposure, financial structure, commodities, and domestic policy.
10.1 Developed ex-U.S. Factor Architecture
For the country universe, we use developed ex-U.S. Fama-French factors rather than U.S. factors. That matters because country equity returns outside the U.S. don’t map perfectly onto U.S. style premia.
but now \(i\) is a country ETF and \(f_t\) is the developed ex-U.S. factor vector. A country can have high value exposure because its market is dominated by banks, insurers, energy, or old-economy exporters. Another country can have lower value exposure because its index has more defensive health care, consumer staples, or high-quality multinationals.
Examples:
Germany often has cyclical/export sensitivity through industrials, autos, and chemicals.
Switzerland tends to have defensive quality exposure through health care and consumer staples.
Australia and Canada can carry commodity and financial exposures.
Hong Kong has strong China/financial/property sensitivity.
Japan has a mix of exporters, financials, industrials, and currency-sensitive behavior.
The factor regression gives a common language for comparing these country ETFs, but residual and trend components become even more important because country-specific macro forces can be large.
The country factor vector uses the same six-factor notation, but its underlying factor portfolios are drawn from the developed ex-U.S. universe. We estimate each country ETF’s loadings on the aligned monthly observations, then use those loadings with the factor states to make a relative country score.
10.2 Economic Differences Across Country ETFs
Country ETF factor investing is harder than sector factor investing because the country ETF return contains several layers:
Even if the ETF is unhedged, U.S. investors receive returns in dollars. That means local equity performance and currency performance both matter. A Japanese equity rally can be reduced for a U.S. investor if the yen weakens. A European market can look better or worse depending on the euro. Hong Kong, Singapore, Australia, and Canada each have different currency and commodity sensitivities.
The sector composition also differs. Switzerland has large health care and staples exposure, Australia and Canada have financials and commodities, Germany has cyclicals and exporters, Hong Kong has financial/property/China sensitivity, and Japan has exporters, industrials, financials, and currency-sensitive companies.
This means a developed ex-U.S. country factor score is not as clean as a U.S. sector factor score. The same \(HML\) or \(SMB\) state can show up differently across countries because local indices have different sector weights and currency exposures. We can check how much the residual signal contributes once the country-level scores and validation results have been calculated.
In exact simple-return terms, \((1+R_{local\ equity})(1+R_{currency})-1\) equals \(R_{local\ equity}+R_{currency}+R_{local\ equity}R_{currency}\). The last product is the interaction term, and it becomes more noticeable when either the local market or the exchange rate moves sharply.
In the latest country scores, EWH ranks highest with a dynamic score of 2.028, followed by EWJ, EWC, EWA, EWU, EWP, and EWS. Germany, France, and Switzerland rank low, with EWG at the bottom.
EWH is strong across factor alignment, residual strength, and trend, although its relative risk reading is positive. The country ETF therefore has strong recent performance beyond what its factor exposures explain, as well as strong price persistence. Japan ranks second with positive factor and residual readings and a favorable relative risk value. Canada ranks third with positive factor, residual, and trend readings. Spain has strong factor and trend values but a weak residual, placing it lower in the combined ranking.
The validation weights are factor 0.542, residual 0.300, trend 0.158, and risk 0.000. Compared with the U.S. sector application, residual receives more weight. That makes sense for country allocation because country-specific shocks are more important than they are in U.S. sector allocation. Currency, local policy, banking stress, commodity exposure, and regional politics can create returns not fully explained by developed-market style factors.
The final country weights follow the score: largest weight in EWH, then EWJ, EWC, EWA, EWU, EWP, and EWS. France, Germany, and Switzerland receive low weights.
Country Signal Validation
The country signal results vary across factors, residuals, and trend, giving us several dimensions to compare with the sector case. Residual strength has the strongest rank IC at 0.045 and a t-stat around 1.286. Dynamic score has rank IC 0.041. Factor score is weaker at 0.027, and trend is negative at -0.015.
The top-bottom table confirms the same idea. Factor score has a small positive top-minus-bottom payoff (0.0001), country trend is negative (-0.0016), while residual strength is positive (0.0025). Dynamic score is positive but small (0.0012).
The signal decay table is more encouraging for residual strength and dynamic score over longer horizons:
residual strength active return is 0.0059 at 3 months and 0.0114 at 6 months,
dynamic score active return is 0.0041 at 3 months and 0.0104 at 6 months,
hit rates for dynamic score rise to about 0.59 at 3 months and 0.64 at 6 months.
This suggests that in international country allocation, residual and dynamic signals are more useful over multi-month horizons than over one-month horizons. Country-level relative performance can take time to unfold because macro, currency, and regional flows don’t reprice instantly.
Country Strategy Comparison
In the country strategy comparison, EFA has CAGR around 9.70%, volatility 16.51%, Sharpe 0.6627, and max drawdown -27.62%. Equal-weight countries have slightly higher CAGR at 10.25% but higher volatility and a deeper drawdown.
The active country strategies reduce volatility and drawdown:
Country Momentum Sharpe: 0.7101,
Factor Score Sharpe: 0.7394,
Factor + Residual Sharpe: 0.7466,
Dynamic Factor Rotation Sharpe: 0.7324.
Factor + Residual is strongest on Sharpe, while Dynamic Factor Rotation has the lowest drawdown at -24.22% and similar volatility around 14.77%. The active models don’t massively raise return, but they improve risk-adjusted behavior and diversification. That is similar to the sector case, but the mechanism differs. For sectors, factor score was the clearest driver. For countries, residual strength matters more.
The effective N values around 10.4 also show that the strategy remains broadly diversified across countries. It is not turning into a concentrated single-country bet.
The summary uses the same country universe and cost assumptions across the active strategies.
Country Allocation Reading
The country results show a different personality from the U.S. sector results. The sector strategy’s strongest edge comes from factor alignment plus controlled residual/trend overlays. The country strategy’s strongest evidence comes from residual strength and dynamic multi-month scoring.
This is economically reasonable. Country ETFs are exposed to idiosyncratic regional forces:
Hong Kong can be driven by China policy, property, and financial conditions.
Spain and Italy can be driven by European rates, banks, tourism, and domestic cyclicals.
Japan can be driven by currency, exporters, corporate governance reform, and Bank of Japan policy.
Australia and Canada can be driven by commodities and financials.
Switzerland can behave defensively but may lag in reflation/value regimes.
A standard style factor model can’t fully encode all of that. Residual strength becomes the place where those country-specific forces appear. The validation results confirm this: residual strength has the best country-level rank IC and strongest 3- to 6-month active return.
So the country application doesn’t simply copy the U.S. sector lesson. It shows that the same framework can adapt when the data structure changes.
Country Plots and Rolling Factor Exposure
For the country application, we can inspect the same factor and portfolio diagnostics together: factor growth, factor drawdowns, factor correlation, latest factor state, country betas, rolling \(R^2\), factor-state history, dynamic country score history, latest scores, signal IC, top-bottom payoff, NAV, drawdowns, rolling active return versus EFA, weights, and turnover.
The rolling factor exposure plot shows the final strategy’s factor profile through time. Market beta stays near one most of the time, which is expected because country ETFs are equity ETFs. Value, investment, profitability, size, and momentum exposures move around this market core as country weights rotate.
The main economic reading is that country factor rotation is a relative allocation overlay on top of global developed equity risk. It can improve drawdown and risk-adjusted performance, but it doesn’t eliminate broad equity-market exposure. When global equities sell off together, country selection helps only at the margin. When regional leadership changes gradually, the factor/residual structure has more room to add value.
This secondary result is useful because it shows the same framework generalizes beyond U.S. sectors, but the strongest component changes. In sectors, factor alignment had the clearest evidence. In countries, residual strength and multi-month dynamic scoring matter more because country-specific macro and currency effects are harder for style factors to fully capture.
11) Interpretation Discipline for Factor Scores
A factor score is a conditional ranking, so the interpretation has to stay conditional too. A high score doesn’t mean the sector is permanently superior. It means the sector’s current estimated exposures line up with factor states that have recently been rewarded.
Each term has a different economic story. Style alignment is about exposure to common premia. Residual strength is about sector-specific behavior beyond the factor model. Trend confirmation is about persistence in realized returns. Risk penalty is about not overpaying for unstable or already-damaged sectors.
This also helps explain disagreements. If a sector has strong factor alignment but weak residual and trend, the estimated exposure matches the broad factor state while recent sector-specific behavior and price persistence are weaker. If factor alignment is weak but residual and trend are strong, recent sector behavior has been favorable outside the common-factor explanation.
These disagreements are where analyst interpretation matters most. The model gives a structured score, but the score components tell us where the evidence is coming from. That is more useful than only reporting final weights.
The component scores can move independently as their inputs change.