feat(research,cli): per-instrument cost factors in one campaign document
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].<knob> 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
This commit is contained in:
@@ -69,11 +69,11 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> 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<PrimitiveBuilder> {
|
||||
pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
|
||||
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<PrimitiveBuilder> {
|
||||
/// (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<String> = 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]
|
||||
|
||||
+50
-17
@@ -1175,9 +1175,15 @@ fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec<aura_research::Cos
|
||||
break;
|
||||
};
|
||||
specs.push(match knob {
|
||||
"cost_per_trade" => 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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].<knob> 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
|
||||
|
||||
+206
-18
@@ -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<String, f64>),
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
CostValue::Scalar(v) => v.serialize(s),
|
||||
CostValue::PerInstrument(m) => m.serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CostValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::Error as _;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Raw {
|
||||
Scalar(f64),
|
||||
PerInstrument(std::collections::BTreeMap<String, f64>),
|
||||
}
|
||||
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<String>, extra: Vec<String> },
|
||||
NoStrategy,
|
||||
EmptyAxes { strategy: usize },
|
||||
EmptyAxis { strategy: usize, axis: String },
|
||||
@@ -839,14 +905,35 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
||||
// 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<String> = doc
|
||||
.data
|
||||
.instruments
|
||||
.iter()
|
||||
.filter(|inst| !keys.contains(&inst.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let extra: Vec<String> = 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<Vec<OpenSlot>, 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::<CostSpec>(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::<CostSpec>(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(),
|
||||
],
|
||||
)]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user