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).
|
/// Candidate axis `<name>=<value>` (repeatable, >=1; one value per axis).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
axis: Vec<String>,
|
axis: Vec<String>,
|
||||||
/// Candidate stop length (single value; required).
|
/// Candidate stop length (single value; optional, defaults to
|
||||||
|
/// [`R_SMA_STOP_LENGTH`]).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
stop_length: Option<i64>,
|
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)]
|
#[arg(long)]
|
||||||
stop_k: Option<f64>,
|
stop_k: Option<f64>,
|
||||||
/// Window start (Unix ms, inclusive).
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// k. `--stop-length`/`--stop-k` are each OPTIONAL (#217): a missing flag defaults
|
||||||
/// idiom) is reused across both `ok_or_else` calls. `--name`/`--trace` are
|
/// independently to the single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`]
|
||||||
/// mutually exclusive, matching the flag's own documented contract and the inline
|
/// regime; a present-but-multi-value stop still refuses (the local `regime`
|
||||||
/// path's `name_persist` refusal; an omitted flag defaults the family name to
|
/// closure, captureless → `Copy`, is reused across both arms). `--name`/`--trace`
|
||||||
/// "walkforward". The IS-refit `--axis` grid is parsed separately at the dispatch
|
/// are mutually exclusive, matching the flag's own documented contract and the
|
||||||
/// site, not here.
|
/// 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)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn walkforward_args_from(
|
fn walkforward_args_from(
|
||||||
a: &WalkforwardCmd,
|
a: &WalkforwardCmd,
|
||||||
@@ -2323,12 +2327,27 @@ fn walkforward_args_from(
|
|||||||
None | Some("") => return Err("walkforward dissolves only over --real <SYMBOL>".to_string()),
|
None | Some("") => return Err("walkforward dissolves only over --real <SYMBOL>".to_string()),
|
||||||
Some(s) => s.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 regime = || "walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string();
|
||||||
let stop_length: Vec<i64> = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
let stop_length = match a.stop_length.as_deref() {
|
||||||
let stop_k: Vec<f64> = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
None => R_SMA_STOP_LENGTH,
|
||||||
if stop_length.len() != 1 || stop_k.len() != 1 {
|
Some(s) => {
|
||||||
return Err("walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string());
|
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()) {
|
let name = match (a.name.as_deref(), a.trace.as_deref()) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => {
|
||||||
return Err("walkforward: --name and --trace are mutually exclusive".to_string());
|
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, Some(t)) => t.to_string(),
|
||||||
(None, None) => "walkforward".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
|
/// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the
|
||||||
/// `--real` campaign branch). Single instrument, single-value stop (Fork A: 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
|
/// 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
|
/// 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
|
/// 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() {
|
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());
|
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 regime = || "mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string();
|
||||||
let stop_length: Vec<i64> = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
let stop_length = match a.stop_length.as_deref() {
|
||||||
let stop_k: Vec<f64> = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
None => R_SMA_STOP_LENGTH,
|
||||||
if stop_length.len() != 1 || stop_k.len() != 1 {
|
Some(s) => {
|
||||||
return Err("mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string());
|
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 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 resamples = a.resamples.map(|v| v as u32).unwrap_or(1000);
|
||||||
let seed = a.seed.unwrap_or(1);
|
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
|
/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar
|
||||||
/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are required
|
/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are single
|
||||||
/// single values. Every refusal whose flag survives #220 reuses the old message
|
/// values, each OPTIONAL (#217) and independently defaulting to the single-
|
||||||
/// string (byte-identical front-end).
|
/// 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)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn generalize_args_from(
|
fn generalize_args_from(
|
||||||
a: &GeneralizeCmd,
|
a: &GeneralizeCmd,
|
||||||
@@ -2400,9 +2440,8 @@ fn generalize_args_from(
|
|||||||
if !symbols.iter().all(|s| seen.insert(s.clone())) {
|
if !symbols.iter().all(|s| seen.insert(s.clone())) {
|
||||||
return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string());
|
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.unwrap_or(R_SMA_STOP_LENGTH);
|
||||||
let stop_length = a.stop_length.ok_or_else(knobs)?;
|
let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K);
|
||||||
let stop_k = a.stop_k.ok_or_else(knobs)?;
|
|
||||||
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
|
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
|
||||||
let name = a.name.clone().unwrap_or_else(|| "generalize".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))
|
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) {
|
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 {
|
let Some(path) = is_blueprint_file(&a.blueprint) else {
|
||||||
eprintln!("aura: {}", usage());
|
eprintln!("aura: {}", usage());
|
||||||
std::process::exit(2);
|
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).
|
/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch).
|
||||||
fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
||||||
// Single-sourced: both arms below read this one closure (house style, #179).
|
// 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) {
|
match is_blueprint_file(&a.blueprint) {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
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).
|
/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch).
|
||||||
fn dispatch_mc(a: McCmd, env: &project::Env) {
|
fn dispatch_mc(a: McCmd, env: &project::Env) {
|
||||||
// Single-sourced: every arm below reads this one closure (house style, #179).
|
// 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) {
|
match is_blueprint_file(&a.blueprint) {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
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}");
|
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]
|
#[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 a = McCmd { real: Some("GER40".to_string()), ..bare_mc_cmd() };
|
||||||
let err = mc_args_from(&a).unwrap_err();
|
let (_, _, stop_length, stop_k, ..) = mc_args_from(&a).expect("stop-less mc resolves");
|
||||||
assert!(
|
assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime");
|
||||||
err.contains("requires --stop-length --stop-k"),
|
assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime");
|
||||||
"missing-knob refusal: {err}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1654,23 +1654,42 @@ fn walkforward_dissolved_refuses_select_plateau() {
|
|||||||
assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}");
|
assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `walkforward_args_from` requires both stop knobs on the campaign path — a
|
/// Property (#217): `walkforward_args_from` no longer requires both stop knobs
|
||||||
/// missing knob is a usage refusal (exit 2), not a silent default.
|
/// 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]
|
#[test]
|
||||||
fn walkforward_dissolved_refuses_missing_knobs() {
|
fn walkforward_dissolved_defaults_a_single_omitted_knob() {
|
||||||
let cwd = temp_cwd("walkforward-missing-knobs");
|
let cwd = temp_cwd("walkforward-single-knob-default");
|
||||||
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args([
|
.args([
|
||||||
"walkforward", &fixture, "--real", "GER40",
|
"walkforward", &fixture, "--real", "GER40",
|
||||||
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
|
||||||
|
"--stop-length", "3",
|
||||||
])
|
])
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura");
|
.expect("spawn aura");
|
||||||
assert_eq!(out.status.code(), Some(2), "missing --stop-length/--stop-k is a usage refusal");
|
if out.status.code() == Some(1) {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(stderr.contains("requires --stop-length --stop-k"), "missing-knob refusal: {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
|
/// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so
|
||||||
|
|||||||
Reference in New Issue
Block a user