Most technical analysis libraries work on closed bars. Feed in 200 closing prices, get 200 SMA values back. Clean, simple, works for backtesting.
But in a live environment, you get OHLCV updates every second before the bar closes. A naive implementation counts each update as a new bar and produces garbage. The safe alternative is to discard mid-bar updates and wait for the close, but then you’re always one bar behind. If you’re building a trading terminal, a dashboard, or an alert system, you need to see RSI crossing 70 as it happens, not after the bar closes.
I’ve built this from scratch before: first in Dart, then in Elixir. When I moved to Rust, I built it again, this time as open source. quantedge-ta is the result of five years of iterating on this problem across three languages. If you want the full story, start with the journey post.
How quantedge-ta handles it
Bar boundaries are tracked via open_time. Same open_time? The bar’s contribution is replaced, not added. New open_time? The window advances.
The streaming design started with a custom strategy that was built around forming bars, not closed ones. Combined with Binance’s websocket pushing forming OHLCVs every second, repainting wasn’t optional: the indicators had to handle mid-bar updates correctly or the strategy wouldn’t work. Once that worked, I noticed the O(1) opportunity: if you maintain running state (a running sum for SMA, a running sum-of-squares for Bollinger Bands), each compute() call is constant time (or amortised constant for indicators like Stochastic that use rolling extremes). No re-scanning the window. That same running state makes repainting trivial: when the same open_time arrives again, replace the previous contribution instead of accumulating it. The performance optimisation and the repainting support turned out to be the same thing.
use quantedge_ta::{Sma, SmaConfig};
use std::num::NonZero;
let mut sma = Sma::new(SmaConfig::close(NonZero::new(20).unwrap()));
for kline in &stream {
if let Some(value) = sma.compute(kline) {
println!("SMA(20): {value}");
}
// None until window fills. No garbage early values.
}
The library accepts any type that implements the Ohlcv trait. Five methods: open, high, low, close, open_time. No forced conversion to a library struct. Indicator configs implement Hash and Eq, so they work as map keys. Configs are also Copy: cheap to pass around, no allocations.
How fast?
Benchmarked on 744 BTC/USDT 1-hour bars from Binance using Criterion. Apple M3 Max, rustc 1.93.1, --release.
Steady-state per-tick cost on a fully converged indicator:
| Indicator | Period | Time |
|---|---|---|
| ATR | 14 | 1.86 ns |
| EMA | 20 | 2.05 ns |
| RSI | 14 | 7.73 ns |
| SMA | 20 | 10.5 ns |
| MACD | 12/26/9 | 10.5 ns |
| BB | 20 | 11.1 ns |
ATR at 1.86 nanoseconds per tick. 537 million updates per second.
End-to-end stream (744 bars from cold start): SMA in 1.07 µs, EMA in 1.37 µs, ATR in 1.76 µs.
What’s in v0.4
Six indicators: SMA, EMA, Bollinger Bands, RSI, MACD, and ATR. Each validated against talipp reference output.
EMA has configurable convergence: suppress values until the SMA seed’s influence decays below 1%, which matters more than most people realise.
RSI uses Wilder’s smoothing (not a standard EMA), with forming-bar support for real-time updates. ATR reuses the same Wilder’s smoothing kernel, producing TradingView-matching output at under 2 nanoseconds per tick.
WASM compatible. Zero dependencies.
Try it
$ cargo add quantedge-ta
Browse the docs, check the benchmarks, open an issue: GitHub | crates.io
