feat(real-family): --real parser + dispatch wiring; complete the feature

Plan 0060 Tasks 4-5 (#106): wire `aura sweep|walkforward --real` end to end.

- A shared RealWindowGrammar accumulator holds the `--real <SYMBOL> [--from <ms>]
  [--to <ms>]` grammar (flag-repeat strictness, empty-symbol + window-without-real
  rejection) for both parsers, mirroring parse_real_args.
- parse_sweep_args grows the DataChoice 4th element; parse_walkforward_args is new
  (no --strategy axis). The sweep + walkforward dispatch arms become rest-matched,
  build the DataSource via from_choice (build-or-refuse on un-vetted symbol /
  absent data), and run. USAGE documents the --real tails. mc stays exact-matched,
  so `aura mc --real` falls through to usage + exit(2) — Fork A: MC excluded (its
  seed varies a synthetic price-walk, undefined over real data).
- Gated integration tests (skip on no local data): sweep --real EURUSD persists 4
  portable member dirs over real bars and charts; walkforward --real EURUSD
  persists one oos<ns> dir per rolling window; same real sweep twice is
  byte-identical (C1). Un-gated: un-vetted symbol, window-without-real, and
  mc --real each refuse with exit 2.
- Remove the now-redundant `#[allow(dead_code)]` from the Task-1 provider: every
  const, both enums, and every method are live once the wiring lands.

Verified end-to-end over real EURUSD data: sweep --real -> 4 members; walkforward
--real over 2024 -> 9 rolling OOS windows; all refusals exit 2. Full workspace
test + clippy -D warnings green.

closes #106
This commit is contained in:
2026-06-21 15:29:28 +02:00
parent 2a6ca47acd
commit 8e5d14b2bb
2 changed files with 357 additions and 40 deletions
+165 -40
View File
@@ -40,18 +40,9 @@ const SYNTHETIC_PIP_SIZE: f64 = 0.0001;
/// 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).
//
// `dead_code` allow: these feed `DataSource::wf_window_sizes`'s real arm, which is
// itself unreachable until Task 3 threads `&DataSource` into `walkforward_family`.
// The allow is removed implicitly once that wiring lands (Task 3 makes them live);
// Task 1 is intentionally additive, so the clippy `-D warnings` gate is Task 5.
#[allow(dead_code)]
const WF_DAY_NS: i64 = 86_400_000_000_000;
#[allow(dead_code)]
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
#[allow(dead_code)]
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
#[allow(dead_code)]
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
@@ -403,12 +394,55 @@ fn parse_real_args(
Ok((symbol.to_string(), from, to, trace))
}
/// The shared `--real <SYMBOL> [--from <ms>] [--to <ms>]` accumulator for the
/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the
/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol
/// rejection, and the "window flags require `--real`" rule — so the two siblings
/// agree with each other and with `parse_real_args`. Each parser owns its other
/// flags (strategy / name / trace); on a `--real`/`--from`/`--to` token it calls
/// `accept`, and at the end calls `finish` to validate and build the `DataChoice`.
#[derive(Default)]
struct RealWindowGrammar {
symbol: Option<String>,
from_ms: Option<i64>,
to_ms: Option<i64>,
}
impl RealWindowGrammar {
/// Consume one `--real`/`--from`/`--to` flag and its already-split value.
/// Returns `Ok(true)` if the flag was a real/window flag (consumed),
/// `Ok(false)` if it is not ours (the caller handles it), `Err` on a malformed
/// real/window flag: a repeated flag (mirroring `parse_real_args`'s
/// `if from.is_none()` strictness) or an empty `--real` symbol.
fn accept(&mut self, flag: &str, value: &str, usage: &impl Fn() -> String) -> Result<bool, String> {
match flag {
"--real" if self.symbol.is_none() && !value.is_empty() => {
self.symbol = Some(value.to_string());
}
"--from" if self.from_ms.is_none() => {
self.from_ms = Some(value.parse().map_err(|_| usage())?);
}
"--to" if self.to_ms.is_none() => {
self.to_ms = Some(value.parse().map_err(|_| usage())?);
}
"--real" | "--from" | "--to" => return Err(usage()), // repeated / empty symbol
_ => return Ok(false),
}
Ok(true)
}
/// Validate (window flags require `--real`) and assemble the `DataChoice`.
fn finish(self, usage: &impl Fn() -> String) -> Result<DataChoice, String> {
match self.symbol {
Some(symbol) => Ok(DataChoice::Real { symbol, from_ms: self.from_ms, to_ms: self.to_ms }),
None if self.from_ms.is_some() || self.to_ms.is_some() => Err(usage()),
None => Ok(DataChoice::Synthetic),
}
}
}
/// 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.
//
// `dead_code` allow: constructed by the `--real` parsers in Task 4; Task 1 only
// defines the type (additive). Clippy `-D warnings` is the Task 5 gate.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
enum DataChoice {
Synthetic,
@@ -428,11 +462,6 @@ enum DataChoice {
/// faces never reach one consumer (a family is either full-window or windowed), so
/// the split is invisible per call site but real across the type — read both
/// family builders to see it whole.
//
// `dead_code` allow: the `Real` variant is constructed by `from_choice` (live once
// Task 4's parsers feed it) and the provider is threaded into the family builders
// in Task 3; Task 1 is additive. Clippy `-D warnings` is the Task 5 gate.
#[allow(dead_code)]
enum DataSource {
Synthetic,
Real {
@@ -485,11 +514,6 @@ fn probe_window(
(first, last)
}
// `dead_code` allow: every method is reached only via the family builders Task 3
// rewires; `from_choice` via the Task 4 dispatch. Task 1 is additive (see the
// type docs above). Clippy `-D warnings` is the Task 5 gate, by which point all
// these are live and the allow is a no-op.
#[allow(dead_code)]
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),
@@ -814,18 +838,26 @@ enum Strategy {
Momentum,
}
/// Parse the `sweep` tail: `[--strategy <sma|momentum>] [--name <n> | --trace <n>]`.
/// Defaults: SMA-cross, name "sweep", no persist (today's bare `aura sweep`).
/// `--name` and `--trace` are mutually exclusive. Pure (no I/O / exit) so the
/// grammar is unit-testable; `main` does the side effects. An unknown token, a
/// flag without its value, an unknown strategy, or both name flags rejects.
fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> {
let usage = || "sweep [--strategy <sma|momentum>] [--name <n> | --trace <n>]".to_string();
/// Parse the `sweep` tail:
/// `[--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura
/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms,
/// `i64`) require `--real` (there is no synthetic window knob). Pure (no I/O /
/// exit) so the grammar is unit-testable; `main` does the side effects. An unknown
/// token, a flag without its value, an unknown strategy, both name flags, or a
/// window flag without `--real` rejects.
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; // (name, persist)
let mut real = RealWindowGrammar::default();
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
if real.accept(flag, value, &usage)? {
tail = t;
continue;
}
match *flag {
"--strategy" => {
strategy = match *value {
@@ -841,7 +873,32 @@ fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> {
tail = t;
}
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
Ok((strategy, name, persist))
Ok((strategy, name, persist, real.finish(&usage)?))
}
/// 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; // (name, persist)
let mut real = RealWindowGrammar::default();
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
if real.accept(flag, value, &usage)? {
tail = t;
continue;
}
match *flag {
"--name" if name.is_none() => name = Some(((*value).to_string(), false)),
"--trace" if name.is_none() => name = Some(((*value).to_string(), true)),
_ => return Err(usage()),
}
tail = t;
}
let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false));
Ok((name, persist, real.finish(&usage)?))
}
/// `aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>]`: run the
@@ -1299,7 +1356,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
}
const USAGE: &str =
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <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 chart <name> [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive,
@@ -1324,15 +1381,23 @@ fn main() {
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep", rest @ ..] => match parse_sweep_args(rest) {
Ok((strategy, name, persist)) => run_sweep(strategy, &name, persist, DataSource::Synthetic),
Ok((strategy, name, persist, choice)) => {
run_sweep(strategy, &name, persist, DataSource::from_choice(choice))
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["walkforward", rest @ ..] => match parse_walkforward_args(rest) {
Ok((name, persist, choice)) => {
run_walkforward(&name, persist, DataSource::from_choice(choice))
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
["walkforward"] => run_walkforward("walkforward", false, DataSource::Synthetic),
["walkforward", "--name", n] => run_walkforward(n, false, DataSource::Synthetic),
["walkforward", "--trace", n] => run_walkforward(n, true, DataSource::Synthetic),
["mc"] => run_mc("mc", false),
["mc", "--name", n] => run_mc(n, false),
["mc", "--trace", n] => run_mc(n, true),
@@ -1880,15 +1945,75 @@ mod tests {
#[test]
fn parse_sweep_args_defaults_selects_and_rejects() {
assert_eq!(parse_sweep_args(&[]), Ok((Strategy::SmaCross, "sweep".to_string(), false)));
assert_eq!(parse_sweep_args(&["--trace", "swp"]), Ok((Strategy::SmaCross, "swp".to_string(), true)));
assert_eq!(parse_sweep_args(&["--name", "s"]), Ok((Strategy::SmaCross, "s".to_string(), false)));
assert_eq!(
parse_sweep_args(&[]),
Ok((Strategy::SmaCross, "sweep".to_string(), false, DataChoice::Synthetic))
);
assert_eq!(
parse_sweep_args(&["--trace", "swp"]),
Ok((Strategy::SmaCross, "swp".to_string(), true, DataChoice::Synthetic))
);
assert_eq!(
parse_sweep_args(&["--name", "s"]),
Ok((Strategy::SmaCross, "s".to_string(), false, DataChoice::Synthetic))
);
assert_eq!(
parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]),
Ok((Strategy::Momentum, "mom".to_string(), true))
Ok((Strategy::Momentum, "mom".to_string(), true, DataChoice::Synthetic))
);
assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err());
assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive
assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value
}
/// `parse_sweep_args` admits a real symbol with an optional `--from`/`--to`
/// window (yielding `DataChoice::Real`), still honouring `--strategy`/`--trace`;
/// a `--real` without its symbol, or a window flag without `--real`, is rejected.
#[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
}
/// `parse_sweep_args` mirrors `parse_real_args`'s real/window strictness: an
/// empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
/// errors (not last-write-wins, not a downstream refusal) — so sibling commands
/// reject the same malformed real-grammar the same way.
#[test]
fn parse_sweep_args_rejects_empty_symbol_and_repeated_real_window() {
assert!(parse_sweep_args(&["--real", ""]).is_err()); // empty symbol
assert!(parse_sweep_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err()); // repeated --real
assert!(parse_sweep_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err()); // repeated --from
assert!(parse_sweep_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err()); // repeated --to
}
/// `parse_walkforward_args` defaults to synthetic / name "walkforward" / no
/// persist, admits `--real <SYMBOL>` (window-less here) yielding `DataChoice::Real`,
/// and rejects two name flags or a `--real` missing its symbol.
#[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());
}
/// `parse_walkforward_args` mirrors `parse_real_args`'s real/window strictness:
/// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage
/// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives.
#[test]
fn parse_walkforward_args_rejects_empty_symbol_and_repeated_real_window() {
assert!(parse_walkforward_args(&["--real", ""]).is_err()); // empty symbol
assert!(parse_walkforward_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err());
assert!(parse_walkforward_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err());
assert!(parse_walkforward_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err());
}
}