Our RSI Was the Number 50, Every Bar, for Every Asset
- `close.diff()` leaves a NaN at index 0, so a 14-period rolling mean of the result is first valid at index 14 — not 13. Seeding the recurrence at 14 read index 13, which was NaN.
- NaN propagated through every subsequent step, so rsi_14 was NaN for the entire series. A `fillna(50)` then converted it to exactly 50.0 — a clean, plausible, completely constant number.
- Verified across 25 real series: every one was exactly 50.0 at every bar.
- 50 hits none of the thresholds the signal branches on — they fire at >=70, <=30, >=55 and <=45 — so RSI had contributed nothing, and RSI divergences could never trigger either.
- Nothing errored, nothing logged, and no chart looked obviously wrong. The fallback that was supposed to handle a division by zero is what made a total failure invisible.
The worst bugs are not the ones that crash. They are the ones that return a number in the right range, of the right type, that no dashboard flags and no test catches — because a plausible wrong answer looks exactly like a right one. Our 14-period RSI was the number 50. Not approximately 50, not usually 50: exactly 50.0, on every bar, for every asset, for as long as the function had existed. Here is how, and why the safety net was the thing that hid it.
An off-by-one in a place off-by-ones do not usually matter
RSI uses Wilder's smoothing, which is genuinely recursive: each average gain depends on the previous one. That recurrence has to be seeded from somewhere, and ours started at index 14 — the obvious choice for a 14-period indicator, and wrong by exactly one. The chain is short. `close.diff()` cannot produce a value at index 0, because there is no prior bar to difference against, so it leaves a NaN there. That NaN flows into the gain series. A 14-period rolling mean over a series whose first element is NaN is therefore first valid at index 14, not index 13. Seeding the loop at i = 14 meant its first read was of index 13 — still NaN.
close.diff() -> [NaN, x1, x2, ...] NaN at index 0
gain.rolling(14).mean() -> first valid at index 14 (not 13)
for i in range(14, len(df)):
avg_gain[i] = (avg_gain[i-1] * 13 + gain[i]) / 14
^^^^^^^^^^^^^
i=14 reads index 13, which is NaNNaN is contagious
Arithmetic on a NaN produces a NaN, so the first iteration wrote a NaN, the second read that and wrote another, and the recurrence carried it to the end of the series. Not a few bad bars at the start — the entire column. Had it stopped there we would have found it immediately, because a chart of nothing is obvious and downstream code tends to complain loudly about missing values.
The line that hid it
It did not stop there, because of one line added for a completely reasonable reason. RSI divides average gain by average loss, and average loss can legitimately be zero on a run of consecutive up bars — a real division by zero that needs handling. So the code ended with a `fillna(50)`, 50 being the neutral midpoint. That fallback did its job on the case it was written for, and on the case it was not it converted a column of NaN into a column of 50.0. The output was numeric, in range, correctly typed, and completely constant. Verified across 25 real series: every one exactly 50.0 at every bar.
rs = avg_gain / avg_loss df["rsi_14"] = 100 - (100 / (1 + rs)) df["rsi_14"] = df["rsi_14"].fillna(50) # for divide-by-zero # intended: a handful of neutral bars where avg_loss == 0 # actual: an entire column of 50.0, on every asset
Why nothing downstream complained
This is the part worth internalising. The signal logic branches on thresholds — overbought at 70 or above, oversold at 30 or below, and softer tilts at 55 and 45. The value 50 satisfies none of them. So RSI never pushed a signal in either direction; it silently abstained on every bar, and an indicator that never fires looks identical to an indicator that has no opinion right now. The divergence detector read the same column, so RSI divergences could never trigger either. A feature contributing exactly zero is invisible unless you go looking for its contribution.
What was not affected, and why that made it harder to spot
ATR and ADX sat in the same function and were fine, which is the sort of detail that keeps a bug alive. True range is built with a `pd.concat().max()` that skips the NaN component, so its first element is valid. The ADX smoothers use `min_periods=1`, so they never depend on a fully-populated window. Two of the three indicators in the file were correct, the file had no errors, and the one that was broken returned a number that looked like a considered neutral reading.
The fix is to seed from the data, not from a constant
The correction is to ask the series where its first real value is rather than assuming the period length answers that. Find the first non-NaN index in the rolling mean and start the recurrence from there. It is one line, it is robust to the leading-NaN question entirely, and it would have been correct whether `diff()` produced a leading NaN or not — which is the property you want, because the next person should not have to re-derive that off-by-one.
# brittle: assumes the rolling mean is first valid at exactly `period` for i in range(14, len(df)): # robust: ask the data where it actually starts valid = np.flatnonzero(~np.isnan(avg_gain_np)) seed = int(valid[0]) if valid.size else len(df) for i in range(seed + 1, len(df)):
The general lesson: a plausible fallback is worse than a crash
A fallback that produces a believable value hides the failure it was written to handle. `fillna(50)` was defensible for the divide-by-zero case and catastrophic for everything else, because it made a total failure indistinguishable from a neutral reading. This is why our display code enforces the opposite rule — a number is either measured or visibly absent, rendered as an em dash, with no third state. The same discipline belongs upstream in the maths: a fallback should be narrow enough to fire only on the case it was written for, and anything else should be loud. If you have an indicator you have never actually plotted, plot it. Then check its variance is not zero.
Summary
An off-by-one seeded a recursive average from a NaN, NaN propagated through the whole series, and a `fillna(50)` written for a genuine divide-by-zero converted total failure into a clean constant. RSI read exactly 50.0 on every bar of 25 real series, satisfied none of the thresholds the signal branches on, and therefore contributed precisely nothing while looking entirely healthy. Nothing errored. The fix is to seed the recurrence from the first index the data actually produces, and the lesson is that a fallback returning a plausible number is more dangerous than one that fails loudly.
Frequently Asked Questions
Why was the RSI exactly 50 rather than roughly 50?
Because it was not a computed value at all. The recurrence read a NaN on its first iteration, NaN propagated through every subsequent step, and the whole column came out NaN. A `fillna(50)` at the end — added to handle a real division by zero when average loss is zero — replaced every one of those NaNs with the literal 50. So it was not a rounding artefact or a flat market; it was a constant substituted for a missing calculation, identical across 25 real series.
Why didn't the strategy break visibly?
Because the signal logic branches at 70, 30, 55 and 45, and 50 satisfies none of them. RSI abstained on every bar, and an indicator that never fires is indistinguishable from one that currently has no opinion. The divergence detector read the same column and so could never trigger either. A feature contributing exactly zero shows up as slightly worse performance, not as an error.
How do I check my own indicators for this?
Plot them, and check their variance is not zero. A constant column, a column of exactly the fallback value, or a column whose first valid index is not where you expect are all cheap to test and none of them will announce themselves. Be especially suspicious of any indicator built on a recurrence seeded at a hardcoded index — seed from the first non-NaN index the data actually produces instead.
Should I remove fallbacks like fillna?
Narrow them, do not remove them. The divide-by-zero case this one was written for is real and does need handling. The problem is that it also caught a case it was never meant to, and produced a value plausible enough to hide it. A fallback should fire only on the condition it was written for, and anything else should be loud. On the display side we enforce the same rule differently: a value is either measured or shown as an em dash, never a stand-in number.
Related guides
Test Quantitative AI Signals Live
Access 14-Agent AI Consensus, real-time L2 orderbook spoofing radar, and quarter-Kelly position sizing capped at 2% of capital.