From 0ce843bada0f9c742352fa1c17feacb8cc1a085a Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 11:13:14 +0200 Subject: [PATCH] feat(aura-cli): aura run --real backtests the sample on real M1 bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real-data backtest from the CLI: `aura run --real [--from ] [--to ]` runs the existing built-in sample signal-quality harness over real M1 *close* bars instead of synthetic prices, printing the same RunReport JSON as `aura run`. Bars are streamed lazily through aura_ingest::M1FieldSource (a Box) — dogfooding the #71 Source seam, O(one chunk) resident, never eager. aura-ingest + data-server enter aura-cli's deps (data-server git line mirrored from aura-ingest). M1 only this cycle, deliberately. The TickSource is deferred: raw ticks are the wrong input for the bar-semantic SMA sample (an SMA over ticks is microstructure noise until a resampler node exists, C2), and tick's real driver is the realistic broker's bid/ask execution (C10) — it should arrive with that consumer, not speculatively here. Tick data is also local to only EURUSD/XAUUSD/GER40, whereas M1 covers the test symbol AAPL.US. Shape: run_sample_real builds sample_harness(), guards has_symbol / M1FieldSource::open (unknown symbol or empty window -> 'aura: no local data' + exit 2, never a panic), and reuses sim_optimal_manifest + the run_sample fold. parse_real_args is a pure, unit-tested grammar (mandatory symbol, then --from/--to in any order; bad/missing/duplicate flag -> Err). Data path is data_server::DEFAULT_DATA_PATH (no --data-path flag this cycle). Known simplification: the manifest window is derived by draining a *separate* single-pass probe source for first/last ts, so an unbounded window streams the archive twice (once to bound, once to run). Acceptable and O(1)-resident for now; a future Harness::run could surface the first/last cycle ts to drop the probe pass. Verified: cargo build/test/clippy --workspace all green; the gated run_sample_real_streams_real_close_bars_deterministically RAN (not skipped) over AAPL.US 2006-08; manual CLI smoke of the success + two error paths. --- Cargo.lock | 2 + crates/aura-cli/Cargo.toml | 4 + crates/aura-cli/src/main.rs | 168 +++++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 467448f..4ea51fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,8 +52,10 @@ version = "0.1.0" dependencies = [ "aura-core", "aura-engine", + "aura-ingest", "aura-registry", "aura-std", + "data-server", ] [[package]] diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index 9f36287..6e43078 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -14,3 +14,7 @@ aura-core = { path = "../aura-core" } aura-engine = { path = "../aura-engine" } aura-registry = { path = "../aura-registry" } aura-std = { path = "../aura-std" } +aura-ingest = { path = "../aura-ingest" } +# data-server: the local M1 archive `aura run --real` streams from. Mirrors the +# git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth). +data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6ecd0d1..31e5fe5 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -158,6 +158,94 @@ fn run_sample() -> RunReport { } } +/// `aura run --real `: run the built-in sample harness over real M1 close +/// bars streamed lazily from the local data-server archive through the #71 Source +/// seam (`M1FieldSource`, a `Box`), not the synthetic `VecSource`. Same +/// fold as `run_sample`. The manifest window is read from a *separate* probe source +/// (a Source is single-pass), so the run source streams the window untouched. +/// A no-local-data condition (unknown symbol, or a window overlapping no file / no +/// bars) is a usage error: stderr + exit(2), not a panic. +fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option) -> RunReport { + let (mut h, rx_eq, rx_ex) = sample_harness(); + let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)); + let no_data = || -> ! { + eprintln!( + "aura: no local data for symbol '{symbol}' at {}", + data_server::DEFAULT_DATA_PATH + ); + std::process::exit(2) + }; + if !server.has_symbol(symbol) { + no_data(); + } + let open = || aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close); + + // Manifest window: drain a separate probe (single-pass Source) for first/last ts. + let mut probe = match open() { + Some(p) => p, + None => no_data(), + }; + let first = match aura_engine::Source::peek(&probe) { + Some(t) => t, + None => no_data(), + }; + let mut last = first; + while let Some((t, _)) = aura_engine::Source::next(&mut probe) { + last = t; + } + let window = (first, last); + + let source: Box = match open() { + Some(s) => Box::new(s), + None => no_data(), + }; + h.run(vec![source]); + + 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: sim_optimal_manifest( + vec![ + ("sma_fast".to_string(), 2.0), + ("sma_slow".to_string(), 4.0), + ("exposure_scale".to_string(), 0.5), + ], + window, + ), + metrics, + } +} + +/// Parse the `run --real` tail: a mandatory `` then zero-or-more +/// `--from ` / `--to ` pairs in any order. Pure (no I/O, no exit) so the +/// arg grammar is unit-testable; `main` does the side effects. An unknown token, a +/// flag without its value, a non-`i64` ms, or a repeated flag is an `Err` carrying +/// a short usage message. +fn parse_real_args(rest: &[&str]) -> Result<(String, Option, Option), String> { + let usage = || "run --real [--from ] [--to ]".to_string(); + let (symbol, mut tail) = match rest.split_first() { + Some((sym, t)) if !sym.is_empty() => (*sym, t), + _ => return Err(usage()), + }; + let mut from: Option = None; + let mut to: Option = None; + while let Some((flag, t)) = tail.split_first() { + let (value, t) = t.split_first().ok_or_else(usage)?; + let ms: i64 = value.parse().map_err(|_| usage())?; + match *flag { + "--from" if from.is_none() => from = Some(ms), + "--to" if to.is_none() => to = Some(ms), + _ => return Err(usage()), + } + tail = t; + } + Ok((symbol.to_string(), from, to)) +} + /// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread). /// CLI-local sample builder; the engine ships no sample (the duplication with /// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA @@ -482,7 +570,7 @@ fn run_macd() -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] | aura graph | aura sweep | aura runs list | aura runs rank "; + "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep | aura runs list | aura runs rank "; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -492,6 +580,13 @@ fn main() { match args.iter().map(String::as_str).collect::>().as_slice() { ["run"] => println!("{}", run_sample().to_json()), ["run", "--macd"] => println!("{}", run_macd().to_json()), + ["run", "--real", rest @ ..] => match parse_real_args(rest) { + Ok((sym, from, to)) => println!("{}", run_sample_real(&sym, from, to).to_json()), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + }, ["graph"] => print!("{}", render::render_html(&sample_blueprint())), ["sweep"] => run_sweep(), ["runs", "list"] => runs_list(), @@ -607,6 +702,77 @@ mod tests { ); } + /// `aura run --real ` dogfoods the #71 streaming Source seam: the same + /// built-in sample signal-quality harness, but fed real M1 **close** bars + /// streamed lazily through `aura_ingest::M1FieldSource` (a `Box`), + /// not synthetic `VecSource` prices. The property: over a bounded real window, + /// `run_sample_real` yields a `RunReport` whose `total_pips` is finite and is + /// C1-deterministic — two runs of the same window are bit-identical JSON. + /// + /// Gated like the ingest `streaming_seam` test: skip (early return) when the + /// local Pepperstone archive is absent, so the spec never fails on a machine + /// without the data. Uses the verified bounded 2006-08 `AAPL.US` window (the + /// same FROM_MS/TO_MS the ingest seam test drives) so the two-run determinism + /// check stays fast. + #[test] + fn run_sample_real_streams_real_close_bars_deterministically() { + // Same bounded 2006-08 inclusive-ms window the ingest streaming_seam test + // uses; AAPL.US M1 data is present there on a data-bearing machine. + const SYMBOL: &str = "AAPL.US"; + const FROM_MS: i64 = 1_154_390_400_000; + const TO_MS: i64 = 1_157_068_799_999; + + // Mirror skip_if_no_data: never fail where the local archive is absent. + let server = std::sync::Arc::new(data_server::DataServer::new( + data_server::DEFAULT_DATA_PATH, + )); + if !server.has_symbol(SYMBOL) { + eprintln!( + "skip: no local data at {} (symbol {SYMBOL} absent)", + data_server::DEFAULT_DATA_PATH + ); + return; + } + + // The headline: a real-data run over the bounded window yields a finite, + // C1-deterministic RunReport. + let r1 = run_sample_real(SYMBOL, Some(FROM_MS), Some(TO_MS)); + let r2 = run_sample_real(SYMBOL, Some(FROM_MS), Some(TO_MS)); + + assert!( + r1.metrics.total_pips.is_finite(), + "real-data run must yield finite pips: {:?}", + r1.metrics + ); + // C1 at the CLI edge: the same real window streamed twice is bit-identical. + assert_eq!( + r1.to_json(), + r2.to_json(), + "two real-data runs of the same window must be bit-identical (C1)" + ); + } + + /// `parse_real_args` accepts a bare symbol (no window) and a full + /// symbol + `--from`/`--to` pair in any order, and rejects a flag missing its + /// value — the pure arg grammar `main` relies on for `run --real`. + #[test] + fn parse_real_args_accepts_symbol_and_optional_window() { + assert_eq!(parse_real_args(&["EURUSD"]), Ok(("EURUSD".to_string(), None, None))); + assert_eq!( + parse_real_args(&["EURUSD", "--from", "100", "--to", "200"]), + Ok(("EURUSD".to_string(), Some(100), Some(200))) + ); + // flags in any order + assert_eq!( + parse_real_args(&["EURUSD", "--to", "200", "--from", "100"]), + Ok(("EURUSD".to_string(), Some(100), Some(200))) + ); + // a flag without its value, an empty symbol, and a non-numeric ms all reject + assert!(parse_real_args(&["EURUSD", "--from"]).is_err()); + assert!(parse_real_args(&[]).is_err()); + assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err()); + } + #[test] fn run_sample_is_deterministic_and_non_trivial() { let r1 = run_sample();