Skip to content

momentum.rsi saturates at 100 for the rest of the series after one null in the input #30

Closed

Description

@stefan-jansen

Wilder's recursive average never recovers from a null in the input, so avg_loss stays at zero and the ratio saturates at exactly 100.0 for the remainder of the series.

Reproduction

Against main at the time of filing:

import numpy as np, polars as pl
from ml4t.engineer.features.momentum import rsi

rng = np.random.default_rng(0)
x = 100 + np.cumsum(rng.normal(0.01, 1, 4000))

clean = pl.DataFrame({"x": x}).select(rsi("x", period=9).alias("r"))["r"].drop_nulls()
print(float((clean == 100.0).mean()), float(clean.median()))

holed = (
    pl.DataFrame({"x": x})
    .with_columns(pl.when(pl.arange(0, 4000) == 500).then(None).otherwise(pl.col("x")).alias("x"))
    .select(rsi("x", period=9).alias("r"))["r"].drop_nulls()
)
print(float((holed == 100.0).mean()), float(holed.median()))
clean      0.0000   median 49.55
one null   0.8750   median 100.00

One null at index 500 of 4000 turns 87.5% of the remaining output into exactly 100.0.

It is not a scale or a sign problem: scaling closes by 1e-6, or demeaning them so the series
crosses zero, both leave the implementation agreeing with a hand-computed Wilder RSI to two
decimals (57.08 against 57.04). Only a null triggers it.

Observed downstream

A caller computing a 24h and 72h RSI on a perpetual-futures premium index, whose input carried 537
nulls, emitted exactly 100.0 on 79,661 of 100,812 rows (79.0%), median 100.0. A hand-computed
Wilder RSI on the same series has median 49.68 and never reaches 100; the series has 3,283 up moves
against 3,267 down moves, so a saturated oscillator is not a property of the data. That caller has
since been changed to compute the oscillator locally, so nothing ships broken today.

Suggested direction

Wilder's average has to restart, or return null, on the far side of a gap. Returning null for
windows that span a null is consistent with every other rolling primitive in the package.

adx, cci, stochastic and any other recursive smoother in the same module have the same shape
and should be checked before this is closed.

Activity

  1. stefan-jansen commented on Aug 9, 2026

    @stefan-jansen
    ContributorAuthor

    Observed in a shipped artifact, not only in a reproduction

    cme_futures/features/financial.parquet carries this defect in production output. Of its 30
    products, exactly one has any rsi_14 at 100.0, and it has a lot:

    product   rows     ==100.0   share
    CL       11,521     1,451    12.59%
    all 29 others                 0.00%
    

    The trigger is a single null. 03_financial_features.py:197 nulls any non-positive price, and
    CL settled negative on 2020-04-20. Rebuilding the notebook's own frame and recomputing
    rsi("adj_close", period=14).over(["product", "position"]) against the released
    ml4t-engineer 0.1.0b10:

    CL adj_close nulls after the >0 gate : 1 of 11,606   (2020-04-20)
    recomputed rsi_14 ==100.0 share      : 12.7378%
    first saturated                      : 2020-04-20
    last saturated                       : 2025-12-31
    

    One null, and five and a half years of the series is affected. Against this branch:

    fixed rsi_14 ==100.0 share : 0.0000%   median 50.80
    rows re-seeding after the gap : 15     (the null plus 14 warmup observations)
    

    That is the third independent instance, and the second in shipped output:

    where input nulls saturated share
    crypto_perps_funding premium_rsi_24h/72h 537 79.0% of 100,812 rows, median 100.0
    cme_futures rsi_14, CL 1 12.59% of the product, all of it after the null
    synthetic, one null at index 500 of 4,000 1 87.5%

    The two production cases differ in how the null arises - a vendor gap in the crypto premium
    index, a price the notebook nulls because it went negative - and neither is exotic. Any series
    that can carry a hole for any reason is exposed.

    Tracked downstream as ml4t/agent-workspace#359, which is now attributed to this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions