feat(aura-cli): MACD strategy PoC over the EMA node

Author MACD as a nested composite -- price -> fast/slow EMA -> Sub (MACD line) -> signal EMA -> Sub (histogram), the histogram exposed as the single output the strategy trades. A richer fixture than sma_cross: an EMA-of-EMA chain with interior fan-out (the MACD line feeds both the signal EMA and the histogram), exercising the nested-composite render shipped in cycle 0017.

It runs over the real path: the composite blueprint is compiled to a flat harness via compile_with_params + Harness::bootstrap (the same machinery as the SMA compiled view), not hand-flattened. New surfaces: aura run --macd (runs the strategy, prints metrics) and aura graph --macd [--compiled]. main's arg parsing is rewritten to a whole-argv match, preserving the #16 strict contract (trailing token / unknown subcommand -> usage, exit 2). MACD gets its own longer synthetic stream because the SMA-seeded EMAs warm up; the SMA sample's stream and goldens are untouched.

This PoC makes two parameter-space gaps concrete for the milestone: the strategy's param_space is [macd.length, macd.length, macd.length, scale] -- three identical names distinguishable only by slot (the named-param case, blueprint #30) -- and a composite exposes only one output port, so the MACD/signal/histogram tuple cannot all be tapped for display (the tuple-output case). The compiled view's valued labels (EMA(2)/EMA(4)/EMA(3)) are what currently disambiguate the three EMAs.

Tested: MACD compiles from the nested composite and runs deterministically (C1), the trace is non-trivial (>=1 exposure sign flip), and the blueprint view renders the composite definition once.
This commit is contained in:
2026-06-07 23:20:42 +02:00
parent ae1d4564f5
commit d8b2a2880d
+197 -22
View File
@@ -13,7 +13,7 @@ use aura_engine::{
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
@@ -173,30 +173,171 @@ fn sample_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
}
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
/// (their spread) → a signal `Ema` of that line → the histogram (line signal),
/// which is the composite's single output — the line the strategy trades on. A
/// richer fixture than `sma_cross`: a nested EMA-of-EMA chain with interior
/// fan-out (the MACD line feeds *both* the signal EMA and the histogram). Three
/// `length` knobs (fast, slow, signal) are injected at compile in node order;
/// value-empty here.
fn macd(name: &str) -> Composite {
Composite::new(
name,
vec![
Ema::factory().into(), // 0 fast EMA
Ema::factory().into(), // 1 slow EMA
Sub::factory().into(), // 2 MACD line = fast slow
Ema::factory().into(), // 3 signal EMA of the MACD line
Sub::factory().into(), // 4 histogram = MACD line signal
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0]
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow → line[1]
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // line → signal EMA
Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // line → histogram[0]
Edge { from: 3, to: 4, slot: 1, from_field: 0 }, // signal → histogram[1]
],
vec![vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
]],
OutPort { node: 4, field: 0 }, // the histogram
)
}
/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Exposure` →
/// `SimBroker` → recording sinks. Channels are threaded so a run can drain the
/// sinks; `macd_blueprint` drops the receivers for the structural render.
fn macd_strategy_blueprint(
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Blueprint {
Blueprint::new(
vec![
BlueprintNode::Composite(macd("macd")),
Exposure::factory().into(),
SimBroker::factory(0.0001).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price → macd role 0
Target { node: 2, slot: 1 }, // price → SimBroker price slot
],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // histogram → Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure → broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity → sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure → sink
],
)
}
/// The MACD strategy blueprint for the structural render (receivers dropped, as
/// the render never runs the graph).
fn macd_blueprint() -> Blueprint {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
macd_strategy_blueprint(tx_eq, tx_ex)
}
/// The point vector for the MACD strategy, in `param_space()` slot order:
/// `[fast EMA length, slow EMA length, signal EMA length, exposure scale]`. Short
/// windows so the 7-tick synthetic stream still produces a non-trivial trace
/// (conventional MACD is 12/26/9, meaningless on 7 points).
fn macd_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::I64(3), Scalar::F64(0.5)]
}
/// A longer synthetic stream than the SMA sample's 7 ticks: MACD's EMAs each warm
/// up over their `length`, so the stream rises, falls, then rises again to give the
/// histogram room to flip sign more than once *after* warm-up. Deterministic (C1).
fn macd_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.collect()
}
/// Run the MACD strategy: compile the nested composite blueprint to a flat harness
/// (the same bootstrap path the SMA sample's compiled view uses), drive it on the
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
/// deterministic (C1).
fn run_macd() -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (nodes, sources, edges) = macd_strategy_blueprint(tx_eq, tx_ex)
.compile_with_params(&macd_point())
.expect("valid macd blueprint");
let mut h = Harness::bootstrap(nodes, sources, edges).expect("valid macd harness");
let prices = macd_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params: vec![
("ema_fast".to_string(), 2.0),
("ema_slow".to_string(), 4.0),
("ema_signal".to_string(), 3.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
/// Compile a blueprint under its point vector and render the flat post-inline
/// (C23) view — the shared body of the `--compiled` paths.
fn render_compiled(bp: Blueprint, point: &[Scalar]) -> String {
let (nodes, sources, edges) = bp.compile_with_params(point).expect("valid blueprint");
graph::render_compilat(&nodes, &sources, &edges)
}
const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]";
fn main() {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
// strict: a bare `run` proceeds; a trailing token falls through to the
// usage-error path rather than masquerading as a successful run (#16).
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
Some("graph") => {
// `--compiled` selects the flat post-inline view; default is the
// blueprint view (main graph + composite definitions). Strictness
// beyond this stays minimal (#16).
let compiled = args.next().as_deref() == Some("--compiled");
let bp = sample_blueprint();
let out = if compiled {
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample blueprint");
graph::render_compilat(&nodes, &sources, &edges)
} else {
graph::render_blueprint(&bp)
};
println!("{out}");
// Collect argv and match the whole vector: every accepted form is exhaustive,
// so an unexpected trailing token falls through to the usage-error path rather
// than masquerading as a successful run (#16 strict reading).
let args: Vec<String> = std::env::args().skip(1).collect();
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint())),
["graph", "--compiled"] => {
println!("{}", render_compiled(sample_blueprint(), &sample_point()));
}
Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"),
["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint())),
["graph", "--macd", "--compiled"] => {
println!("{}", render_compiled(macd_blueprint(), &macd_point()));
}
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: usage: aura run | aura graph [--compiled]");
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
@@ -388,6 +529,40 @@ sma_cross:
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
}
#[test]
fn run_macd_compiles_from_nested_composite_and_is_deterministic() {
// the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a
// flat runnable harness (the call not panicking proves the compile+bootstrap
// path), and runs it. C1 determinism: two runs are bit-identical.
let r1 = run_macd();
let r2 = run_macd();
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
// the synthetic stream is carried end-to-end and the trace is well-formed.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 18));
assert!(r1.metrics.total_pips.is_finite(), "macd pips must be finite: {:?}", r1.metrics);
assert!(r1.metrics.max_drawdown >= 0.0, "drawdown is non-negative: {:?}", r1.metrics);
// after warm-up the EMA-of-EMA histogram crosses zero, so the strategy
// reverses exposure at least once — a genuinely non-trivial trace.
assert!(
r1.metrics.exposure_sign_flips >= 1,
"macd trace should flip exposure: {:?}",
r1.metrics
);
}
#[test]
fn macd_blueprint_renders_a_nested_composite_definition() {
// the MACD blueprint view shows the composite opaque in the main graph and
// defines its EMA-of-EMA interior once under `where:`.
let out = graph::render_blueprint(&macd_blueprint());
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
assert_eq!(out.matches("macd:").count(), 1, "macd defined once:\n{out}");
assert!(out.contains("[EMA]"), "macd interior must show EMA leaves:\n{out}");
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();