#2297·qlib

Bug: Rsquare(N=0) expanding path leaks inf/garbage on near-constant windows (rolling path is guarded)

Author: DogInfantryCreated Jul 9, 2026Updated Jul 9, 2026

Description

Rsquare in qlib/data/ops.py computes R² via a Cython kernel as num / sqrt(var_x * var_y). For a near-constant window var_y ≈ 0, so floating-point cancellation yields inf or a spurious finite value instead of NaN (a degenerate 0/0 regression).

Rsquare._load_internal guards against this by masking windows whose std is ≈0 to NaN — but only on the rolling (N != 0) branch:

def _load_internal(self, instrument, start_index, end_index, *args):
    _series = self.feature.load(instrument, start_index, end_index, *args)
    if self.N == 0:
        series = pd.Series(expanding_rsquare(_series.values), index=_series.index)
        # <-- no guard here
    else:
        series = pd.Series(rolling_rsquare(_series.values, self.N), index=_series.index)
        series.loc[np.isclose(_series.rolling(self.N, min_periods=1).std(), 0, atol=2e-05)] = np.nan
    return series

The expanding (N == 0) branch is unguarded, so Rsquare($feature, 0) returns inf/garbage on near-constant windows. Because ops.py sets np.seterr(invalid="ignore"), no warning is emitted — the bad values silently propagate into features (e.g. Alpha158/Alpha360) and downstream models.

Reproduction

Near-constant series [100, 100, 100, 100.000001, 100, 100]:

expanding_rsquare (N==0 path): [nan, nan, nan, inf, 0.01717987, inf]
rolling_rsquare(4) after mask: [nan, nan, nan, nan, nan, nan]

The expanding path leaks inf and a spurious 0.0172; the rolling path is correctly NaN.

Fix

Apply the same std≈0 → NaN mask on the expanding branch (using expanding std). Slope/Resi are unaffected — they divide by the x-variance (index 1..N), which is always well-conditioned; only Rsquare divides by the y-variance.

PR incoming.