RSI looks simple on paper. It’s a 0-100 momentum oscillator. Everyone knows what it looks like on a chart. Most developers assume implementing it is an afternoon’s work.
It took me longer than I expected. Not because the math is hard, but because there are three places where a naive implementation produces wrong answers, and none of them are documented anywhere obvious.
You won’t know what’s wrong until you properly test it. Here’s how I know the implementation is actually correct, before getting into what went wrong.
How I Know the Numbers Are Right
Reading the code and checking the spec is not enough. For real confidence you need reference tests. And better still, reference tests against real market data and an industry-standard library. One month of BTCUSDT 1h candles from the Binance public archives, RSI(14) values computed by talipp, stored in the repository. quantedge-ta runs the same bars and has to match every value. That proves it is not only fast, but correct.
The reference test uses 744 bars of real market data (January 2025, full month). I check that:
- The first output appears at the 15th bar (length + 1)
- Every subsequent value matches the talipp reference to within 1e-6
- The full month of values matches exactly
This gives me confidence that the implementation is correct, not just internally consistent. The rest of this post explains the three things I had to get right to make that test pass.
Wilder’s Smoothing Isn’t an EMA (Even Though It Looks Like One)
RSI uses Wilder’s smoothed moving average. The formula looks like EMA with alpha = 1/length, but the seeding is different.
Standard EMA seeds from an SMA. Wilder’s RSI seeds by averaging the first length gains and losses separately, then enters a recursive smoothing phase:
avg_gain = (prev_avg_gain × (length - 1) + current_gain) / length
avg_loss = (prev_avg_loss × (length - 1) + current_loss) / length
Why does this matter? Because if you get the seeding wrong, your early values won’t match talipp or any professional platform. The divergence decays over time but it’s there.
quantedge-ta uses Wilder’s original seeding, which means length + 1 price bars before the first output. None until then. No silent garbage values.
The Forming Bar Problem, Again
I wrote about the forming bar problem in the first post in this series. RSI makes it harder than anything else in the library.
For SMA, updating a forming bar means swapping out the last value in the window. For Wilder’s RSI, the smoothed averages are recursive: once you’ve applied a gain or loss, you can’t undo it. The standard approach breaks down here. You need to snapshot the state before the forming bar starts, then recompute from scratch on every tick:
// When a new bar opens: snapshot the settled Wilder state
self.prev_avg_gain = self.avg_gain;
self.prev_avg_loss = self.avg_loss;
// When the same open_time arrives again (forming bar update):
// recompute from the snapshot, not the running value
self.avg_gain = (self.prev_avg_gain * (length - 1) + gain) / length;
self.avg_loss = (self.prev_avg_loss * (length - 1) + loss) / length;
The actual implementation uses mul_add and precomputed reciprocals for performance; the logic is identical.
Without this, repeated price updates within the same bar compound against the Wilder averages. The RSI drifts away from the correct value as the bar progresses. Not massively, but enough to fail the reference comparison.
Handling Corner Cases
Three market conditions produce division-by-zero or undefined behaviour in the standard formula.
All gains, no losses: avg_loss = 0, avg_gain > 0. The formula 100 * avg_gain / (avg_gain + avg_loss) naturally returns 100. No special case needed.
All losses, no gains: avg_gain = 0, avg_loss > 0. RS = 0. RSI = 0. Same — falls out of the formula.
Flat market (all closes identical): Both averages are 0. The formula is undefined. quantedge-ta returns 50: a market with no movement has no directional momentum, so the midpoint is the only defensible choice.
The Result
With the seeding, forming bar, and corner cases handled, the implementation is clean to use.
use quantedge_ta::{Rsi, RsiConfig};
let mut rsi = Rsi::new(RsiConfig::default());
for kline in &stream {
if let Some(value) = rsi.compute(kline) {
println!("RSI(14): {value:.2}");
}
// None for the first 15 bars (length + 1). Then values that match the talipp reference.
}
RSI set the pattern for every recursive indicator that followed. The snapshot-and-recompute approach for forming bars, the explicit None during seeding, the edge case handling, all of it carried forward. Each new indicator that ships with quantedge-ta inherits these decisions for free.
quantedge-ta is at crates.io. RSI shipped in v0.2.0. The library is now at v0.17.0 with 19 indicators.
