Files
Aura/crates/aura-engine/tests/ohlc_from_blueprint_data.rs
T
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
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
2026-07-19 20:28:20 +02:00

139 lines
5.2 KiB
Rust

//! Acceptance pin for the harness-input-binding spec (#231): a MULTI-COLUMN
//! blueprint (open/high/low/close input roles) composes from blueprint data
//! and runs over four inline `VecSource` columns to hand-computed values —
//! pinning the role-declaration/merge-order contract (role i is fed by source
//! i; same-timestamp ties break by source index, i.e. canonical column order —
//! C4) without any archive. Mirrors `rsi_from_blueprint_data.rs` (the same
//! public seam: a JSON document + `blueprint_from_json` + an injected
//! vocabulary, C24). Pure engine machinery — no CLI, no binding module.
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;
// value = (high - low) + (close - open): every column is load-bearing, so any
// mis-pairing of roles to sources (or a wrong merge tie-break) changes the
// recorded rows. Node indices: 0 Sub(range = high - low) ·
// 1 Sub(body = close - open) · 2 Add(range + body).
const OHLC_BLUEPRINT_JSON: &str = r#"{
"format_version": 1,
"blueprint": {
"name": "ohlc_probe",
"nodes": [
{"primitive":{"type":"Sub"}},
{"primitive":{"type":"Sub"}},
{"primitive":{"type":"Add"}}
],
"edges": [
{"from":0,"to":2,"slot":0,"from_field":0},
{"from":1,"to":2,"slot":1,"from_field":0}
],
"input_roles": [
{"name":"open","targets":[{"node":1,"slot":1}]},
{"name":"high","targets":[{"node":0,"slot":0}]},
{"name":"low","targets":[{"node":0,"slot":1}]},
{"name":"close","targets":[{"node":1,"slot":0}]}
],
"output": [{"node":2,"field":0,"name":"value"}]
}
}"#;
// Nest the loaded signal under a Rust root that declares the four source roles
// in canonical column order (open, high, low, close) and records the single
// output (the rsi_from_blueprint_data idiom — sinks are outside the std
// vocabulary, so the recorder is added in Rust). Root role slot i targets the
// nested composite's role i (input_roles order).
fn run_recording(
signal: Composite,
columns: [Vec<(Timestamp, Scalar)>; 4],
) -> Vec<(Timestamp, Vec<Scalar>)> {
let (tx, rx) = mpsc::channel();
let roles: Vec<Role> = ["open", "high", "low", "close"]
.iter()
.enumerate()
.map(|(slot, name)| Role {
name: (*name).to_string(),
targets: vec![Target { node: 0, slot }],
source: Some(ScalarKind::F64),
})
.collect();
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 }],
roles,
vec![],
);
let mut h = root.bootstrap_with_params(vec![]).expect("bootstraps (no open params)");
let sources: Vec<Box<dyn aura_engine::Source>> = columns
.into_iter()
.map(|c| Box::new(VecSource::new(c)) as Box<dyn aura_engine::Source>)
.collect();
h.run(sources);
rx.try_iter().collect()
}
fn col(values: &[f64]) -> Vec<(Timestamp, Scalar)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v)))
.collect()
}
/// Hand-computed contract. The four sources at one bar timestamp arrive in
/// FOUR consecutive engine cycles (the k-way merge breaks the timestamp tie
/// by source index = canonical column order: open, high, low, close); every
/// `Firing::Any` node re-evaluates per fresh input over the newest value of
/// each leg (sample-and-hold on the stale leg). Bars (o,h,l,c):
/// (1,10,5,3), (2,20,8,6), (4,40,16,12).
///
/// Bar 1: open=1 -> body fires, close leg cold -> None; high=10 -> range
/// fires, low leg cold -> None; low=5 -> range = 10-5 = 5, add fires, body
/// leg cold -> None; close=3 -> body = 3-1 = 2, add = 5+2 = 7 -> (t1, 7).
/// Bar 2: open=2 -> body = 3-2 = 1 (close held at 3), add = 5+1 = 6;
/// high=20 -> range = 20-5 = 15 (low held), add = 15+1 = 16;
/// low=8 -> range = 20-8 = 12, add = 12+1 = 13;
/// close=6 -> body = 6-2 = 4, add = 12+4 = 16.
/// Bar 3: open=4 -> body = 6-4 = 2, add = 12+2 = 14; high=40 -> range =
/// 40-8 = 32, add = 32+2 = 34; low=16 -> range = 40-16 = 24, add = 26;
/// close=12 -> body = 12-4 = 8, add = 24+8 = 32.
#[test]
fn ohlc_blueprint_runs_over_four_columns_in_canonical_order() {
let signal = blueprint_from_json(OHLC_BLUEPRINT_JSON, &|t| std_vocabulary(t))
.expect("loads through the std vocabulary");
let trace = run_recording(
signal,
[
col(&[1.0, 2.0, 4.0]), // open
col(&[10.0, 20.0, 40.0]), // high
col(&[5.0, 8.0, 16.0]), // low
col(&[3.0, 6.0, 12.0]), // close
],
);
let want: Vec<(Timestamp, Vec<Scalar>)> = [
(1, 7.0),
(2, 6.0),
(2, 16.0),
(2, 13.0),
(2, 16.0),
(3, 14.0),
(3, 34.0),
(3, 26.0),
(3, 32.0),
]
.iter()
.map(|&(t, v)| (Timestamp(t), vec![Scalar::f64(v)]))
.collect();
assert_eq!(
trace, want,
"role i must be fed by source i in canonical column order",
);
}