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_lagsandnewey_west_lagsare resolved quantities. The resolution really ran, so the bandwidth reported is the one the kernel would have used — attempt side, kept.beta_ols_uncorrectedandfactor_stdwere measured before the bail — kept, and the former is the slope the metric declined to publish as a headline.hac_appliedandstambaugh_adjustedare past-tense claims that a stage of the inference ran. Neither did, so both areFalse. 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 |
required |
newey_west_lags
|
int | None
|
Optional explicit Bartlett lag. |
None
|
overlap_periods
|
int
|
Forward-return horizon injected by |
DEFAULT_FORWARD_PERIODS
|
adf_threshold
|
float | None
|
Augmented Dickey-Fuller p-value above which the
factor is flagged as a unit-root suspect. |
0.1
|
factor_col
|
str
|
Predictor column. |
'factor'
|
return_col
|
str
|
Forward-return column. |
'forward_return'
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
|
MetricResult
|
= its |
MetricResult
|
perfect fit or a degenerate design withholds the test |
MetricResult
|
( |
MetricResult
|
|
MetricResult
|
Every metadata key names the fit it belongs to. |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
|
MetricResult
|
the model |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
reference, on the full finite-pair sample |
MetricResult
|
( |
MetricResult
|
corrected \(R^2\): the Amihud-Hurvich slope does not minimise the sum |
MetricResult
|
of squares, so |
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_betais the(*, DENSE, TIMESERIES)metric for a one-asset panel. It fitsforward_return ~ factorand tests whether the slope differs from zero. -
Not a
common_betafallback
common_betatests the cross-asset mean of per-asset betas and remainsPANEL-only.predictive_betais 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. Atoverlap_periods = 1it 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=Falseandhar_lags=Noneath = 1; both switch to the HAR path ath > 1. Themethodstring names the covariance that produced the headline test, andhar_lagsis the bandwidth the kernel ran at — the augmented design isn_periodsrows, 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_pexceedsadf_threshold, metadata setsunit_root_suspected=Trueand the result carriesWarningCode.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: atT = 500it fires on ~75% of draws while the test rejects 13% (h = 1) / 35% (h = 21) at a nominal 5%; atT = 2500the 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 whethervaluestill carries the bias. -
Overlap and residual persistence
Three further screens read the sample the standard error actually has.
WarningCode.OVERLAPPING_PREDICTIVE_INFERENCEis present wheneveroverlap_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 anh = 1or genuinely non-overlapping sensitivity check machine-readable. This is separate fromEVENT_WINDOW_OVERLAP, which belongs to event studies and removes overlapping events before testing.WarningCode.SERIAL_CORRELATION_DETECTEDfires when the reported model's residuals —y - alpha - value * factorover then_obsrows, so the bias-corrected fit's residuals whenever the correction applies — have a lag-1 autocorrelation abovePERSISTENT_SERIES_AUTOCORR(0.3) — the same rulefm_betaand the series-mean inference members apply.WarningCode.UNRELIABLE_SE_SHORT_PERIODSreadsn_periods_effective = n_periods // overlap_periods, not the raw row count:noverlapping rows carry aboutn / hindependent observations while the HAC lag floor rises withh. AtT = 120,h = 21the 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-ngate could not see. -
Stability is a workflow
predictive_betareturns 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_valueis the canonical single-asset dense inference.directional_hit_ratechecks whether the factor gets the return sign right.sign_consistencyasks how often rolling betas keep the full-sample sign.recent_beta,median_beta,min_beta, andmax_betashow 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.