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.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
//! average — a worked producer node proving the `aura-core` `Node` contract.
|
//! average — a worked producer node proving the `aura-core` `Node` contract.
|
||||||
|
|
||||||
mod add;
|
mod add;
|
||||||
|
mod ema;
|
||||||
mod exposure;
|
mod exposure;
|
||||||
mod lincomb;
|
mod lincomb;
|
||||||
mod recorder;
|
mod recorder;
|
||||||
@@ -23,6 +24,7 @@ mod sim_broker;
|
|||||||
mod sma;
|
mod sma;
|
||||||
mod sub;
|
mod sub;
|
||||||
pub use add::Add;
|
pub use add::Add;
|
||||||
|
pub use ema::Ema;
|
||||||
pub use exposure::Exposure;
|
pub use exposure::Exposure;
|
||||||
pub use lincomb::LinComb;
|
pub use lincomb::LinComb;
|
||||||
pub use recorder::Recorder;
|
pub use recorder::Recorder;
|
||||||
|
|||||||
Reference in New Issue
Block a user