Skip to content

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.

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) -> MetricResult

Test \(H_0: \mathrm{mean}(\beta) = 0\) across assets.

Uses the cross-sectional distribution of per-asset betas.

Notes

Stage 2 of the BJS-style aggregation order: \(\overline{\beta} = \mathrm{mean}_i \beta_i\); \(t = \overline{\beta} / (\mathrm{std}(\beta) / \sqrt{N})\) with \(H_0: \mathbb{E}[\beta] = 0\) across assets. The std is the sample cross-sectional std with ddof=1.

factrix uses an iid cross-asset t at this stage rather than a clustered/heteroskedasticity-and-autocorrelation-consistent (HAC) variant: the headline null is over the cross-asset beta distribution, not the per-asset time-series slope estimates. The upstream compute_common_betas fits each asset on its full pairwise-complete forward-return series; when forward_periods > 1 those returns may overlap, but only the resulting beta vector enters this cross-asset test. A strong latent common factor can still link betas across assets.

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, seed=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) -> 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:compute_common_betas.

required
neutral_epsilon float

Absolute-beta threshold treated as neutral. Betas with abs(beta) <= neutral_epsilon are counted as neutral rather than positive or negative.

EPSILON

Returns:

Type Description
MetricResult

MetricResult with descriptive beta-profile metadata and

MetricResult

p_value=None.

factrix.metrics.common_beta.common_beta_r_squared

common_beta_r_squared(common_betas_df: DataFrame) -> 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 non-null \(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, seed=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) -> 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, seed=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 date t ≥ window): For each asset i, take the trailing window rows ending at t. 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 rows 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 date, value where value is the rolling

DataFrame

cross-asset mean β. Shape compatible with oos / ic_trend.

Notes

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, seed=0),
...     forward_periods=5,
... )
>>> rolling = compute_rolling_common_beta(panel, window=60)
>>> set(rolling.columns) >= {"date", "value"}
True

Timeseries-mode 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 Timeseries-mode 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, and common_beta_sign_consistency. Assets with fewer than MIN_COMMON_BETA_PERIODS_HARD rows or a singular design are dropped.

  • Cross-asset mean-\(\beta\) significance


    Stage 2 of BJS: \(t = \overline{\beta} / (\mathrm{std}(\beta) / \sqrt{N})\) with \(H_0: \mathbb{E}[\beta] = 0\) across assets. Cross-asset iid \(t\) is used because the headline null is over the cross-asset beta distribution, not the per-asset time-series slope SEs. A strong latent common factor can still link betas across assets. metadata["beta_std"] and metadata["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_squared reports \(\overline{R^2}\) and median_r_squared on 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_betas with common_beta_profile and common_beta_sign_consistency when equities, duration, commodities, or currencies may load with opposite signs.

  • Rolling-window stability


    compute_rolling_common_beta emits a (date, value) series of rolling cross-asset mean \(\beta\) at stride window. Output schema matches the time-series tools so callers can pipe rolling betas into trend / 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, seed=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_obs │
# ├──────────┼─────────┼─────────┼────────┼───────────┼───────┤
# │ 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_beta into the series diagnostics for \(\beta\)-stability and OOS-survival reads.

    api/metrics/trend →

  • by_slice


    Axis-agnostic slice dispatcher for per-slice \(\beta\) summaries.

    api/by-slice →

  • Timeseries-mode conventions


    Stambaugh bias, plain stage-1 SE rationale, augmented Dickey-Fuller (ADF) persistence discipline.

    reference/ts-mode-conventions →

  • Statistical methods


    Cross-asset \(t\), the BJS aggregation pattern, and unit-root discipline on the common factor.

    reference/statistical-methods →

  • Metric applicability reference


    When this metric applies and the sample-size guards that gate it (MIN_COMMON_BETA_PERIODS_HARD, n_assets >= 3 for cross-asset t-stat, n_assets >= 2 for sign consistency).

    reference/metric-applicability →

  • Common × Continuous landing


    Adjacent macro-common metrics in the same cell.

    api/metrics/common-continuous →