diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 24cfb36..0423d65 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2581,6 +2581,7 @@ struct CostConfig { /// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1 /// close bars for a vetted symbol over an optional window. +#[derive(Debug)] enum RunData { Synthetic, Real { symbol: String, from: Option, to: Option }, @@ -3111,6 +3112,7 @@ fn persist_traces_r( /// Which built-in harness `aura run` drives. A fixed compile-time enumeration over /// Rust-authored harnesses — NOT a runtime node registry and NOT a DSL (C9/C17): the /// CLI *runs* an authored harness, it does not wire one. +#[derive(Debug)] enum HarnessKind { Sma, Macd, @@ -3119,6 +3121,7 @@ enum HarnessKind { /// The parsed `aura run` invocation: a harness, a data source, an optional trace name, and /// an optional flat round-trip cost per trade (`--cost-per-trade`, stage1-r only this cycle). +#[derive(Debug)] struct RunArgs { harness: HarnessKind, data: RunData, @@ -3128,6 +3131,18 @@ struct RunArgs { carry_per_cycle: Option, } +/// Parse a cost-rate flag value and enforce non-negativity, naming the offending +/// flag so a sign typo is distinguishable from a mistyped flag name. A *malformed* +/// value stays a grammar usage error (unchanged); only a parsed-but-negative value +/// yields the named message. Pure — no I/O, no exit. +fn parse_nonneg_rate(flag: &str, value: &str, usage: &impl Fn() -> String) -> Result { + let v: f64 = value.parse().map_err(|_| usage())?; + if v < 0.0 { + return Err(format!("{flag} must be non-negative, got {v}")); + } + Ok(v) +} + /// Parse the `run` tail: `[--harness ] [--real [--from ] [--to ]] /// [--trace ] [--cost-per-trade ] [--slip-vol-mult ]` in any order, each flag at most once. /// `--harness sma` is the default (`--harness macd` / `--harness stage1-r` select the @@ -3135,8 +3150,9 @@ struct RunArgs { /// grammar is unit-testable; `main` does the side effects. fn parse_run_args(rest: &[&str]) -> Result { let usage = || { - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ]" - .to_string() + format!( + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ]{COST_FLAGS_NOTE}" + ) }; let mut harness: Option = None; let mut symbol: Option = None; @@ -3184,35 +3200,35 @@ fn parse_run_args(rest: &[&str]) -> Result { } "--cost-per-trade" if cost.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; - let v: f64 = value.parse().map_err(|_| usage())?; - if v < 0.0 { - return Err(usage()); - } - cost = Some(v); + cost = Some(parse_nonneg_rate("--cost-per-trade", value, &usage)?); tail = t; } "--slip-vol-mult" if slip_vol_mult.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; - let v: f64 = value.parse().map_err(|_| usage())?; - if v < 0.0 { - return Err(usage()); - } - slip_vol_mult = Some(v); + slip_vol_mult = Some(parse_nonneg_rate("--slip-vol-mult", value, &usage)?); tail = t; } "--carry-per-cycle" if carry_per_cycle.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; - let v: f64 = value.parse().map_err(|_| usage())?; - if v < 0.0 { - return Err(usage()); - } - carry_per_cycle = Some(v); + carry_per_cycle = Some(parse_nonneg_rate("--carry-per-cycle", value, &usage)?); tail = t; } _ => return Err(usage()), } } let harness = harness.unwrap_or(HarnessKind::Sma); + // Cost flags are defined only against the R chain (gross-R -> net-R); a non-R + // harness produces no R to charge against, so a cost flag there is a usage error, + // not a silent no-op (refuse-don't-guess, C10). + if !matches!(harness, HarnessKind::Stage1R) + && (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some()) + { + return Err( + "cost flags require an R-evaluator harness (stage1-r); \ + --harness sma/macd produces no R to charge against" + .to_string(), + ); + } // --from/--to require --real. if symbol.is_none() && (from.is_some() || to.is_some()) { return Err(usage()); @@ -3241,6 +3257,14 @@ fn run_dispatch(args: RunArgs) -> Result { }) } +/// One-line note appended to the `aura run` usage surfaces (the grammar-error +/// usage closure and `--help`) so the cost-flag units, scale, per-engine-cycle +/// semantics, non-negativity, and R-harness requirement are discoverable at the +/// point of use (#153). A leading newline puts it on its own line under the usage. +const COST_FLAGS_NOTE: &str = "\ncost flags (stage1-r only; all >= 0, charged in R as cost/|entry−stop|): \ +--cost-per-trade = per-trade cost in price units; --slip-vol-mult = slippage multiplier on realized vol; \ +--carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)"; + const USAGE: &str = "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ]"; @@ -3262,7 +3286,7 @@ fn main() { // subcommands (#131): `aura chart --help` shows usage instead of erroring // "unexpected argument", and the bare `aura --help` is the same affordance. if args.iter().any(|a| a == "--help" || a == "-h") { - println!("{USAGE}"); + println!("{USAGE}{COST_FLAGS_NOTE}"); return; } match args.iter().map(String::as_str).collect::>().as_slice() { @@ -4531,6 +4555,58 @@ mod tests { assert!(parse_run_args(&["bogus"]).is_err()); // unknown trailing token } + #[test] + fn parse_run_args_negative_cost_rates_are_named_not_usage() { + for flag in ["--cost-per-trade", "--slip-vol-mult", "--carry-per-cycle"] { + let err = parse_run_args(&["--harness", "stage1-r", flag, "-0.5"]) + .expect_err("a negative cost rate must be refused"); + assert!(err.contains("must be non-negative"), "{flag}: {err}"); + assert!(err.contains(flag), "message must name the offending flag: {err}"); + assert!(err.contains("-0.5"), "message must carry the value: {err}"); + } + } + + #[test] + fn parse_run_args_malformed_cost_rate_stays_grammar_usage() { + let err = parse_run_args(&["--harness", "stage1-r", "--cost-per-trade", "x"]) + .expect_err("a malformed rate is a grammar error"); + assert!(err.contains("usage"), "a malformed value stays the usage path: {err}"); + // The grammar-error usage surface — the more frequently hit one — carries the + // cost-flag note too (#153), so pin that dropping {COST_FLAGS_NOTE} from the + // `usage` closure regresses it, not only the `--help` print. + assert!(err.contains("per ENGINE cycle"), "the grammar usage must carry the cost-flag note: {err}"); + } + + #[test] + fn parse_run_args_cost_flags_require_r_harness() { + let cases: [(&[&str], &str); 3] = [ + (&["--harness", "sma"], "--cost-per-trade"), + (&["--harness", "macd"], "--slip-vol-mult"), + (&["--harness", "sma"], "--carry-per-cycle"), + ]; + for (harness, flag) in cases { + let mut argv: Vec<&str> = harness.to_vec(); + argv.extend_from_slice(&[flag, "0.001"]); + let err = parse_run_args(&argv).expect_err("cost flag on a non-R harness must be refused"); + assert!(err.contains("R-evaluator harness"), "{harness:?}+{flag}: {err}"); + } + } + + #[test] + fn parse_run_args_cost_flag_without_harness_defaults_sma_and_is_refused() { + let err = parse_run_args(&["--carry-per-cycle", "0.5"]) + .expect_err("the default harness is sma → cost flag refused"); + assert!(err.contains("R-evaluator harness"), "{err}"); + } + + #[test] + fn parse_run_args_cost_flags_accepted_on_stage1_r() { + let a = parse_run_args(&["--harness", "stage1-r", "--cost-per-trade", "0.001"]) + .expect("cost flags are accepted on the R harness"); + assert!(matches!(a.harness, HarnessKind::Stage1R)); + assert_eq!(a.cost, Some(0.001)); + } + #[test] fn parse_mc_args_bare_and_name_route_to_synthetic() { assert_eq!(parse_mc_args(&[]), Ok(McArgs::Synthetic { name: "mc".to_string(), persist: false })); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index a03cb93..2dc5ff6 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -662,6 +662,40 @@ fn per_subcommand_help_is_uniform_stdout_exit_zero() { } } +/// Property (#153): the cost-flag units/constraints are discoverable from +/// `aura run --help` — the appended note states the in-R charge, the per-ENGINE- +/// cycle carry semantics, the `>= 0` constraint, and that cost flags are stage1-r +/// only. Pre-#153 the usage listed the flags with no unit or scale guidance. +#[test] +fn run_help_surfaces_cost_flag_units_note() { + let out = Command::new(BIN).args(["run", "--help"]).output().expect("spawn aura run --help"); + assert_eq!(out.status.code(), Some(0), "run --help exits 0: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!(stdout.contains("per ENGINE cycle"), "help must explain carry units: {stdout}"); + assert!(stdout.contains("stage1-r only"), "help must state the R-harness requirement: {stdout}"); +} + +/// Property (#153): the cost-flag note reaches the user through the *grammar-error* +/// path too, not only `--help`. A malformed cost value (`--cost-per-trade x`) is a +/// usage error → exit 2 with the note on STDERR (nothing on stdout). The note is +/// appended at two independent source sites (the `USAGE` const for `--help` and the +/// `parse_run_args` usage closure for grammar errors); the help test pins the first, +/// this pins the second — the more frequently hit surface — at the binary boundary, +/// where the closure's String must actually route to stderr+exit-2. +#[test] +fn run_malformed_cost_value_usage_error_carries_note_on_stderr() { + let out = Command::new(BIN) + .args(["run", "--harness", "stage1-r", "--cost-per-trade", "x"]) + .output() + .expect("spawn aura run --cost-per-trade x"); + assert_eq!(out.status.code(), Some(2), "a malformed cost value is a usage error (exit 2); stderr: {}", + String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout.is_empty(), "a usage error must not emit a report on stdout: {:?}", out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("usage"), "the grammar-error path stays a usage message: {stderr:?}"); + assert!(stderr.contains("per ENGINE cycle"), "the grammar-error usage must carry the cost-flag note: {stderr:?}"); +} + /// Property: `aura graph` emits a single self-contained HTML page — the /// interactive pin-graph viewer with the deterministic model inlined and the /// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through @@ -1583,6 +1617,49 @@ fn run_harness_sma_is_pip_only_no_r_block() { assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}"); } +/// Property (#153): a cost flag on a non-R harness (sma/macd, which produce no R) +/// is a usage error — exit 2 with a named diagnostic, no silent no-op, no `runs/` +/// write. The pre-#153 behaviour silently ignored the flag and exited 0. +#[test] +fn run_cost_flag_on_non_r_harness_is_refused_exit_2() { + let dir = temp_cwd("sma-cost-refused"); + let out = Command::new(BIN) + .current_dir(&dir) + .args(["run", "--harness", "sma", "--cost-per-trade", "0.001"]) + .output() + .expect("spawn aura run --harness sma --cost-per-trade"); + assert_eq!(out.status.code(), Some(2), "cost on a non-R harness must exit 2; stderr: {}", + String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}"); + assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Property (#153): the non-R-harness cost guard fires on the IMPLICIT default +/// harness too — `aura run --cost-per-trade ...` with no `--harness` defaults to +/// sma (non-R) and is refused (exit 2, named cause, no `runs/` write). Distinct +/// from the explicit `--harness sma` case: this pins that the guard reads the +/// *resolved* harness (after the `unwrap_or(Sma)` default), so a refactor that only +/// guarded an explicitly-named harness would leak a silent no-op through the default. +#[test] +fn run_cost_flag_on_default_harness_is_refused_exit_2() { + let dir = temp_cwd("default-harness-cost-refused"); + let out = Command::new(BIN) + .current_dir(&dir) + .args(["run", "--cost-per-trade", "0.001"]) + .output() + .expect("spawn aura run --cost-per-trade (no --harness)"); + assert_eq!(out.status.code(), Some(2), "cost on the default (sma) harness must exit 2; stderr: {}", + String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}"); + assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Property: an unknown `--harness` name is a usage error at the binary boundary — /// exit 2 (never a silent default run), the `Err` arm wiring through to `exit(2)`. #[test] @@ -1814,16 +1891,15 @@ fn stage1_r_carry_net_r_equity_bleeds_over_the_hold() { let _ = std::fs::remove_dir_all(&dir); } -/// Property (cycle 0085, cost-flag validation symmetry): a NEGATIVE `--carry-per-cycle` -/// is refused at the binary boundary — exit 2, usage on stderr, nothing on stdout, no -/// `runs/` write — the same shape the sibling `--cost-per-trade`/`--slip-vol-mult` flags -/// enforce. The `v < 0.0` parse guard is the load-bearing line: without it a negative -/// carry would reach `CarryCost::new` and PANIC (assert), crashing the binary instead of -/// a clean usage refusal, and a negative price-unit carry would CREDIT R — inverting the -/// sign of the whole accrual mechanism. None of the cycle-0085 goldens/bleed tests -/// exercise the rejection branch. Deterministic; the refusal precedes any data access. +/// Property (cycle 0085 + #153, cost-flag validation symmetry): a NEGATIVE +/// `--carry-per-cycle` is refused at the binary boundary — exit 2, a named +/// non-negativity diagnostic on stderr, nothing on stdout, no `runs/` write — the +/// same shape the sibling `--cost-per-trade`/`--slip-vol-mult` flags enforce. The +/// `v < 0.0` guard is load-bearing: without it a negative carry reaches +/// `CarryCost::new` and PANICs, and a negative price-unit carry would CREDIT R — +/// inverting the accrual sign. Deterministic; the refusal precedes any data access. #[test] -fn stage1_r_negative_carry_per_cycle_refused_with_usage_exit_2() { +fn stage1_r_negative_carry_per_cycle_refused_non_negative_exit_2() { let dir = temp_cwd("stage1-r-carry-neg"); let out = Command::new(BIN) .current_dir(&dir) @@ -1835,7 +1911,27 @@ fn stage1_r_negative_carry_per_cycle_refused_with_usage_exit_2() { out.status, String::from_utf8_lossy(&out.stderr)); assert!(out.stdout.is_empty(), "a refused run must not emit a report on stdout: {:?}", out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("usage"), "negative carry stderr must carry usage: {stderr:?}"); + assert!(stderr.contains("must be non-negative"), "negative carry stderr must name the cause: {stderr:?}"); + assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Property (#153): a negative `--cost-per-trade` is refused at the binary +/// boundary — exit 2, a named non-negativity diagnostic on stderr, no `runs/` +/// write — the sibling of the carry guard, exercising the per-trade arm. +#[test] +fn run_negative_cost_per_trade_refused_non_negative_exit_2() { + let dir = temp_cwd("cost-per-trade-neg"); + let out = Command::new(BIN) + .current_dir(&dir) + .args(["run", "--harness", "stage1-r", "--cost-per-trade", "-0.5"]) + .output() + .expect("spawn aura run --cost-per-trade -0.5"); + assert_eq!(out.status.code(), Some(2), "negative cost must exit 2; stderr: {}", + String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("must be non-negative"), "stderr must name the cause: {stderr:?}"); assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 737d1c0..ade8d2d 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -830,6 +830,13 @@ integration test. **Deferred (each its own #148 cycle):** a notional-based carry (rollover-boundary timing, 3× Wednesday, long/short asymmetry — deploy-edge realism), and sweep-path cost. Decision log: #148. +**Realization (cost-flag harness scoping, #153).** Cost flags are defined only +against an R-evaluator harness (the gross-R → net-R chain). On a non-R harness +(`--harness sma`/`macd`, which produce no R) a cost flag is a **usage error +(exit 2)**, not a silent no-op — refuse-don't-guess. Negative cost rates are +likewise rejected (exit 2) with a named diagnostic that identifies the offending +flag. CLI ergonomics only; the cost-model graph and the R math are untouched. + **Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).** [HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency / realistic-broker / register / flat-1R-vs-compounding portions are SUPERSEDED by that