Module 4

Floating point & the noise floor

A double carries about 16 significant decimal digits. That sounds like plenty — until you subtract two nearly-equal numbers. The leading digits agree, so they cancel, and what's left is whatever rounding noise was hiding in the tails. Subtract two numbers that agree to 12 digits and your answer has ~4 real digits dressed up as 16. This is catastrophic cancellation, and it is the tax you pay for the prefix-sum trick from Module 3: every window sum is a difference of two large accumulators.

In the beta engine the danger concentrates in one place: the denominator n·Σy² − (Σy)². Mathematically it equals n·Σ(y−ȳ)², which is ≥ 0, always. But computed from prefix differences on a near-constant baseline, the two terms agree to almost every digit — and the subtraction returns tiny noise of either sign. Divide by it and you don't get an exception. You get a confident, absurd beta like 8.4e+8, served with four tidy decimals.

That last part is the real lesson: numerical bugs don't throw — they serve wrong numbers.

🎛 Cancellation lab

1. Watch digits die: (10^k + 0.1234567890123456…) − 10^k

10^6

a − b where a = 10^6 + 0.1234567890123456, b = 10^6

true 0.1234567890123456

computed 0.1234567890642211

Digits survived

10 / 16

Digits lost to cancellation

6

one per power of ten in the big terms

2. The engine's guard: n·Σy² − (Σy)² computed from prefix differences

The two cancelled terms have magnitude ≈ 1 here, so prefix differencing leaves ±1e-12 of noise on the result. Slide the true baseline variance down and watch the noise take over.

1e-14

true variance 1.000e-14

cancellation noise -6.009e-13

computed denominator -5.909e-13

noise floor (1e-9 × terms) 1.000e-9

Naive check: denominator == 0 ?

false — divides anyway

beta = -1.422e+9

Noise floor: denom ≤ 1e-9 × terms ?

true — returns NaN

Naive path would serve a garbage beta — the noise-floor check correctly serves NaN

This mirrors RollingOlsEstimator exactly: the denominator n·Σy² − (Σy)² is mathematically ≥ 0, but computed from prefix differences it can come out as tiny noise of either sign. A == 0 check almost never fires on floats, so the engine compares against 1e-9 × the cancelled terms instead — anything at or below that is indistinguishable from zero and returns NaN rather than a confident wrong number. Educational tool — not investment advice.

The real guard: a relative noise floor, not == 0

Checking denominator == 0 is useless — cancellation noise is almost never exactly zero. The engine instead asks whether the denominator is meaningfully large relative to the terms it was cancelled from:

private static final double VARIANCE_NOISE_FLOOR = 1e-9;

double denominator = m.count() * m.sumYY() - m.sumY() * m.sumY();
double noiseFloor = VARIANCE_NOISE_FLOOR
        * Math.max(m.count() * m.sumYY(), m.sumY() * m.sumY());
if (Double.isNaN(denominator) || denominator <= noiseFloor) {
    return Double.NaN; // baseline variance is zero/noise (or data was NaN)
}
return (m.count() * m.sumXY() - m.sumX() * m.sumY()) / denominator;

From RollingOlsEstimator.betaAt() — core-api, com.schonfeld.betaservice.calc.RollingOlsEstimator

The floor is relative: 1e-9 times the cancelled terms, not an absolute cutoff. That's about 1,000× the ~1e-12 relative error prefix differencing leaves on decades-long series, while real return variance sits many orders of magnitude above it — so the check swallows noise without ever eating a legitimate beta. At or below the floor, the honest answer is NaN.

Tiny windows get a different treatment entirely

A 5-day window divides by a near-zero variance, so the ~1e-12 prefix error stops being invisible — on 40-year series it was enough to flip the 4th decimal of the served beta. Rather than tolerate that, small windows skip the prefix trick and compute directly, mean-centred:

/** Windows with at most this many returns take the direct path. */
private static final int SMALL_WINDOW_RETURNS = 16;

if (n <= SMALL_WINDOW_RETURNS) {
    return smallWindowBeta(pair, firstReturn, lastReturn);
}

RollingOlsEstimator — the small-window branch (still bounded O(1) per output day)

The direct path reads each return via an adjacent prefix difference (error ~1 ulp per element instead of ~1e-12 on the whole window sum) and centres on the mean before multiplying — numerically exact where it matters, and a bounded constant cost since n ≤ 16.

And the last rounding is done properly

The service publishes betas to 4 decimals. Binary floats can't represent most decimal fractions, so decimal rounding goes through BigDecimal with an explicit, symmetric rule:

public static double round4(double value) {
    if (Double.isNaN(value) || Double.isInfinite(value)) {
        return value;
    }
    return BigDecimal.valueOf(value)
            .setScale(4, RoundingMode.HALF_UP).doubleValue();
}

Rounding.round4() — core-api, com.schonfeld.betaservice.calc.Rounding

HALF_UP is symmetric — ties round away from zero, so −0.00005 becomes −0.0001 — and NaN/infinities pass through untouched because BigDecimal.valueOf would throw on them. Because none of these failure modes announce themselves, the service also cross-checks every result against an independent reference implementation: the only reliable way to catch a bug whose symptom is a plausible-looking number.

Things to try

  • • In the lab, step k from 0 to 12 and watch one digit die per power of ten — the red tail is pure rounding noise.
  • • Set the true variance to 1e-14: the computed denominator is all noise, the naive check happily divides, and the “beta” comes out around 10⁸.
  • • Find the crossover: slide the variance up until the noise-floor check flips from NaN to a safe divide — that boundary is exactly 1e-9 × terms.
  • • Notice the noise changes sign as you slide. A < 0 check alone would miss the positive-noise cases — which is why the floor is one-sided and relative.