quantedge-ta: Real-Time Technical Analysis for Rust

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:

IndicatorPeriodTime
ATR141.86 ns
EMA202.05 ns
RSI147.73 ns
SMA2010.5 ns
MACD12/26/910.5 ns
BB2011.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

Leave a Reply

Your email address will not be published. Required fields are marked *