From ae1d4564f5eaeff4c5e0b1b615fd5a23d50b6f5d Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 7 Jun 2026 23:20:25 +0200 Subject: [PATCH] feat(aura-std): EMA node (SMA-seeded, O(1) per cycle) Add Ema, the std-lib's first recursive/stateful producer: exponential moving average with alpha = 2/(length+1). The building block MACD (and DEMA, MACD-histogram, etc.) needs, and a worked example of a node whose param sizes its math, not its input window. Seeding is ratified as SMA-seeded (ta-lib convention): the EMA stays silent until it has seen length samples, seeds with their SMA, then runs the recurrence. Chosen over a first-value seed for three substantive reasons -- warm-up consistency with Sma (both emit None until length samples), parity with the EMA traders expect (ta-lib MACD seeds this way), and statistical honesty (no average claimed from a single observation). A first-value seed was the proof-of-concept convenience; rejected. Performance: recursive state means lookback = 1 (length sizes alpha, not the window); eval is O(1) time and state via ema += alpha*(x - ema), with no hot-path allocation (the output buffer is reused). Tested: warm-up-then-smooth (a step input separates it from a plain SMA), length-1 identity, label, and factory/schema agreement. --- crates/aura-std/src/ema.rs | 168 +++++++++++++++++++++++++++++++++++++ crates/aura-std/src/lib.rs | 2 + 2 files changed, 170 insertions(+) create mode 100644 crates/aura-std/src/ema.rs diff --git a/crates/aura-std/src/ema.rs b/crates/aura-std/src/ema.rs new file mode 100644 index 0000000..a9f38b3 --- /dev/null +++ b/crates/aura-std/src/ema.rs @@ -0,0 +1,168 @@ +//! `Ema` — exponential moving average of one f64 input, smoothing constant +//! `alpha = 2 / (length + 1)` (the conventional length↔alpha identity, so an +//! `Ema` and an `Sma` of the same `length` are roughly comparable). +//! +//! Two design points worth stating: +//! +//! - **Seeding (ta-lib convention).** Like `Sma`, the EMA stays silent (`None`) +//! until it has seen `length` samples, then seeds itself with the *SMA* of those +//! first `length` values and runs the recurrence from there. This keeps warm-up +//! semantics uniform across the moving-average nodes, matches the EMA traders +//! know (ta-lib's MACD seeds its EMAs this way), and avoids claiming an average +//! from a single observation. (A first-value seed — emit from sample 1 — is the +//! other common convention; rejected here for the consistency/honesty reasons +//! above.) +//! - **Recursive, so `lookback = 1`.** The running average lives in internal +//! state, so the node reads only the newest sample each cycle — `length` sizes +//! `alpha`, not the input window. `eval` is O(1) time, O(1) state, and allocates +//! nothing on the hot path (the output buffer is reused). + +use aura_core::{ + Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind, +}; + +/// Exponential moving average of one f64 input, smoothing `alpha = 2/(length+1)`, +/// seeded with the SMA of the first `length` samples. +pub struct Ema { + alpha: f64, + length: usize, + // Warm-up accumulator: until `length` samples have arrived the node is silent + // and sums them; on the `length`-th sample it locks in the SMA seed (`seeded`) + // and `ema` carries the running value from then on. + seeded: bool, + warmup_sum: f64, + count: usize, + ema: f64, + out: [Scalar; 1], +} + +impl Ema { + /// Build an EMA of window `length` (must be >= 1); `alpha = 2/(length+1)` + /// (`length == 1` gives `alpha == 1`, the identity). + pub fn new(length: usize) -> Self { + assert!(length >= 1, "EMA length must be >= 1"); + Self { + alpha: 2.0 / (length as f64 + 1.0), + length, + seeded: false, + warmup_sum: 0.0, + count: 0, + ema: 0.0, + out: [Scalar::F64(0.0)], + } + } + + /// The param-generic recipe for a blueprint leaf: declares `length` and builds + /// through `Ema::new` (the single sizing/validation gate; the slice is + /// kind-checked before `build` runs, so the typed read is total). + pub fn factory() -> LeafFactory { + LeafFactory::new( + "EMA", + vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + |p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)), + ) + } +} + +impl Node for Ema { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { + kind: ScalarKind::F64, + // recursive: the running average lives in internal state, so only + // the newest sample is read — `length` sizes alpha, not the window. + lookback: 1, + firing: Firing::Any, + }], + output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; // no sample yet + } + let x = w[0]; // index 0 = newest (financial indexing) + if !self.seeded { + // warm up over `length` samples, then seed with their SMA (ta-lib + // convention) — silent until ready, exactly like `Sma`. + self.warmup_sum += x; + self.count += 1; + if self.count < self.length { + return None; + } + self.ema = self.warmup_sum / self.length as f64; + self.seeded = true; + } else { + // O(1) recurrence: one sub, one mul, one add. + self.ema += self.alpha * (x - self.ema); + } + self.out[0] = Scalar::F64(self.ema); + Some(&self.out) + } + + fn label(&self) -> String { + format!("EMA({})", self.length) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Timestamp}; + + #[test] + fn ema_warms_up_like_sma_then_smooths() { + // length 3 -> alpha = 0.5: silent for the first 2 samples, seeds with the + // SMA of the first 3, then runs the recurrence. The jump to 10 separates the + // EMA from a plain SMA (EMA = 7.0 here, SMA(4,6,10) would be 6.667); all + // magnitudes are exact in f64. + let mut ema = Ema::new(3); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + + let feed = [2.0_f64, 4.0, 6.0, 10.0]; + // None, None, SMA(2,4,6) = 4.0, then 4.0 + 0.5*(10 - 4.0) = 7.0 + let expect = [None, None, Some(4.0), Some(7.0)]; + + for (v, want) in feed.iter().zip(expect) { + inputs[0].push(Scalar::F64(*v)).unwrap(); + let got = ema.eval(Ctx::new(&inputs, Timestamp(0))); + match want { + None => assert_eq!(got, None), + Some(m) => assert_eq!(got, Some([Scalar::F64(m)].as_slice())), + } + } + } + + #[test] + fn ema_length_one_is_identity() { + // alpha = 2/2 = 1.0: the seed locks in on the first sample (length 1) and + // the recurrence reduces to ema = x. + let mut ema = Ema::new(1); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + + inputs[0].push(Scalar::F64(7.0)).unwrap(); + assert_eq!(ema.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(7.0)].as_slice())); + + inputs[0].push(Scalar::F64(9.0)).unwrap(); + assert_eq!(ema.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice())); + } + + #[test] + fn label_carries_the_window() { + assert_eq!(Ema::new(12).label(), "EMA(12)"); + assert_eq!(Ema::new(26).label(), "EMA(26)"); + } + + #[test] + fn factory_params_match_built_node_schema() { + let f = Ema::factory(); + let built = f.build(&[Scalar::I64(9)]); + assert_eq!(f.params(), built.schema().params.as_slice()); + // the built node carries the declared I64 `length` knob + assert_eq!(built.schema().params[0].name, "length"); + assert_eq!(built.schema().params[0].kind, ScalarKind::I64); + } +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 8d1fdf1..7126eae 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -16,6 +16,7 @@ //! average — a worked producer node proving the `aura-core` `Node` contract. mod add; +mod ema; mod exposure; mod lincomb; mod recorder; @@ -23,6 +24,7 @@ mod sim_broker; mod sma; mod sub; pub use add::Add; +pub use ema::Ema; pub use exposure::Exposure; pub use lincomb::LinComb; pub use recorder::Recorder;