diff --git a/crates/aura-engine/tests/rsi_from_blueprint_data.rs b/crates/aura-engine/tests/rsi_from_blueprint_data.rs new file mode 100644 index 0000000..d1b376e --- /dev/null +++ b/crates/aura-engine/tests/rsi_from_blueprint_data.rs @@ -0,0 +1,123 @@ +//! Acceptance proof for issue #236: an **RSI-class** signal — the classic +//! gain/loss split of the price change and the smoothed **ratio** of average gain +//! to average loss — composes *purely from blueprint data* through the injected +//! `aura_std::std_vocabulary` and runs to hand-computable RS values. This is the +//! property that pins the four std-vocabulary gaps the issue names as a single +//! honest behaviour: `Div`, a `Const` source, `Abs`, and pairwise `Max`/`Min`. +//! +//! The composition is authored as a JSON document (never as Rust node identifiers), +//! so the file compiles regardless of whether the new node types exist yet: it is +//! RED because `std_vocabulary` does not resolve the new type ids (the public loader +//! returns `LoadError::UnknownNodeType("Const")` for the first absent one), and GREEN +//! only once all five are rostered AND behave correctly. It exercises the same public +//! seam a World / project `cdylib` uses (`blueprint_from_json` + an injected +//! vocabulary — C24), mirroring `blueprint_serde_e2e.rs`; the recording sink is added +//! in Rust because a sink is deliberately outside the #155 std vocabulary. +//! +//! Contract the GREEN implementation must satisfy (the type ids a blueprint writes +//! and the one new param name — bound params re-apply by name on load): +//! - type ids: `"Const"`, `"Div"`, `"Abs"`, `"Max"`, `"Min"` +//! - `Const` is unary (one clock input, value ignored) with an f64 param `"value"` +//! - `Max`/`Min`/`Div` are binary (slots 0,1); `Abs` is unary (slot 0) + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{blueprint_from_json, BlueprintNode, Composite, Edge, Role, Target, VecSource}; +use aura_std::{std_vocabulary, Recorder}; + +// The RSI-class signal as blueprint data. Raw price fans out to a zero `Const` +// (its clock is the price role), a `Delay[1]`, and a `Sub`; `delta = price - prev` +// splits into `gain = Max(delta, 0)` and `loss = Abs(Min(delta, 0))`; each is +// smoothed by `SMA(2)`; `rs = Div(avg_gain, avg_loss)` is the single output. All +// params are bound, so the blueprint has an empty open-param space. The recording +// sink is NOT here (sinks are outside the std vocabulary — added in Rust below). +// +// Node indices: 0 Const · 1 Delay · 2 Sub · 3 Max · 4 Min · 5 Abs · +// 6 SMA(avg_gain) · 7 SMA(avg_loss) · 8 Div. +const RSI_BLUEPRINT_JSON: &str = r#"{ + "format_version": 1, + "blueprint": { + "name": "rsi_signal", + "nodes": [ + {"primitive":{"type":"Const","bound":[{"pos":0,"name":"value","kind":"F64","value":{"F64":0.0}}]}}, + {"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}}, + {"primitive":{"type":"Sub"}}, + {"primitive":{"type":"Max"}}, + {"primitive":{"type":"Min"}}, + {"primitive":{"type":"Abs"}}, + {"primitive":{"type":"SMA","name":"avg_gain","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}}, + {"primitive":{"type":"SMA","name":"avg_loss","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}}, + {"primitive":{"type":"Div"}} + ], + "edges": [ + {"from":1,"to":2,"slot":1,"from_field":0}, + {"from":2,"to":3,"slot":0,"from_field":0}, + {"from":0,"to":3,"slot":1,"from_field":0}, + {"from":2,"to":4,"slot":0,"from_field":0}, + {"from":0,"to":4,"slot":1,"from_field":0}, + {"from":4,"to":5,"slot":0,"from_field":0}, + {"from":3,"to":6,"slot":0,"from_field":0}, + {"from":5,"to":7,"slot":0,"from_field":0}, + {"from":6,"to":8,"slot":0,"from_field":0}, + {"from":7,"to":8,"slot":1,"from_field":0} + ], + "input_roles": [ + {"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":2,"slot":0}]} + ], + "output": [{"node":8,"field":0,"name":"rs"}] + } +}"#; + +// Nest the loaded RSI signal under a Rust-built root that records its single `rs` +// output, feed the price fixture through the public `VecSource`, and collect the +// recorded `(ts, [rs])` trace — the only observable behaviour asserted on. +fn run_recording(signal: Composite, prices: Vec<(Timestamp, Scalar)>) -> Vec<(Timestamp, Vec)> { + let (tx, rx) = mpsc::channel(); + let root = Composite::new( + "h", + vec![ + BlueprintNode::Composite(signal), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // rs -> recorder col[0] + vec![Role { + name: "src".into(), + targets: vec![Target { node: 0, slot: 0 }], // price -> nested signal's price role + source: Some(ScalarKind::F64), + }], + vec![], + ); + let mut h = root.bootstrap_with_params(vec![]).expect("bootstraps (no open params)"); + h.run(vec![Box::new(VecSource::new(prices))]); + rx.try_iter().collect() +} + +/// The RSI-class gain/loss-split-and-ratio composes from blueprint data through the +/// std vocabulary and runs to the hand-computed RS sequence. Prices `[10,12,11,14]` +/// give deltas `[+2,-1,+3]`, so gains `[2,0,3]` and losses `[0,1,0]`; SMA(2) of each +/// warms up on the first delta and then yields RS = avg_gain/avg_loss = 1.0/0.5 = 2.0 +/// at t3 and 1.5/0.5 = 3.0 at t4. Every new operator is load-bearing to these two +/// numbers: `Max` picks the positive part, `Min`+`Abs` the loss magnitude, `Const` +/// the zero threshold, `Div` the ratio. +#[test] +fn rsi_composes_from_blueprint_data_and_yields_hand_computed_rs() { + let signal = + blueprint_from_json(RSI_BLUEPRINT_JSON, &|t| std_vocabulary(t)).expect("loads through the std vocabulary"); + + let prices: Vec<(Timestamp, Scalar)> = [(1_i64, 10.0_f64), (2, 12.0), (3, 11.0), (4, 14.0)] + .iter() + .map(|&(t, p)| (Timestamp(t), Scalar::f64(p))) + .collect(); + + let trace = run_recording(signal, prices); + + assert_eq!( + trace, + vec![ + (Timestamp(3), vec![Scalar::f64(2.0)]), + (Timestamp(4), vec![Scalar::f64(3.0)]), + ], + "RSI gain/loss-split-and-ratio must emit RS = 2.0 at t3 and 3.0 at t4; got {trace:?}", + ); +}