diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ad4f40e..fba2346 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -666,31 +666,6 @@ fn r_sma_broker_label(pip_size: f64) -> String { format!("sim-optimal+risk-executor(pip_size={pip_size})") } -/// The four-knob r-sma candidate grid the dissolved `generalize` verb resolves its -/// candidate from (`generalize_args_from`): the SMA fast / slow lengths and the -/// vol-stop length / `k`-multiplier, each a value list. Absent knobs fall back to the -/// historical defaults (fast `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k -/// `{2.0}`). The built-in `--strategy` sweep and its CLI grid flags retired with the -/// demo harnesses (#159 cuts 1b/2/3); this struct now serves `generalize` alone. -#[derive(Clone, Debug, PartialEq)] -struct RGrid { - fast: Vec, - slow: Vec, - stop_length: Vec, - stop_k: Vec, -} - -impl Default for RGrid { - fn default() -> Self { - Self { - fast: vec![2, 3], - slow: vec![6, 12], - stop_length: vec![R_SMA_STOP_LENGTH], - stop_k: vec![R_SMA_STOP_K], - } - } -} - /// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is /// the bare-best pick deflated for trials (#144, the default); `Plateau` argmaxes /// the neighbourhood-smoothed surface instead (opt-in via `--select`). @@ -1979,13 +1954,13 @@ fn parse_scalar_csv(csv: &str) -> Option> { // The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` // handlers below convert each `*Cmd` into the argument shapes the existing execution -// fns accept, reusing the value helpers (`RGrid`, `Selection`, +// fns accept, reusing the value helpers (`Selection`, // `DataSource::from_choice`, `parse_scalar_csv`, `parse_csv_list`, `parse_select`, // `parse_param_cells`). The four subcommands with an optional `[blueprint]` positional // dispatch on `is_blueprint_file`: a first-positional naming an existing `.json` file // selects the loaded-blueprint branch. There is no second built-in grammar anymore -// (#159 demo retirement): without a blueprint the dispatcher prints a usage error and -// exits 2 — except the real-archive r-sma sugar arms of walkforward/mc, which route to +// (#159 demo retirement / #220 axis generalization): without a blueprint the +// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to // the campaign path. Usage errors (clap parse + argv-applicability guards) exit 2; // runtime failures exit 1. @@ -2100,18 +2075,14 @@ struct GraphIntrospectCmd { #[derive(Args)] struct GeneralizeCmd { - /// The candidate strategy; must be r-sma (the candidate must produce R). - #[arg(long)] - strategy: Option, + /// The candidate blueprint (.json, required) — graded across instruments. + blueprint: Option, /// Comma-separated instrument list (>=2 distinct, required). #[arg(long)] real: Option, - /// Candidate fast-MA length (single value; required). + /// Candidate axis `=` (repeatable, >=1; one value per axis). #[arg(long)] - fast: Option, - /// Candidate slow-MA length (single value; required). - #[arg(long)] - slow: Option, + axis: Vec, /// Candidate stop length (single value; required). #[arg(long)] stop_length: Option, @@ -2424,18 +2395,13 @@ fn mc_args_from( } /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar -/// consumes. The candidate is a single cell (clap already types `--fast`/etc. as -/// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma -/// list; every refusal reuses the old message string (byte-identical front-end). +/// 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). #[allow(clippy::type_complexity)] fn generalize_args_from( a: &GeneralizeCmd, -) -> Result<(String, Vec, RGrid, String, Option, Option), String> { - if let Some(s) = a.strategy.as_deref() - && s != "r-sma" - { - return Err("generalize requires --strategy r-sma (the candidate must produce R)".to_string()); - } +) -> Result<(String, Vec, i64, f64, String, Option, Option), String> { let symbols: Vec = match a.real.as_deref() { None => return Err("generalize requires --real — a comma list of two or more instruments".to_string()), Some(v) => { @@ -2456,16 +2422,12 @@ 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 all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string(); - let grid = RGrid { - fast: vec![a.fast.ok_or_else(knobs)?], - slow: vec![a.slow.ok_or_else(knobs)?], - stop_length: vec![a.stop_length.ok_or_else(knobs)?], - stop_k: vec![a.stop_k.ok_or_else(knobs)?], - }; + 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 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, grid, metric, a.from, a.to)) + Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to)) } /// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a @@ -2607,32 +2569,74 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) { } fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { - let (name, symbols, grid, metric, from, to) = generalize_args_from(&a).unwrap_or_else(|m| { - eprintln!("aura: {m}"); + let usage = || "Usage: aura generalize --real --axis = [--axis …] --stop-length --stop-k [--metric ] [--name ] [--from ] [--to ]".to_string(); + let Some(path) = is_blueprint_file(&a.blueprint) else { + eprintln!("aura: {}", usage()); + std::process::exit(2); + }; + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - // Data-free R-metric refusal, byte-identical to the inline path (exit 2) - // before any archive is touched. + if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { + eprintln!("aura: {path}: {msg}"); + std::process::exit(2); + } + let (name, symbols, stop_length, stop_k, metric, from, to) = + generalize_args_from(&a).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + // Data-free R-metric refusal, byte-identical to the retired welded path + // (exit 2) before any archive is touched. if let Err(e) = check_r_metric(&metric) { eprintln!("aura: {e}"); std::process::exit(2); } - // Embed the shipped open SMA example as the stored strategy blueprint (the - // same shape the sweep-dissolution fixture `sma_signal_open.json` stores); - // fast/slow become single-value campaign axes and the stop rides the risk - // regime (mapped back to StopRule::Vol by the member runner). topology_hash - // == content_id_of(canonical), so the strategy ref resolves against this one - // blueprint-store write. - let canonical = include_str!("../examples/r_sma_open.json"); + let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + if axes.is_empty() { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + // A candidate is a single grid cell, not a sweep: every axis carries + // exactly one value (the refusal the retired `--fast 2,3` grammar made). + for (n, vals) in &axes { + if vals.len() != 1 { + eprintln!( + "aura: generalize: each --axis takes exactly one value; axis \"{n}\" has {}", + vals.len() + ); + std::process::exit(2); + } + } + // Validate every --axis name against the WRAPPED probe namespace before + // stripping to the raw campaign namespace (the sweep sequence, #210 c0110). + let space = blueprint_axis_probe(&doc, env).param_space(); + for (n, _) in &axes { + if !space.iter().any(|p| &p.name == n) { + eprintln!( + "aura: axis \"{n}\" is not one of this blueprint's \ + sweepable axes — run 'aura sweep --list-axes' \ + to see them" + ); + std::process::exit(2); + } + } + let blueprint = blueprint_from_json(&doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); let reg = env.registry(); - let topo = content_id(canonical); - reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { + let topo = topology_hash(&blueprint); + reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); // Window: the explicit --from/--to when both present (byte-identical to the - // inline path, verified by the exact-grade anchor); otherwise the first - // symbol's full archive window as the single shared campaign window. + // retired welded path, verified by the exact-grade anchor); otherwise the + // first symbol's full archive window as the single shared campaign window. let (from_ms, to_ms) = match (from, to) { (Some(f), Some(t)) => (f, t), _ => { @@ -2647,20 +2651,22 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { ) } }; - verb_sugar::run_generalize_sugar( - grid.fast[0], - grid.slow[0], - grid.stop_length[0], - grid.stop_k[0], - &metric, - &name, - &symbols, + // Campaign documents speak the RAW campaign-axis namespace; `--axis` is + // typed against the WRAPPED probe. Strip one segment (the sweep strip). + let raw_axes: Vec<(String, Vec)> = axes + .iter() + .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) + .collect(); + let inv = verb_sugar::SugarInvocation { + axes: &raw_axes, + name, + symbols, from_ms, to_ms, - canonical, - env, - ) - .unwrap_or_else(|m| { + blueprint_canonical: &canonical, + stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), + }; + verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(1); }); @@ -2839,10 +2845,16 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { .iter() .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) .collect(); - verb_sugar::run_sweep_sugar( - &raw_axes, &name, &symbol, from_ms, to_ms, &canonical, env, - ) - .unwrap_or_else(|m| { + let inv = verb_sugar::SugarInvocation { + axes: &raw_axes, + name: name.clone(), + symbols: vec![symbol.clone()], + from_ms, + to_ms, + blueprint_canonical: &canonical, + stop: None, + }; + verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(1); }); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index ecbf1f5..b906d60 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -23,6 +23,52 @@ pub(crate) struct GeneratedSweep { pub campaign: CampaignDoc, } +/// One dissolved-verb invocation's shared shape (#214/#220): arbitrary RAW +/// axes (post `wrapped_to_raw_axis` strip), the family name, the instrument +/// list, the shared window, the canonical blueprint, and the optional single +/// Vol stop regime (`None` binds no regime — sweep's contract). Sweep and +/// generalize route their dispatch args through this shape today; +/// walkforward/mc still build their args directly via their own +/// `--fast`/`--slow` builders, pending their own #220 dissolution. +pub(crate) struct SugarInvocation<'a> { + pub axes: &'a [(String, Vec)], + pub name: String, + /// Instruments: sweep/walkforward/mc carry exactly one; generalize >=2. + pub symbols: Vec, + pub from_ms: i64, + pub to_ms: i64, + pub blueprint_canonical: &'a str, + pub stop: Option, +} + +/// The single protective-stop regime a dissolved verb binds (`RiskRegime::Vol`). +#[derive(Clone, Copy)] +pub(crate) struct VolStop { + pub length: i64, + pub k: f64, +} + +/// The generic doc-axes map: every invocation axis under its arbitrary raw +/// bind-key (the sweep shape, adopted by all four verbs — #220). +fn doc_axes_from(axes: &[(String, Vec)]) -> Result, String> { + let mut doc_axes: BTreeMap = BTreeMap::new(); + for (axis_name, values) in axes { + doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?); + } + Ok(doc_axes) +} + +/// The optional single Vol stop regime -> the campaign risk vector. +fn risk_from(stop: Option) -> Vec { + stop.map(|s| vec![RiskRegime::Vol { length: s.length, k: s.k }]).unwrap_or_default() +} + +/// One probe param per axis (any one grid value — `bind_axes` checks NAME +/// coverage/uniqueness, never the bound value). +fn probe_params_from(axes: &[(String, Vec)]) -> Vec<(String, Scalar)> { + axes.iter().filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v))).collect() +} + /// Map one verb axis (`--axis name=csv`, already scalar-parsed) to a /// document `Axis`: the kind is the first value's kind; a mixed-kind CSV is /// refused (the document axis declares its kind ONCE — #189). @@ -52,16 +98,9 @@ fn axis_from_values(name: &str, values: &[Scalar]) -> Result { } /// Translate a real-data blueprint sweep invocation into its two generated -/// documents. `blueprint_canonical` is the canonical blueprint JSON already +/// documents. `inv.blueprint_canonical` is the canonical blueprint JSON already /// stored by topology hash; its content id is the strategy ref. -pub(crate) fn translate_sweep( - axes: &[(String, Vec)], - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, -) -> Result { +pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result { let process = ProcessDoc { format_version: FORMAT_VERSION, kind: DocKind::Process, @@ -69,28 +108,25 @@ pub(crate) fn translate_sweep( description: None, pipeline: vec![StageBlock::Sweep { selection: None }], }; - let mut doc_axes: BTreeMap = BTreeMap::new(); - for (axis_name, values) in axes { - doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?); - } let campaign = CampaignDoc { format_version: FORMAT_VERSION, kind: DocKind::Campaign, - name: name.to_string(), + name: inv.name.clone(), description: None, data: DataSection { - instruments: vec![symbol.to_string()], - windows: vec![Window { from_ms, to_ms }], + instruments: inv.symbols.clone(), + windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], }, strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), - axes: doc_axes, + r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), + axes: doc_axes_from(inv.axes)?, }], process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - // No stop regime is bound for a dissolved single-sweep member; the - // R-path stop is a wrap_r constant baked outside the signal axes - // (absent risk == empty risk, per the established parity). - risk: vec![], + // No stop regime is bound for a dissolved single-sweep member (the + // dispatch passes `stop: None`; absent risk == empty risk, per the + // established parity). The other verbs bind the single Vol regime + // through this same seam. + risk: risk_from(inv.stop), // No stage of a selection-free single-sweep pipeline consumes the // seed; a fixed zero keeps generated bytes deterministic so identical // invocations dedupe onto identical content ids. @@ -127,22 +163,12 @@ pub(crate) struct GeneratedGeneralize { /// a **selection-bearing** process (`[std::sweep(argmax), std::generalize]` — /// generalize needs a nominee, so the sweep stage is selection-bearing, unlike /// the selection-free single-sweep translator) and a campaign running the one -/// fixed candidate (single-value raw axes) across all instruments under a single -/// risk regime that carries the stop. `blueprint_canonical` is the bare -/// `sma_signal` blueprint already stored by topology hash; its content id is the -/// strategy ref. -#[allow(clippy::too_many_arguments)] +/// fixed candidate (single-value raw axes) across all instruments under the +/// invocation's single risk regime. `inv.blueprint_canonical` is the user's +/// blueprint already stored by topology hash; its content id is the strategy ref. pub(crate) fn translate_generalize( - fast: i64, - slow: i64, - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - name: &str, - symbols: &[String], - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, ) -> Result { let process = ProcessDoc { format_version: FORMAT_VERSION, @@ -160,32 +186,23 @@ pub(crate) fn translate_generalize( StageBlock::Generalize { metric: metric.to_string() }, ], }; - let mut doc_axes: BTreeMap = BTreeMap::new(); - doc_axes.insert( - "fast.length".to_string(), - axis_from_values("fast.length", &[Scalar::i64(fast)])?, - ); - doc_axes.insert( - "slow.length".to_string(), - axis_from_values("slow.length", &[Scalar::i64(slow)])?, - ); let campaign = CampaignDoc { format_version: FORMAT_VERSION, kind: DocKind::Campaign, - name: name.to_string(), + name: inv.name.clone(), description: None, data: DataSection { - instruments: symbols.to_vec(), - windows: vec![Window { from_ms, to_ms }], + instruments: inv.symbols.clone(), + windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], }, strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), - axes: doc_axes, + r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), + axes: doc_axes_from(inv.axes)?, }], process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - // The stop is a single structural risk regime (the just-shipped axis); - // the member runner maps it back to StopRule::Vol at run time. - risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }], + // The stop is a single structural risk regime; the member runner maps + // it back to StopRule::Vol at run time. + risk: risk_from(inv.stop), seed: 0, presentation: Presentation { persist_taps: vec![], @@ -254,22 +271,12 @@ fn validate_before_register( /// Run one dissolved sweep invocation end-to-end: register the generated /// documents, then execute through the one campaign path in sugar mode. pub(crate) fn run_sweep_sugar( - axes: &[(String, Vec)], - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, + inv: &SugarInvocation, env: &crate::project::Env, ) -> Result<(), String> { - let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?; - - let probe_params: Vec<(String, Scalar)> = axes - .iter() - .filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v))) - .collect(); - validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; - + let generated = translate_sweep(inv)?; + let probe_params = probe_params_from(inv.axes); + validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; let reg = env.registry(); let (_process_id, campaign_id) = register_generated(®, &generated)?; crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly) @@ -303,37 +310,21 @@ fn cross_instrument_members( /// documents, run through the one campaign path, then reprint today's exact /// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and /// persist the `CrossInstrument` family. The stdout is byte-identical to the -/// inline path (the committed exact-grade anchor is the gate). -#[allow(clippy::too_many_arguments)] +/// retired welded path (the committed exact-grade anchor is the gate). pub(crate) fn run_generalize_sugar( - fast: i64, - slow: i64, - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - name: &str, - symbols: &[String], - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, env: &crate::project::Env, ) -> Result<(), String> { - let generated = translate_generalize( - fast, slow, stop_length, stop_k, metric, name, symbols, from_ms, to_ms, blueprint_canonical, - )?; - - let probe_params: Vec<(String, Scalar)> = vec![ - ("fast.length".to_string(), Scalar::i64(fast)), - ("slow.length".to_string(), Scalar::i64(slow)), - ]; - validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; - + let generated = translate_generalize(inv, metric)?; + let probe_params = probe_params_from(inv.axes); + validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; let reg = env.registry(); let (_process_id, campaign_id) = register_generated_g(®, &generated)?; let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; // Reprint the verb's aggregate line from the recorded cross-instrument - // grade — byte-identical to the inline `generalize_json(&agg)`. + // grade — byte-identical to the retired welded path's `generalize_json(&agg)`. let cg = run .outcome .record @@ -345,7 +336,7 @@ pub(crate) fn run_generalize_sugar( let members = cross_instrument_members(&run.outcome); let family_id = reg - .append_family(name, aura_registry::FamilyKind::CrossInstrument, &members) + .append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members) .map_err(|e| e.to_string())?; println!("{{\"family_id\":\"{family_id}\"}}"); Ok(()) @@ -677,10 +668,37 @@ mod tests { ] } + fn inv<'a>( + axes: &'a [(String, Vec)], + name: &str, + symbols: &[&str], + stop: Option, + bp: &'a str, + ) -> SugarInvocation<'a> { + SugarInvocation { + axes, + name: name.to_string(), + symbols: symbols.iter().map(|s| s.to_string()).collect(), + from_ms: 100, + to_ms: 200, + blueprint_canonical: bp, + stop, + } + } + + fn g_axes() -> Vec<(String, Vec)> { + vec![ + ("fast.length".to_string(), vec![Scalar::i64(3)]), + ("slow.length".to_string(), vec![Scalar::i64(12)]), + ] + } + #[test] fn translate_sweep_is_deterministic_and_names_flow() { - let a = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap(); - let b = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap(); + let ax = axes(); + let i = inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}"); + let a = translate_sweep(&i).unwrap(); + let b = translate_sweep(&i).unwrap(); assert_eq!( content_id_of(&campaign_to_json(&a.campaign)), content_id_of(&campaign_to_json(&b.campaign)), @@ -704,30 +722,38 @@ mod tests { #[test] fn translate_sweep_refuses_a_mixed_kind_axis() { let mixed = vec![("k".to_string(), vec![Scalar::I64(1), Scalar::F64(2.5)])]; - let err = translate_sweep(&mixed, "n", "GER40", 1, 2, "{}").unwrap_err(); + let err = translate_sweep(&inv(&mixed, "n", &["GER40"], None, "{}")).unwrap_err(); assert!(err.contains("mixed value kinds"), "{err}"); } #[test] fn generated_campaign_validates_intrinsically_and_preflights() { - let g = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap(); + let ax = axes(); + let g = translate_sweep(&inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}")).unwrap(); assert!(aura_research::validate_campaign(&g.campaign).is_empty()); assert!(aura_research::validate_process(&g.process).is_empty()); assert!(aura_campaign::preflight(&g.process, &g.campaign).is_ok()); } + /// The one seam all four verbs share (#220): `stop: Some(VolStop)` binds the + /// single Vol regime; `stop: None` binds no regime (sweep's contract). + #[test] + fn sugar_invocation_stop_maps_to_the_vol_regime() { + let ax = axes(); + let mut i = inv(&ax, "n", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); + let with = translate_sweep(&i).unwrap(); + assert_eq!(with.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); + i.stop = None; + let without = translate_sweep(&i).unwrap(); + assert!(without.campaign.risk.is_empty()); + } + #[test] fn translate_generalize_is_deterministic_and_carries_the_regime() { - let a = translate_generalize( - 3, 12, 14, 2.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let b = translate_generalize( - 3, 12, 14, 2.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = g_axes(); + let i = inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); + let a = translate_generalize(&i, "expectancy_r").unwrap(); + let b = translate_generalize(&i, "expectancy_r").unwrap(); assert_eq!( content_id_of(&campaign_to_json(&a.campaign)), content_id_of(&campaign_to_json(&b.campaign)), @@ -777,21 +803,10 @@ mod tests { /// different-k pair and a different-length, same-k pair. #[test] fn translate_generalize_diverging_regimes_do_not_collide_content_ids() { - let base = translate_generalize( - 3, 12, 14, 2.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_k = translate_generalize( - 3, 12, 14, 3.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_length = translate_generalize( - 3, 12, 20, 2.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = g_axes(); + let base = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); + let different_k = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); + let different_length = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 20, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); let base_id = content_id_of(&campaign_to_json(&base.campaign)); let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); let length_id = content_id_of(&campaign_to_json(&different_length.campaign)); @@ -822,9 +837,10 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); + let ax = g_axes(); let generated = translate_generalize( - 3, 12, 14, 2.0, "expectancy_r", "generalize", - &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}", + &inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), + "expectancy_r", ) .unwrap(); let (process_id, campaign_id) = register_generated_g(®, &generated).unwrap(); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index b15ec5b..f9936a8 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1306,6 +1306,33 @@ fn sweep_channel_flag_is_removed_from_the_grammar() { let _ = std::fs::remove_dir_all(&cwd); } +/// Property (#220 generalize vertical): `--strategy`/`--fast`/`--slow` are +/// genuinely removed from the generalize grammar, not merely unused — clap +/// rejects `--strategy` as a structurally unknown argument (exit 2) rather +/// than silently accepting and ignoring it now that the candidate is an +/// arbitrary blueprint positional + generic `--axis`. A regression that left +/// the old flags on `GeneralizeCmd` while deleting only their plumbing would +/// parse successfully here and this test would fail on the exit code. +#[test] +fn generalize_strategy_flag_is_removed_from_the_grammar() { + let cwd = temp_cwd("generalize-strategy-flag-retired"); + let out = Command::new(BIN) + .args([ + "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura generalize --strategy"); + assert_eq!(out.status.code(), Some(2), "--strategy must be an unrecognized clap argument: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--strategy") || stderr.to_lowercase().contains("unexpected argument"), + "clap must name the unknown flag: {stderr:?}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + /// Property: `aura generalize` grades a single r-sma candidate across two /// instruments — its stdout carries the per-instrument breakdown + the worst-case /// floor + the sign-agreement, then the persisted CrossInstrument family handle. @@ -1316,10 +1343,12 @@ fn generalize_grades_a_candidate_across_two_instruments() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-two"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1360,10 +1389,12 @@ fn generalize_real_e2e_pins_the_exact_current_grade() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-exact-grade"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1399,6 +1430,53 @@ fn generalize_real_e2e_pins_the_exact_current_grade() { assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}"); } +/// Property (#220 generalize vertical): `aura generalize` grades ANY sweepable +/// blueprint, not just the historical r-sma candidate — the whole point of +/// dropping `--strategy r-sma`/`--fast`/`--slow` for a positional blueprint + +/// generic `--axis`. Every other generalize E2E test here still exercises +/// `r_sma_open.json`, so a regression that silently special-cased r-sma paths +/// back in (e.g. resolving axes against a hardcoded `sma_signal.*` namespace) +/// would ship green there while breaking every other blueprint. This runs the +/// unrelated r-breakout candidate (`channel_hi`/`channel_lo` axes, no +/// `sma_signal` node in sight) end-to-end and asserts a well-formed two- +/// instrument grade. Gated on the shared GER40/USDJPY Sept-2024 archive; skips +/// cleanly on a data refusal. +#[test] +fn generalize_grades_a_non_r_sma_blueprint() { + const FROM_MS: &str = "1725148800000"; + const TO_MS: &str = "1727740799999"; + let cwd = temp_cwd("generalize-non-r-sma"); + let fixture = format!("{}/examples/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "r_breakout_signal.channel_hi.length=20", + "--axis", "r_breakout_signal.channel_lo.length=20", + "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + 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/USDJPY data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}"); + assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}"); + assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}"); + assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}"); + assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}"); + assert!(stdout.contains("\"family_id\":"), "family handle: {stdout}"); +} + /// Characterization pin (byte-identity anchor for the walkforward dissolution, /// #210). The current `aura walkforward` runs its per-window IS-refit inline /// (`walkforward_family` -> `select_winner` -> OOS run -> stitch/pool); the @@ -1494,9 +1572,8 @@ fn walkforward_dissolved_refuses_select_plateau() { assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}"); } -/// `walkforward_args_from` requires all four grid knobs on the dissolved r-sma-real -/// path (unlike the inline path, which defaults an absent flag via `RGrid::default()`) -/// — a missing knob is a usage refusal (exit 2), not a silent default. +/// `walkforward_args_from` requires both stop knobs on the campaign path — a +/// missing knob is a usage refusal (exit 2), not a silent default. #[test] fn walkforward_dissolved_refuses_missing_knobs() { let cwd = temp_cwd("walkforward-missing-knobs"); @@ -1665,10 +1742,12 @@ fn generalize_dissolves_through_the_campaign_path() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-dissolves"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1754,11 +1833,13 @@ fn generalize_repeated_identical_invocation_does_not_litter_the_store() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-repeat-idempotent"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let invoke = || { std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1807,11 +1888,14 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-distinct-content"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let invoke = |fast: &str| { + let fast_axis = format!("sma_signal.fast.length={fast}"); std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", fast, "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", &fast_axis, "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1853,10 +1937,12 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() { #[test] fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_window() { let cwd = temp_cwd("generalize-default-window"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", ]) .current_dir(&cwd) .output() @@ -1883,10 +1969,12 @@ fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_windo /// before any data access — so it asserts the arity refusal on any machine. #[test] fn generalize_refuses_a_single_instrument() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", ]) .output() .expect("spawn aura"); @@ -1897,10 +1985,12 @@ fn generalize_refuses_a_single_instrument() { /// `check_r_metric` pre-check), so this asserts the R-only refusal on any machine. #[test] fn generalize_refuses_a_non_r_metric() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--metric", "total_pips", ]) .output() @@ -1920,10 +2010,12 @@ fn generalize_refuses_a_non_r_metric() { /// check precedes any geometry/archive lookup, so it asserts on any machine. #[test] fn generalize_refuses_a_duplicate_instrument() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", ]) .output() .expect("spawn aura"); @@ -1937,22 +2029,24 @@ fn generalize_refuses_a_duplicate_instrument() { assert!(stderr.contains("generalize"), "must surface the generalize usage, got: {stderr}"); } -/// Property: a non-`r-sma` strategy is refused at the binary boundary (exit 2, -/// no run, data-free). The candidate must produce R (C10) — the cross-instrument -/// reduction is R-only — so only the r-sma grid is an admissible candidate. The -/// parser unit test pins the grammar refusal; this pins that `--strategy sma` -/// reaches `exit(2)` through the dispatch arm rather than silently defaulting to a -/// running candidate. Asserts on any machine (the strategy check precedes any run). +/// Property: a multi-value axis is refused at the binary boundary (exit 2, no +/// run, data-free). A *candidate* is a single grid cell, not a sweep — so any +/// `--axis` carrying more than one value is refused, never silently widened +/// into a multi-cell run whose generalization grade would be ill-defined. #[test] -fn generalize_refuses_a_non_r_sma_strategy() { +fn generalize_refuses_a_multi_value_axis() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", ]) .output() .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a non-r-sma strategy must exit 2"); + assert_eq!(out.status.code(), Some(2), "a multi-value axis must exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("each --axis takes exactly one value"), "arity refusal: {stderr}"); assert!( out.stdout.is_empty(), "the refusal must not leak an aggregate to stdout, got: {:?}", @@ -1960,22 +2054,50 @@ fn generalize_refuses_a_non_r_sma_strategy() { ); } -/// Property: a multi-value candidate flag is refused at the binary boundary (exit 2, -/// no run, data-free). A *candidate* is a single grid cell, not a sweep — so any of -/// `--fast/--slow/--stop-length/--stop-k` carrying more than one value (`--fast 2,3`) -/// is refused, never silently widened into a multi-cell run whose generalization -/// grade would be ill-defined (the reduction grades one candidate, not a family). -/// The parser unit test pins the grammar; this pins the wiring to `exit(2)`. +/// Property: a `--axis` with no `--axis` flags at all is a usage error (exit 2, +/// no run) — generalize needs >= 1 axis to name a candidate, mirroring the sibling +/// sweep/walkforward "no axis" refusals. Data-free (fires before any archive access). #[test] -fn generalize_refuses_a_multi_value_candidate_flag() { +fn generalize_refuses_no_axis() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "2,3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--stop-length", "14", "--stop-k", "2.0", ]) .output() .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a multi-value candidate flag must exit 2"); + assert_eq!(out.status.code(), Some(2), "zero axes must exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Usage: aura generalize"), "must print the generalize usage: {stderr}"); + assert!( + out.stdout.is_empty(), + "the refusal must not leak an aggregate to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); +} + +/// Property: an `--axis` name that is not among the loaded blueprint's sweepable +/// axes is refused before the raw-namespace strip or any archive access (exit 2), +/// echoing exactly the name the user typed — the same WRAPPED-probe preflight +/// `aura sweep` enforces (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`). +#[test] +fn generalize_refuses_an_unknown_axis_name() { + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", + ]) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), + "must name the unresolved axis exactly as typed: {stderr}" + ); assert!( out.stdout.is_empty(), "the refusal must not leak an aggregate to stdout, got: {:?}", @@ -1998,10 +2120,12 @@ fn generalize_persists_a_discoverable_cross_instrument_family() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-family"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -2061,10 +2185,12 @@ fn generalize_family_members_are_attributed_to_the_right_instrument() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("generalize-member-attribution"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "generalize", &fixture, "--real", "GER40,USDJPY", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd)