From d8b2a2880d1b742d34d0f1f52c89a9ff80c70b45 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 7 Jun 2026 23:20:42 +0200 Subject: [PATCH] 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. --- crates/aura-cli/src/main.rs | 219 ++++++++++++++++++++++++++++++++---- 1 file changed, 197 insertions(+), 22 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3de72cd..7091842 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 { 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)>, + tx_ex: mpsc::Sender<(Timestamp, Vec)>, +) -> 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 { + 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)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = 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 = std::env::args().skip(1).collect(); + match args.iter().map(String::as_str).collect::>().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();