Skip to content

factrix.metrics.ts_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_ts_betas: per-asset full-sample TS regression. ts_beta: cross-sectional test on the β distribution. mean_r_squared: average explanatory power across assets. compute_rolling_mean_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.ts_beta.compute_ts_betas

compute_ts_betas(df: DataFrame, *, factor_col: str = 'factor', return_col: str = 'forward_return') -> DataFrame

Per-asset time-series ordinary least squares (OLS): R_{i,t} = α_i + β_i · F_t + ε.

Parameters:

Name Type Description Default
df DataFrame

Long panel with date, asset_id, factor, forward_return.

required
factor_col str

Column carrying the (broadcast) factor.

'factor'
return_col str

Column carrying the per-asset forward return.

'forward_return'

Returns:

Type Description
DataFrame

DataFrame with ``asset_id, beta, alpha, t_stat, r_squared,

DataFrame

n_obs. Assets with fewer thanMIN_TS_OBS`` valid rows or a

DataFrame

singular design are dropped.

Notes

Per asset i, run OLS R_{i,t} = alpha_i + beta_i * F_t + eps over the asset's full sample with homoskedastic SE; emit beta_i, alpha_i, t_i, R^2_i, n_i. The output is the per-asset stage feeding the cross-asset aggregation in ts_beta (time-series-then-cross-section order in the Black-Jensen-Scholes (BJS) tradition).

factrix reports homoskedastic per-asset t (not Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC)) at this stage because the inferential burden lives downstream — the cross-asset t in ts_beta is what a caller decides on, and adding HAC at the per-asset stage would only smear the per-asset diagnostic without affecting stage-2 inference.

References

Black-Jensen-Scholes 1972: beta-sorted-portfolio time-series CAPM tests; the two-stage time-series-then-cross-section aggregation order is what ts_beta adopts. The cross-asset t on mean β here is a simplified analogue of that aggregation order, not a replication of the original BJS grouped-portfolio intercept test.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.ts_beta import compute_ts_betas
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0),
...     forward_periods=5,
... )
>>> ts_betas_df = compute_ts_betas(panel)
>>> set(ts_betas_df.columns) >= {"asset_id", "beta", "r_squared"}
True

factrix.metrics.ts_beta.ts_beta

ts_beta(ts_betas_df: DataFrame) -> MetricOutput

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: per-asset betas come from non-overlapping time-series fits in compute_ts_betas, so the betas are approximately independent across assets unless a strong latent common factor links them.

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_ts_betas output:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.ts_beta import compute_ts_betas, ts_beta
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0),
...     forward_periods=5,
... )
>>> ts_betas_df = compute_ts_betas(panel)
>>> result = ts_beta(ts_betas_df)
>>> result.name
'ts_beta'

factrix.metrics.ts_beta.mean_r_squared

mean_r_squared(ts_betas_df: DataFrame) -> MetricOutput

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_ts_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_ts_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 signal the factor explains a small subset of assets rather than the cross-section as a whole.

Examples:

Chain from :func:compute_ts_betas output:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.ts_beta import compute_ts_betas, mean_r_squared
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0),
...     forward_periods=5,
... )
>>> ts_betas_df = compute_ts_betas(panel)
>>> result = mean_r_squared(ts_betas_df)
>>> result.name
'mean_r_squared'

factrix.metrics.ts_beta.ts_beta_sign_consistency

ts_beta_sign_consistency(ts_betas_df: DataFrame) -> MetricOutput

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 fama_macbeth.beta_sign_consistency this is direction-agnostic — it does not require a prior on the factor's expected sign.

Requires N ≥ 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 >= 2 so a single-asset max(pos, 1-pos) = 1.0 cannot leak into downstream inference as spurious "perfect agreement". Pair with fama_macbeth.beta_sign_consistency when a directional prior is available.

Examples:

Chain from :func:compute_ts_betas output:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.ts_beta import (
...     compute_ts_betas,
...     ts_beta_sign_consistency,
... )
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0),
...     forward_periods=5,
... )
>>> ts_betas_df = compute_ts_betas(panel)
>>> result = ts_beta_sign_consistency(ts_betas_df)
>>> result.name
'ts_beta_sign_consistency'

factrix.metrics.ts_beta.compute_rolling_mean_beta

compute_rolling_mean_beta(df: 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.ts_beta import compute_rolling_mean_beta
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, seed=0),
...     forward_periods=5,
... )
>>> rolling = compute_rolling_mean_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. FACTOR_ADF_P is emitted on the input series; non-overlap resampling is not applied. 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 ts_beta / mean_r_squared / ts_beta_sign_consistency. Assets with fewer than MIN_TS_OBS 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 per-asset betas come from non-overlapping time-series fits and are approximately independent unless a strong latent common factor links them.

  • Explanatory power across the cross-section


    mean_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.

  • Rolling-window stability


    compute_rolling_mean_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_ts_betas
Mean-\(\beta\) significance across assets (Stage 2 of BJS) ts_beta
Average explanatory power \(\overline{R^2}\) across assets mean_r_squared
Direction-agnostic sign agreement on per-asset \(\beta\) ts_beta_sign_consistency
Rolling cross-asset mean \(\beta\) series for trend / out-of-sample (OOS) pipes compute_rolling_mean_beta
\(N=1\) degenerate-case fallback used by Profile / Factor entry points ts_beta_single_asset_fallback

Worked example — per-asset TS betas then cross-asset \(t\)

compute_ts_betas → ts_beta on a broadcast common-factor panel

import factrix as fx
import polars as pl
from factrix.metrics.ts_beta import compute_ts_betas, ts_beta, mean_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_ts_betas(panel)
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 = ts_beta(betas_df)
r2  = mean_r_squared(betas_df)
print(out.value, out.stat, out.metadata["p_value"], r2.value)
# 0.078  6.84  4.1e-09  0.021   (approximate)

See also

  • trend / oos


    Pipe compute_rolling_mean_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_TS_OBS, \(N \geq 3\) for cross-asset \(t\), \(N \geq 2\) for sign consistency).

    reference/metric-applicability →

  • Common × Continuous landing


    Adjacent macro-common metrics in the same cell.

    api/metrics/common-continuous →