feat(cli): dissolve aura mc --strategy r-sma --real (#210)
The FOURTH and LAST verb dissolution. `aura mc --strategy r-sma --real` now runs as thin sugar over the one campaign executor: a generated, content-addressed [std::sweep(argmax), std::walk_forward, std::monte_carlo] campaign document whose terminal monte_carlo stage produces the same StageBootstrap::PooledOos(RBootstrap). The sugar reads it from run.outcome.record.cells[].stages[].bootstrap and reprints the existing mc_r_bootstrap_json — one line, byte-identical to the inline path. Pure sugar, NO executor change (aura-campaign/aura-engine/aura-research diff-free). Mirrors the shipped walkforward dissolution: - verb_sugar.rs: GeneratedMc, translate_mc, register_generated_mc, run_mc_sugar (record-read + one grade line, no member lines). - main.rs: mc_args_from + the dispatch_mc built-in-arm split gated on `--strategy r-sma && --real`. The one new load-bearing decision is the seed mapping: translate_mc sets campaign.seed = the mc --seed. The wf winners are argmax hence deflation-seed-independent (proven by the shipped walkforward anchor), so the pooled OOS series is unchanged while the terminal r_bootstrap at that seed reproduces the inline bootstrap. A new e2e (mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series) pins exactly this: two --seed values yield identical n_trades but a moved E[R] mean. Fork C retention (as walkforward): the arm splits, run_mc_r_bootstrap / run_mc stay inline for the fenced synthetic/non-real paths (until #159). The three synthetic-r-sma tests use no --real, so they never enter the sugar branch and run the inline path unchanged. --name/--trace are rejected on the sugar path, matching the inline mc r-path. Verification: the committed byte-identity anchor mc_r_bootstrap_real_e2e_pins_the_exact_current_grade runs the real GER40 archive and passes byte-for-byte through the new path; full workspace suite green; clippy --all-targets -D warnings clean. refs #210
This commit is contained in:
@@ -4264,6 +4264,44 @@ fn walkforward_args_from(
|
|||||||
Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to))
|
Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the
|
||||||
|
/// dissolved `--strategy r-sma --real` branch). Single instrument, multi-value
|
||||||
|
/// fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is a risk
|
||||||
|
/// regime, not a swept axis); all four grid knobs required. `--block-len`/
|
||||||
|
/// `--resamples`/`--seed` default to `1`/`1000`/`1` — byte-identical to the inline
|
||||||
|
/// `McArgs::RealR` defaults (`main.rs:5064-5066`), so an omitted flag produces the
|
||||||
|
/// same document either way. The usize->u32 conversion for `block_len`/`resamples`
|
||||||
|
/// lands HERE, at the argv boundary where the CLI's `usize` meets the document's
|
||||||
|
/// `u32` vocabulary (`StageBlock::MonteCarlo`), keeping every downstream signature
|
||||||
|
/// in the document's native type. `--name`/`--trace` are rejected: the inline mc
|
||||||
|
/// R-path rejects them too (`main.rs:5047`), so the sugar preserves that (the
|
||||||
|
/// R-bootstrap records without a family name — the campaign name is a constant
|
||||||
|
/// "mc").
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn mc_args_from(
|
||||||
|
a: &McCmd,
|
||||||
|
) -> Result<(String, String, Vec<i64>, Vec<i64>, i64, f64, u32, u32, u64, Option<i64>, Option<i64>), String> {
|
||||||
|
let symbol = match a.real.as_deref() {
|
||||||
|
None | Some("") => return Err("mc --strategy r-sma dissolves only over --real <SYMBOL>".to_string()),
|
||||||
|
Some(s) => s.to_string(),
|
||||||
|
};
|
||||||
|
if a.name.is_some() || a.trace.is_some() {
|
||||||
|
return Err("mc --strategy r-sma: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string());
|
||||||
|
}
|
||||||
|
let knobs = || "mc requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string();
|
||||||
|
let fast: Vec<i64> = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||||
|
let slow: Vec<i64> = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?;
|
||||||
|
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 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, fast, slow, stop_length[0], stop_k[0], 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. The candidate is a single cell (clap already types `--fast`/etc. as
|
/// 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
|
/// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma
|
||||||
@@ -4947,6 +4985,51 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
|
|||||||
eprintln!("aura: {}", usage());
|
eprintln!("aura: {}", usage());
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
// Dissolution split (#210, Reading A): only the real-archive r-sma
|
||||||
|
// R-bootstrap execution routes through the campaign path. Everything else —
|
||||||
|
// synthetic-r-sma (no --real), the synthetic seed-resweep, the blueprint
|
||||||
|
// path — stays on the inline handler below, fenced until #159.
|
||||||
|
if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() {
|
||||||
|
let (name, symbol, fast, slow, stop_length, stop_k, block_len, resamples, seed, from, to) =
|
||||||
|
mc_args_from(&a).unwrap_or_else(|m| {
|
||||||
|
eprintln!("aura: {m}");
|
||||||
|
std::process::exit(2);
|
||||||
|
});
|
||||||
|
let signal = sma_signal(None, None);
|
||||||
|
let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes");
|
||||||
|
let reg = env.registry();
|
||||||
|
let topo = topology_hash(&signal);
|
||||||
|
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
// The inline `walkforward_family` sources its span from
|
||||||
|
// `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to`
|
||||||
|
// to the archive's actual first/last bar in range: a holiday/weekend edge
|
||||||
|
// shifts every IS/OOS window's calendar placement, so the roller must clip
|
||||||
|
// identically here too, even when both flags are given, or the per-window
|
||||||
|
// winners and pooled OOS series diverge from the committed exact-grade anchor.
|
||||||
|
let source = DataSource::from_choice(
|
||||||
|
DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to },
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
let (from_ts, to_ts) = source.full_window(env);
|
||||||
|
let (from_ms, to_ms) = (
|
||||||
|
aura_ingest::epoch_ns_to_unix_ms(from_ts),
|
||||||
|
aura_ingest::epoch_ns_to_unix_ms(to_ts),
|
||||||
|
);
|
||||||
|
let (is_ms, oos_ms, step_ms) = wf_ms_sizes();
|
||||||
|
verb_sugar::run_mc_sugar(
|
||||||
|
&fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC,
|
||||||
|
is_ms, oos_ms, step_ms, resamples, block_len, seed, &name, &symbol, from_ms, to_ms,
|
||||||
|
&canonical, env,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|m| {
|
||||||
|
eprintln!("aura: {m}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The R-bootstrap path is selected by any r-sma knob; otherwise the
|
// The R-bootstrap path is selected by any r-sma knob; otherwise the
|
||||||
// synthetic seed-resweep family. Name flags are invalid on the R path.
|
// synthetic seed-resweep family. Name flags are invalid on the R path.
|
||||||
let r_path = a.strategy.is_some()
|
let r_path = a.strategy.is_some()
|
||||||
@@ -6498,4 +6581,81 @@ mod tests {
|
|||||||
mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env),
|
mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bare `McCmd` with every optional field defaulted to `None`/empty, so each
|
||||||
|
/// `mc_args_from` refusal test below only sets the fields its scenario needs.
|
||||||
|
fn bare_mc_cmd() -> McCmd {
|
||||||
|
McCmd {
|
||||||
|
blueprint: None,
|
||||||
|
strategy: None,
|
||||||
|
real: None,
|
||||||
|
from: None,
|
||||||
|
to: None,
|
||||||
|
name: None,
|
||||||
|
trace: None,
|
||||||
|
fast: None,
|
||||||
|
slow: None,
|
||||||
|
stop_length: None,
|
||||||
|
stop_k: None,
|
||||||
|
block_len: None,
|
||||||
|
resamples: None,
|
||||||
|
seed: None,
|
||||||
|
seeds: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mc_args_from_refuses_without_a_real_symbol() {
|
||||||
|
let a = bare_mc_cmd();
|
||||||
|
let err = mc_args_from(&a).unwrap_err();
|
||||||
|
assert!(err.contains("dissolves only over --real"), "refuse message: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mc_args_from_refuses_an_empty_real_symbol() {
|
||||||
|
let a = McCmd { real: Some(String::new()), ..bare_mc_cmd() };
|
||||||
|
let err = mc_args_from(&a).unwrap_err();
|
||||||
|
assert!(err.contains("dissolves only over --real"), "refuse message: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mc_args_from_refuses_name_and_trace() {
|
||||||
|
let a = McCmd {
|
||||||
|
real: Some("GER40".to_string()),
|
||||||
|
name: Some("a".to_string()),
|
||||||
|
trace: Some("b".to_string()),
|
||||||
|
..bare_mc_cmd()
|
||||||
|
};
|
||||||
|
let err = mc_args_from(&a).unwrap_err();
|
||||||
|
assert!(err.contains("--name/--trace are not accepted"), "refuse message: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mc_args_from_refuses_missing_knobs() {
|
||||||
|
let a = McCmd {
|
||||||
|
real: Some("GER40".to_string()),
|
||||||
|
fast: Some("3".to_string()),
|
||||||
|
slow: Some("12".to_string()),
|
||||||
|
..bare_mc_cmd()
|
||||||
|
};
|
||||||
|
let err = mc_args_from(&a).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("requires --fast --slow --stop-length --stop-k"),
|
||||||
|
"missing-knob refusal: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mc_args_from_refuses_a_multi_value_stop() {
|
||||||
|
let a = McCmd {
|
||||||
|
real: Some("GER40".to_string()),
|
||||||
|
fast: Some("3".to_string()),
|
||||||
|
slow: Some("12".to_string()),
|
||||||
|
stop_length: Some("14,20".to_string()),
|
||||||
|
stop_k: Some("2.0".to_string()),
|
||||||
|
..bare_mc_cmd()
|
||||||
|
};
|
||||||
|
let err = mc_args_from(&a).unwrap_err();
|
||||||
|
assert!(err.contains("single risk regime"), "stop-regime refusal: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,170 @@ pub(crate) fn run_walkforward_sugar(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two generated documents of one dissolved `mc` invocation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct GeneratedMc {
|
||||||
|
pub process: ProcessDoc,
|
||||||
|
pub campaign: CampaignDoc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate one `aura mc --strategy r-sma --real` invocation into its two generated
|
||||||
|
/// documents: a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process
|
||||||
|
/// (the sweep enumerates the IS-refit survivor grid for the wf stage; the terminal
|
||||||
|
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps E[R])
|
||||||
|
/// and a campaign running the fast/slow grid over one instrument under a single risk
|
||||||
|
/// regime that carries the stop. Unlike `translate_walkforward` (which hardcodes
|
||||||
|
/// `seed: 0`), `translate_mc` sets `campaign.seed = seed` (the mc `--seed`): the wf
|
||||||
|
/// winners are argmax hence deflation-seed-independent (the shipped walkforward
|
||||||
|
/// anchor proved this), so remapping the campaign seed leaves the pooled series
|
||||||
|
/// unchanged while making the terminal `r_bootstrap` at that seed reproduce the
|
||||||
|
/// inline bootstrap. The roller sizes (`in_sample_ms`/`out_of_sample_ms`/`step_ms`)
|
||||||
|
/// and `blueprint_canonical` come from the caller, exactly as for walkforward.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn translate_mc(
|
||||||
|
fast: &[i64],
|
||||||
|
slow: &[i64],
|
||||||
|
stop_length: i64,
|
||||||
|
stop_k: f64,
|
||||||
|
metric: &str,
|
||||||
|
in_sample_ms: u64,
|
||||||
|
out_of_sample_ms: u64,
|
||||||
|
step_ms: u64,
|
||||||
|
resamples: u32,
|
||||||
|
block_len: u32,
|
||||||
|
seed: u64,
|
||||||
|
name: &str,
|
||||||
|
symbol: &str,
|
||||||
|
from_ms: i64,
|
||||||
|
to_ms: i64,
|
||||||
|
blueprint_canonical: &str,
|
||||||
|
) -> Result<GeneratedMc, String> {
|
||||||
|
let process = ProcessDoc {
|
||||||
|
format_version: FORMAT_VERSION,
|
||||||
|
kind: DocKind::Process,
|
||||||
|
name: "mc".to_string(),
|
||||||
|
description: None,
|
||||||
|
pipeline: vec![
|
||||||
|
StageBlock::Sweep {
|
||||||
|
selection: Some(SweepSelection {
|
||||||
|
metric: metric.to_string(),
|
||||||
|
select: SelectRule::Argmax,
|
||||||
|
deflate: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
StageBlock::WalkForward {
|
||||||
|
in_sample_ms,
|
||||||
|
out_of_sample_ms,
|
||||||
|
step_ms,
|
||||||
|
mode: WfMode::Rolling,
|
||||||
|
metric: metric.to_string(),
|
||||||
|
select: SelectRule::Argmax,
|
||||||
|
},
|
||||||
|
StageBlock::MonteCarlo { resamples, block_len },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
|
||||||
|
doc_axes.insert(
|
||||||
|
"fast.length".to_string(),
|
||||||
|
axis_from_values("fast.length", &fast.iter().map(|&v| Scalar::i64(v)).collect::<Vec<_>>())?,
|
||||||
|
);
|
||||||
|
doc_axes.insert(
|
||||||
|
"slow.length".to_string(),
|
||||||
|
axis_from_values("slow.length", &slow.iter().map(|&v| Scalar::i64(v)).collect::<Vec<_>>())?,
|
||||||
|
);
|
||||||
|
let campaign = CampaignDoc {
|
||||||
|
format_version: FORMAT_VERSION,
|
||||||
|
kind: DocKind::Campaign,
|
||||||
|
name: name.to_string(),
|
||||||
|
description: None,
|
||||||
|
data: DataSection {
|
||||||
|
instruments: vec![symbol.to_string()],
|
||||||
|
windows: vec![Window { from_ms, to_ms }],
|
||||||
|
},
|
||||||
|
strategies: vec![StrategyEntry {
|
||||||
|
r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)),
|
||||||
|
axes: doc_axes,
|
||||||
|
}],
|
||||||
|
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
|
||||||
|
risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }],
|
||||||
|
seed,
|
||||||
|
presentation: Presentation {
|
||||||
|
persist_taps: vec![],
|
||||||
|
emit: vec!["family_table".to_string()],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(GeneratedMc { process, campaign })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-register both generated mc documents (content-addressed, idempotent).
|
||||||
|
/// Returns `(process_id, campaign_id)`.
|
||||||
|
pub(crate) fn register_generated_mc(
|
||||||
|
reg: &aura_registry::Registry,
|
||||||
|
generated: &GeneratedMc,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
|
let process_id =
|
||||||
|
reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?;
|
||||||
|
let campaign_id =
|
||||||
|
reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?;
|
||||||
|
Ok((process_id, campaign_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one dissolved `mc` invocation end-to-end: register the generated documents,
|
||||||
|
/// run through the one campaign path, then reprint the single `mc_r_bootstrap` grade
|
||||||
|
/// line from the recorded terminal `PooledOos` bootstrap. The stdout line is
|
||||||
|
/// byte-identical to the inline path (the committed exact-grade anchor is the gate).
|
||||||
|
/// mc prints ONLY the one summary line — no per-window member lines (unlike
|
||||||
|
/// walkforward): the monte_carlo stage is a terminal annotator, not a family.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn run_mc_sugar(
|
||||||
|
fast: &[i64],
|
||||||
|
slow: &[i64],
|
||||||
|
stop_length: i64,
|
||||||
|
stop_k: f64,
|
||||||
|
metric: &str,
|
||||||
|
in_sample_ms: u64,
|
||||||
|
out_of_sample_ms: u64,
|
||||||
|
step_ms: u64,
|
||||||
|
resamples: u32,
|
||||||
|
block_len: u32,
|
||||||
|
seed: u64,
|
||||||
|
name: &str,
|
||||||
|
symbol: &str,
|
||||||
|
from_ms: i64,
|
||||||
|
to_ms: i64,
|
||||||
|
blueprint_canonical: &str,
|
||||||
|
env: &crate::project::Env,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let generated = translate_mc(
|
||||||
|
fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms,
|
||||||
|
resamples, block_len, seed, name, symbol, from_ms, to_ms, blueprint_canonical,
|
||||||
|
)?;
|
||||||
|
let probe_params: Vec<(String, Scalar)> = vec![
|
||||||
|
("fast.length".to_string(), Scalar::i64(fast[0])),
|
||||||
|
("slow.length".to_string(), Scalar::i64(slow[0])),
|
||||||
|
];
|
||||||
|
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
|
||||||
|
let reg = env.registry();
|
||||||
|
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
||||||
|
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||||
|
|
||||||
|
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
|
||||||
|
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
|
||||||
|
let boot = run
|
||||||
|
.outcome
|
||||||
|
.record
|
||||||
|
.cells
|
||||||
|
.iter()
|
||||||
|
.flat_map(|c| c.stages.iter())
|
||||||
|
.find_map(|s| match &s.bootstrap {
|
||||||
|
Some(aura_registry::StageBootstrap::PooledOos(b)) => Some(b),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or("mc produced no pooled-OOS bootstrap")?;
|
||||||
|
println!("{}", crate::mc_r_bootstrap_json(boot));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -792,4 +956,123 @@ mod tests {
|
|||||||
assert_eq!(stored_process, process_to_json(&generated.process));
|
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_mc_is_deterministic_and_carries_the_regime_and_seed() {
|
||||||
|
let a = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 2.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 42,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let b = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 2.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 42,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
content_id_of(&campaign_to_json(&a.campaign)),
|
||||||
|
content_id_of(&campaign_to_json(&b.campaign)),
|
||||||
|
"identical invocations must generate identical campaign ids"
|
||||||
|
);
|
||||||
|
// pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo
|
||||||
|
assert_eq!(
|
||||||
|
a.process.pipeline,
|
||||||
|
vec![
|
||||||
|
StageBlock::Sweep {
|
||||||
|
selection: Some(SweepSelection {
|
||||||
|
metric: "sqn_normalized".to_string(),
|
||||||
|
select: SelectRule::Argmax,
|
||||||
|
deflate: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
StageBlock::WalkForward {
|
||||||
|
in_sample_ms: 7_776_000_000,
|
||||||
|
out_of_sample_ms: 2_592_000_000,
|
||||||
|
step_ms: 2_592_000_000,
|
||||||
|
mode: WfMode::Rolling,
|
||||||
|
metric: "sqn_normalized".to_string(),
|
||||||
|
select: SelectRule::Argmax,
|
||||||
|
},
|
||||||
|
StageBlock::MonteCarlo { resamples: 1000, block_len: 5 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(a.campaign.seed, 42, "the campaign seed carries the mc --seed");
|
||||||
|
assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]);
|
||||||
|
assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]);
|
||||||
|
assert_eq!(a.campaign.strategies.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
a.campaign.strategies[0].axes["fast.length"].values,
|
||||||
|
vec![Scalar::i64(3), Scalar::i64(5)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a.campaign.strategies[0].axes["slow.length"].values,
|
||||||
|
vec![Scalar::i64(12), Scalar::i64(20)]
|
||||||
|
);
|
||||||
|
assert_eq!(a.process.name, "mc");
|
||||||
|
assert!(aura_research::validate_campaign(&a.campaign).is_empty());
|
||||||
|
assert!(aura_research::validate_process(&a.process).is_empty());
|
||||||
|
assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids() {
|
||||||
|
let base = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 2.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 42,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let different_seed = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 2.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 7,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let different_k = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 3.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 42,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let base_id = content_id_of(&campaign_to_json(&base.campaign));
|
||||||
|
let seed_id = content_id_of(&campaign_to_json(&different_seed.campaign));
|
||||||
|
let k_id = content_id_of(&campaign_to_json(&different_k.campaign));
|
||||||
|
assert_ne!(base_id, seed_id, "a different mc seed must not collide onto the same campaign id");
|
||||||
|
assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id");
|
||||||
|
assert_ne!(seed_id, k_id, "the two diverging documents must not collide with each other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"aura-verb-sugar-mc-registry-test-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||||
|
let generated = translate_mc(
|
||||||
|
&[3, 5], &[12, 20], 14, 2.0, "sqn_normalized",
|
||||||
|
7_776_000_000, 2_592_000_000, 2_592_000_000,
|
||||||
|
1000, 5, 42,
|
||||||
|
"mc", "GER40", 100, 200, "{\"bp\":1}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (process_id, campaign_id) = register_generated_mc(®, &generated).unwrap();
|
||||||
|
let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored");
|
||||||
|
assert_eq!(stored_campaign, campaign_to_json(&generated.campaign));
|
||||||
|
let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else {
|
||||||
|
panic!("mc's process ref is a content id");
|
||||||
|
};
|
||||||
|
assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref");
|
||||||
|
let stored_process = reg.get_process(ref_id).unwrap().expect("process stored");
|
||||||
|
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5278,3 +5278,185 @@ fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() {
|
|||||||
assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {grade_line}");
|
assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {grade_line}");
|
||||||
assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}");
|
assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property: `aura mc --strategy r-sma --real` is now thin sugar over the one campaign
|
||||||
|
/// path — a successful run durably auto-registers exactly one generated process
|
||||||
|
/// document, one generated campaign document (carrying the constant "mc" name and the
|
||||||
|
/// stop as a non-empty single risk regime), and one campaign-run record, and emits the
|
||||||
|
/// single `mc_r_bootstrap` grade line. The pipeline's leading argmax sweep and its
|
||||||
|
/// walk_forward stage each persist one family; the terminal monte_carlo stage is an
|
||||||
|
/// annotator and adds NO family. This is the observable proof the inline path is gone
|
||||||
|
/// and the dissolution runs through the executor. Gated on the GER40 2025 archive;
|
||||||
|
/// skips cleanly on a data refusal.
|
||||||
|
#[test]
|
||||||
|
fn mc_dissolves_through_the_campaign_path() {
|
||||||
|
const FROM_MS: &str = "1735689600000";
|
||||||
|
const TO_MS: &str = "1767225599000";
|
||||||
|
let cwd = temp_cwd("mc-dissolves");
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args([
|
||||||
|
"mc", "--strategy", "r-sma", "--real", "GER40",
|
||||||
|
"--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0",
|
||||||
|
"--block-len", "5", "--resamples", "1000", "--seed", "42",
|
||||||
|
"--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 data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||||||
|
|
||||||
|
// the single grade line is emitted (and nothing else on stdout).
|
||||||
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||||
|
assert_eq!(stdout.lines().count(), 1, "mc prints exactly one grade line: {stdout:?}");
|
||||||
|
assert!(
|
||||||
|
stdout.lines().next().is_some_and(|l| l.starts_with("{\"mc_r_bootstrap\":")),
|
||||||
|
"the one line is the mc_r_bootstrap grade: {stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = |sub: &str| {
|
||||||
|
std::fs::read_dir(cwd.join("runs").join(sub))
|
||||||
|
.map(|d| d.count())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
assert_eq!(count("processes"), 1, "one generated process document registered");
|
||||||
|
assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
|
||||||
|
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
|
||||||
|
.expect("campaign_runs.jsonl exists after a sugar run");
|
||||||
|
assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");
|
||||||
|
|
||||||
|
let campaigns_dir = cwd.join("runs").join("campaigns");
|
||||||
|
let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
|
||||||
|
.expect("campaigns dir exists")
|
||||||
|
.next()
|
||||||
|
.expect("exactly one campaign document")
|
||||||
|
.expect("readable dir entry")
|
||||||
|
.path();
|
||||||
|
let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
|
||||||
|
assert!(
|
||||||
|
campaign_doc_json.contains("\"name\":\"mc\""),
|
||||||
|
"the generated campaign document carries the constant mc name: {campaign_doc_json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
campaign_doc_json.contains("\"risk\":[")
|
||||||
|
&& campaign_doc_json.contains("\"length\":14")
|
||||||
|
&& campaign_doc_json.contains("\"k\":2.0"),
|
||||||
|
"the stop rides a non-empty risk regime: {campaign_doc_json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["runs", "families"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn families");
|
||||||
|
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||||||
|
assert_eq!(
|
||||||
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||||||
|
1,
|
||||||
|
"one Sweep family from the pipeline's leading argmax stage: {fams_out}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
||||||
|
1,
|
||||||
|
"one WalkForward family holding the per-window OOS reports: {fams_out}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"MonteCarlo\"")).count(),
|
||||||
|
0,
|
||||||
|
"the terminal monte_carlo stage is an annotator, not a family: {fams_out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fork A: the stop is a single risk regime on the dissolved mc path — a multi-value
|
||||||
|
/// `--stop-length`/`--stop-k` is a usage refusal (exit 2), not a swept axis. Mirrors
|
||||||
|
/// `walkforward_dissolved_refuses_a_multi_value_stop`. NOT gated — the refusal is pure
|
||||||
|
/// argument parsing, before any data access.
|
||||||
|
#[test]
|
||||||
|
fn mc_dissolved_refuses_a_multi_value_stop() {
|
||||||
|
let cwd = temp_cwd("mc-multi-stop");
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args([
|
||||||
|
"mc", "--strategy", "r-sma", "--real", "GER40",
|
||||||
|
"--fast", "3", "--slow", "12", "--stop-length", "14,20", "--stop-k", "2.0",
|
||||||
|
])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura");
|
||||||
|
assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: the one load-bearing decision new to the mc dissolution — `translate_mc`
|
||||||
|
/// maps `campaign.seed` to the mc `--seed` (unlike `translate_walkforward`, which
|
||||||
|
/// hardcodes `seed: 0`) — actually flows through the executor to the emitted grade,
|
||||||
|
/// not just into the generated document's content id (already unit-pinned by
|
||||||
|
/// `translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids`). Two runs that
|
||||||
|
/// differ ONLY in `--seed` must emit a different `e_r` distribution (the bootstrap
|
||||||
|
/// resample draw is seeded) while pooling the IDENTICAL `n_trades` (the wf winners are
|
||||||
|
/// argmax-selected, hence deflation-seed-independent — the walkforward anchor already
|
||||||
|
/// proved this for the shared roller): the seed perturbs the resample, never the
|
||||||
|
/// underlying OOS trade-R series. Without this test, a dropped seed remap (e.g. mc
|
||||||
|
/// always bootstrapping at a fixed internal seed) would still pass every other mc
|
||||||
|
/// dissolution test, since none of them compare two distinct `--seed` runs. Gated on
|
||||||
|
/// the GER40 2025 archive; skips cleanly on a data refusal.
|
||||||
|
#[test]
|
||||||
|
fn mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series() {
|
||||||
|
const FROM_MS: &str = "1735689600000";
|
||||||
|
const TO_MS: &str = "1767225599000";
|
||||||
|
let run = |seed: &str| {
|
||||||
|
let cwd = temp_cwd(&format!("mc-seed-{seed}"));
|
||||||
|
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args([
|
||||||
|
"mc", "--strategy", "r-sma", "--real", "GER40",
|
||||||
|
"--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0",
|
||||||
|
"--block-len", "5", "--resamples", "1000", "--seed", seed,
|
||||||
|
"--from", FROM_MS, "--to", TO_MS,
|
||||||
|
])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura")
|
||||||
|
};
|
||||||
|
let out42 = run("42");
|
||||||
|
if out42.status.code() == Some(1) {
|
||||||
|
let stderr = String::from_utf8_lossy(&out42.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;
|
||||||
|
}
|
||||||
|
let out7 = run("7");
|
||||||
|
assert_eq!(out42.status.code(), Some(0), "exit (seed 42): {:?}", out42.status);
|
||||||
|
assert_eq!(out7.status.code(), Some(0), "exit (seed 7): {:?}", out7.status);
|
||||||
|
|
||||||
|
let parse = |out: &std::process::Output| -> serde_json::Value {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||||
|
let line = stdout
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("{\"mc_r_bootstrap\":"))
|
||||||
|
.unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}"))
|
||||||
|
.to_string();
|
||||||
|
serde_json::from_str(&line).expect("grade line parses as JSON")
|
||||||
|
};
|
||||||
|
let v42 = parse(&out42);
|
||||||
|
let v7 = parse(&out7);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v42["mc_r_bootstrap"]["n_trades"], v7["mc_r_bootstrap"]["n_trades"],
|
||||||
|
"the pooled OOS trade-R series is seed-independent (argmax winners): {v42} vs {v7}"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
v42["mc_r_bootstrap"]["e_r"]["mean"], v7["mc_r_bootstrap"]["e_r"]["mean"],
|
||||||
|
"a different --seed must move the resampled E[R] mean: {v42} vs {v7}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user