From e00e2642521d3f1e615ea2074a81c1c0c7e577a9 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 14 Jul 2026 01:20:59 +0200 Subject: [PATCH] feat(research,cli): per-instrument cost factors in one campaign document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each cost component's knob (cost_per_trade / slip_vol_mult / carry_per_cycle) now accepts one number for every cell — today's form, byte-identical on the wire so stored documents keep their content ids (CostValue's custom serde, the Axis precedent) — or an instrument-keyed map resolved per cell at member construction. A mixed-scale matrix (GER40 ~2e4, EURUSD ~1.1) keeps consistent cost units in ONE document: the N-copies workaround retires and generalize-under-constant-costs becomes expressible again. Validation is strict both ways at the intrinsic tier: a map missing a campaign instrument, or naming one the campaign does not list, refuses at validate with prose naming the component and instruments (no silent default — a partial map would reproduce exactly the silent unit inconsistency the issue reports). The instrument threads through cost_knob/cost_nodes_for/run_blueprint_member from the cell (run side and persist re-run side receive the same value — the C1 drift-alarm lockstep); the manifest stamps the resolved per-cell value under the unchanged cost[k]. key, and the sugar/reproduce paths pass an inert instrument (their specs are scalar by construction). Fork decisions and the discarded first attempt: issue #260 comments. New coverage: serde round-trips (map form; scalar stays bare), validate cross-check units, both prose directions pinned, and two hostless e2es over the synthetic SYMA+SYMB archive — per-cell resolution visible in the member manifests, and the validate refusal. closes #260 --- crates/aura-cli/src/campaign_run.rs | 47 ++++-- crates/aura-cli/src/main.rs | 67 ++++++-- crates/aura-cli/src/research_docs.rs | 29 ++++ crates/aura-cli/tests/research_docs.rs | 139 +++++++++++++++ crates/aura-research/src/lib.rs | 224 +++++++++++++++++++++++-- 5 files changed, 456 insertions(+), 50 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index c50942c..a1aebc8 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -69,11 +69,11 @@ fn stop_rule_for_regime(regime: Option) -> aura_compo /// cannot diverge from the run-side cost model (the #219 divergence class, /// cost edition). The bound knob names are the builders' own `ParamSpec` names /// — the `CostSpec` serde vocabulary conforms to them. -pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec { +pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec { specs .iter() .map(|s| { - let (knob, v) = cost_knob(s); + let (knob, v) = cost_knob(s, instrument); match s { CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)), CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)), @@ -88,12 +88,19 @@ pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec { /// (main.rs): the stamp key must equal the bind key for reproduce to /// re-derive a `CostSpec` from a stored manifest, so both sites read the /// name off this single function rather than each carrying its own literal. -pub(crate) fn cost_knob(spec: &CostSpec) -> (&'static str, f64) { - match spec { - CostSpec::Constant { cost_per_trade } => ("cost_per_trade", *cost_per_trade), - CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", *slip_vol_mult), - CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", *carry_per_cycle), - } +pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) { + let (knob, value) = match spec { + CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), + CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult), + CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), + }; + let v = value.resolve(instrument).unwrap_or_else(|| { + // Unreachable after intrinsic validation (map keys ≡ instruments); + // refuse loudly rather than charging 0 silently if it ever surfaces. + eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); + std::process::exit(1); + }); + (knob, v) } /// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed @@ -397,6 +404,7 @@ impl MemberRunner for CliMemberRunner<'_> { stop, &binding, &self.cost, + &cell.instrument, ); report.manifest.instrument = Some(cell.instrument.clone()); Ok(report) @@ -975,7 +983,7 @@ pub(crate) fn persist_campaign_traces( let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel(); let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg { - nodes: cost_nodes_for(&campaign.cost), + nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), tx_cost, tx_net, }); @@ -1393,18 +1401,27 @@ mod tests { /// param, so the wrapped param_space stays cost-invariant (the probe/member /// space equivalence the campaign path relies on). fn cost_nodes_for_maps_each_component_to_its_bound_builder() { - let nodes = cost_nodes_for(&[ - aura_research::CostSpec::Constant { cost_per_trade: 2.0 }, - aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 }, - aura_research::CostSpec::Carry { carry_per_cycle: 0.1 }, - ]); + let nodes = cost_nodes_for( + &[ + aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(2.0), + }, + aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(0.5), + }, + aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(0.1), + }, + ], + "GER40", + ); let labels: Vec = nodes.iter().map(|n| n.label()).collect(); assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); assert!( nodes.iter().all(|n| n.params().is_empty()), "every component must be fully bound (no open param leaks into param_space)" ); - assert!(cost_nodes_for(&[]).is_empty(), "an empty model binds no nodes"); + assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes"); } #[test] diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e589e77..8344ab3 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1175,9 +1175,15 @@ fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec aura_research::CostSpec::Constant { cost_per_trade: v.as_f64() }, - "slip_vol_mult" => aura_research::CostSpec::VolSlippage { slip_vol_mult: v.as_f64() }, - "carry_per_cycle" => aura_research::CostSpec::Carry { carry_per_cycle: v.as_f64() }, + "cost_per_trade" => aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()), + }, + "slip_vol_mult" => aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()), + }, + "carry_per_cycle" => aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()), + }, other => { // Corrupted-on-disk manifest data — the reproduce path's clean // exit register (`aura:` + exit 1), like point_from_params. @@ -1347,6 +1353,10 @@ fn reproduce_family_in( stop, &binding, &cost, + // The re-run specs come from the stamp and are scalar by + // construction (`cost_specs_from_params`), so the instrument is + // inert; the fallback is never resolved against a map. + stored.manifest.instrument.as_deref().unwrap_or(""), ); outcomes.push((label, rerun.metrics == stored.metrics)); } @@ -1730,6 +1740,14 @@ fn run_signal_r( RunReport { manifest, metrics } } +/// #260: the r-sma sugar/MC paths below run with either no cost model (empty +/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params` +/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never +/// originate on these paths and the instrument context is genuinely inert. +/// Named once so every such call site states its intent by reference instead +/// of repeating the justifying comment. +const NO_INSTRUMENT_CONTEXT: &str = ""; + /// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path /// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the /// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a @@ -1750,6 +1768,7 @@ fn run_blueprint_member( stop: StopRule, binding: &binding::ResolvedBinding, cost: &[aura_research::CostSpec], + instrument: &str, ) -> RunReport { let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below let (tx_eq, rx_eq) = mpsc::channel(); @@ -1762,7 +1781,7 @@ fn run_blueprint_member( let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = (!cost.is_empty()).then(|| CostLeg { - nodes: campaign_run::cost_nodes_for(cost), + nodes: campaign_run::cost_nodes_for(cost, instrument), tx_cost, tx_net, }); @@ -1788,7 +1807,7 @@ fn run_blueprint_member( // reads this stamp back via `cost_specs_from_params` (the #233 stop-regime // pattern), so a costed family reproduces net, not gross. for (k, spec) in cost.iter().enumerate() { - let (knob, v) = campaign_run::cost_knob(spec); + let (knob, v) = campaign_run::cost_knob(spec, instrument); named.push((format!("cost[{k}].{knob}"), Scalar::f64(v))); } let mut manifest = sim_optimal_manifest(named, window, seed, pip); @@ -2055,7 +2074,7 @@ fn blueprint_sweep_family( .sweep(|point| { // fresh per-member graph (Composite is !Clone, reload per member) run through // the shared reduce-mode member path — the same fn reproduction re-runs. - run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[]) + run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) }) // render the sweep terminal's BindError to prose (#247), the fn's String error // contract — never the raw Debug struct. @@ -2101,7 +2120,7 @@ fn blueprint_sweep_over( binder.sweep_with_lattice(|point| { let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]) + run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT) }) } @@ -2127,7 +2146,7 @@ fn run_oos_blueprint( let pip = data.pip_size(); let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty out-of-sample window"); - let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT); (Vec::new(), report) } @@ -2307,7 +2326,7 @@ fn blueprint_mc_family( let family = monte_carlo(&base_point, &seeds, |seed, _base| { let sources = synthetic_walk_sources(seed); let window = window_of(&sources).expect("non-empty synthetic walk"); - run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[]) + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) }); // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's // metrics are bit-identical to the first, no seed reached a distinguishable realization — @@ -4828,10 +4847,13 @@ mod tests { stop, &binding, cost, + "GER40", ) }; let gross = run(&[]); - let netted = run(&[aura_research::CostSpec::Constant { cost_per_trade: CPT }]); + let netted = run(&[aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(CPT), + }]); // The trade geometry, independently: a non-reduce cost-less wrap over // the same realization, draining the dense R record whose ledger @@ -4918,7 +4940,9 @@ mod tests { let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; let window = data.full_window(&env); let pip = data.pip_size(); - let cost = [aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }]; + let cost = [aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(0.0005), + }]; // The recorded member: reduce-mode, cost-bound — `run_blueprint_member`, // the exact path `CliMemberRunner::run_member` calls. @@ -4935,6 +4959,7 @@ mod tests { stop, &binding, &cost, + "GER40", ); // The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` @@ -4948,7 +4973,7 @@ mod tests { let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = - Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost), tx_cost, tx_net }); + Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg) .compile_with_params(&[]) .expect("the persist-side wrap builds"); @@ -5010,10 +5035,10 @@ mod tests { let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let report_a = run_blueprint_member( - signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], + signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40", ); let report_b = run_blueprint_member( - r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], + r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40", ); // Guard: the run must have actually traded, else the invariant is vacuous. assert!( @@ -5676,6 +5701,7 @@ mod tests { non_default, &binding, &[], + "GER40", ); // persist exactly as the sweep/campaign paths do: store the blueprint, append the family. @@ -5725,9 +5751,15 @@ mod tests { let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let cost = vec![ - aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }, - aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 }, - aura_research::CostSpec::Carry { carry_per_cycle: 0.0001 }, + aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(0.0005), + }, + aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(0.5), + }, + aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(0.0001), + }, ]; let report = run_blueprint_member( reload(), @@ -5742,6 +5774,7 @@ mod tests { StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &cost, + "GER40", ); // Non-vacuity: the cost model must actually bite, else a DIVERGED // verdict could never be observed and this pin proves nothing. diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index d8e321e..12a7b8e 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -147,6 +147,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { DocFault::BadCost { index } => { format!("cost[{index}]: the component's price-unit knob must be finite and >= 0") } + DocFault::CostInstrumentKeys { index, missing, extra } => { + let mut parts = Vec::new(); + if !missing.is_empty() { + parts.push(format!("misses campaign instrument(s) {}", missing.join(", "))); + } + if !extra.is_empty() { + parts.push(format!("names no campaign instrument(s): {}", extra.join(", "))); + } + format!("cost[{index}]: instrument map {}", parts.join("; ")) + } DocFault::NoStrategy => "strategies is empty".into(), DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"), DocFault::EmptyAxis { strategy, axis } => { @@ -654,4 +664,23 @@ mod tests { ); assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}"); } + + /// #260: the instrument-map key-mismatch fault is path-addressed and names + /// both directions — the campaign instruments the map misses AND the map + /// keys naming no campaign instrument — in one line, no Debug leak. The + /// extra-keys branch is otherwise unpinned (the e2e covers only missing). + #[test] + fn cost_instrument_keys_prose_names_both_directions() { + let prose = doc_fault_prose(&DocFault::CostInstrumentKeys { + index: 1, + missing: vec!["EURUSD".into()], + extra: vec!["GER40.cash".into()], + }); + assert_eq!( + prose, + "cost[1]: instrument map misses campaign instrument(s) EURUSD; \ + names no campaign instrument(s): GER40.cash" + ); + assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}"); + } } diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index e75cbdd..54e3126 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -1109,6 +1109,37 @@ fn campaign_doc_json( campaign_doc_json_for("GER40", bp_id, proc_id, window, persist_taps, emit) } +/// [`campaign_doc_json_for`] over TWO instruments plus a verbatim cost block +/// (#260) — the per-instrument-cost e2es need both synthetic symbols in one +/// document. `cost` is spliced verbatim (a full `"cost": [...],` line +/// including the trailing comma, or `""` for none). +fn campaign_doc_json_two( + instruments: (&str, &str), + cost: &str, + bp_id: &str, + proc_id: &str, + window: (i64, i64), +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam", + "data": {{ "instruments": ["{a}", "{b}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }}, + "slow.length": {{ "kind": "I64", "values": [8] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + {cost}"seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + a = instruments.0, + b = instruments.1, + from = window.0, + to = window.1, + ) +} + /// An mc-bearing process: intrinsically valid AND (since the v2 executor) an /// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep /// survivor. The two addressing-mode tests below run it over the [1, 2] 1970 @@ -2221,6 +2252,114 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() { } } +/// Property (#260): an instrument-keyed cost knob resolves PER CELL — each +/// emitted member's manifest stamps the value for ITS instrument under the +/// unchanged cost[k]. key. Hostless over the synthetic SYMA+SYMB +/// archive (SYMB's 2024-03..06 span contains the window). +#[test] +fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() { + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("percost.process.json")), + ScratchPath::File(dir.join("percost.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-percost-seed"); + let proc_id = register_process_doc(&dir, "percost.process.json", SWEEP_ONLY_PROCESS_DOC); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + &bp_id, + &proc_id, + (1709251200000, 1717199999999), + ); + write_doc(&dir, "percost.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + + // Member emit lines carry report.manifest; group the stamped resolved + // value by the member's instrument stamp (the #220-era assertion idiom). + let mut seen = std::collections::BTreeMap::new(); + for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses"); + let inst = v["report"]["manifest"]["instrument"] + .as_str() + .expect("instrument stamped") + .to_string(); + let params = v["report"]["manifest"]["params"].as_array().expect("params array"); + let cost_param = params + .iter() + .find(|p| p[0].as_str() == Some("cost[0].cost_per_trade")) + .unwrap_or_else(|| panic!("cost stamp missing on {inst} member: {line}")); + let val = cost_param[1]["F64"].as_f64().expect("stamped as F64"); + seen.insert(inst, val); + } + assert_eq!( + seen, + std::collections::BTreeMap::from([ + ("SYMA".to_string(), 2.5), + ("SYMB".to_string(), 0.25), + ]), + "each cell must stamp ITS instrument's resolved cost: {out}" + ); +} + +/// Property (#260): map-key mismatches refuse at validate with prose naming +/// the component and instruments — never mid-run, never a Debug struct. +#[test] +fn campaign_validate_refuses_a_cost_map_missing_an_instrument() { + let dir = temp_cwd("campaign-validate-percost-missing"); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + "9f3a", + "9f3a", + (1709251200000, 1717199999999), + ); + write_doc(&dir, "bad.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]); + assert_eq!(code, Some(1), "a key-set mismatch is a validate fault: {out}"); + assert!( + out.contains("cost[0]") && out.contains("SYMB"), + "prose names the component and the missing instrument: {out}" + ); + assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}"); +} + +/// Property (#260): an instrument map naming a key that is NOT one of the +/// campaign's instruments (a typo'd symbol) is refused too — the sibling +/// defect to the missing-instrument case above, and a distinct prose branch +/// (`doc_fault_prose`'s `extra` arm) that was otherwise never exercised past +/// the raw `DocFault` value in the aura-research unit tests. +#[test] +fn campaign_validate_refuses_a_cost_map_with_an_unknown_instrument_key() { + let dir = temp_cwd("campaign-validate-percost-extra"); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25, \"SYMA.cash\": 1.0 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + "9f3a", + "9f3a", + (1709251200000, 1717199999999), + ); + write_doc(&dir, "bad_extra.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad_extra.campaign.json"]); + assert_eq!(code, Some(1), "an unmatched map key is a validate fault: {out}"); + assert!( + out.contains("cost[0]") && out.contains("SYMA.cash"), + "prose names the component and the unmatched key: {out}" + ); + assert!( + out.contains("names no campaign instrument"), + "prose uses the extra-key phrasing, not the missing-instrument one: {out}" + ); + assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}"); +} + /// The campaign `data.bindings` override end to end (#231, 6b): the same /// seeded strategy, window, and seed run twice — bare (price<-close default) /// and with `price` rebound to the open column. Both exit 0; the realized diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 8987d46..09d120b 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -509,12 +509,77 @@ pub enum RiskRegime { /// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc /// vocabulary conforms to the node vocabulary. Externally tagged so a future /// component is additive — no content-id churn on stored components. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum CostSpec { - Constant { cost_per_trade: f64 }, - VolSlippage { slip_vol_mult: f64 }, - Carry { carry_per_cycle: f64 }, + Constant { cost_per_trade: CostValue }, + VolSlippage { slip_vol_mult: CostValue }, + Carry { carry_per_cycle: CostValue }, +} + +/// One cost knob's value (#260): one number for every cell, or an +/// instrument-keyed map resolved per cell at member construction. Custom +/// serde keeps the scalar form byte-identical to the historical bare number +/// (content-id parity for stored docs — the `Axis` precedent); the map form +/// is a plain JSON object of numbers (BTreeMap: deterministic key order). +#[derive(Clone, Debug, PartialEq)] +pub enum CostValue { + Scalar(f64), + PerInstrument(std::collections::BTreeMap), +} + +impl CostValue { + /// The per-cell value: the scalar, or the instrument's map entry. `None` + /// only for an uncovered instrument — unreachable after intrinsic + /// validation; callers refuse with prose rather than defaulting. + pub fn resolve(&self, instrument: &str) -> Option { + match self { + CostValue::Scalar(v) => Some(*v), + CostValue::PerInstrument(m) => m.get(instrument).copied(), + } + } + /// Every numeric value the validator finiteness/sign-checks. + pub fn values(&self) -> Vec { + match self { + CostValue::Scalar(v) => vec![*v], + CostValue::PerInstrument(m) => m.values().copied().collect(), + } + } + /// The map's key set (empty for a scalar) — the validate cross-check input. + pub fn instrument_keys(&self) -> Vec<&str> { + match self { + CostValue::Scalar(_) => Vec::new(), + CostValue::PerInstrument(m) => m.keys().map(String::as_str).collect(), + } + } +} + +impl Serialize for CostValue { + fn serialize(&self, s: S) -> Result { + match self { + CostValue::Scalar(v) => v.serialize(s), + CostValue::PerInstrument(m) => m.serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for CostValue { + fn deserialize>(d: D) -> Result { + use serde::de::Error as _; + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Scalar(f64), + PerInstrument(std::collections::BTreeMap), + } + match Raw::deserialize(d)? { + Raw::Scalar(v) => Ok(CostValue::Scalar(v)), + Raw::PerInstrument(m) if m.is_empty() => { + Err(D::Error::custom("instrument map is empty")) + } + Raw::PerInstrument(m) => Ok(CostValue::PerInstrument(m)), + } + } } /// Bare wire value of one Scalar (strips the tagged form via Scalar's own @@ -724,6 +789,7 @@ pub enum DocFault { BadWindow { index: usize }, BadRegime { index: usize }, BadCost { index: usize }, + CostInstrumentKeys { index: usize, missing: Vec, extra: Vec }, NoStrategy, EmptyAxes { strategy: usize }, EmptyAxis { strategy: usize, axis: String }, @@ -839,14 +905,35 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { // knob (`>= 0`); the doc tier refuses the same bounds earlier, // path-addressed (the BadRegime precedent). NaN/inf are unreachable // via JSON but refused defensively. - let v = match c { - CostSpec::Constant { cost_per_trade } => *cost_per_trade, - CostSpec::VolSlippage { slip_vol_mult } => *slip_vol_mult, - CostSpec::Carry { carry_per_cycle } => *carry_per_cycle, + let value = match c { + CostSpec::Constant { cost_per_trade } => cost_per_trade, + CostSpec::VolSlippage { slip_vol_mult } => slip_vol_mult, + CostSpec::Carry { carry_per_cycle } => carry_per_cycle, }; - if !v.is_finite() || v < 0.0 { + if value.values().iter().any(|v| !v.is_finite() || *v < 0.0) { faults.push(DocFault::BadCost { index: i }); } + // #260: an instrument map must cover the campaign's instruments + // exactly — a missing instrument silently un-costs its cells, an + // extra key is almost always a symbol typo; both refuse up front. + let keys = value.instrument_keys(); + if !keys.is_empty() { + let missing: Vec = doc + .data + .instruments + .iter() + .filter(|inst| !keys.contains(&inst.as_str())) + .cloned() + .collect(); + let extra: Vec = keys + .iter() + .filter(|k| !doc.data.instruments.iter().any(|inst| inst == *k)) + .map(|k| k.to_string()) + .collect(); + if !missing.is_empty() || !extra.is_empty() { + faults.push(DocFault::CostInstrumentKeys { index: i, missing, extra }); + } + } } if doc.strategies.is_empty() { faults.push(DocFault::NoStrategy); @@ -1096,6 +1183,7 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), ], )); } @@ -1473,15 +1561,15 @@ mod tests { // vocabulary conforms to the node vocabulary, not the other way round. for (spec, wire) in [ ( - CostSpec::Constant { cost_per_trade: 2.0 }, + CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) }, r#"{"constant":{"cost_per_trade":2.0}}"#, ), ( - CostSpec::VolSlippage { slip_vol_mult: 0.5 }, + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) }, r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#, ), ( - CostSpec::Carry { carry_per_cycle: 0.1 }, + CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.1) }, r#"{"carry":{"carry_per_cycle":0.1}}"#, ), ] { @@ -1493,6 +1581,86 @@ mod tests { assert!(serde_json::from_str::(r#"{"constant":{"per_close_r":2.0}}"#).is_err()); } + /// Property (#260): a cost knob's map form round-trips as a plain JSON + /// object keyed by instrument, and the scalar form stays the bare number + /// (content-id parity for stored docs is pinned by the sibling scalar + /// round-trip test, which must stay green untouched). + #[test] + fn cost_value_map_form_round_trips_and_scalar_stays_bare() { + let json = r#"{"constant":{"cost_per_trade":{"EURUSD":0.00012,"GER40":2.0}}}"#; + let spec: CostSpec = serde_json::from_str(json).expect("map form parses"); + assert_eq!(serde_json::to_string(&spec).expect("serializes"), json); + let bare: CostSpec = serde_json::from_str(r#"{"constant":{"cost_per_trade":2.0}}"#) + .expect("scalar form parses"); + assert_eq!( + serde_json::to_string(&bare).expect("serializes"), + r#"{"constant":{"cost_per_trade":2.0}}"#, + "scalar wire form must stay the bare number byte-identically" + ); + let CostSpec::Constant { cost_per_trade } = &spec else { panic!("constant") }; + assert_eq!(cost_per_trade.resolve("GER40"), Some(2.0)); + assert_eq!(cost_per_trade.resolve("EURUSD"), Some(0.00012)); + assert_eq!(cost_per_trade.resolve("XAUUSD"), None); + } + + /// Property (#260): an empty instrument map (`{}`) is refused at + /// deserialize time — `validate_campaign`'s cross-check skips a knob + /// whose `instrument_keys()` is empty (that shape also means "scalar"), + /// so an empty map that slipped past this guard would silently escape + /// the instrument-coverage check entirely. + #[test] + fn cost_value_empty_map_is_refused_at_deserialize() { + let err = serde_json::from_str::(r#"{"constant":{"cost_per_trade":{}}}"#) + .expect_err("an empty instrument map must not parse"); + assert!( + err.to_string().contains("instrument map is empty"), + "error names the defect: {err}" + ); + } + + /// Property (#260): the intrinsic tier cross-checks every instrument map + /// against the doc's own data.instruments — a missing instrument and an + /// unknown extra key are each a fault naming the component; a complete + /// map is valid. No silent default, no partial coverage. + #[test] + fn validate_campaign_cross_checks_cost_instrument_maps() { + let mk = |cost: &str| { + let doc = format!( + r#"{{ "format_version": 1, "kind": "campaign", "name": "t", + "data": {{ "instruments": ["GER40", "EURUSD"], + "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "9f" }}, + "axes": {{ "a": {{ "kind": "I64", "values": [1] }} }} }} ], + "process": {{ "ref": {{ "content_id": "9f" }} }}, + "cost": [ {cost} ], + "presentation": {{ "persist_taps": [], "emit": [] }}, + "seed": 1 }}"# + ); + let parsed = parse_campaign(&doc).expect("parses"); + validate_campaign(&parsed) + }; + assert!( + mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 0.00012 } } }"#) + .is_empty(), + "a complete map must validate" + ); + let missing = mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0 } } }"#); + assert!( + missing.iter().any(|f| matches!( + f, + DocFault::CostInstrumentKeys { index: 0, .. } + )), + "fault names the component: {missing:?}" + ); + let extra = mk( + r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 1.0, "GER40.cash": 3.0 } } }"#, + ); + assert!( + extra.iter().any(|f| matches!(f, DocFault::CostInstrumentKeys { index: 0, .. })), + "fault names the component: {extra:?}" + ); + } + #[test] fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() { // The risk precedent's sibling: cost-less documents keep their content ids. @@ -1513,8 +1681,8 @@ mod tests { fn campaign_with_cost_serializes_and_round_trips_the_components() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); doc.cost = vec![ - CostSpec::Constant { cost_per_trade: 2.0 }, - CostSpec::VolSlippage { slip_vol_mult: 0.5 }, + CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) }, + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) }, ]; let out = campaign_to_json(&doc); assert!( @@ -1529,9 +1697,9 @@ mod tests { fn validate_campaign_flags_each_bad_cost_component() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); doc.cost = vec![ - CostSpec::Constant { cost_per_trade: 0.0 }, // 0: valid (zero cost is a legal stress point) - CostSpec::VolSlippage { slip_vol_mult: -0.5 }, // 1: negative - CostSpec::Carry { carry_per_cycle: f64::NAN }, // 2: non-finite (unreachable via JSON; defensive) + CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) }, // 0: valid (zero cost is a legal stress point) + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) }, // 1: negative + CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) }, // 2: non-finite (unreachable via JSON; defensive) ]; let faults = validate_campaign(&doc); assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}"); @@ -1539,10 +1707,28 @@ mod tests { assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}"); } + /// Property (#260): the finiteness/sign check applies to EVERY value + /// inside a `PerInstrument` map, not only the scalar form — a negative + /// entry nested in an otherwise fully-keyed map is refused exactly like + /// a bad scalar (quality-review gap: the map cross-check test above only + /// exercised valid map values, leaving this path unexercised). + #[test] + fn validate_campaign_flags_a_bad_value_inside_an_instrument_map() { + let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); + doc.cost = vec![CostSpec::Constant { + cost_per_trade: CostValue::PerInstrument(std::collections::BTreeMap::from([( + "GER40".to_string(), + -1.0, + )])), + }]; + let faults = validate_campaign(&doc); + assert!(faults.contains(&DocFault::BadCost { index: 0 }), "{faults:?}"); + } + #[test] fn validate_campaign_accepts_a_valid_cost_section() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); - doc.cost = vec![CostSpec::Carry { carry_per_cycle: 0.25 }]; + doc.cost = vec![CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.25) }]; assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc)); } @@ -1968,6 +2154,7 @@ mod tests { "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), ], ), ] @@ -2049,6 +2236,7 @@ mod tests { "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), ], )] );