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:
+165
-40
@@ -40,18 +40,9 @@ const SYNTHETIC_PIP_SIZE: f64 = 0.0001;
|
|||||||
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
|
/// 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
|
/// 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).
|
/// 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;
|
const WF_DAY_NS: i64 = 86_400_000_000_000;
|
||||||
#[allow(dead_code)]
|
|
||||||
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
|
const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
|
||||||
#[allow(dead_code)]
|
|
||||||
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
|
const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
|
||||||
#[allow(dead_code)]
|
|
||||||
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
|
const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
|
||||||
|
|
||||||
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
|
/// 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))
|
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
|
/// 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.
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
enum DataChoice {
|
enum DataChoice {
|
||||||
Synthetic,
|
Synthetic,
|
||||||
@@ -428,11 +462,6 @@ enum DataChoice {
|
|||||||
/// faces never reach one consumer (a family is either full-window or windowed), so
|
/// 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
|
/// the split is invisible per call site but real across the type — read both
|
||||||
/// family builders to see it whole.
|
/// 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 {
|
enum DataSource {
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Real {
|
Real {
|
||||||
@@ -485,11 +514,6 @@ fn probe_window(
|
|||||||
(first, last)
|
(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 {
|
impl DataSource {
|
||||||
/// Build a provider from a parsed choice, or refuse (stderr + exit 2) on an
|
/// 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),
|
/// un-vetted symbol / absent data — both BEFORE any member runs (Fork C/G),
|
||||||
@@ -814,18 +838,26 @@ enum Strategy {
|
|||||||
Momentum,
|
Momentum,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the `sweep` tail: `[--strategy <sma|momentum>] [--name <n> | --trace <n>]`.
|
/// Parse the `sweep` tail:
|
||||||
/// Defaults: SMA-cross, name "sweep", no persist (today's bare `aura sweep`).
|
/// `[--strategy <sma|momentum>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
|
||||||
/// `--name` and `--trace` are mutually exclusive. Pure (no I/O / exit) so the
|
/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura
|
||||||
/// grammar is unit-testable; `main` does the side effects. An unknown token, a
|
/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms,
|
||||||
/// flag without its value, an unknown strategy, or both name flags rejects.
|
/// `i64`) require `--real` (there is no synthetic window knob). Pure (no I/O /
|
||||||
fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> {
|
/// exit) so the grammar is unit-testable; `main` does the side effects. An unknown
|
||||||
let usage = || "sweep [--strategy <sma|momentum>] [--name <n> | --trace <n>]".to_string();
|
/// 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 strategy = Strategy::SmaCross;
|
||||||
let mut name: Option<(String, bool)> = None; // (name, persist)
|
let mut name: Option<(String, bool)> = None; // (name, persist)
|
||||||
|
let mut real = RealWindowGrammar::default();
|
||||||
let mut tail = rest;
|
let mut tail = rest;
|
||||||
while let Some((flag, t)) = tail.split_first() {
|
while let Some((flag, t)) = tail.split_first() {
|
||||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||||
|
if real.accept(flag, value, &usage)? {
|
||||||
|
tail = t;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match *flag {
|
match *flag {
|
||||||
"--strategy" => {
|
"--strategy" => {
|
||||||
strategy = match *value {
|
strategy = match *value {
|
||||||
@@ -841,7 +873,32 @@ fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> {
|
|||||||
tail = t;
|
tail = t;
|
||||||
}
|
}
|
||||||
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
|
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
|
/// `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 =
|
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() {
|
fn main() {
|
||||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
// 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),
|
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
||||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
["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) => {
|
Err(msg) => {
|
||||||
eprintln!("aura: {msg}");
|
eprintln!("aura: {msg}");
|
||||||
std::process::exit(2);
|
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"] => run_mc("mc", false),
|
||||||
["mc", "--name", n] => run_mc(n, false),
|
["mc", "--name", n] => run_mc(n, false),
|
||||||
["mc", "--trace", n] => run_mc(n, true),
|
["mc", "--trace", n] => run_mc(n, true),
|
||||||
@@ -1880,15 +1945,75 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_sweep_args_defaults_selects_and_rejects() {
|
fn parse_sweep_args_defaults_selects_and_rejects() {
|
||||||
assert_eq!(parse_sweep_args(&[]), Ok((Strategy::SmaCross, "sweep".to_string(), false)));
|
assert_eq!(
|
||||||
assert_eq!(parse_sweep_args(&["--trace", "swp"]), Ok((Strategy::SmaCross, "swp".to_string(), true)));
|
parse_sweep_args(&[]),
|
||||||
assert_eq!(parse_sweep_args(&["--name", "s"]), Ok((Strategy::SmaCross, "s".to_string(), false)));
|
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!(
|
assert_eq!(
|
||||||
parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]),
|
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(&["--strategy", "bogus"]).is_err());
|
||||||
assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive
|
assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive
|
||||||
assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1007,3 +1007,195 @@ fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&cwd);
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Real-data family path (#106, plan 0060 Task 5) -------------------------
|
||||||
|
//
|
||||||
|
// These drive `aura sweep|walkforward --real <SYMBOL>` end to end over the local
|
||||||
|
// data-server archive. The gated tests need the Pepperstone M1 archive present;
|
||||||
|
// they skip (print + pass) on a data-less machine, the project's
|
||||||
|
// skip-on-no-data convention. The pip-refusal test is NOT gated: the un-specced
|
||||||
|
// symbol is rejected before any data access.
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// EURUSD 2024 full year (UTC inclusive ms): 2024-01-01 00:00:00.000 ..
|
||||||
|
// 2024-12-31 23:59:59.999 — a span wide enough for several 90/30/30-day
|
||||||
|
// walk-forward windows.
|
||||||
|
const EURUSD_2024_FROM_MS: i64 = 1_704_067_200_000;
|
||||||
|
const EURUSD_2024_TO_MS: i64 = 1_735_689_599_999;
|
||||||
|
|
||||||
|
fn local_data_present() -> bool {
|
||||||
|
std::path::Path::new(data_server::DEFAULT_DATA_PATH).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: `aura sweep --real EURUSD` runs the whole built-in grid over REAL M1
|
||||||
|
/// close bars (not the synthetic VecSource), persisting one filesystem-portable
|
||||||
|
/// member dir per grid point — the four-point SMA grid lands as four
|
||||||
|
/// `[A-Za-z0-9._-]` dirs under `runs/traces/<name>/`, and one of them charts as a
|
||||||
|
/// self-contained uPlot page. This pins the real source actually flowing through
|
||||||
|
/// the family builders (Tasks 2-4) at the built-binary boundary, gated on local
|
||||||
|
/// data so it never fails on a data-less machine.
|
||||||
|
#[test]
|
||||||
|
fn sweep_real_persists_portable_member_dirs_over_real_bars() {
|
||||||
|
if !local_data_present() {
|
||||||
|
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||||
|
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!(chart.status.success(), "stderr: {}", String::from_utf8_lossy(&chart.stderr));
|
||||||
|
assert!(String::from_utf8_lossy(&chart.stdout).contains("uPlot"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: `aura walkforward --real EURUSD` over a full year of real bars rolls
|
||||||
|
/// the real-data window roller (90/30/30-day, ns-stamped) into SEVERAL OOS
|
||||||
|
/// windows, persisting one `oos<ns>`-keyed member dir per window. Pins that the
|
||||||
|
/// windowed real path draws its span from the probed `--from..--to` (not the
|
||||||
|
/// synthetic showcase span) and that the roller produces a multi-window family
|
||||||
|
/// over real data, gated on the local archive.
|
||||||
|
#[test]
|
||||||
|
fn walkforward_real_persists_one_oos_member_per_window() {
|
||||||
|
if !local_data_present() {
|
||||||
|
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||||
|
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", &EURUSD_2024_FROM_MS.to_string(),
|
||||||
|
"--to", &EURUSD_2024_TO_MS.to_string(),
|
||||||
|
"--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:?}");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: the per-instrument pip refusal fires for `aura sweep --real
|
||||||
|
/// <unspecced>` exactly as it does for `aura run --real` — an un-vetted symbol
|
||||||
|
/// (`AAPL.US`, absent from the instrument table) is rejected with exit 2 and the
|
||||||
|
/// "no vetted pip" message BEFORE any data access. NOT gated: the refusal
|
||||||
|
/// precedes the archive entirely, so it is CI-safe and runs on every machine.
|
||||||
|
#[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"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (C1): the same real sweep run twice is byte-identical — re-running
|
||||||
|
/// `aura sweep --real EURUSD` over the same bounded window produces an identical
|
||||||
|
/// report body. The whole determinism contract rests on a real backtest being
|
||||||
|
/// reproducible; a thread-race in the real source ingestion or member fold would
|
||||||
|
/// surface as a drift here. Gated on the local archive.
|
||||||
|
#[test]
|
||||||
|
fn sweep_real_is_byte_deterministic_across_runs() {
|
||||||
|
if !local_data_present() {
|
||||||
|
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let run = || {
|
||||||
|
// a fresh wiped cwd per call -> an empty registry each run, so the
|
||||||
|
// `family_id` ordinal is `sweep-0` for both; the raw stdout bodies are
|
||||||
|
// directly comparable with no ordinal normalisation needed.
|
||||||
|
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();
|
||||||
|
let body = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
body
|
||||||
|
};
|
||||||
|
assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (spec-0060 MC exclusion): `aura mc` admits NO `--real` flag. The
|
||||||
|
/// spec carves MC out of the real-data path — its seed varies a *synthetic*
|
||||||
|
/// price-walk realization, undefined over real bars (one realization -> identical
|
||||||
|
/// members). The observable contract is that `aura mc --real EURUSD` is REJECTED
|
||||||
|
/// (usage on stderr, exit 2) at the binary boundary, never silently accepted as a
|
||||||
|
/// real run. This pins the exclusion: if an `["mc", rest @ ..]` arm ever wired
|
||||||
|
/// `--real` into MC, this test fails loudly. NOT gated — the refusal precedes any
|
||||||
|
/// data access, so it is CI-safe on every machine.
|
||||||
|
#[test]
|
||||||
|
fn mc_rejects_real_flag_with_usage_exit_2() {
|
||||||
|
let dir = temp_cwd("mc_no_real");
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["mc", "--real", "EURUSD"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura mc --real");
|
||||||
|
assert_eq!(out.status.code(), Some(2), "mc --real must exit 2; status: {:?}", out.status);
|
||||||
|
assert!(out.stdout.is_empty(), "mc --real must not emit a run on stdout: {:?}", out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("usage"), "mc --real stderr must carry usage: {stderr:?}");
|
||||||
|
// the exclusion is total: no `runs/` registry write either.
|
||||||
|
assert!(!dir.join("runs").exists(), "mc --real must not start a real run");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (window-flags-require-`--real`, at the binary boundary): a family
|
||||||
|
/// command given a `--from`/`--to` window WITHOUT `--real` is a usage error —
|
||||||
|
/// there is no synthetic window knob, so `aura sweep --from 100` and
|
||||||
|
/// `aura walkforward --to 200` exit 2 (usage on stderr), never a silent synthetic
|
||||||
|
/// run over the whole built-in stream. The pure grammar is unit-tested in
|
||||||
|
/// `main.rs`; this pins that the `Err` arm actually wires through to `exit(2)` at
|
||||||
|
/// the dispatch for BOTH family commands. NOT gated — the refusal is pure
|
||||||
|
/// argument parsing, before any data access.
|
||||||
|
#[test]
|
||||||
|
fn family_window_flag_without_real_refuses_with_usage_exit_2() {
|
||||||
|
// (subcommand args, the subcommand token the usage message must name)
|
||||||
|
for (args, cmd) in [
|
||||||
|
(&["sweep", "--from", "100"][..], "sweep"),
|
||||||
|
(&["sweep", "--to", "200"][..], "sweep"),
|
||||||
|
(&["walkforward", "--from", "100"][..], "walkforward"),
|
||||||
|
(&["walkforward", "--to", "200"][..], "walkforward"),
|
||||||
|
] {
|
||||||
|
let dir = temp_cwd("family_window_no_real");
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(args)
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||||||
|
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||||||
|
assert!(out.stdout.is_empty(), "`aura {args:?}` must emit no run on stdout: {:?}", out.stdout);
|
||||||
|
// the refusal carries the subcommand's usage, which names `--real` — the
|
||||||
|
// flag the window needs and was given without.
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains(cmd), "`aura {args:?}` stderr must name `{cmd}`: {stderr:?}");
|
||||||
|
assert!(stderr.contains("--real"), "`aura {args:?}` stderr must name `--real`: {stderr:?}");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user