Skip to content

factrix.metrics.predictive_beta

Single-asset predictive regression beta.

Notes

Pipeline. One asset, dense factor, time-series OLS with Newey-West heteroskedasticity-and-autocorrelation-consistent (HAC) standard error on the slope.

Input. Long panel with date, asset_id, factor, forward_return where asset_id has one unique value.

Output. MetricResult.value is the predictive slope beta and p_value tests H0: beta = 0.

factrix.metrics.predictive_beta.predictive_beta

predictive_beta(data: DataFrame, *, newey_west_lags: int | None = None, overlap_periods: int = DEFAULT_FORWARD_PERIODS, adf_threshold: float | None = 0.1, factor_col: str = 'factor', return_col: str = 'forward_return', expected_warnings: tuple[str, ...] = ()) -> MetricResult

Predictive beta for a single-asset dense factor.

Fits the direct predictive regression \(R_{t+h} = \alpha + \beta F_t + \varepsilon_t\) on one asset and tests H0: beta = 0. The headline bias-corrected slope test uses the project's scalar HAR bandwidth; the raw OLS reference retained in metadata uses the narrower Newey-West rule.

value is the Stambaugh-bias-corrected slope, not the raw OLS one. Stambaugh (1999) showed OLS here is biased by \((\sigma_{ev}/\sigma_v^2)(1+3\phi)/T\) whenever the predictor is persistent and its AR(1) innovation correlates with the return innovation — the classic dividend-yield artefact. The bias is the product of persistence \(\phi\) and innovation correlation \(\rho\), so an ADF screen on the regressor cannot detect it: ADF proxies \(\phi\) only and has low power at exactly the \(\phi \approx 0.95\) values where the bias bites. Against a true \(\beta = 0\) at \(T=60,\ \phi=0.95,\ \rho=-0.9\), plain OLS averaged \(+0.076\) and rejected 20.6% at a nominal 5%.

The correction is the Amihud-Hurvich (2004) augmented regression — see factrix._stats.ols._amihud_hurvich_beta for the construction, the generated-regressor standard error, the two factrix departures from AH and the measured bias / size table. The raw OLS slope stays in metadata["beta_ols_uncorrected"] and the correction applied in metadata["stambaugh_bias_estimate"].

What the correction does and does not fix. At overlap_periods = 1 the corrected test is calibrated: 4.3–5.5% at a nominal 5% when \(\rho = 0\) and 6.2–8.3% in the strongest Stambaugh cells, against 8.4–18.3% for plain OLS. At overlap_periods > 1 it is not — 7.5–14.5% measured, and the excess is there at \(\rho = 0\) for every \(\phi\), so it is neither the Stambaugh channel nor the near-unit-root regime. It is the overlapping-regression HAC problem (plain OLS-NW carries the same excess), only partly repaired by the HAR bandwidth and fixed-\(b\) effective df this test now uses. Read an h > 1 predictive \(p\) against a raised hurdle regardless of the correction. WarningCode.OVERLAPPING_PREDICTIVE_INFERENCE makes that known regime explicit on every such result. WarningCode.HAC_BANDWIDTH_ILL_CONDITIONED records the separate kernel estimation problem, and it reads both pairs: the resolved bandwidth against the finite pairs, which is the quantity it was calibrated on, and the applied bandwidth against the augmented design's rows, which is where the Bartlett sum actually formed its lag products. The two are not interchangeable — an explicit newey_west_lags can leave the design thin while the finite pairs are not — and they are not a partition with WarningCode.UNRELIABLE_SE_SHORT_PERIODS either: both fire together wherever both hold.

The covariance follows the same horizon split. At overlap_periods = 1 the corrected test uses Amihud-Hurvich's homoskedastic covariance; only overlap_periods > 1 uses the resolved Newey-West HAR bandwidth. metadata["method"], hac_applied and har_lags report that branch directly. har_lags is the bandwidth the kernel ran at: the augmented design is n_periods rows and a Bartlett sum cannot use a lag it has no observation pair for, so a long horizon leaves it narrower than the bandwidth resolved from the full series.

On the no_amihud_hurvich_fit short circuit each key is judged on its own side of the envelope, not as one "inference" bundle:

  • har_lags and newey_west_lags are resolved quantities. The resolution really ran, so the bandwidth reported is the one the kernel would have used — attempt side, kept.
  • beta_ols_uncorrected and factor_std were measured before the bail — kept, and the former is the slope the metric declined to publish as a headline.
  • hac_applied and stambaugh_adjusted are past-tense claims that a stage of the inference ran. Neither did, so both are False. The resulting (hac_applied=False, har_lags=<int>) pair is unreachable on any computed result — an impossible combination a consumer can detect, rather than a plausible one that reads as a completed fit.

Branch on metadata["reason"] before reading any of them.

Each clause of the HAC_BANDWIDTH_ILL_CONDITIONED message names the key whose value it prints — n_periods_finite for the finite pairs, n_periods for the augmented design's rows. This metric is the one place the two diverge, which is why it cannot borrow the single n_periods token the other HAR consumers share.

The correction costs power where OLS's apparent power was partly its own bias: at \(T=60,\ \phi=0.95,\ \rho=-0.9\) the corrected test rejects 28.8% of a true alternative against OLS's 88.6%. At \(\rho = 0\), where OLS is unbiased, the gap is small (63.2% against 70.5%). A metric that stops being significant after the correction was not necessarily significant before it.

Parameters:

Name Type Description Default
data DataFrame

Single-asset long panel with date, asset_id, factor_col and return_col.

required
newey_west_lags int | None

Optional explicit Bartlett lag. None uses the project default bandwidth.

None
overlap_periods int

Forward-return horizon injected by evaluate from the panel metadata; standalone calls may pass it directly.

DEFAULT_FORWARD_PERIODS
adf_threshold float | None

Augmented Dickey-Fuller p-value above which the factor is flagged as a unit-root suspect. None disables the check. This is a persistence screen on the regressor, not a Stambaugh-bias screen — the bias itself is corrected unconditionally, and WarningCode.PERSISTENT_REGRESSOR also fires off the measured bias channel |rho_hat * phi_corrected| above _STAMBAUGH_CHANNEL_WARN, which is the trigger that tracks the problem.

0.1
factor_col str

Predictor column.

'factor'
return_col str

Forward-return column.

'forward_return'

Returns:

Type Description
MetricResult

MetricResult with value = the bias-corrected beta, stat

MetricResult

= its t statistic, and p_value for H0: beta = 0. A

MetricResult

perfect fit or a degenerate design withholds the test

MetricResult

(stat / p_value are None under

MetricResult

WarningCode.DEGENERATE_VARIANCE) while keeping value.

MetricResult

Every metadata key names the fit it belongs to.

MetricResult

metadata["alpha"], metadata["residual_lag1_autocorr"],

MetricResult

n_obs, metadata["n_periods"] and

MetricResult

metadata["n_periods_effective"] describe the corrected fit —

MetricResult

the model value came from, on the rows it was estimated on.

MetricResult

metadata["beta_ols_uncorrected"] and

MetricResult

metadata["r_squared_ols_uncorrected"] are the pre-correction OLS

MetricResult

reference, on the full finite-pair sample

MetricResult

(metadata["n_periods_finite"]). There is deliberately no

MetricResult

corrected \(R^2\): the Amihud-Hurvich slope does not minimise the sum

MetricResult

of squares, so 1 - SSR/SST off its residual can go negative and

MetricResult

has no standing in this literature, which reports \(R^2\) for the OLS

MetricResult

regression.

Notes

This is not a common_beta fallback. common_beta tests the cross-asset mean of per-asset betas and therefore remains a PANEL metric. predictive_beta is the explicit TIMESERIES dense metric for a single asset.

Sample definition: the regression runs on the finite (factor, return) pairs — non-null and neither NaN nor ±inf. polars' drop_nulls keeps float NaN (unlike pandas, where NaN is the missing marker), and a single NaN reaching the Newey-West slope routine either raises or yields a NaN beta that MetricResult rejects — one bad cell would fail the whole series. metadata["n_periods_finite"] counts those pairs.

n_obs counts the rows the headline test ran on, which is n_periods_finite - overlap_periods: the augmented design spends the first observation on the AR(1) lag and, at overlap_periods > 1, the last overlap_periods - 1 windows on the horizon-summed innovation proxy (a zero-padded truncated sum is a different regressor from the one every other row carries). If that design cannot be formed, the metric short-circuits instead of substituting an uncorrected OLS test.

What the persistence flags do and do not say. PERSISTENT_REGRESSOR is an ADF verdict on the regressor: the unit-root null was not rejected at adf_threshold. It is not a statement about the size of this test. Under the classic Stambaugh setup (AR(1) \(\phi = 0.99\), \(\mathrm{corr}(u_x, \varepsilon_r) = -0.9\), true \(\beta = 0\), 300 seeds) the flag and the distortion move in opposite directions with the sample: at \(T = 500\) it fires on 74-75% of draws while the test rejects 13% (h=1) / 35% (h=21) at a nominal 5%; at \(T = 2500\) the ADF test has the power to reject the unit root, the flag falls silent (0.0-0.3% of draws) and the test still rejects 9% / 14%. Read it as "this slope carries persistent-regressor risk", never as "no bias here" when it is absent. The Stambaugh bias itself is corrected unconditionally — this flag is about the regressor, not about whether value still carries the bias.

SERIAL_CORRELATION_DETECTED is the complementary screen, on the residuals of the reported corrected model — y - alpha - value * factor over the n_obs rows — read at stride overlap_periods: above PERSISTENT_SERIES_AUTOCORR no HAC or bootstrap path in the library is calibrated, which is the same rule fm_beta and the series-mean inference members apply to their own tested series. The stride matters — overlapping forward returns give the raw residuals an MA(\(h-1\)) structure by construction, which the \(h - 1\) Bartlett floor already absorbs, so an unstrided screen would fire on every \(h > 1\) run and carry no information; :class:~factrix.inference.NonOverlapping strides its own screen for the same reason.

UNRELIABLE_SE_SHORT_PERIODS reads the effective sample n_obs // overlap_periods, not the raw pair count. Overlapping forward returns mean n rows carry about n / h independent observations, and the HAC lag floor rises with h at the same time, so a raw count well clear of the floor can still be a handful of independent draws: at \(T = 120\), \(h = 21\) the regression runs on 98 rows with a Bartlett lag of 20 and rejects 17.5% of null draws at a nominal 5%, while the raw-n gate stayed silent. At h = 1 the effective and raw counts coincide, so nothing changes there.


Use cases

  • Single-asset dense predictive slope


    predictive_beta is the (*, DENSE, TIMESERIES) metric for a one-asset panel. It fits forward_return ~ factor and tests whether the slope differs from zero.

  • Not a common_beta fallback


    common_beta tests the cross-asset mean of per-asset betas and remains PANEL-only. predictive_beta is the explicit single-series predictive regression.

  • HAC inference for overlapping returns


    At overlap_periods > 1, the headline bias-corrected slope test uses the project's scalar HAR bandwidth and effective degrees of freedom. Its Amihud-Hurvich generated-regressor covariance does not apply the separate finite-sample variance scale used by scalar Wald consumers. At overlap_periods = 1 it uses the original homoskedastic covariance. The raw OLS reference retained in metadata stays on the narrower Newey-West bandwidth and is labelled as uncorrected. Metadata reports the headline branch directly: hac_applied=False and har_lags=None at h = 1; both switch to the HAR path at h > 1. The method string names the covariance that produced the headline test, and har_lags is the bandwidth the kernel ran at — the augmented design is n_periods rows, so a long horizon can leave it narrower than the bandwidth resolved from the full series.

  • Persistent predictor diagnostic


    The metric also runs a lightweight ADF check on the factor series. When adf_p exceeds adf_threshold, metadata sets unit_root_suspected=True and the result carries WarningCode.PERSISTENT_REGRESSOR. The beta is still returned; the warning tells you to read the slope as a persistent-regressor risk. The Stambaugh bias correction still applies; this diagnostic describes the remaining inference regime, not whether the correction ran.

    Read it as a verdict on the regressor, not on this test's size. Under a classic Stambaugh design (AR(1) phi = 0.99, corr(u_x, eps_r) = -0.9, true beta = 0, 300 seeds) the flag and the distortion move apart as the sample grows: at T = 500 it fires on ~75% of draws while the test rejects 13% (h = 1) / 35% (h = 21) at a nominal 5%; at T = 2500 the ADF test can reject the unit root, the flag goes silent (~0% of draws) and the test still rejects 9% / 14%. A silent flag is not evidence of an unbiased slope. The Stambaugh bias itself is corrected unconditionally, so the flag is about the regressor, not about whether value still carries the bias.

  • Overlap and residual persistence


    Three further screens read the sample the standard error actually has. WarningCode.OVERLAPPING_PREDICTIVE_INFERENCE is present whenever overlap_periods > 1: the corrected test remains 7.5-14.5% oversized across the measured null cells, including independent-regressor designs. The estimate and p-value remain available, but the code makes the need for a raised hurdle and an h = 1 or genuinely non-overlapping sensitivity check machine-readable. This is separate from EVENT_WINDOW_OVERLAP, which belongs to event studies and removes overlapping events before testing.

    WarningCode.SERIAL_CORRELATION_DETECTED fires when the reported model's residuals — y - alpha - value * factor over the n_obs rows, so the bias-corrected fit's residuals whenever the correction applies — have a lag-1 autocorrelation above PERSISTENT_SERIES_AUTOCORR (0.3) — the same rule fm_beta and the series-mean inference members apply. WarningCode.UNRELIABLE_SE_SHORT_PERIODS reads n_periods_effective = n_periods // overlap_periods, not the raw row count: n overlapping rows carry about n / h independent observations while the HAC lag floor rises with h. At T = 120, h = 21 the regression runs on 98 rows with a Bartlett lag of 20 and rejects 17.5% of null draws at a nominal 5%, a regime the raw-n gate could not see.

  • Stability is a workflow


    predictive_beta returns the full-sample HAC slope test. Rolling or expanding beta checks should be treated as descriptive stability diagnostics with pre-declared windows, not as a second family of p-values to rank or feed into multiple-testing correction.

Worked example

single-asset panel -> predictive_beta

import polars as pl
import factrix as fx
from factrix.metrics import predictive_beta
from factrix.preprocess import compute_forward_return

raw = fx.datasets.make_cs_panel(n_assets=4, n_dates=180, rng=0)
asset = raw["asset_id"].unique().sort()[0]
panel = compute_forward_return(
    raw.filter(pl.col("asset_id") == asset),
    forward_periods=5,
)

out = fx.evaluate(
    panel,
    metrics={"predictive_beta": predictive_beta()},
    factor_cols=["factor"],
    forward_periods=5,
)["factor"].metrics["predictive_beta"]

print(out.value, out.stat, out.p_value, out.metadata["unit_root_suspected"])

Stability workflow

For a single-asset dense factor, the first question is still the full-sample HAC slope: does factor_t have a statistically legible relation to forward_return_{t+h}? Stability checks come after that and should answer a different question: whether the slope is broadly persistent through time or mostly supported by one segment of the sample.

Use pre-declared windows and read the rolling betas descriptively. The windowed calls below reuse the same predictive_beta estimator, but the resulting t_stat / p_value values are not independent tests because overlapping windows share observations.

rolling beta series as a diagnostic

import polars as pl
import factrix as fx
from factrix.metrics import directional_hit_rate, predictive_beta
from factrix.preprocess import compute_forward_return

raw = fx.datasets.make_cs_panel(n_assets=4, n_dates=180, rng=0)
asset = raw["asset_id"].unique().sort()[0]
panel = compute_forward_return(
    raw.filter(pl.col("asset_id") == asset),
    forward_periods=5,
)

full = predictive_beta(panel, overlap_periods=5)
hit = directional_hit_rate(panel, overlap_periods=5)

dates = panel.select("date").unique().sort("date")["date"].to_list()
window_periods = 60
step_periods = 20
rows = []
for end_idx in range(window_periods, len(dates) + 1, step_periods):
    window_dates = dates[end_idx - window_periods : end_idx]
    window = panel.filter(
        pl.col("date").is_between(window_dates[0], window_dates[-1])
    )
    result = predictive_beta(window, overlap_periods=5)
    rows.append(
        {
            "date": window_dates[-1],
            "window_start": window_dates[0],
            "window_end": window_dates[-1],
            "beta": result.value,
            "t_stat": result.stat,
            "n_periods": result.metadata["n_periods"],
            "r_squared_ols": result.metadata["r_squared_ols_uncorrected"],
        }
    )

beta_series = pl.DataFrame(rows)
reference_sign = 1.0 if full.value >= 0.0 else -1.0
stability = beta_series.select(
    ((pl.col("beta") * reference_sign) > 0).mean().alias("sign_consistency"),
    pl.col("beta").median().alias("median_beta"),
    pl.col("beta").last().alias("recent_beta"),
    pl.col("beta").min().alias("min_beta"),
    pl.col("beta").max().alias("max_beta"),
)

print("full beta:", full.value, "p:", full.p_value)
print("directional hit:", hit.value, "p:", hit.p_value)
print(stability)

Read this output as a stability profile:

  • full.value / full.p_value is the canonical single-asset dense inference.
  • directional_hit_rate checks whether the factor gets the return sign right.
  • sign_consistency asks how often rolling betas keep the full-sample sign.
  • recent_beta, median_beta, min_beta, and max_beta show whether the signal is decaying, flipping, or concentrated in one segment.

Avoid turning this workflow into automatic model selection. Do not choose the window after looking at the strongest beta, do not feed overlapping-window p_value values into bhy, and do not interpret sign_consistency as a formal structural-break test. If the stability profile points to a regime change, split that regime hypothesis explicitly and test it as a separate research design.