factrix.metrics.common_beta ¶
Time-series beta metrics for macro common factors.
macro_common factors (VIX, gold, USD index) are a single time series shared across all assets. Per-asset time-series regression measures each asset's sensitivity (β) to the common factor.
compute_common_betas: per-asset full-sample TS regression → {factor: per-asset DataFrame}.
common_beta: cross-sectional test on the β distribution.
common_beta_r_squared: average explanatory power across assets.
compute_rolling_common_beta: rolling window mean β for stability analysis.
The common factor here is an exogenous, given series — not a jointly-estimated Barra/APT factor return.
Notes
Pipeline. Per-asset full-sample ordinary least squares (OLS) β (time-series step), then cross-asset t on the β distribution; rolling-window variant slices the time axis before the per-asset step.
factrix.metrics.common_beta.common_beta ¶
common_beta(common_betas_df: DataFrame, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Test \(H_0: \mathrm{mean}(\beta) = 0\) across assets.
value is the cross-sectional mean of the per-asset betas; the test
reads its standard error off the calendar-time portfolio (see Notes).
Notes
Stage 2 of the BJS-style aggregation order:
\(\overline{\beta} = \mathrm{mean}_i \hat\beta_i\) with
\(H_0: \mathbb{E}[\beta] = 0\) across assets. The textbook form is the
iid cross-asset \(t = \overline{\beta} / (\mathrm{std}(\beta) /
\sqrt{N})\); it is kept as metadata["stat_uncorrected"] when the
calendar-time test runs, and is reported as the headline only when
the input is a hand-built beta table
(calendar_time_se_source = "unavailable_hand_built_frame"), with
WarningCode.COMMON_BETA_IID_FALLBACK. If producer-supplied
calendar-time inference cannot be formed, factrix keeps the mean beta
but withholds the headline statistic and p-value.
What factrix tests with, and why. \(\mathrm{std}(\beta)/\sqrt{N}\)
is the SE of a mean over independent draws. Assets loading on a
common component do not give independent betas, and the
understatement is unbounded in the correlation: at \(N = 8\) the iid
test rejected 3.0% of true nulls at \(\rho = 0\), 44.8% at 0.5 and
79.2% at 0.9. With one regressor shared by every asset,
\(\overline{\hat\beta}\) is exactly the OLS slope of the equal-weight
portfolio return on the factor (the calendar-time construction
caar uses; Fama (1998)), and that regression's
Newey-West(\(h - 1\)) variance \(V_{\mathrm{EW}}\) carries whatever
cross-asset residual covariance, heteroskedasticity and overlap there
is, with no equicorrelation estimate. The random-effects null adds
the dispersion of the true betas, so
\(\mathrm{SE}^2 = V_{\mathrm{EW}} + \hat\tau^2 / N\) with
\(\hat\tau^2 = \max(0, s^2_\beta - \mathrm{mean}_i
\widehat{\mathrm{Var}}(\hat\beta_i))\) (DerSimonian-Laird), and the
\(t\) is read against a Welch-Satterthwaite df across the two
components (:func:_calendar_time_se).
An earlier version deflated the iid \(t\) by the full Kolari-Pynnönen
factor \(\sqrt{(1 - \bar r)/(1 + (N - 1)\bar r)}\) on the mean
residual correlation \(\bar r\). That factor is exact only when the
betas are equal, the residuals homoskedastic and equicorrelated, and
the cross-sectional spread is estimation noise; with true beta
dispersion (sd 0.5 around a mean of 0.2, \(\rho = 0.5\)) it rejected
0.0% against 41% uncorrected, and 0.7% on a heteroskedastic null.
Measured with the calendar-time SE (300 draws, \(T = 300\), nominal
5%): null size 4.0 / 5.0 / 5.3% at \(N = 20\) and \(\rho = 0 / 0.5 /
0.9\) (5.7 / 6.7% at \(N = 5\), \(\rho = 0.5 / 0.9\); 5.0 / 5.3% at
\(N = 100\)), 5.3% on the heteroskedastic null, 7.0% with beta sd 1
around a zero mean at \(\rho = 0.5\); power at mean 0.2 / 0.4, sd 0.5,
\(\rho = 0.5\): 28.7% / 80.7% (0.0% under the old factor). Per-asset
\(\widehat{\mathrm{Var}}(\hat\beta_i)\)
is the homoskedastic OLS variance behind t_stat, which at
\(h > 1\) understates the overlap-inflated noise, so \(\hat\tau^2\) is
over-stated and the SE errs conservative there. On an unbalanced
panel the equal-weight slope (ew_portfolio_beta) can differ from
the mean of the per-asset slopes; both are reported. On a
COMMON-scope panel null the test measures 2.0-7.3% across
\(T \in \{60, 120, 240\}\) and \(h \in \{1, 5\}\)
(reference/inference-calibration).
References
Black-Jensen-Scholes 1972: beta-sorted-portfolio time-series CAPM tests. factrix's cross-asset t on mean β is a simplified analogue of the BJS aggregation order, not a replication of the grouped-portfolio intercept test BJS run on assets sorted into beta deciles.
Examples:
Chain from :func:compute_common_betas output:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.common_beta import compute_common_betas, common_beta
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
... forward_periods=5,
... )
>>> common_betas_df = compute_common_betas(panel)["factor"]
>>> result = common_beta(common_betas_df)
>>> result.name == ""
True
factrix.metrics.common_beta.common_beta_profile ¶
common_beta_profile(common_betas_df: DataFrame, *, neutral_epsilon: float = EPSILON, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Descriptive sign and dispersion profile of per-asset common-factor betas.
value is the positive-minus-negative beta mean spread when both sides
exist; otherwise it is NaN and the side counts in metadata explain why.
No hypothesis test is run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
common_betas_df
|
DataFrame
|
Per-asset beta table from
:func: |
required |
neutral_epsilon
|
float
|
Absolute-beta threshold treated as neutral. Betas with
|
EPSILON
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with descriptive beta-profile metadata and |
MetricResult
|
|
factrix.metrics.common_beta.common_beta_r_squared ¶
common_beta_r_squared(common_betas_df: DataFrame, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Average \(R^2\) across per-asset TS regressions — value \(= \mathrm{mean}_i R^2_i\).
\(R^2_i\) comes from asset \(i\)'s regression
\(R_{i,t} = \alpha_i + \beta_i \cdot F_t + \varepsilon\) (computed
upstream in compute_common_betas). Metadata carries
median_r_squared as well — useful when a few high-\(R^2\) assets
pull the mean. Low values (\(< 0.05\)) indicate the factor is too
weak or noisy to drive individual-asset returns even when its
cross-asset mean \(\beta\) looks nonzero.
Short-circuits to NaN when no assets have a finite \(R^2\).
Notes
value \(= \mathrm{mean}_i R^2_i\) and median_r_squared
\(= \mathrm{median}_i R^2_i\) on the per-asset ordinary least squares (OLS) fits from
compute_common_betas. Pure descriptive statistic — no formal
\(H_0\).
factrix reports both mean and median because a few high-\(R^2\) assets can dominate the mean; large mean-vs-median gaps density the factor explains a small subset of assets rather than the cross-section as a whole.
Examples:
Chain from :func:compute_common_betas output:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.common_beta import compute_common_betas, common_beta_r_squared
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
... forward_periods=5,
... )
>>> common_betas_df = compute_common_betas(panel)["factor"]
>>> result = common_beta_r_squared(common_betas_df)
>>> result.name == ""
True
factrix.metrics.common_beta.common_beta_sign_consistency ¶
common_beta_sign_consistency(common_betas_df: DataFrame, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Symmetric sign-agreement across per-asset βs — value = max(pos, 1−pos) where pos = mean_i 1{β_i > 0}.
Range [0.5, 1.0]: 0.5 = βs evenly split (no directional consensus);
1.0 = all βs share one sign. Unlike
fm_beta.fm_beta_sign_consistency this is direction-agnostic
— it does not require a prior on the factor's expected sign.
Requires n_assets >= 2: a single β is trivially "100% consistent with
itself" (the max collapses to 1.0 for any nonzero β), which would
read as strong evidence on a dashboard but carries zero information.
Short-circuits to NaN in that case so the degenerate value never
leaks into downstream inference.
Notes
pos = mean_i 1{beta_i > 0}; value = max(pos, 1 - pos).
Direction-agnostic: returns 1 when all assets have positive
beta or all negative.
factrix gates this metric at n_assets >= 2 so a single-asset
max(pos, 1-pos) = 1.0 cannot leak into downstream
inference as spurious "perfect agreement". Pair with
fm_beta.fm_beta_sign_consistency when a directional prior
is available.
Examples:
Chain from :func:compute_common_betas output:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.common_beta import (
... compute_common_betas,
... common_beta_sign_consistency,
... )
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
... forward_periods=5,
... )
>>> common_betas_df = compute_common_betas(panel)["factor"]
>>> result = common_beta_sign_consistency(common_betas_df)
>>> result.name == ""
True
factrix.metrics.common_beta.compute_rolling_common_beta ¶
compute_rolling_common_beta(data: DataFrame, *, window: int = 60, factor_col: str = 'factor', return_col: str = 'forward_return') -> DataFrame
Rolling-window mean β across assets — time-series input for out-of-sample (OOS) / trend.
Formula (per period t ≥ window):
For each asset i, take the trailing window of dates
[dates[i - window], dates[i - 1]] — the window dates
strictly before t; date t itself is excluded.
If ≥ 10 valid (factor, return) pairs, run ordinary least squares (OLS):
R_{i,s} = α_i + β_i·F_s + ε (s in window)
β_t = mean_i β_i (cross-asset mean of this window's βs)
Dates with fewer than window trailing dates are skipped. Assets
with < 10 valid obs in the window are dropped from that date's β
calculation. If no asset qualifies at a given date, that date is
absent from the output entirely.
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with |
DataFrame
|
cross-asset mean β. Shape compatible with |
Notes
Window convention — lag-1, out-of-sample. The value stamped on
output date t is estimated on the half-open history
[t - window, t - 1]: it uses only dates strictly before t and
never t's own observation. That makes each value_t usable as a
signal at t without look-ahead — the series can be joined directly
onto date t and fed to oos / ic_trend (or traded) with no
further shifting. The alternative and more common convention (pandas
rolling(window), which closes the window at t and therefore
includes t) would make value_t contemporaneous and quietly
look-ahead-biased for exactly the OOS/trend use this producer exists
to feed; factrix pays one date of history for that guarantee.
Zero-variance regressors are skipped, not solved. An asset whose
factor is constant over the window has no identifiable slope;
lstsq does not raise there, it returns the minimum-norm solution
— slope exactly 0.0 — which would enter the cross-asset mean as a
real "no relationship" estimate and shrink value_t toward zero.
Such assets are dropped from that date's mean exactly like the
< 10-obs assets, so only identifiable slopes are averaged; a date on
which every asset is degenerate is absent from the output.
Per date t >= window, run the per-asset TS OLS over the
trailing window rows and compute value_t = mean_i beta_i.
Output schema matches the time-series tools (oos /
ic_trend), so callers can pipe rolling betas into stability
and trend diagnostics.
factrix requires at least 10 valid rows per asset within each
rolling window; below that, the asset is dropped from that
date's mean rather than imputed — keeps each value_t an
average over identifiable per-asset slopes.
Examples:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.common_beta import compute_rolling_common_beta
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
... forward_periods=5,
... )
>>> rolling = compute_rolling_common_beta(panel, window=60)
>>> set(rolling.columns) >= {"date", "value"}
True
Common-factor regression conventions
Stage-1 per-asset ordinary least squares (OLS) uses plain SE, not heteroskedasticity-and-autocorrelation-consistent (HAC) — the dominant bias under a persistent predictor is Stambaugh coefficient bias, which HAC does not address. Non-overlap resampling is not applied (stage-1 runs on the full overlapping series). See Common-factor regression conventions for the full rationale.
Use cases¶
-
Compute per-asset TS betas
Stage 1 of the Black-Jensen-Scholes aggregation: per-asset OLS \(R_{i,t} = \alpha_i + \beta_i \cdot F_t + \varepsilon\) over each asset's full sample. Pre-step for
common_beta,common_beta_profile,common_beta_r_squared, andcommon_beta_sign_consistency. Assets with fewer thanMIN_COMMON_BETA_PERIODS_HARDrows or a singular design are dropped. -
Cross-asset mean-\(\beta\) significance
Stage 2 of BJS tests \(H_0: \mathbb{E}[\beta] = 0\) across assets. The headline standard error combines the Newey-West variance of the equal-weight portfolio slope with cross-asset beta dispersion. This calendar-time term names the
CS_THEN_TSaggregation order, not a calendar interval; it retains shared residual shocks that an iid \(\mathrm{std}(\beta)/\sqrt{N}\) reference discards.A hand-built beta table has no underlying panel from which to recover the calendar-time term.
common_betathen reports the iid reference withCOMMON_BETA_IID_FALLBACK. If acompute_common_betasoutput contains the calendar-time audit columns but its variance cannot be formed, the mean beta is retained whilestatandp_valueare withheld withDEGENERATE_VARIANCE; iid inference is not silently substituted.metadata["beta_std"]andmetadata["median_beta"]are reported so asset-allocation users can see whether offsetting positive and negative betas are cancelling the average. -
Explanatory power across the cross-section
common_beta_r_squaredreports \(\overline{R^2}\) andmedian_r_squaredon the per-asset fits. Low values (\(< 0.05\)) say the factor is too weak or noisy to drive individual-asset returns even when its cross-asset mean \(\beta\) looks nonzero; large mean-vs-median gaps say the factor explains a small subset of assets rather than the cross-section as a whole. -
Heterogeneous beta profiles
A common macro factor can separate asset classes even when its average beta is close to zero. Pair
compute_common_betaswithcommon_beta_profileandcommon_beta_sign_consistencywhen equities, duration, commodities, or currencies may load with opposite signs. -
Rolling-window stability
compute_rolling_common_betaemits a(date, value)series of rolling cross-asset mean \(\beta\) at stridewindow. Output schema matches the time-series tools so callers can pipe rolling betas intotrend/oos.
Choosing a function¶
| Goal | Function |
|---|---|
| Per-asset TS beta table for downstream inspection / slicing | compute_common_betas |
| Mean-\(\beta\) significance across assets (Stage 2 of BJS) | common_beta |
| Positive / negative / neutral beta profile and sign spread | common_beta_profile |
| Average explanatory power \(\overline{R^2}\) across assets | common_beta_r_squared |
| Direction-agnostic sign agreement on per-asset \(\beta\) | common_beta_sign_consistency |
| Rolling cross-asset mean \(\beta\) series for trend / out-of-sample (OOS) pipes | compute_rolling_common_beta |
Allocation reading¶
For asset-allocation panels, read common_beta as the average exposure test, not
as the whole common-factor story. A non-significant mean beta can still sit on
top of a useful rotation profile when one group of assets has positive beta and
another has negative beta.
| Signal in the output | What to inspect |
|---|---|
| Mean beta is near zero, but the factor seems economically relevant | common_beta_profile.metadata["beta_std"], compute_common_betas(...)[factor]["beta"] |
| Betas split between positive and negative assets | common_beta_profile.metadata["n_positive_beta"], metadata["n_negative_beta"], metadata["positive_minus_negative_beta_spread"] |
| A few assets drive the common-factor fit | common_beta_r_squared.value, metadata["median_r_squared"] |
| The factor matters only in high / low states | common_quantile_spread |
These are diagnostics for factor validation. Turning the beta profile into weights, hedges, leverage, or rebalance rules belongs in the downstream portfolio/backtest layer.
Group-aware beta profile¶
common_beta_profile summarizes the whole beta vector. When the research
question is about asset classes, regions, or sectors, join user-supplied labels
to the compute_common_betas(...)[factor] table and summarize the same sign
and dispersion fields by group. Missing labels should be named explicitly
rather than silently dropped. For guidance on matching metrics to allocation
signals, see Validating allocation signals.
import polars as pl
from factrix.metrics.common_beta import compute_common_betas
betas_df = compute_common_betas(panel, factor_cols=["macro_growth"])["macro_growth"]
asset_groups = pl.DataFrame(
{
"asset_id": ["A00", "A01", "A02"],
"asset_group": ["equity", "duration", "commodity"],
}
)
grouped = (
betas_df.join(asset_groups, on="asset_id", how="left")
.with_columns(pl.col("asset_group").fill_null("__missing_group__"))
.group_by("asset_group")
.agg(
pl.len().alias("n_assets"),
(pl.col("beta") > 0).sum().alias("n_positive_beta"),
(pl.col("beta") < 0).sum().alias("n_negative_beta"),
pl.col("beta").mean().alias("beta_mean"),
pl.col("beta").std().alias("beta_std"),
pl.col("beta").abs().mean().alias("abs_beta_mean"),
(
pl.col("beta").filter(pl.col("beta") > 0).mean()
- pl.col("beta").filter(pl.col("beta") < 0).mean()
).alias("positive_minus_negative_beta_spread"),
)
)
The grouped table is a diagnostic read of beta heterogeneity, not a portfolio allocation rule.
Worked example — per-asset TS betas then cross-asset \(t\)¶
compute_common_betas → common_beta on a broadcast common-factor panel
import factrix as fx
import polars as pl
from factrix.metrics.common_beta import compute_common_betas, common_beta, common_beta_r_squared
from factrix.preprocess import compute_forward_return
# Build a panel where ``factor`` is broadcast (one value per date,
# shared across all assets) — VIX / USD-index / NFP surprise style.
raw = fx.datasets.make_cs_panel(
n_assets=50, n_dates=500, ic_target=0.08, rng=2024,
)
common = (
raw.group_by("date").agg(pl.col("factor").mean().alias("factor"))
)
panel = (
raw.drop("factor").join(common, on="date")
)
panel = compute_forward_return(panel, forward_periods=5)
betas_df = compute_common_betas(panel)["factor"]
print(betas_df.head())
# ┌──────────┬─────────┬─────────┬────────┬───────────┬───────────┐
# │ asset_id ┆ beta ┆ alpha ┆ t_stat ┆ r_squared ┆ n_periods │
# ├──────────┼─────────┼─────────┼────────┼───────────┼───────────┤
# │ A00 ┆ 0.082 ┆ 0.0001 ┆ 1.91 ┆ 0.018 ┆ 494 │
# │ ... ┆ ... ┆ ... ┆ ... ┆ ... ┆ ... │
# └──────────┴─────────┴─────────┴────────┴───────────┴───────────┘
out = common_beta(betas_df)
r2 = common_beta_r_squared(betas_df)
print(out.value, out.stat, out.p_value, r2.value)
# 0.078 6.84 4.1e-09 0.021 (approximate)
See also¶
-
trend/oos
Pipe
compute_rolling_common_betainto the series diagnostics for \(\beta\)-stability and OOS-survival reads. -
by_slice
Axis-agnostic slice dispatcher for per-slice \(\beta\) summaries.
-
Common-factor regression conventions
Stambaugh bias, plain stage-1 SE rationale, augmented Dickey-Fuller (ADF) persistence discipline.
-
Statistical methods
Cross-asset \(t\), the BJS aggregation pattern, and unit-root discipline on the common factor.
-
Metric applicability reference
When this metric applies and the sample-size guards that gate it (
MIN_COMMON_BETA_PERIODS_HARD,n_assets >= 3for cross-asset t-stat,n_assets >= 2for sign consistency). -
Common × Continuous landing
Adjacent macro-common metrics in the same cell.