feat(trace-charts): persist recorder taps to disk (iter 1)
Iteration 1 (persist half) of the visual face — spec 0056 / plan 0055:
tap data from a graph and save it to disk so it can later be charted
(the serve/chart half is iteration 2).
- ColumnarTrace (aura-engine): a drained tap <-> columnar SoA form
({tap, kinds, ts, columns}); values coerced to f64 for plotting with
the base type preserved in `kinds` (C7); to_rows rebuilds uniformly-f64
rows for the serve-side join. Pure (C1), beside join_on_ts/summarize.
- TraceStore (aura-registry): a per-run directory under
runs/traces/<name>/ — one <tap>.json per tap + an index.json (manifest
+ tap order), beside the families.jsonl sibling store. Missing run =
NotFound (no panic); corrupt file = Parse{file}.
- CLI: a shared persist_traces helper threaded into run_sample /
run_macd / run_sample_real via an orthogonal `trace: Option<&str>`;
new arms `aura run [--macd] --trace <name>` and `--trace <name>` on
`run --real`. The default (no --trace) run is byte-for-byte unchanged.
Verified by the orchestrator: `cargo test --workspace` green (incl. the
4 columnar_trace, 3 trace_store, 4 cli_run e2e, and the 4-tuple
parse_real_args tests); `cargo clippy --workspace --all-targets -D
warnings` exit 0. Implementer deviations folded in, all
compiler-prescribed: #[derive(Debug)] on RunTraces (the RED tests
panic-format a Result over it), #[allow(clippy::type_complexity)] on
the parse_real_args 4-tuple (mirrors the crate's sample_harness idiom).
Default-run byte-identity stays pinned by run_prints_json_and_exits_zero.
refs #101
This commit is contained in:
+106
-78
@@ -15,14 +15,14 @@ mod render;
|
||||
|
||||
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, Composite, Edge,
|
||||
FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode, RunManifest, RunReport,
|
||||
SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds,
|
||||
WindowRoller, WindowRun,
|
||||
f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, ColumnarTrace,
|
||||
Composite, Edge, FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode,
|
||||
RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource,
|
||||
WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||
walkforward_member_reports, FamilyKind, Registry,
|
||||
walkforward_member_reports, FamilyKind, Registry, TraceStore,
|
||||
};
|
||||
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
@@ -152,10 +152,31 @@ fn sim_optimal_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a run's drained taps to the on-disk trace store under `runs/traces/<name>/`
|
||||
/// (beside the run registry's `runs/`). Shared by every run form — all drain the same
|
||||
/// two f64 taps (equity off the broker, exposure off the strategy) and carry a
|
||||
/// `RunManifest`. Pure wiring over `ColumnarTrace` + `TraceStore`; the engine is
|
||||
/// untouched. An I/O failure is a usage-level error (stderr + exit 2), per the spec.
|
||||
fn persist_traces(
|
||||
name: &str,
|
||||
manifest: &RunManifest,
|
||||
eq_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
ex_rows: &[(Timestamp, Vec<Scalar>)],
|
||||
) {
|
||||
let taps = vec![
|
||||
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
|
||||
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
|
||||
];
|
||||
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
|
||||
eprintln!("aura: trace persist failed: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
|
||||
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
|
||||
/// (C1): the same build yields the same report.
|
||||
fn run_sample() -> RunReport {
|
||||
fn run_sample(trace: Option<&str>) -> RunReport {
|
||||
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||
vec![Box::new(VecSource::new(synthetic_prices()))];
|
||||
@@ -164,23 +185,21 @@ fn run_sample() -> RunReport {
|
||||
|
||||
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(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
SYNTHETIC_PIP_SIZE,
|
||||
),
|
||||
metrics,
|
||||
let manifest = sim_optimal_manifest(
|
||||
vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
SYNTHETIC_PIP_SIZE,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(name, &manifest, &eq_rows, &ex_rows);
|
||||
}
|
||||
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// `aura run --real <SYMBOL>`: run the built-in sample harness over real M1 close
|
||||
@@ -190,7 +209,7 @@ fn run_sample() -> RunReport {
|
||||
/// (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 {
|
||||
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
|
||||
// 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) {
|
||||
@@ -237,23 +256,21 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Ru
|
||||
|
||||
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(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
spec.pip_size,
|
||||
),
|
||||
metrics,
|
||||
let manifest = sim_optimal_manifest(
|
||||
vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
spec.pip_size,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(name, &manifest, &eq_rows, &ex_rows);
|
||||
}
|
||||
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// Parse the `run --real` tail: a mandatory `<SYMBOL>` then zero-or-more
|
||||
@@ -261,25 +278,29 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Ru
|
||||
/// 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();
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn parse_real_args(
|
||||
rest: &[&str],
|
||||
) -> Result<(String, Option<i64>, Option<i64>, Option<String>), String> {
|
||||
let usage = || "run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>]".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;
|
||||
let mut trace: Option<String> = 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),
|
||||
"--from" if from.is_none() => from = Some(value.parse().map_err(|_| usage())?),
|
||||
"--to" if to.is_none() => to = Some(value.parse().map_err(|_| usage())?),
|
||||
"--trace" if trace.is_none() => trace = Some((*value).to_string()),
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
tail = t;
|
||||
}
|
||||
Ok((symbol.to_string(), from, to))
|
||||
Ok((symbol.to_string(), from, to, trace))
|
||||
}
|
||||
|
||||
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
|
||||
@@ -820,7 +841,7 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
/// (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 {
|
||||
fn run_macd(trace: Option<&str>) -> RunReport {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
|
||||
@@ -835,28 +856,26 @@ fn run_macd() -> RunReport {
|
||||
|
||||
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![
|
||||
("ema_fast".to_string(), Scalar::i64(2)),
|
||||
("ema_slow".to_string(), Scalar::i64(4)),
|
||||
("ema_signal".to_string(), Scalar::i64(3)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
SYNTHETIC_PIP_SIZE,
|
||||
),
|
||||
metrics,
|
||||
let manifest = sim_optimal_manifest(
|
||||
vec![
|
||||
("ema_fast".to_string(), Scalar::i64(2)),
|
||||
("ema_slow".to_string(), Scalar::i64(4)),
|
||||
("ema_signal".to_string(), Scalar::i64(3)),
|
||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window,
|
||||
0,
|
||||
SYNTHETIC_PIP_SIZE,
|
||||
);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(name, &manifest, &eq_rows, &ex_rows);
|
||||
}
|
||||
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -864,10 +883,14 @@ fn main() {
|
||||
// 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()),
|
||||
["run"] => println!("{}", run_sample(None).to_json()),
|
||||
["run", "--macd"] => println!("{}", run_macd(None).to_json()),
|
||||
["run", "--trace", name] => println!("{}", run_sample(Some(name)).to_json()),
|
||||
["run", "--macd", "--trace", name] => println!("{}", run_macd(Some(name)).to_json()),
|
||||
["run", "--real", rest @ ..] => match parse_real_args(rest) {
|
||||
Ok((sym, from, to)) => println!("{}", run_sample_real(&sym, from, to).to_json()),
|
||||
Ok((sym, from, to, trace)) => {
|
||||
println!("{}", run_sample_real(&sym, from, to, trace.as_deref()).to_json())
|
||||
}
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
@@ -1096,8 +1119,8 @@ mod tests {
|
||||
// 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();
|
||||
let r1 = run_macd(None);
|
||||
let r2 = run_macd(None);
|
||||
assert_eq!(r1.metrics, r2.metrics);
|
||||
assert_eq!(r1.to_json(), r2.to_json());
|
||||
|
||||
@@ -1177,8 +1200,8 @@ mod tests {
|
||||
|
||||
// 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));
|
||||
let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
|
||||
let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
|
||||
|
||||
assert!(
|
||||
r1.metrics.total_pips.is_finite(),
|
||||
@@ -1212,12 +1235,12 @@ mod tests {
|
||||
// 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));
|
||||
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
|
||||
// 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));
|
||||
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
|
||||
assert_eq!(report.manifest.broker, again.manifest.broker);
|
||||
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
|
||||
}
|
||||
@@ -1227,15 +1250,20 @@ mod tests {
|
||||
/// 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"]), Ok(("EURUSD".to_string(), None, None, None)));
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--from", "100", "--to", "200"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200)))
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
|
||||
);
|
||||
// flags in any order
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--to", "200", "--from", "100"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200)))
|
||||
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
|
||||
);
|
||||
// the --trace tail flag carries a string name, parsed alongside the window
|
||||
assert_eq!(
|
||||
parse_real_args(&["EURUSD", "--from", "100", "--trace", "demo"]),
|
||||
Ok(("EURUSD".to_string(), Some(100), None, Some("demo".to_string())))
|
||||
);
|
||||
// a flag without its value, an empty symbol, and a non-numeric ms all reject
|
||||
assert!(parse_real_args(&["EURUSD", "--from"]).is_err());
|
||||
@@ -1245,8 +1273,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_sample_is_deterministic_and_non_trivial() {
|
||||
let r1 = run_sample();
|
||||
let r2 = run_sample();
|
||||
let r1 = run_sample(None);
|
||||
let r2 = run_sample(None);
|
||||
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
|
||||
assert_eq!(r1.metrics, r2.metrics);
|
||||
assert_eq!(r1.to_json(), r2.to_json());
|
||||
|
||||
Reference in New Issue
Block a user