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:
2026-07-14 01:20:59 +02:00
parent ca4a89864c
commit e00e264252
5 changed files with 456 additions and 50 deletions
+206 -18
View File
@@ -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(),
],
)]
);