feat(aura-cli): aura run --real <SYMBOL> backtests the sample on real M1 bars
The first real-data backtest from the CLI: `aura run --real <SYMBOL> [--from <ms>] [--to <ms>]` 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<dyn Source>) — 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.
This commit is contained in:
Generated
+2
@@ -52,8 +52,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-std",
|
||||
"data-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+167
-1
@@ -158,6 +158,94 @@ fn run_sample() -> RunReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura run --real <SYMBOL>`: 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<dyn Source>`), 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<i64>, to_ms: Option<i64>) -> 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<dyn aura_engine::Source> = match open() {
|
||||
Some(s) => Box::new(s),
|
||||
None => no_data(),
|
||||
};
|
||||
h.run(vec![source]);
|
||||
|
||||
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: 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 `<SYMBOL>` then zero-or-more
|
||||
/// `--from <ms>` / `--to <ms>` 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<i64>, Option<i64>), String> {
|
||||
let usage = || "run --real <SYMBOL> [--from <ms>] [--to <ms>]".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<i64> = None;
|
||||
let mut to: Option<i64> = 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 <metric>";
|
||||
"usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura runs list | aura runs rank <metric>";
|
||||
|
||||
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::<Vec<_>>().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 <SYMBOL>` 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<dyn Source>`),
|
||||
/// 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();
|
||||
|
||||
Reference in New Issue
Block a user