feat(cli): default the stop regime on walkforward/mc/generalize
GREEN side of the #217 executable-spec: --stop-length/--stop-k are now optional on the three dissolved verbs; each missing flag independently defaults to the single-sourced regime constants (length 3, k 2.0) at the argv-boundary extractors — the same regime the campaign member runner resolves for an unbound regime. The generated campaign document still binds the regime explicitly, so the default spelling is content-identical to an explicit --stop-length 3 --stop-k 2.0 (pinned by registry dedup in the headline test). The multi-value stop refusal survives byte-identically; sweep is untouched. Sanctioned contract migrations (decision on the issue, 2026-07-09): walkforward_dissolved_refuses_missing_knobs now pins the surviving multi-value refusal; mc_args_from_refuses_missing_knobs pins the default path. Usage strings name the defaults. Held quality nit (deferred to cycle close, minimal-slice constraint): the None=>default / Some=>parse stop block recurs 4x across the walkforward/mc extractors. closes #217
This commit is contained in:
+78
-38
@@ -2081,10 +2081,12 @@ struct GeneralizeCmd {
|
||||
/// Candidate axis `<name>=<value>` (repeatable, >=1; one value per axis).
|
||||
#[arg(long)]
|
||||
axis: Vec<String>,
|
||||
/// Candidate stop length (single value; required).
|
||||
/// Candidate stop length (single value; optional, defaults to
|
||||
/// [`R_SMA_STOP_LENGTH`]).
|
||||
#[arg(long)]
|
||||
stop_length: Option<i64>,
|
||||
/// Candidate stop-k multiple (single value; required).
|
||||
/// Candidate stop-k multiple (single value; optional, defaults to
|
||||
/// [`R_SMA_STOP_K`]).
|
||||
#[arg(long)]
|
||||
stop_k: Option<f64>,
|
||||
/// Window start (Unix ms, inclusive).
|
||||
@@ -2309,12 +2311,14 @@ fn select_is_plateau(select: Option<&str>) -> bool {
|
||||
/// consumes (the dissolved `.json --real` branch). Single instrument, single-value
|
||||
/// stop (Fork A: the stop is a risk regime, not a swept axis); `parse_csv_list` is
|
||||
/// generic over `T: FromStr`, so the same helper parses the i64 length and the f64
|
||||
/// k; the local `knobs` closure (captureless → `Copy`, mirroring `mc_args_from`'s
|
||||
/// idiom) is reused across both `ok_or_else` calls. `--name`/`--trace` are
|
||||
/// mutually exclusive, matching the flag's own documented contract and the inline
|
||||
/// path's `name_persist` refusal; an omitted flag defaults the family name to
|
||||
/// "walkforward". The IS-refit `--axis` grid is parsed separately at the dispatch
|
||||
/// site, not here.
|
||||
/// k. `--stop-length`/`--stop-k` are each OPTIONAL (#217): a missing flag defaults
|
||||
/// independently to the single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`]
|
||||
/// regime; a present-but-multi-value stop still refuses (the local `regime`
|
||||
/// closure, captureless → `Copy`, is reused across both arms). `--name`/`--trace`
|
||||
/// are mutually exclusive, matching the flag's own documented contract and the
|
||||
/// inline path's `name_persist` refusal; an omitted flag defaults the family name
|
||||
/// to "walkforward". The IS-refit `--axis` grid is parsed separately at the
|
||||
/// dispatch site, not here.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn walkforward_args_from(
|
||||
a: &WalkforwardCmd,
|
||||
@@ -2323,12 +2327,27 @@ fn walkforward_args_from(
|
||||
None | Some("") => return Err("walkforward dissolves only over --real <SYMBOL>".to_string()),
|
||||
Some(s) => s.to_string(),
|
||||
};
|
||||
let knobs = || "walkforward requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string();
|
||||
let stop_length: Vec<i64> = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||
let stop_k: Vec<f64> = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||
if stop_length.len() != 1 || stop_k.len() != 1 {
|
||||
return Err("walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string());
|
||||
}
|
||||
let regime = || "walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string();
|
||||
let stop_length = match a.stop_length.as_deref() {
|
||||
None => R_SMA_STOP_LENGTH,
|
||||
Some(s) => {
|
||||
let v: Vec<i64> = parse_csv_list(s).map_err(|()| regime())?;
|
||||
if v.len() != 1 {
|
||||
return Err(regime());
|
||||
}
|
||||
v[0]
|
||||
}
|
||||
};
|
||||
let stop_k = match a.stop_k.as_deref() {
|
||||
None => R_SMA_STOP_K,
|
||||
Some(s) => {
|
||||
let v: Vec<f64> = parse_csv_list(s).map_err(|()| regime())?;
|
||||
if v.len() != 1 {
|
||||
return Err(regime());
|
||||
}
|
||||
v[0]
|
||||
}
|
||||
};
|
||||
let name = match (a.name.as_deref(), a.trace.as_deref()) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("walkforward: --name and --trace are mutually exclusive".to_string());
|
||||
@@ -2337,12 +2356,15 @@ fn walkforward_args_from(
|
||||
(None, Some(t)) => t.to_string(),
|
||||
(None, None) => "walkforward".to_string(),
|
||||
};
|
||||
Ok((name, symbol, stop_length[0], stop_k[0], a.from, a.to))
|
||||
Ok((name, symbol, stop_length, stop_k, a.from, a.to))
|
||||
}
|
||||
|
||||
/// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the
|
||||
/// `--real` campaign branch). Single instrument, single-value stop (Fork A: the
|
||||
/// stop is a risk regime, not a swept axis). `--block-len`/`--resamples`/`--seed`
|
||||
/// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are each
|
||||
/// OPTIONAL (#217), independently defaulting to the single-sourced
|
||||
/// [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — a present-but-
|
||||
/// multi-value stop still refuses. `--block-len`/`--resamples`/`--seed`
|
||||
/// default to `1`/`1000`/`1` — the same defaults the retired real-R dispatch
|
||||
/// used, so an omitted flag produces the same document either way. The
|
||||
/// usize->u32 conversion lands HERE, at the argv boundary where the CLI's
|
||||
@@ -2360,22 +2382,40 @@ fn mc_args_from(
|
||||
if a.name.is_some() || a.trace.is_some() {
|
||||
return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string());
|
||||
}
|
||||
let knobs = || "mc requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string();
|
||||
let stop_length: Vec<i64> = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||
let stop_k: Vec<f64> = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||
if stop_length.len() != 1 || stop_k.len() != 1 {
|
||||
return Err("mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string());
|
||||
}
|
||||
let regime = || "mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string();
|
||||
let stop_length = match a.stop_length.as_deref() {
|
||||
None => R_SMA_STOP_LENGTH,
|
||||
Some(s) => {
|
||||
let v: Vec<i64> = parse_csv_list(s).map_err(|()| regime())?;
|
||||
if v.len() != 1 {
|
||||
return Err(regime());
|
||||
}
|
||||
v[0]
|
||||
}
|
||||
};
|
||||
let stop_k = match a.stop_k.as_deref() {
|
||||
None => R_SMA_STOP_K,
|
||||
Some(s) => {
|
||||
let v: Vec<f64> = parse_csv_list(s).map_err(|()| regime())?;
|
||||
if v.len() != 1 {
|
||||
return Err(regime());
|
||||
}
|
||||
v[0]
|
||||
}
|
||||
};
|
||||
let block_len = a.block_len.map(|v| v as u32).unwrap_or(1);
|
||||
let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000);
|
||||
let seed = a.seed.unwrap_or(1);
|
||||
Ok(("mc".to_string(), symbol, stop_length[0], stop_k[0], block_len, resamples, seed, a.from, a.to))
|
||||
Ok(("mc".to_string(), symbol, stop_length, stop_k, block_len, resamples, seed, a.from, a.to))
|
||||
}
|
||||
|
||||
/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar
|
||||
/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are required
|
||||
/// single values. Every refusal whose flag survives #220 reuses the old message
|
||||
/// string (byte-identical front-end).
|
||||
/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are single
|
||||
/// values, each OPTIONAL (#217) and independently defaulting to the single-
|
||||
/// sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — clap's
|
||||
/// typed `Option<i64>`/`Option<f64>` already forbids a multi-value spelling, so
|
||||
/// no separate regime refusal is needed here. Every refusal whose flag survives
|
||||
/// #220 reuses the old message string (byte-identical front-end).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn generalize_args_from(
|
||||
a: &GeneralizeCmd,
|
||||
@@ -2400,9 +2440,8 @@ fn generalize_args_from(
|
||||
if !symbols.iter().all(|s| seen.insert(s.clone())) {
|
||||
return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string());
|
||||
}
|
||||
let knobs = || "generalize requires --stop-length and --stop-k, each a single value".to_string();
|
||||
let stop_length = a.stop_length.ok_or_else(knobs)?;
|
||||
let stop_k = a.stop_k.ok_or_else(knobs)?;
|
||||
let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH);
|
||||
let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K);
|
||||
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
|
||||
let name = a.name.clone().unwrap_or_else(|| "generalize".to_string());
|
||||
Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to))
|
||||
@@ -2633,7 +2672,7 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) {
|
||||
}
|
||||
|
||||
fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
|
||||
let usage = || "Usage: aura generalize <blueprint.json> --real <SYM1,SYM2[,…]> --axis <name>=<value> [--axis …] --stop-length <n> --stop-k <x> [--metric <m>] [--name <n>] [--from <ms>] [--to <ms>]".to_string();
|
||||
let usage = || format!("Usage: aura generalize <blueprint.json> --real <SYM1,SYM2[,…]> --axis <name>=<value> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--metric <m>] [--name <n>] [--from <ms>] [--to <ms>]");
|
||||
let Some(path) = is_blueprint_file(&a.blueprint) else {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
@@ -2857,7 +2896,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch).
|
||||
fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
||||
// Single-sourced: both arms below read this one closure (house style, #179).
|
||||
let usage = || "Usage: aura walkforward <blueprint.json> --axis <name>=<csv> [--axis …] [--select <argmax|plateau:mean|plateau:worst>] [--name <n>] | aura walkforward <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] --stop-length <n> --stop-k <x> [--from <ms>] [--to <ms>] [--name <n> | --trace <n>]".to_string();
|
||||
let usage = || format!("Usage: aura walkforward <blueprint.json> --axis <name>=<csv> [--axis …] [--select <argmax|plateau:mean|plateau:worst>] [--name <n>] | aura walkforward <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--from <ms>] [--to <ms>] [--name <n> | --trace <n>]");
|
||||
match is_blueprint_file(&a.blueprint) {
|
||||
Some(path) => {
|
||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
@@ -2965,7 +3004,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
||||
/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch).
|
||||
fn dispatch_mc(a: McCmd, env: &project::Env) {
|
||||
// Single-sourced: every arm below reads this one closure (house style, #179).
|
||||
let usage = || "Usage: aura mc <blueprint.json> --seeds <n> [--name <n>] | aura mc <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] --stop-length <n> --stop-k <x> [--block-len <n>] [--resamples <n>] [--seed <n>] [--from <ms>] [--to <ms>]".to_string();
|
||||
let usage = || format!("Usage: aura mc <blueprint.json> --seeds <n> [--name <n>] | aura mc <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--block-len <n>] [--resamples <n>] [--seed <n>] [--from <ms>] [--to <ms>]");
|
||||
match is_blueprint_file(&a.blueprint) {
|
||||
Some(path) => {
|
||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
@@ -4155,14 +4194,15 @@ mod tests {
|
||||
assert!(err.contains("--name/--trace are not accepted"), "refuse message: {err}");
|
||||
}
|
||||
|
||||
/// Property (#217): a missing `--stop-length`/`--stop-k` no longer refuses —
|
||||
/// each independently defaults to the single-sourced [`R_SMA_STOP_LENGTH`]/
|
||||
/// [`R_SMA_STOP_K`] regime (length 3, k 2.0).
|
||||
#[test]
|
||||
fn mc_args_from_refuses_missing_knobs() {
|
||||
fn mc_args_from_defaults_missing_knobs_to_the_regime() {
|
||||
let a = McCmd { real: Some("GER40".to_string()), ..bare_mc_cmd() };
|
||||
let err = mc_args_from(&a).unwrap_err();
|
||||
assert!(
|
||||
err.contains("requires --stop-length --stop-k"),
|
||||
"missing-knob refusal: {err}"
|
||||
);
|
||||
let (_, _, stop_length, stop_k, ..) = mc_args_from(&a).expect("stop-less mc resolves");
|
||||
assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime");
|
||||
assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1654,23 +1654,42 @@ fn walkforward_dissolved_refuses_select_plateau() {
|
||||
assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}");
|
||||
}
|
||||
|
||||
/// `walkforward_args_from` requires both stop knobs on the campaign path — a
|
||||
/// missing knob is a usage refusal (exit 2), not a silent default.
|
||||
/// Property (#217): `walkforward_args_from` no longer requires both stop knobs
|
||||
/// together on the campaign path — each defaults independently to the
|
||||
/// single-sourced regime. Omitting only `--stop-k` while keeping `--stop-length`
|
||||
/// explicit still succeeds, proving the defaulting is per-flag, not
|
||||
/// all-or-nothing (the sibling
|
||||
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`
|
||||
/// pins the both-omitted case). Gated on the shared GER40 archive; skips
|
||||
/// cleanly on a data refusal.
|
||||
#[test]
|
||||
fn walkforward_dissolved_refuses_missing_knobs() {
|
||||
let cwd = temp_cwd("walkforward-missing-knobs");
|
||||
fn walkforward_dissolved_defaults_a_single_omitted_knob() {
|
||||
let cwd = temp_cwd("walkforward-single-knob-default");
|
||||
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"walkforward", &fixture, "--real", "GER40",
|
||||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||||
"--stop-length", "3",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert_eq!(out.status.code(), Some(2), "missing --stop-length/--stop-k is a usage refusal");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("requires --stop-length --stop-k"), "missing-knob refusal: {stderr}");
|
||||
if out.status.code() == Some(1) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||||
"exit 1 must be a data refusal, got: {stderr}"
|
||||
);
|
||||
eprintln!("skip: no local GER40 data");
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"omitting only --stop-k must still succeed (it defaults independently): {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so
|
||||
|
||||
Reference in New Issue
Block a user