b39fd63396
Phase 4 of the Stratification milestone. aura-std held four C28 ladder layers in one roster; this cuts them into layer-aligned, aura-core-only node crates so the import direction is enforced by the crate graph: - aura-std — engine nodes only (arithmetic/logic/rolling + sinks) - aura-market — session, resample - aura-strategy — bias, stops, sizer, cost-model machinery - aura-backtest — sim_broker, position_management - aura-vocabulary — the relocated closed std_vocabulary roster Node modules move verbatim (byte-identical renames); consumers are rewired by import path only. A new structural test (aura-vocabulary/tests/c28_layering.rs) asserts each node crate's [dependencies] stay within its C28-permitted inner set, catching the acyclic-but-outward violation the compiler misses. Behaviour byte-identical: full workspace suite green (1448 tests), no golden edited, clippy -D warnings clean. C28 Status block updated. closes #288
125 lines
6.0 KiB
Rust
125 lines
6.0 KiB
Rust
//! 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_vocabulary::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::Recorder;
|
|
use aura_vocabulary::std_vocabulary;
|
|
|
|
// 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<Scalar>)> {
|
|
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:?}",
|
|
);
|
|
}
|