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:
+197
-22
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user