operators sma sliding-window streaming

SMA from scratch — why sliding windows change everything

You've written a moving average a hundred times. Running it on an event stream instead of an array changes how you think about it — warmup, sliding mechanics, running sums, and the mental shift from batches to samples.

6 min read

You know what SMA is. Sum divided by count. You’ve written it a hundred times. But running it on a stream changes everything about how you think about it.

Let’s walk through what happens when you declare one in tflo’s graph.

Speed:Jitter2sine · 0%
Loading demo…

The two windows

SMA in tflo comes in two flavors — time-based and count-based:

pub enum Window {
    Time(Duration),   // SMA over the last N seconds
    Count(usize),     // SMA over the last N ticks
}

In the graph, this becomes a node:

pub enum Node {
    Sma(NodeId, Window),
    // ...
}

You create one through the Comp API:

use tflo_core::prelude::*;
use tflo_ops::prelude::*;

// Time-based: SMA over the last 5 seconds
let sma_time = price.sma(5_u64.secs());

// Count-based: SMA over the last 20 ticks
let sma_count = price.sma(20usize);

The WindowSpec shorthand also works if you prefer a two-step declaration:

let sma = price.over(20_u64.secs()).sma();

Same result. Choose whichever reads better in your pipeline.

How it actually works

A count-based SMA(20) keeps a VecDeque<f64> buffer and a running sum:

// Simplified from CountWindow
struct CountWindow {
    max_count: usize,
    buffer: VecDeque<f64>,
    sum: f64,       // running sum — avoids O(N) per tick
    sum_sq: f64,    // used by variance operators
}

Each new tick:

  1. Push the new value onto the back of the deque.
  2. Add it to the running sum.
  3. If the deque is full, pop the oldest value from the front and subtract it from the sum.
  4. Return sum / max_count.

That’s O(1) per tick. No re-summing the entire window every time.

Time-based SMA works the same way, except eviction is by timestamp instead of count:

struct TimeWindow {
    window_ms: i64,
    buffer: VecDeque<(i64, f64)>,  // (timestamp, value)
    sum: f64,
    sum_sq: f64,
}

When a new value arrives at timestamp T, every entry with timestamp older than T - window_ms is popped from the front. The sum is adjusted accordingly. Only values within the window contribute to the average.

Warmup is not optional

SMA(20) needs 20 records before it can produce a result. You can’t average what you don’t have.

During warmup, step() returns None:

pub fn step(&mut self, record: &R) -> Option<PipelineItem<C, O>>

No output, no error, no warning. Just silence.

If you want visibility, use step_with_status():

pub fn step_with_status(&mut self, record: &R) -> StepResult<C, O>

Returns StepResult::WarmingUp { remaining } with the count of records still needed:

pub enum StepResult<C: PipelineContext, O> {
    Ready(PipelineItem<C, O>),
    WarmingUp { remaining: usize },
    Error(ComputeError),
}

The overall graph’s min_warmup is the maximum across all operators. If you have SMA(20) and EMA(10), the graph needs 20 records before anything produces output:

let plan = graph.graph_plan();
// plan.min_warmup == 20
// plan.records_seen == 0

After 15 records:

// plan.records_seen == 15
// plan.warmup_remaining == 5

You can check this at any time without stepping.

The array vs stream mental model

When you write SMA on an array:

fn sma(data: &[f64], n: usize) -> Vec<f64> {
    data.windows(n)
        .map(|w| w.iter().sum::<f64>() / n as f64)
        .collect()
}

You have all the data. Warmup doesn’t exist — you just don’t return values for the first n-1 positions. The computation is O(N) per window because windows(n) resums each slice.

When you write SMA on a stream:

  • You don’t know how much data is coming. The buffer must grow, then cap.
  • You can’t resum — that’s O(N*window). You maintain a running sum and adjust it per tick.
  • Warmup is a real phase you have to handle. The first n ticks produce nothing.
  • State persists between ticks. The buffer lives in the graph node, not in a local variable.

That last point is the key difference. Array-based SMA is a pure function: (&[f64]) -> Vec<f64>. Streaming SMA is stateful: you push a value, get back a value, and the buffer lives on.

When to use which

  • Count-based SMA — fixed window size, good for regular-interval data where “last N ticks” is the natural measure.
  • Time-based SMA — catches values that arrive at irregular intervals. A 5-second window always covers 5 seconds, even if you got 100 ticks or 2.

The graph handles both. The only difference is what you pass to sma() — a Duration for time-based or a usize for count-based.


If you want to see the full SMA machinery in the graph context, check the quick start guide. If you’re wondering how EMA differs — spoiler: it doesn’t wait for the window to fill — that’s the next post.

Source code

The full working example is available on GitHub: tflo-examples/examples/sma-from-scratch

— Matt