audit(real-family): cycle close 0060 — ledger records the real-data family axis

Architect drift review (b92aab7..HEAD): the cycle is contract-clean — C9 (engine
untouched, whole change CLI-side), C6/C1 (real bars streamed from the pre-recorded
archive, real sweep byte-deterministic), C10 (refuse-don't-guess shared by run
--real and the family path), C12 (MC-exclusion fenced: mc --real exits 2). One
medium ledger-gap, now closed; one low item ratified as carry-on.

- Ledger: docs/design/INDEX.md C22 gains a "real-data family source" amendment
  (#106) recording the opt-in real-data axis on sweep/walkforward families, the
  DataSource provider, the MC exclusion (Fork A), and the oos{ns} key widening.
  This was the only drift — a documentation gap, not code drift.
- Ratified carry-on [low]: the real walk-forward member key oos{from.0} is now an
  epoch-ns integer; portable and collision-free, covered in practice by the
  walkforward_real test (>=2 distinct oos<ns> dirs) and the 9-window E2E probe.
  No explicit ns-uniqueness assertion beyond starts_with("oos") — acceptable; a
  dedicated pin is not worth an iteration.
- Retire ephemeral spec/plan 0060 (git rm) per the cycle-artifact lifecycle; the
  durable record is this ledger amendment + the git history.

No regression scripts configured (architect is the gate). Full workspace test +
clippy -D warnings green at close.

refs #106
This commit is contained in:
2026-06-21 15:33:02 +02:00
parent 8e5d14b2bb
commit 607e5487e9
3 changed files with 17 additions and 958 deletions
+17
View File
@@ -1042,6 +1042,23 @@ as a subpath, `aura chart <name>/<member_key>` charts any single member with no
view-side change — the write-side precondition for the family-comparison **view**
(overlay / small-multiples across members), which itself remains open.
**Amendment (real-data family source, #106, 8e5d14b).** The family runs gain an
**opt-in real-data source axis**: `aura sweep|walkforward --real <SYMBOL> [--from
<ms>] [--to <ms>]` streams real M1 close bars from the data-server archive (the #71
`M1FieldSource` seam — C6 record-then-replay: a pre-recorded archive, never a live
call mid-sim) instead of the built-in synthetic stream, per family member. A
CLI-side `DataSource` provider (synthetic | real) supplies each member's source,
pip (`instrument_spec` — C10 refuse-don't-guess; an un-vetted symbol exits 2 before
any data access), window, and — for walk-forward — its `WindowRoller` sizes
(synthetic: bar-index 24/12/12; real: fixed calendar-time 90d/30d/30d in ns; CLI
flags for these are deferred). The engine, ingest, and registry are untouched (C9 —
the whole change is in `aura-cli`); the synthetic path is byte-unchanged.
**MC is excluded** (#106 Fork A): its seed varies a *synthetic* price-walk
realization (C12 axis 4), which is undefined over real data's single realization —
`aura mc --real` refuses with exit 2; a real MC needs a bootstrap-resampling axis
(its own cycle). The real walk-forward member key `oos{from.0}` widens from a small
synthetic bar index to an epoch-ns integer (still portable, collision-free).
### C23 — Graph compilation and behaviour-preserving optimisation
**Guarantee.** The bootstrap (C19) is a **compilation**: it lowers a param-generic,
named **blueprint** (the authoring source — nodes, composites, strategy, harness;
-685
View File
@@ -1,685 +0,0 @@
# Real-data source path for family runs — Implementation Plan
> **Parent spec:** `docs/specs/0060-real-data-family-runs.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add opt-in `--real <SYMBOL> [--from <ms>] [--to <ms>]` to `aura sweep`
and `aura walkforward` so each family member streams real M1 close bars (the #71
`M1FieldSource` seam) instead of the synthetic stream. Without `--real`,
byte-unchanged. MC excluded.
**Architecture:** One CLI-side `DataSource` provider (synthetic|real) supplies a
member's source, pip, window, and (walk-forward) roller sizes, replacing the
hardcoded `VecSource` / `SYNTHETIC_PIP_SIZE`. Engine, ingest, registry untouched.
**Tech Stack:** `crates/aura-cli/src/main.rs` only (+ its `tests/cli_run.rs`).
`aura-cli` is binary-only: test via `cargo test -p aura-cli --bin aura <filter>`
(src `#[cfg(test)]`) and `cargo test -p aura-cli --test cli_run <name>`
(integration). NEVER `--lib`. The authoritative caller set for every signature
change is `cargo build -p aura-cli --all-targets` (the compiler enumerates the
`#[cfg(test)]` callers a grep misses).
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/main.rs` — all production + unit-test changes.
- Test: `crates/aura-cli/tests/cli_run.rs` — real-path integration tests.
All anchors below are current as of HEAD `9637730` (the spec commit added only
`docs/`, so `main.rs` line numbers are unchanged from recon).
---
### Task 1: `DataSource` provider type, `DataChoice`, ns constants (additive)
Pure addition — nothing calls it yet, so the tree stays green. Establishes the
provider the later tasks thread.
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (new items near `SYNTHETIC_PIP_SIZE` :38
and before the family builders)
- [ ] **Step 1: Add the real walk-forward roller-size constants** beside
`SYNTHETIC_PIP_SIZE` (after `main.rs:38`):
```rust
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
/// stream's epoch-unit; for real M1 that is nanoseconds. A classic 3-month
/// in-sample / 1-month out-of-sample / 1-month step (contiguous OOS tiling).
const WF_DAY_NS: i64 = 86_400_000_000_000;
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
```
- [ ] **Step 2: Add `DataChoice` (parsed) and `DataSource` (opened) + the refuse
helper.** Place after `parse_real_args` (ends ~:430), before the family
builders. Uses the same qualified paths as `run_sample_real` (`std::sync::Arc`,
`data_server::*`, `aura_ingest::*`, `aura_engine::Source`):
```rust
/// What `--real` parsing yields: the synthetic default, or a real symbol + an
/// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable.
#[derive(Debug, Clone, PartialEq)]
enum DataChoice {
Synthetic,
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
}
/// The source provider threaded into the family builders: synthetic built-in
/// streams, or real M1 close bars from the data-server archive. Replaces the
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
/// from one place (Fork B/D/F).
enum DataSource {
Synthetic,
Real {
server: std::sync::Arc<data_server::DataServer>,
symbol: String,
from_ms: Option<i64>,
to_ms: Option<i64>,
pip: f64,
},
}
/// No-local-data refusal — stderr + exit(2), mirroring `run_sample_real`.
fn no_real_data(symbol: &str) -> ! {
eprintln!("aura: no local data for symbol '{symbol}' at {}", data_server::DEFAULT_DATA_PATH);
std::process::exit(2)
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 2) on an
/// un-vetted symbol / absent data — both BEFORE any member runs (Fork C/G),
/// mirroring `run_sample_real:323-341`.
fn from_choice(choice: DataChoice) -> DataSource {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Real { symbol, from_ms, to_ms } => {
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 server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH));
if !server.has_symbol(&symbol) {
no_real_data(&symbol);
}
DataSource::Real { server, symbol, from_ms, to_ms, pip: spec.pip_size }
}
}
}
fn pip_size(&self) -> f64 {
match self {
DataSource::Synthetic => SYNTHETIC_PIP_SIZE,
DataSource::Real { pip, .. } => *pip,
}
}
/// The full run window, probed once. Synthetic: the showcase span. Real: drain
/// a separate probe source for first/last ts (a Source is single-pass), as
/// `run_sample_real` does.
fn full_window(&self) -> (Timestamp, Timestamp) {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
window_of(&s).expect("non-empty showcase stream")
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
let mut probe = aura_ingest::M1FieldSource::open(server, symbol, *from_ms, *to_ms, aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol));
let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol));
let mut last = first;
while let Some((t, _)) = aura_engine::Source::next(&mut probe) {
last = t;
}
(first, last)
}
}
}
/// A fresh full-window source per member (single-pass). Synthetic: showcase.
fn run_sources(&self) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
DataSource::Real { server, symbol, from_ms, to_ms, .. } => vec![Box::new(
aura_ingest::M1FieldSource::open(server, symbol, *from_ms, *to_ms, aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol)),
)],
}
}
/// A fresh windowed source for an IS/OOS sub-window (walk-forward). Synthetic:
/// `walkforward_window_source`. Real: `open_window` (ns-native `Timestamp`).
fn windowed_sources(&self, from: Timestamp, to: Timestamp) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
DataSource::Real { server, symbol, .. } => vec![Box::new(
aura_ingest::M1FieldSource::open_window(server, symbol, Some(from), Some(to), aura_ingest::M1Field::Close)
.unwrap_or_else(|| no_real_data(symbol)),
)],
}
}
/// WindowRoller sizes per data kind (Fork F): bar-index for synthetic (24/12/12
/// over the 60-bar span), calendar-ns for real.
fn wf_window_sizes(&self) -> (i64, i64, i64) {
match self {
DataSource::Synthetic => (24, 12, 12),
DataSource::Real { .. } => (WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS),
}
}
}
```
- [ ] **Step 3: Add unit tests** for the synthetic arm + the constants (in the
`main.rs` `#[cfg(test)] mod tests`). These also serve as the RED→GREEN for the
additive type (def + tests land in one diff per the project's RED-in-one-diff
convention):
```rust
#[test]
fn data_source_synthetic_pip_and_window_match_the_built_ins() {
let d = DataSource::Synthetic;
assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE);
assert!(!d.run_sources().is_empty());
assert_eq!(d.wf_window_sizes(), (24, 12, 12));
// full_window equals window_of over the showcase stream (byte-unchanged source)
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
assert_eq!(d.full_window(), window_of(&s).unwrap());
}
#[test]
fn wf_real_roller_sizes_are_90_30_30_days_in_ns() {
assert_eq!(WF_REAL_IS_NS, 90 * 86_400_000_000_000);
assert_eq!(WF_REAL_OOS_NS, 30 * 86_400_000_000_000);
assert_eq!(WF_REAL_STEP_NS, 30 * 86_400_000_000_000);
}
```
- [ ] **Step 4: Build + run the new tests.**
Run: `cargo build -p aura-cli --all-targets`
Expected: builds clean (additive; `DataSource` may warn-unused until Task 3 — if
`-D warnings` is not set on build it is fine; the final clippy gate is Task 5).
Run: `cargo test -p aura-cli --bin aura data_source_synthetic_pip_and_window`
Expected: PASS
Run: `cargo test -p aura-cli --bin aura wf_real_roller_sizes`
Expected: PASS
---
### Task 2: Pip-parametrize the two blueprints + thread all callers
Signature change with a compile-gate: `sample_blueprint_with_sinks` and
`momentum_blueprint_with_sinks` gain `pip_size: f64`; EVERY caller is threaded in
this same task (all still pass `SYNTHETIC_PIP_SIZE` — no behaviour change).
**Files:**
- Modify: `crates/aura-cli/src/main.rs`
- [ ] **Step 1: Add the `pip_size` param to `sample_blueprint_with_sinks`**
(:465). Signature `fn sample_blueprint_with_sinks(pip_size: f64) -> (...)`; the
broker line at :475 becomes `let broker = g.add(SimBroker::builder(pip_size));`.
- [ ] **Step 2: Add the `pip_size` param to `momentum_blueprint_with_sinks`**
(:551). Same shape; broker at :566 becomes `SimBroker::builder(pip_size)`.
- [ ] **Step 3: Thread every caller — pass `SYNTHETIC_PIP_SIZE`.** The compiler
(Step 4) enumerates them; the known set (all in `main.rs`):
- `build_sample` (:491): `sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE)`.
- `sweep_family` (:506 + closure :520): `sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE)`.
- `momentum_sweep_family` (:587 + closure :596): `momentum_blueprint_with_sinks(SYNTHETIC_PIP_SIZE)`.
- `walkforward_family` (:735): `sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE).0.param_space()`.
- `sweep_over` (:753 + closure :764): `sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE)`.
- `run_oos` (:790): `sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE)`.
- test `sample_blueprint_with_sinks_bootstraps_runs_and_drains` (:1274): pass `SYNTHETIC_PIP_SIZE`.
- test `momentum_param_space_*` (:1660): `momentum_blueprint_with_sinks(SYNTHETIC_PIP_SIZE).0.param_space()`.
- [ ] **Step 4: Compile-gate — all callers threaded.**
Run: `cargo build -p aura-cli --all-targets`
Expected: 0 errors. (If the compiler names any caller not in Step 3's list, thread
it the same way — `SYNTHETIC_PIP_SIZE` — before proceeding.)
- [ ] **Step 5: Prove byte-unchanged.**
Run: `cargo test -p aura-cli --bin aura sweep_report_renders_four_points`
Expected: PASS
Run: `cargo test -p aura-cli --test cli_run sweep_prints_four_family_json_lines_and_exits_zero`
Expected: PASS
---
### Task 3: Thread `&DataSource` into the family builders + run functions
The core change. Signature change + compile-gate: the five family builders and the
two `run_*` functions gain a `DataSource`; the dispatch passes `DataSource::Synthetic`
(the `--real` parser is Task 4), so behaviour stays synthetic and byte-unchanged.
**Files:**
- Modify: `crates/aura-cli/src/main.rs`
- [ ] **Step 1: `sweep_family`** (:505) → `fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily`.
Replace the body's source/pip/window:
```rust
fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window();
let bp = sample_blueprint_with_sinks(pip).0;
let space = bp.param_space();
let binder = bp
.axis("signals.trend.fast.length", [2, 3])
.axis("signals.trend.slow.length", [4, 5])
.axis("signals.momentum.fast.length", [2])
.axis("signals.momentum.slow.length", [4])
.axis("signals.momentum.signal.length", [3])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("exposure.scale", [0.5]);
let varying: HashSet<String> = binder.varying_axes().into_iter().collect();
binder
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip);
let mut h = bp
.bootstrap_with_cells(point)
.expect("grid points are kind-checked against param_space");
let sources = data.run_sources();
h.run(sources);
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let named = zip_params(&space, point);
let key = member_key(&named, &varying);
let manifest = sim_optimal_manifest(named, window, 0, pip);
if let Some(name) = trace {
persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows);
}
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
RunReport { manifest, metrics: summarize(&equity, &exposure) }
})
.expect("the built-in named grid matches the sample param-space")
}
```
- [ ] **Step 2: `momentum_sweep_family`** (:586) → same transform: add `data: &DataSource`,
`let pip = data.pip_size(); let window = data.full_window();`, build with
`momentum_blueprint_with_sinks(pip)`, source `data.run_sources()`, manifest
`sim_optimal_manifest(named, window, 0, pip)`.
- [ ] **Step 3: `walkforward_family`** (:729) → add `data: &DataSource`; span +
roller-sizes from the provider; replace the `.expect` with a typed exit(2) gate:
```rust
fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResult {
let span = data.full_window();
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e}");
std::process::exit(2);
}
};
let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space();
walk_forward(roller, space, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1, data);
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
})
}
```
- [ ] **Step 4: `sweep_over`** (:752) → `fn sweep_over(from: Timestamp, to: Timestamp, data: &DataSource) -> SweepFamily`.
`let pip = data.pip_size();` build with `sample_blueprint_with_sinks(pip)`; in
the closure `let sources = data.windowed_sources(from, to);` (keep
`let window = window_of(&sources).expect("non-empty in-sample window");`);
manifest `sim_optimal_manifest(zip_params(&space, point), window, 0, pip)`.
- [ ] **Step 5: `run_oos`** (:784) → `fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource) -> (...)`.
`let pip = data.pip_size();` build with `sample_blueprint_with_sinks(pip)`;
`let sources = data.windowed_sources(from, to);` (keep its `window_of`); manifest
pip `pip`; the trace key `format!("{name}/oos{}", from.0)` is unchanged.
- [ ] **Step 6: `run_sweep`** (:681) → `fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource)`.
Pass `&data` into the two builders:
`Strategy::SmaCross => sweep_family(persist.then_some(name), &data)`,
`Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data)`.
- [ ] **Step 7: `run_walkforward`** (:707) → `fn run_walkforward(name: &str, persist: bool, data: DataSource)`;
`walkforward_family(persist.then_some(name), &data)`.
- [ ] **Step 8: Thread the synthetic dispatch + test callers.** The compiler
(Step 9) names them; known set:
- dispatch sweep (:1150-1152): `run_sweep(strategy, &name, persist, DataSource::Synthetic)`.
- dispatch walkforward (:1157-1159, three exact arms): pass `DataSource::Synthetic`
to `run_walkforward(...)` (the arms stay as-is this task; Task 4 rewrites them).
- test helper `sweep_report` (:624): `sweep_family(None, &DataSource::Synthetic)`.
- test helper `walkforward_report` (:855): `walkforward_family(None, &DataSource::Synthetic)`.
- tests `families_*` (:1349, :1358): `sweep_family(None, &DataSource::Synthetic)` /
`walkforward_family(None, &DataSource::Synthetic)`.
- tests momentum determinism (:1673, :1674): `momentum_sweep_family(None, &DataSource::Synthetic)`.
- [ ] **Step 9: Compile-gate.**
Run: `cargo build -p aura-cli --all-targets`
Expected: 0 errors. (Thread any caller the compiler names not in Step 8 with
`&DataSource::Synthetic` / `DataSource::Synthetic`.)
- [ ] **Step 10: Prove byte-unchanged across the synthetic family paths.**
Run: `cargo test -p aura-cli --bin aura walkforward_report_has_one_oos_line_per_window`
Expected: PASS
Run: `cargo test -p aura-cli --test cli_run sweep_trace_persists_a_member_dir_per_grid_point`
Expected: PASS
Run: `cargo test -p aura-cli --test cli_run walkforward_trace_persists_a_member_dir_per_oos_window`
Expected: PASS
---
### Task 4: `--real` parser grammar + dispatch wiring + USAGE
Wire `--real` end to end: the parsers yield a `DataChoice`, the dispatch builds the
`DataSource` (build-or-refuse), USAGE documents the tails.
**Files:**
- Modify: `crates/aura-cli/src/main.rs`
- [ ] **Step 1: `parse_sweep_args`** (:651) → return type grows a 4th element:
`Result<(Strategy, String, bool, DataChoice), String>`. Add `--real <SYMBOL>`
with optional `--from <ms>` / `--to <ms>` (ms parsed as `i64`); default
`DataChoice::Synthetic`. Keep `--strategy` / `--name` / `--trace` and their
mutual-exclusion. New shape:
```rust
fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice), String> {
let usage = || "sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
let mut strategy = Strategy::SmaCross;
let mut name: Option<(String, bool)> = None;
let mut real_symbol: Option<String> = None;
let mut from_ms: Option<i64> = None;
let mut to_ms: Option<i64> = None;
let mut it = rest.iter();
while let Some(tok) = it.next() {
match *tok {
"--strategy" => {
let v = it.next().ok_or_else(usage)?;
strategy = match *v {
"sma" => Strategy::SmaCross,
"momentum" => Strategy::Momentum,
_ => return Err(usage()),
};
}
"--real" => {
let v = it.next().ok_or_else(usage)?;
real_symbol = Some((*v).to_string());
}
"--from" => from_ms = Some(it.next().ok_or_else(usage)?.parse().map_err(|_| usage())?),
"--to" => to_ms = Some(it.next().ok_or_else(usage)?.parse().map_err(|_| usage())?),
"--name" if name.is_none() => name = Some((it.next().ok_or_else(usage)?.to_string(), false)),
"--trace" if name.is_none() => name = Some((it.next().ok_or_else(usage)?.to_string(), true)),
_ => return Err(usage()),
}
}
// --from / --to without --real is a usage error (no synthetic window knob).
if real_symbol.is_none() && (from_ms.is_some() || to_ms.is_some()) {
return Err(usage());
}
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
let data = match real_symbol {
Some(symbol) => DataChoice::Real { symbol, from_ms, to_ms },
None => DataChoice::Synthetic,
};
Ok((strategy, name, persist, data))
}
```
(Note: the existing parser uses a `value` binding pattern; the implementer may
keep that style as long as the grammar + return tuple match. Preserve the existing
`--name`/`--trace` mutual exclusion semantics.)
- [ ] **Step 2: Add `parse_walkforward_args`** mirroring it, no `--strategy`:
```rust
/// Parse the `walkforward` tail: `[--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
/// Defaults: synthetic, name "walkforward", no persist. `--name`/`--trace` are
/// mutually exclusive; `--from`/`--to` require `--real`. Pure (no I/O / exit).
fn parse_walkforward_args(rest: &[&str]) -> Result<(String, bool, DataChoice), String> {
let usage = || "walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".to_string();
let mut name: Option<(String, bool)> = None;
let mut real_symbol: Option<String> = None;
let mut from_ms: Option<i64> = None;
let mut to_ms: Option<i64> = None;
let mut it = rest.iter();
while let Some(tok) = it.next() {
match *tok {
"--real" => real_symbol = Some(it.next().ok_or_else(usage)?.to_string()),
"--from" => from_ms = Some(it.next().ok_or_else(usage)?.parse().map_err(|_| usage())?),
"--to" => to_ms = Some(it.next().ok_or_else(usage)?.parse().map_err(|_| usage())?),
"--name" if name.is_none() => name = Some((it.next().ok_or_else(usage)?.to_string(), false)),
"--trace" if name.is_none() => name = Some((it.next().ok_or_else(usage)?.to_string(), true)),
_ => return Err(usage()),
}
}
if real_symbol.is_none() && (from_ms.is_some() || to_ms.is_some()) {
return Err(usage());
}
let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false));
let data = match real_symbol {
Some(symbol) => DataChoice::Real { symbol, from_ms, to_ms },
None => DataChoice::Synthetic,
};
Ok((name, persist, data))
}
```
- [ ] **Step 3: Rewrite the dispatch arms** in `main()`. Sweep (:1150-1156) builds
the `DataSource` from the `DataChoice` (which may refuse):
```rust
["sweep", rest @ ..] => match parse_sweep_args(rest) {
Ok((strategy, name, persist, choice)) => {
run_sweep(strategy, &name, persist, DataSource::from_choice(choice))
}
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
},
```
Walkforward (:1157-1159, replace the three exact arms with one):
```rust
["walkforward", rest @ ..] => match parse_walkforward_args(rest) {
Ok((name, persist, choice)) => {
run_walkforward(&name, persist, DataSource::from_choice(choice))
}
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
},
```
(The `mc` arms at :1160-1162 are unchanged.)
- [ ] **Step 4: Update `USAGE`** (:1126) — change the `sweep` and `walkforward`
clauses to:
`aura sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>]`
and
`aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>]`.
- [ ] **Step 5: Update + add parser unit tests.** Extend the existing
`parse_sweep_args_defaults_selects_and_rejects` (:1680) so every `assert_eq!`
expects the 4-tuple (append `, DataChoice::Synthetic` to the synthetic
expectations), and add `--real` cases + the walkforward parser test:
```rust
#[test]
fn parse_sweep_args_accepts_real_symbol_and_window() {
assert_eq!(
parse_sweep_args(&["--real", "EURUSD", "--from", "100", "--to", "200", "--trace", "s"]),
Ok((Strategy::SmaCross, "s".to_string(), true,
DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) }))
);
assert!(parse_sweep_args(&["--real"]).is_err()); // --real needs a symbol
assert!(parse_sweep_args(&["--from", "100"]).is_err()); // --from without --real
}
#[test]
fn parse_walkforward_args_defaults_and_accepts_real() {
assert_eq!(parse_walkforward_args(&[]), Ok(("walkforward".to_string(), false, DataChoice::Synthetic)));
assert_eq!(
parse_walkforward_args(&["--real", "EURUSD", "--trace", "w"]),
Ok(("w".to_string(), true, DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None }))
);
assert!(parse_walkforward_args(&["--name", "a", "--trace", "b"]).is_err());
assert!(parse_walkforward_args(&["--real"]).is_err());
}
```
- [ ] **Step 6: Compile-gate + parser tests.**
Run: `cargo build -p aura-cli --all-targets`
Expected: 0 errors.
Run: `cargo test -p aura-cli --bin aura parse_sweep_args`
Expected: PASS (both the updated default test and the new real test)
Run: `cargo test -p aura-cli --bin aura parse_walkforward_args`
Expected: PASS
---
### Task 5: Real-path integration tests + full-suite + lint gate
Prove the real path end to end (gated on local data), the refusal (not gated), and
the whole-workspace green + clippy.
**Files:**
- Test: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Add the real-path integration tests.** Follow the existing gated
real test pattern (skip when `/mnt/tickdata` absent) and the existing
`temp_cwd` / `BIN` helpers. Use a vetted symbol (EURUSD, pip 0.0001) and a
bounded window (e.g. one month of 2024):
```rust
// EURUSD 2024-06 (UTC inclusive ms): a bounded vetted real window.
const EURUSD_JUN2024_FROM_MS: i64 = 1_717_200_000_000;
const EURUSD_JUN2024_TO_MS: i64 = 1_719_791_999_999;
fn local_data_present() -> bool {
std::path::Path::new("/mnt/tickdata/Pepperstone").is_dir()
}
#[test]
fn sweep_real_persists_portable_member_dirs_over_real_bars() {
if !local_data_present() {
eprintln!("skip: no local data at /mnt/tickdata/Pepperstone");
return;
}
let dir = temp_cwd("sweep_real");
let out = std::process::Command::new(BIN)
.args(["sweep", "--real", "EURUSD",
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
"--to", &EURUSD_JUN2024_TO_MS.to_string(),
"--trace", "swpr"])
.current_dir(&dir).output().unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/swpr")).unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
assert_eq!(members.len(), 4, "four sweep members: {members:?}");
assert!(members.iter().all(|k| k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))),
"all keys filesystem-portable: {members:?}");
// one member charts as a uPlot page
let key = &members[0];
let chart = std::process::Command::new(BIN)
.args(["chart", &format!("swpr/{key}")]).current_dir(&dir).output().unwrap();
assert!(out.status.success());
assert!(String::from_utf8_lossy(&chart.stdout).contains("uPlot"));
}
#[test]
fn walkforward_real_persists_one_oos_member_per_window() {
if !local_data_present() {
eprintln!("skip: no local data at /mnt/tickdata/Pepperstone");
return;
}
let dir = temp_cwd("wf_real");
// a full year so 90/30/30-day rolling fits several windows
let out = std::process::Command::new(BIN)
.args(["walkforward", "--real", "EURUSD",
"--from", "1704067200000", "--to", "1735689599999",
"--trace", "wfr"])
.current_dir(&dir).output().unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/wfr")).unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
assert!(members.iter().all(|k| k.starts_with("oos")), "oos<ns> member dirs: {members:?}");
assert!(members.len() >= 2, "several OOS windows over a year: {members:?}");
}
#[test]
fn sweep_real_unspecced_symbol_refuses_with_exit_2() {
// not gated: the pip refusal happens before any data access.
let dir = temp_cwd("sweep_real_refuse");
let out = std::process::Command::new(BIN)
.args(["sweep", "--real", "AAPL.US"]).current_dir(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("no vetted pip"));
}
#[test]
fn sweep_real_is_byte_deterministic_across_runs() {
if !local_data_present() {
eprintln!("skip: no local data at /mnt/tickdata/Pepperstone");
return;
}
let run = || {
let dir = temp_cwd("sweep_real_det");
let o = std::process::Command::new(BIN)
.args(["sweep", "--real", "EURUSD",
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
"--to", &EURUSD_JUN2024_TO_MS.to_string()])
.current_dir(&dir).output().unwrap();
// family_id increments per registry; compare the report bodies, not the id.
String::from_utf8_lossy(&o.stdout).replace("sweep-0", "sweep-N")
};
assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)");
}
```
(If `temp_cwd` / `BIN` / a `local_data_present`-equivalent already exist in
`cli_run.rs`, reuse them rather than redefining. Verify the existing real test's
skip idiom and match it.)
- [ ] **Step 2: Run the new integration tests.**
Run: `cargo test -p aura-cli --test cli_run sweep_real`
Expected: PASS (the gated ones run if `/mnt/tickdata` is present, else print skip
and pass; `sweep_real_unspecced_symbol_refuses_with_exit_2` always runs)
Run: `cargo test -p aura-cli --test cli_run walkforward_real`
Expected: PASS
- [ ] **Step 3: Full-suite gate.**
Run: `cargo test --workspace`
Expected: all PASS (no synthetic test regressed; real tests pass-or-skip)
- [ ] **Step 4: Lint gate.**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean (0 warnings — `DataSource` is now used, so no dead-code warning)
-273
View File
@@ -1,273 +0,0 @@
# Real-data source path for family runs — Design Spec
**Date:** 2026-06-21
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
> **Reference issue:** Brummel/Aura #106 (carries the load-bearing fork
> decisions, derived under the bold-decide stance).
## Goal
Add an opt-in real-data source path to the **family** commands `aura sweep` and
`aura walkforward`, mirroring the existing `aura run --real`. With
`--real <SYMBOL> [--from <ms>] [--to <ms>]`, each family member runs over real M1
close bars streamed from the local data-server archive (the #71 `M1FieldSource`
seam) instead of the built-in synthetic stream. Without `--real`, behaviour is
**byte-unchanged**.
`aura mc` is **excluded** (Fork A): its seed varies the realization of a
*synthetic* price walk (`SyntheticSpec.source(seed)`), which is undefined over
real data (one realization → identical members). Real MC needs a
bootstrap-resampling axis — a separate cycle.
## Architecture
The synthetic-vs-real choice is captured by one small CLI-side provider type,
`DataSource`, threaded into the family builders in place of the hardcoded
`VecSource::new(showcase_prices())`. The engine, the blueprint topology, and the
family types (`SweepFamily` / `WalkForwardResult`) are untouched — `M1FieldSource`
already impls `aura_engine::Source`, and `WindowRoller` already rolls over
epoch-unit timestamps.
```text
DataSource (CLI provider)
├─ Synthetic → VecSource::new(showcase_prices()/walkforward_prices())
└─ Real { server: Arc<DataServer>, → M1FieldSource::open[_window](&server, symbol, from, to, Close)
symbol, from_ms, to_ms, pip }
Responsibilities:
pip_size() → f64 (Synthetic: SYNTHETIC_PIP_SIZE; Real: instrument_spec(symbol).pip_size)
full_window() → (Timestamp,Timestamp) probed once (Real: drain a probe source, as run_sample_real does)
run_source() → Vec<Box<dyn Source>> fresh per member (sweep: the whole --from..--to stream)
windowed_source(from,to) → Vec<Box<dyn Source>> fresh per IS/OOS window (walk-forward)
```
A `DataSource::Real` is built only after `instrument_spec(symbol)` succeeds and
`server.has_symbol(symbol)` is true — both checked **before** any member runs, so
an un-vetted symbol or absent data refuses with stderr + `exit(2)` (no panic, no
partial family), exactly as `run_sample_real` does today.
## Concrete code shapes
### Worked user-facing examples (the acceptance evidence)
```console
# Param sweep over real EURUSD — every grid member streams the same real window;
# member dirs are the generic portable keys from #105 (#104 persistence).
$ aura sweep --real EURUSD --from 1704067200000 --to 1735689599999 --trace swp24
{"family_id":"swp24-0","report":{"manifest":{"broker":"sim-optimal(pip_size=0.0001)",
"params":[["signals.trend.fast.length",{"I64":2}], ...],"window":[1704153600000000000, ...]}, ...}}
... (4 members)
$ ls runs/traces/swp24/
signals.trend.fast.length-2_signals.trend.slow.length-4/ ... # 4 member dirs, each index/equity/exposure.json
# Rolling walk-forward over real EURUSD 2024 — IS/OOS roll over real calendar time;
# one OOS member dir per window.
$ aura walkforward --real EURUSD --from 1704067200000 --to 1735689599999 --trace wf24
{"family_id":"wf24-0","report":{ ... "window":[<oos0_from_ns>, <oos0_to_ns>] }}
... (one line per OOS window) + the stitched summary line
$ ls runs/traces/wf24/
oos<ns>/ oos<ns>/ ... # one OOS member dir per window
# Un-vetted symbol refuses before any data access (Fork C), unchanged from run --real:
$ aura sweep --real AAPL.US
aura: no vetted pip/instrument spec for symbol 'AAPL.US' — refusing ... # exit 2
# No --real → byte-identical to today (synthetic):
$ aura sweep --trace swp # 4 synthetic members, exactly as before
```
### Provider type (new)
```rust
// crates/aura-cli/src/main.rs
enum DataSource {
Synthetic, // showcase_prices / walkforward_prices, SYNTHETIC_PIP_SIZE
Real { server: Arc<DataServer>, symbol: String, from_ms: Option<i64>, to_ms: Option<i64>, pip: f64 },
}
impl DataSource {
fn pip_size(&self) -> f64 { /* SYNTHETIC_PIP_SIZE | self.pip */ }
// Build a Real provider or refuse (stderr + exit 2) on un-vetted symbol / absent data.
// Mirrors run_sample_real:323-341 (spec lookup BEFORE data access).
fn real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> DataSource { ... }
// Sweep: the whole --from..--to stream (Synthetic: the built-in showcase stream).
fn run_sources(&self) -> Vec<Box<dyn Source>> { ... } // fresh each call (single-pass)
// Walk-forward: a sub-window [from,to] (Synthetic: walkforward_window_source).
fn windowed_sources(&self, from: Timestamp, to: Timestamp) -> Vec<Box<dyn Source>> { ... }
}
```
### sweep_family / momentum_sweep_family — before → after
```rust
// BEFORE (main.rs:519-538): hardcoded synthetic source + SYNTHETIC_PIP_SIZE
fn sweep_family(trace: Option<&str>) -> SweepFamily {
...
binder.sweep(|point| {
let sources: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
let window = window_of(&sources).expect("non-empty showcase stream");
...
let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE);
...
})
}
// AFTER: source + pip + window come from the provider; the member closure is otherwise unchanged.
fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily {
let pip = data.pip_size();
let window = data.full_window(); // probed once (Real: a probe source)
...
binder.sweep(|point| {
let sources = data.run_sources(); // fresh per member
...
h.run(sources);
let manifest = sim_optimal_manifest(named, window, 0, pip);
...
})
}
```
`sample_blueprint_with_sinks()` gains a `pip_size: f64` parameter (its
`SimBroker::builder(SYNTHETIC_PIP_SIZE)` at main.rs:475 is the hardcode); the
graph-render callers (`build_sample` at :490) pass `SYNTHETIC_PIP_SIZE`. The
`momentum_blueprint_with_sinks()` broker (main.rs:566) likewise threads the pip.
(Momentum sweep over real data: `momentum_sweep_family(trace, data)` mirrors
`sweep_family`.)
### walkforward — before → after
```rust
// BEFORE (main.rs:729-748): synthetic span + bar-index roller + synthetic window source
fn walkforward_family(trace: Option<&str>) -> WalkForwardResult {
let sources = vec![Box::new(VecSource::new(walkforward_prices()))];
let span = window_of(&sources).expect("non-empty synthetic stream");
let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling).expect(...);
...
walk_forward(roller, space, |w| {
let is_family = sweep_over(w.is.0, w.is.1); // synthetic window source inside
let best = optimize(&is_family, "total_pips")...;
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace);
...
})
}
// AFTER: span = the real --from..--to (Synthetic: the built-in span); roller sizes
// come from the provider — bar-index for Synthetic (24/12/12), calendar-ns for Real.
fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResult {
let span = data.full_window();
let (is_len, oos_len, step) = data.wf_window_sizes(); // Synthetic: (24,12,12); Real: (90d,30d,30d) in ns
let roller = WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling)
.unwrap_or_else(|e| { eprintln!("aura: walk-forward window config: {e}"); std::process::exit(2) });
...
walk_forward(roller, space, |w| {
let is_family = sweep_over(w.is.0, w.is.1, data); // provider's windowed_sources inside
...
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data);
...
})
}
```
`sweep_over` and `run_oos` take `data: &DataSource` and call
`data.windowed_sources(from, to)` in place of `walkforward_window_source(from,
to)`; their `sim_optimal_manifest(..., data.pip_size())` threads the pip. The
real IS/OOS/step defaults (Fork D), as named ns constants:
```rust
const WF_DAY_NS: i64 = 86_400_000_000_000; // 1 day in ns (M1 stream timestamp unit)
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS; // 90-day in-sample
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS; // 30-day out-of-sample
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS; // 30-day step (contiguous OOS tiling)
```
A real window shorter than one full IS+OOS span yields `WalkForwardError` from
`WindowRoller::new` → stderr + `exit(2)` (the typed gate, not a panic).
### Parser + dispatch — before → after
```rust
// sweep: add an optional --real tail to parse_sweep_args; --real is exclusive with the
// synthetic default. Returns the data choice alongside (strategy, name, persist).
fn parse_sweep_args(rest) -> Result<(Strategy, String, bool, DataChoice), String>
// DataChoice = Synthetic | Real { symbol, from_ms, to_ms } (parsed, not yet opened)
// walkforward: a parse_walkforward_args mirroring it (no --strategy axis):
// [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]
// dispatch arms build the DataSource (which may refuse) then call run_sweep / run_walkforward.
```
`run_sweep` / `run_walkforward` gain a `DataSource` parameter and pass it to the
family builders.
## Components
| Component | Change |
|-----------|--------|
| `DataSource` enum + impl | **new** — synthetic/real provider (pip, window, sources) |
| `parse_sweep_args` | add `--real` tail → `DataChoice`; keep `--strategy`/`--name`/`--trace` |
| `parse_walkforward_args` | **new**`--real` + `--name`/`--trace` grammar |
| `sample_blueprint_with_sinks` / `momentum_blueprint_with_sinks` | add `pip_size: f64` param (un-hardcode the broker) |
| `sweep_family` / `momentum_sweep_family` | take `&DataSource`; source/pip/window from it |
| `walkforward_family` / `sweep_over` / `run_oos` | take `&DataSource`; provider window source; real roller sizes |
| `run_sweep` / `run_walkforward` + dispatch arms | thread `DataSource`; build-or-refuse before running |
| `USAGE` | document the `--real` tails |
The engine (`aura-engine`), `aura-ingest`, and the registry are **not** modified.
## Data flow
1. Parse the tail → `DataChoice` (pure, unit-testable: symbol + optional window).
2. Build `DataSource``Real` looks up the pip (refuse if un-vetted) and checks
`has_symbol` (refuse if absent), both before any member runs.
3. Family builder asks the provider for the pip, the full window (probed once),
and a fresh source per member / per IS/OOS window.
4. Each member runs the same disjoint harness (C1) over real bars; `--trace`
persists it under `runs/traces/<name>/<member_key>/` exactly as #104.
## Error handling
- Un-vetted symbol → `instrument_spec` `None` → stderr + `exit(2)`, before data.
- Symbol absent from the archive → `has_symbol` false → stderr + `exit(2)`.
- Real window too short for window 0 → `WindowRoller::new` `Err` → stderr +
`exit(2)`.
- `--real` without a symbol, unknown flag, non-`i64` ms, `--name` with `--trace`,
`--real` with no synthetic fallback conflict → `Err(usage)` from the parser →
stderr + `exit(2)`.
- No `--real` → the synthetic path runs unchanged.
## Testing strategy
- **Parser unit tests** (`--bin aura`): `parse_sweep_args` accepts `--real SYM`,
`--real SYM --from .. --to ..`, rejects `--real` (no symbol) and `--real` +
conflicting flags; `parse_walkforward_args` mirror.
- **`DataSource` unit tests**: `pip_size()` Synthetic vs Real; `Synthetic`
`run_sources()` non-empty.
- **Byte-unchanged guard**: existing `sweep_report` / walk-forward report tests
stay green (the synthetic path is the default), proving no `--real` is
byte-identical.
- **Integration** (`--test cli_run`, gated on local data like the existing real
test): `aura sweep --real EURUSD --from <m> --to <m> --trace t` persists member
dirs whose keys match `^[A-Za-z0-9._-]+$`; `aura walkforward --real EURUSD …
--trace t` persists one `oos<ns>` dir per window; both members chart
(`aura chart t/<key>` contains `uPlot`). C1: the same real sweep twice is
bit-identical. Un-vetted symbol refuses with exit 2.
## Acceptance criteria
- `aura sweep --real EURUSD --from <m> --to <m> --trace s` runs every grid member
over real M1 close bars and persists one member dir per point.
- `aura walkforward --real EURUSD --from <m> --to <m> --trace w` rolls IS/OOS over
real calendar time and persists one OOS member dir per window.
- `aura mc` is unchanged (real explicitly excluded).
- No `--real` → family stdout + registry + trace output byte-unchanged.
- Un-vetted symbol / absent data / too-short window → stderr + `exit(2)`, no
panic, no partial family.
- C1 preserved: the same real window streamed twice is bit-identical.
- `cargo build --workspace`, `cargo test --workspace`, `cargo clippy --workspace
--all-targets -- -D warnings` all green.