diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index d49b810..8383bcb 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -27,6 +27,14 @@ use aura_registry::{ use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; +/// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major +/// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no +/// `instrument_spec` to thread; a single named source keeps the broker's divisor +/// (`sample_harness` / `SimBroker::builder`) and the recorded broker label +/// (`sim_optimal_manifest`) in lockstep, so they cannot silently drift apart. +/// The real path threads the looked-up `spec.pip_size` instead. +const SYNTHETIC_PIP_SIZE: f64 = 0.0001; + /// The built-in synthetic price stream: rises through t=4 then reverses, so the /// demo trace carries one exposure sign flip and a real drawdown (C22 populated /// trace). Deterministic and fixed (C1). @@ -70,7 +78,7 @@ fn showcase_prices() -> Vec<(Timestamp, Scalar)> { // The harness-plus-two-drained-sink-receivers tuple has exactly one call site // (`run_sample`); a named type would be speculative abstraction this cycle. #[allow(clippy::type_complexity)] -fn sample_harness() -> ( +fn sample_harness(pip_size: f64) -> ( Harness, Receiver<(Timestamp, Vec)>, Receiver<(Timestamp, Vec)>, @@ -88,7 +96,7 @@ fn sample_harness() -> ( Box::new(Sma::new(4)), // 1 slow SMA Box::new(Sub::new()), // 2 spread Box::new(Exposure::new(0.5)), // 3 exposure - Box::new(SimBroker::new(0.0001)), // 4 sim-optimal broker + Box::new(SimBroker::new(pip_size)), // 4 sim-optimal broker Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink ], @@ -97,7 +105,7 @@ fn sample_harness() -> ( Sma::builder().schema().clone(), Sub::builder().schema().clone(), Exposure::builder().schema().clone(), - SimBroker::builder(0.0001).schema().clone(), + SimBroker::builder(pip_size).schema().clone(), f64_recorder_sig(), f64_recorder_sig(), ], @@ -125,12 +133,13 @@ fn sample_harness() -> ( /// Build the sim-optimal `RunManifest`: the engine-external descriptor fields /// (commit, seed-free synthetic run, broker label) are constant across the CLI's /// built-in harnesses — only `params` and `window` vary. Centralizing the broker -/// label keeps it a single source (and a single edit point for #22's per-asset -/// pip work). +/// label keeps it a single source (and a single edit point for per-asset pip +/// work): the `pip_size` it renders is the one its caller ran the broker at. fn sim_optimal_manifest( params: Vec<(String, Scalar)>, window: (Timestamp, Timestamp), seed: u64, + pip_size: f64, ) -> RunManifest { // Typed params pass straight through: the manifest carries self-describing // Scalars, so a length stays `i64` and a scale `f64` in the record. @@ -139,7 +148,7 @@ fn sim_optimal_manifest( params, window, seed, - broker: "sim-optimal(pip_size=0.0001)".to_string(), + broker: format!("sim-optimal(pip_size={pip_size})"), } } @@ -147,7 +156,7 @@ fn sim_optimal_manifest( /// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic /// (C1): the same build yields the same report. fn run_sample() -> RunReport { - let (mut h, rx_eq, rx_ex) = sample_harness(); + let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); let sources: Vec> = vec![Box::new(VecSource::new(synthetic_prices()))]; let window = window_of(&sources).expect("non-empty synthetic stream"); @@ -168,6 +177,7 @@ fn run_sample() -> RunReport { ], window, 0, + SYNTHETIC_PIP_SIZE, ), metrics, } @@ -181,7 +191,16 @@ fn run_sample() -> RunReport { /// 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(); + // Per-instrument pip: look up BEFORE any data access, so an un-specced symbol + // refuses without touching local data. Honest by construction. + let spec = match aura_ingest::instrument_spec(symbol) { + Some(s) => s, + None => { + eprintln!("aura: no vetted pip/instrument spec for symbol '{symbol}' — refusing to run a real instrument with a guessed pip (add it to the instrument table)"); + std::process::exit(2); + } + }; + let (mut h, rx_eq, rx_ex) = sample_harness(spec.pip_size); let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)); let no_data = || -> ! { eprintln!( @@ -231,6 +250,7 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option) -> Ru ], window, 0, + spec.pip_size, ), metrics, } @@ -323,7 +343,7 @@ fn sample_blueprint_with_sinks() -> ( let mut g = GraphBuilder::new("sample"); let sig = g.add(signals("signals")); let exposure = g.add(Exposure::builder()); - let broker = g.add(SimBroker::builder(0.0001)); + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); let price = g.source_role("price", ScalarKind::F64); @@ -376,7 +396,7 @@ fn sweep_family() -> SweepFamily { let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); RunReport { - manifest: sim_optimal_manifest(zip_params(&space, point), window, 0), + manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, SYNTHETIC_PIP_SIZE), metrics: summarize(&equity, &exposure), } }) @@ -495,7 +515,7 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily { let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); RunReport { - manifest: sim_optimal_manifest(zip_params(&space, point), window, 0), + manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, SYNTHETIC_PIP_SIZE), metrics: summarize(&equity, &exposure), } }) @@ -517,7 +537,7 @@ fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp, let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); let report = RunReport { - manifest: sim_optimal_manifest(zip_params(&space, params), window, 0), + manifest: sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE), metrics: summarize(&equity, &exposure), }; (equity, report) @@ -586,7 +606,7 @@ fn walkforward_report() -> String { fn mc_family() -> McFamily { let base_point: Vec = Vec::new(); monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { - let (mut h, rx_eq, rx_ex) = sample_harness(); + let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; let sources: Vec> = vec![Box::new(spec.source(seed))]; let window = window_of(&sources).expect("non-empty synthetic stream"); @@ -602,6 +622,7 @@ fn mc_family() -> McFamily { ], window, seed, + SYNTHETIC_PIP_SIZE, ), metrics: summarize(&equity, &exposure), } @@ -783,7 +804,7 @@ fn macd_strategy_blueprint( let mut g = GraphBuilder::new("macd_strategy"); let macd_node = g.add(macd("macd")); let exposure = g.add(Exposure::builder()); - let broker = g.add(SimBroker::builder(0.0001)); + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); let price = g.source_role("price", ScalarKind::F64); @@ -859,6 +880,7 @@ fn run_macd() -> RunReport { ], window, 0, + SYNTHETIC_PIP_SIZE, ), metrics, } @@ -906,6 +928,15 @@ fn main() { mod tests { use super::*; + // The vetted GER40 real-data window: the whole of September 2024 (UTC, + // inclusive), the same calendar month the gated ingest `ger40_breakout_real` + // test drives. Expressed in Unix-ms (`run_sample_real`'s window currency): + // `[2024-09-01T00:00:00Z, 2024-10-01T00:00:00Z - 1ms]`. Both gated GER40 + // tests bound their runs to this window so the C1-determinism check stays + // fast — an unbounded `None, None` run drains the full archive every call. + const GER40_SEP2024_FROM_MS: i64 = 1_725_148_800_000; + const GER40_SEP2024_TO_MS: i64 = 1_727_740_799_999; + #[test] fn walkforward_report_is_deterministic() { // The built-in WFO render is byte-identical across two @@ -943,7 +974,7 @@ mod tests { /// drained sink trace is returned alongside the report. Every byte of both /// is a function of `seed`. fn run_sample_seeded(seed: u64) -> (RunReport, SeededTrace) { - let (mut h, rx_eq, rx_ex) = sample_harness(); + let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 }; let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1)); h.run(vec![Box::new(spec.source(seed))]); @@ -959,6 +990,7 @@ mod tests { ], window, seed, + SYNTHETIC_PIP_SIZE, ), metrics, }; @@ -1144,22 +1176,25 @@ 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. + /// not synthetic `VecSource` prices. The property: over the verified bounded + /// Sept-2024 GER40 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. Bounding the window (vs the full unbounded + /// archive) keeps the determinism check fast. /// /// Gated like the ingest `streaming_seam` test: skip (early return) when the /// local Pepperstone archive is absent, so the test 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. + /// without the data. Uses `GER40` — a *vetted* symbol (pip 1.0): the + /// per-instrument-pip refusal makes `run_sample_real` reject an un-specced + /// symbol before any data access (`std::process::exit(2)`), so this CLI-level + /// test must drive a symbol in the instrument table. The bounded-window AAPL.US + /// streaming property still lives in the ingest `streaming_seam` test, which + /// builds its source literally without the spec lookup. #[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; + // GER40 is in the vetted instrument table (index pip 1.0); the un-specced + // AAPL.US would now refuse at the spec lookup before any data access. + const SYMBOL: &str = "GER40"; // Mirror skip_if_no_data: never fail where the local archive is absent. let server = std::sync::Arc::new(data_server::DataServer::new( @@ -1173,10 +1208,10 @@ mod tests { 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)); + // The headline: a real-data run over the bounded Sept-2024 window yields + // a finite, C1-deterministic RunReport. + let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS)); + let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS)); assert!( r1.metrics.total_pips.is_finite(), @@ -1191,6 +1226,35 @@ mod tests { ); } + #[test] + fn sim_optimal_manifest_renders_per_instrument_pip() { + let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0); + assert_eq!(m.broker, "sim-optimal(pip_size=1)"); + let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001); + assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)"); + } + + #[test] + fn run_real_ger40_uses_index_pip() { + // Gated: needs local GER40 data. Mirrors the existing real-path test's skip. + let server = data_server::DataServer::new(data_server::DEFAULT_DATA_PATH); + if !server.has_symbol("GER40") { + eprintln!("skip: no local GER40 data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + // Bounded to the vetted Sept-2024 window so the pip-label + determinism + // checks run fast (not the full unbounded archive). + let report = + run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS)); + // The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001. + assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)"); + // Deterministic (C1): a second run yields the same report. + let again = + run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS)); + assert_eq!(report.manifest.broker, again.manifest.broker); + assert_eq!(report.metrics.total_pips, again.metrics.total_pips); + } + /// `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`. diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 4ec3126..38c30d9 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -130,6 +130,124 @@ fn run_with_trailing_token_is_strict_and_exits_two() { ); } +#[test] +fn run_real_unspecced_symbol_refuses_with_exit_2() { + // The instrument_spec lookup precedes any data access, so an un-specced symbol + // refuses with NO local data required (CI-safe). "NONEXISTENT" is not in the + // vetted table. + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--real", "NONEXISTENT"]) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "expected exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no vetted pip/instrument spec for symbol 'NONEXISTENT'"), + "expected the per-instrument-pip refusal message, got: {stderr}" + ); +} + +/// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING +/// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD +/// of emitting nonsense": the pip lookup precedes harness construction and data +/// access, so a `None` spec must short-circuit to `exit(2)` before any JSON line +/// can reach stdout. A regression that moved the lookup after a partial run would +/// leak a `{"manifest":...}` line here while still exiting non-zero; this pins +/// that it cannot. Archive-independent (the lookup never touches local data). +#[test] +fn run_real_unspecced_symbol_emits_no_stdout_report() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--real", "NONEXISTENT"]) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "expected exit 2: {:?}", out.status); + assert!( + out.stdout.is_empty(), + "the refusal must not leak a partial report to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); +} + +/// Property: the pip-honesty refusal keys on the SYMBOL being un-specced, not on +/// `--real` being taken at all. A *vetted* symbol (`EURUSD`, in the instrument +/// table) gets PAST the `instrument_spec` lookup, so it never produces the +/// pip-refusal message — it either runs to a report (local data present) or hits +/// the DISTINCT no-local-data usage path ("no local data for symbol"). Either +/// way the "no vetted pip/instrument spec" string is absent. This distinguishes +/// the per-instrument-pip honesty lever from the unrelated arg-grammar / no-data +/// error paths, and is archive-independent (asserts only on the absence of the +/// pip-refusal string, true whether or not EURUSD data is on the machine). +#[test] +fn run_real_vetted_symbol_never_hits_the_pip_refusal() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--real", "EURUSD"]) + .output() + .expect("spawn aura"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("no vetted pip/instrument spec"), + "a vetted symbol must clear the pip lookup, never the pip-refusal; stderr: {stderr}" + ); + // It resolves to exactly one of the two legitimate outcomes: a clean report + // (exit 0, data present) or the distinct no-local-data refusal (exit 2). + match out.status.code() { + Some(0) => assert!( + String::from_utf8_lossy(&out.stdout) + .trim_start() + .starts_with("{\"manifest\":"), + "exit 0 must carry a JSON report, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ), + Some(2) => assert!( + stderr.contains("no local data for symbol 'EURUSD'"), + "exit 2 for a vetted symbol must be the no-data path, got: {stderr}" + ), + other => panic!("unexpected exit for a vetted real run: {other:?}; stderr: {stderr}"), + } +} + +/// Property: a vetted symbol threads its looked-up pip all the way to the binary's +/// emitted manifest — `aura run --real GER40` renders the INDEX pip +/// `sim-optimal(pip_size=1)`, not the FX `0.0001` literal the synthetic path uses. +/// This is the #22 headline at the built-binary boundary (criterion 2: GER40=1.0 +/// vs EURUSD=0.0001 vs un-specced→refuse): the metadata channel reaches stdout, so +/// equity is honestly scaled per instrument. Gated on local GER40 data — skip +/// cleanly when the archive is absent (the project's skip-on-no-data convention), +/// so it never fails on a data-less machine; the no-data path is the distinct +/// "no local data" refusal (exit 2), not the pip-refusal, asserted above. +#[test] +fn run_real_vetted_index_pip_reaches_the_emitted_manifest() { + // The vetted GER40 Sept-2024 window (inclusive Unix-ms), the same calendar + // month the gated ingest path drives; bounding it keeps the run fast. + const FROM_MS: &str = "1725148800000"; + const TO_MS: &str = "1727740799999"; + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) + .output() + .expect("spawn aura"); + + // Skip on a data-less machine: a vetted symbol with no local data takes the + // distinct no-data path (exit 2, "no local data"), never the pip-refusal. + if out.status.code() == Some(2) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data for symbol 'GER40'"), + "exit 2 for vetted GER40 must be the no-data path, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let line = stdout.trim_end(); + // the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001. + assert!( + line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), + "GER40 must render the index pip in the manifest, got: {line}" + ); +} + #[test] fn no_args_prints_usage_and_exits_two() { let out = Command::new(BIN).output().expect("spawn aura"); diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index 10475fb..de651e9 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -339,10 +339,46 @@ pub fn default_data_server() -> Arc { Arc::new(DataServer::new(DEFAULT_DATA_PATH)) } +/// Per-instrument reference metadata held beside the hot path (C7/C15): non-scalar, +/// never streamed. Minimal today — extend with tick size / digits / quote currency +/// as later cycles need, without breaking this signature. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InstrumentSpec { + /// Price units per pip / point (> 0). The sim-optimal broker's divisor. + pub pip_size: f64, +} + +/// Look up per-instrument reference metadata by `symbol` at the ingestion edge. +/// Rust-authored vetted table (NOT `Aura.toml`); `None` for an instrument with no +/// vetted spec — the caller must refuse rather than guess a pip. +/// +/// Seeded with the instruments the engine's own paths exercise. Indices quote in +/// points (pip = 1.0); 5-decimal FX majors = 0.0001. NB: JPY pairs are 0.01, NOT +/// 0.0001 — only vetted values are seeded. +pub fn instrument_spec(symbol: &str) -> Option { + let pip_size = match symbol { + "GER40" | "FRA40" => 1.0, + "EURUSD" | "GBPUSD" | "USDCAD" => 0.0001, + _ => return None, + }; + Some(InstrumentSpec { pip_size }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn instrument_spec_lookup_by_symbol() { + // index points → pip 1.0 + assert_eq!(instrument_spec("GER40"), Some(InstrumentSpec { pip_size: 1.0 })); + assert_eq!(instrument_spec("FRA40"), Some(InstrumentSpec { pip_size: 1.0 })); + // 5-decimal FX major → pip 0.0001 + assert_eq!(instrument_spec("EURUSD"), Some(InstrumentSpec { pip_size: 0.0001 })); + // un-specced → None (the honesty lever) + assert_eq!(instrument_spec("NONEXISTENT"), None); + } + #[test] fn unix_ms_to_epoch_ns_scales_ms_to_ns() { // data-server's own epoch fixture: 2017-03-01 00:00 UTC.