feat(research,cli): the CostSpec campaign vocabulary (#234 task 2)
CampaignDoc gains an additive cost: Vec<CostSpec> block — a closed, externally tagged vocabulary over the three shipped cost nodes (constant/vol_slippage/carry, field names = the builders' ParamSpec names), mirroring the risk block: serde default + skip-if-empty (cost- less documents keep their content ids, pinned), DocFault::BadCost validation (finite/bounds per component), std::cost construct- vocabulary block + SlotKind::Costs + open-slot mirror, prose arm, and all CampaignDoc struct literals across crates carry cost: vec![] (compile-driven). Verified: aura-research suite green, campaign validate e2e green, full workspace suite green, clippy -D warnings clean; in-loop spec review compliant and quality review approved (real diff fingerprint — the worktree-anchored review prompt works). refs #234
This commit is contained in:
@@ -478,6 +478,7 @@ mod tests {
|
|||||||
bindings: BTreeMap::new(),
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
|
cost: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId("0".repeat(64)),
|
r#ref: DocRef::ContentId("0".repeat(64)),
|
||||||
axes: BTreeMap::from([(
|
axes: BTreeMap::from([(
|
||||||
@@ -905,6 +906,7 @@ mod wf_tests {
|
|||||||
bindings: BTreeMap::new(),
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
|
cost: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId("c".repeat(64)),
|
r#ref: DocRef::ContentId("c".repeat(64)),
|
||||||
axes: BTreeMap::from([(
|
axes: BTreeMap::from([(
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ fn campaign(instruments: &[&str]) -> CampaignDoc {
|
|||||||
bindings: BTreeMap::new(),
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
|
cost: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId(STRATEGY_ID.to_string()),
|
r#ref: DocRef::ContentId(STRATEGY_ID.to_string()),
|
||||||
axes: axes_2x2(),
|
axes: axes_2x2(),
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
|||||||
DocFault::BadRegime { index } => {
|
DocFault::BadRegime { index } => {
|
||||||
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
|
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
|
||||||
}
|
}
|
||||||
|
DocFault::BadCost { index } => {
|
||||||
|
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
||||||
|
}
|
||||||
DocFault::NoStrategy => "strategies is empty".into(),
|
DocFault::NoStrategy => "strategies is empty".into(),
|
||||||
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
||||||
DocFault::EmptyAxis { strategy, axis } => {
|
DocFault::EmptyAxis { strategy, axis } => {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result<GeneratedSweep, S
|
|||||||
// established parity). The other verbs bind the single Vol regime
|
// established parity). The other verbs bind the single Vol regime
|
||||||
// through this same seam.
|
// through this same seam.
|
||||||
risk: risk_from(inv.stop),
|
risk: risk_from(inv.stop),
|
||||||
|
cost: vec![],
|
||||||
// No stage of a selection-free single-sweep pipeline consumes the
|
// No stage of a selection-free single-sweep pipeline consumes the
|
||||||
// seed; a fixed zero keeps generated bytes deterministic so identical
|
// seed; a fixed zero keeps generated bytes deterministic so identical
|
||||||
// invocations dedupe onto identical content ids.
|
// invocations dedupe onto identical content ids.
|
||||||
@@ -241,6 +242,7 @@ pub(crate) fn translate_generalize(
|
|||||||
// The stop is a single structural risk regime; the member runner maps
|
// The stop is a single structural risk regime; the member runner maps
|
||||||
// it back to StopRule::Vol at run time.
|
// it back to StopRule::Vol at run time.
|
||||||
risk: risk_from(inv.stop),
|
risk: risk_from(inv.stop),
|
||||||
|
cost: vec![],
|
||||||
seed: 0,
|
seed: 0,
|
||||||
presentation: Presentation {
|
presentation: Presentation {
|
||||||
persist_taps: vec![],
|
persist_taps: vec![],
|
||||||
@@ -441,6 +443,7 @@ pub(crate) fn translate_walkforward(
|
|||||||
}],
|
}],
|
||||||
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
|
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
|
||||||
risk: risk_from(inv.stop),
|
risk: risk_from(inv.stop),
|
||||||
|
cost: vec![],
|
||||||
seed: 0,
|
seed: 0,
|
||||||
presentation: Presentation {
|
presentation: Presentation {
|
||||||
persist_taps: persist_taps_from(inv.trace),
|
persist_taps: persist_taps_from(inv.trace),
|
||||||
@@ -596,6 +599,7 @@ pub(crate) fn translate_mc(
|
|||||||
}],
|
}],
|
||||||
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
|
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
|
||||||
risk: risk_from(inv.stop),
|
risk: risk_from(inv.stop),
|
||||||
|
cost: vec![],
|
||||||
seed: mc.seed,
|
seed: mc.seed,
|
||||||
presentation: Presentation {
|
presentation: Presentation {
|
||||||
persist_taps: vec![],
|
persist_taps: vec![],
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ fn campaign_introspect_vocabulary_lists_sections() {
|
|||||||
// #216: std::risk joins the roster the CLI's own --vocabulary listing
|
// #216: std::risk joins the roster the CLI's own --vocabulary listing
|
||||||
// enumerates — the regime axis is discoverable from the public surface,
|
// enumerates — the regime axis is discoverable from the public surface,
|
||||||
// not only from the design ledger or a generated document.
|
// not only from the design ledger or a generated document.
|
||||||
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk"] {
|
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk", "std::cost"] {
|
||||||
assert!(out.contains(id), "vocabulary misses {id}: {out}");
|
assert!(out.contains(id), "vocabulary misses {id}: {out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,6 +625,23 @@ fn campaign_introspect_block_risk_describes_the_regime_shape() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #234: `--block std::cost` names the component shape and its
|
||||||
|
/// absent-means-zero-costs semantics — the cost model is discoverable from
|
||||||
|
/// the public introspection surface (the `std::risk` #216 precedent).
|
||||||
|
#[test]
|
||||||
|
fn campaign_introspect_block_cost_describes_the_component_shape() {
|
||||||
|
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::cost"]);
|
||||||
|
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"),
|
||||||
|
"the cost slot must render as optional with the component shape: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("std::cost — campaign section: the cost model"),
|
||||||
|
"stdout/stderr: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// #216: a complete-but-risk-less campaign document (the exact shape a
|
/// #216: a complete-but-risk-less campaign document (the exact shape a
|
||||||
/// verb-generated document has before a `--stop-*` flag is added) has its one
|
/// verb-generated document has before a `--stop-*` flag is added) has its one
|
||||||
/// remaining open slot — the optional risk axis — enumerated by the public
|
/// remaining open slot — the optional risk axis — enumerated by the public
|
||||||
@@ -660,22 +677,23 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #216: a bound (non-empty) risk list closes the slot — a complete document
|
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
|
||||||
/// with risk reports no open slots at all.
|
/// slots — a complete document with both reports no open slots at all.
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_introspect_unwired_omits_a_bound_risk_slot() {
|
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
|
||||||
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
||||||
let with_risk = CAMPAIGN_DOC.replacen(
|
let with_both = CAMPAIGN_DOC.replacen(
|
||||||
"\"seed\": 1,",
|
"\"seed\": 1,",
|
||||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
|
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "bound.campaign.json", &with_risk);
|
write_doc(&dir, "bound.campaign.json", &with_both);
|
||||||
let (out, code) =
|
let (out, code) =
|
||||||
run_code_in(&dir, &["campaign", "introspect", "--unwired", "bound.campaign.json"]);
|
run_code_in(&dir, &["campaign", "introspect", "--unwired", "bound.campaign.json"]);
|
||||||
assert_eq!(code, Some(0));
|
assert_eq!(code, Some(0));
|
||||||
assert!(!out.contains("open slot: risk"), "bound risk must not list: {out}");
|
assert!(!out.contains("open slot: risk"), "bound risk must not list: {out}");
|
||||||
|
assert!(!out.contains("open slot: cost"), "bound cost must not list: {out}");
|
||||||
assert!(out.contains("no open slots"), "stdout/stderr: {out}");
|
assert!(out.contains("no open slots"), "stdout/stderr: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,6 +983,33 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #234: the register validate-gate extends to the cost model — a document
|
||||||
|
/// whose only fault is a negative cost knob is refused with prose (exit 1)
|
||||||
|
/// and no file is ever written under `runs/campaigns/` (the risk-section
|
||||||
|
/// precedent).
|
||||||
|
#[test]
|
||||||
|
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
|
||||||
|
let dir = temp_cwd("campaign-register-invalid-cost");
|
||||||
|
let bad = CAMPAIGN_DOC.replacen(
|
||||||
|
"\"seed\": 1,",
|
||||||
|
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_ne!(bad, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||||||
|
write_doc(&dir, "bad-cost.campaign.json", &bad);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad-cost.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
|
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("cost[0]: the component's price-unit knob must be finite and >= 0"),
|
||||||
|
"stdout/stderr: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dir.join("runs").join("campaigns").exists(),
|
||||||
|
"register must not create a store entry for an invalid cost section"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Seed one open-param blueprint into the built demo project's store via a
|
/// Seed one open-param blueprint into the built demo project's store via a
|
||||||
/// real sweep and return its content id (the referential test's recipe).
|
/// real sweep and return its content id (the referential test's recipe).
|
||||||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||||
|
|||||||
+175
-12
@@ -159,6 +159,7 @@ pub enum SlotKind {
|
|||||||
/// The closed tap vocabulary (`tap_vocabulary()` entries).
|
/// The closed tap vocabulary (`tap_vocabulary()` entries).
|
||||||
TapKinds,
|
TapKinds,
|
||||||
Regimes,
|
Regimes,
|
||||||
|
Costs,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One typed slot of a block.
|
/// One typed slot of a block.
|
||||||
@@ -436,6 +437,12 @@ pub struct CampaignDoc {
|
|||||||
/// parity for every risk-less document (the `description` precedent).
|
/// parity for every risk-less document (the `description` precedent).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub risk: Vec<RiskRegime>,
|
pub risk: Vec<RiskRegime>,
|
||||||
|
/// The campaign's cost model (#234): additive components (C10) every
|
||||||
|
/// member runs under. Absent or empty = zero costs (`summarize_r`'s
|
||||||
|
/// empty-slice baseline: net == gross). Absent-serializes → content-id
|
||||||
|
/// parity for every cost-less document (the `risk` precedent above).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub cost: Vec<CostSpec>,
|
||||||
pub strategies: Vec<StrategyEntry>,
|
pub strategies: Vec<StrategyEntry>,
|
||||||
pub process: ProcessRef,
|
pub process: ProcessRef,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
@@ -496,6 +503,20 @@ pub enum RiskRegime {
|
|||||||
Vol { length: i64, k: f64 },
|
Vol { length: i64, k: f64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One component of the campaign's cost model (#234): a closed, additive
|
||||||
|
/// vocabulary over the shipped C10 cost nodes — components sum (`CostSum`).
|
||||||
|
/// Serde field names are the builders' own `ParamSpec` names
|
||||||
|
/// (`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)]
|
||||||
|
#[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 },
|
||||||
|
}
|
||||||
|
|
||||||
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
|
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
|
||||||
/// serde as the oracle — no Scalar internals assumed).
|
/// serde as the oracle — no Scalar internals assumed).
|
||||||
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
|
fn scalar_to_bare(s: &Scalar) -> serde_json::Value {
|
||||||
@@ -702,6 +723,7 @@ pub enum DocFault {
|
|||||||
NoWindow,
|
NoWindow,
|
||||||
BadWindow { index: usize },
|
BadWindow { index: usize },
|
||||||
BadRegime { index: usize },
|
BadRegime { index: usize },
|
||||||
|
BadCost { index: usize },
|
||||||
NoStrategy,
|
NoStrategy,
|
||||||
EmptyAxes { strategy: usize },
|
EmptyAxes { strategy: usize },
|
||||||
EmptyAxis { strategy: usize, axis: String },
|
EmptyAxis { strategy: usize, axis: String },
|
||||||
@@ -812,6 +834,20 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
|||||||
faults.push(DocFault::BadRegime { index: i });
|
faults.push(DocFault::BadRegime { index: i });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (i, c) in doc.cost.iter().enumerate() {
|
||||||
|
// Every shipped cost node asserts a finite, non-negative price-unit
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
if !v.is_finite() || v < 0.0 {
|
||||||
|
faults.push(DocFault::BadCost { index: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
if doc.strategies.is_empty() {
|
if doc.strategies.is_empty() {
|
||||||
faults.push(DocFault::NoStrategy);
|
faults.push(DocFault::NoStrategy);
|
||||||
}
|
}
|
||||||
@@ -863,6 +899,11 @@ pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[
|
|||||||
doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)",
|
doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)",
|
||||||
slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }],
|
slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }],
|
||||||
},
|
},
|
||||||
|
BlockSchema {
|
||||||
|
id: "std::cost",
|
||||||
|
doc: "campaign section: the cost model — additive components (C10) every member runs under (absent = zero costs; net == gross)",
|
||||||
|
slots: &[SlotInfo { name: "cost", kind: SlotKind::Costs, required: false }],
|
||||||
|
},
|
||||||
BlockSchema {
|
BlockSchema {
|
||||||
id: "std::strategy",
|
id: "std::strategy",
|
||||||
doc: "campaign section: strategy ref (content or identity id) + param axes",
|
doc: "campaign section: strategy ref (content or identity id) + param axes",
|
||||||
@@ -906,6 +947,9 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
|
|||||||
SlotKind::Regimes => {
|
SlotKind::Regimes => {
|
||||||
"list of stop regimes { vol: { length, k } }; absent = one default regime"
|
"list of stop regimes { vol: { length, k } }; absent = one default regime"
|
||||||
}
|
}
|
||||||
|
SlotKind::Costs => {
|
||||||
|
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,6 +1069,15 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
|||||||
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
|
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The optional cost slot, mirroring the risk axis (#216's discoverability
|
||||||
|
// lesson applied to #234): absent, empty, or non-array = open.
|
||||||
|
let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
|
||||||
|
if !cost_bound {
|
||||||
|
slots.push(open(
|
||||||
|
"cost",
|
||||||
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
));
|
||||||
|
}
|
||||||
match v.get("strategies").and_then(|s| s.as_array()) {
|
match v.get("strategies").and_then(|s| s.as_array()) {
|
||||||
None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
|
None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")),
|
||||||
Some(list) if list.is_empty() => {
|
Some(list) if list.is_empty() => {
|
||||||
@@ -1392,6 +1445,98 @@ mod tests {
|
|||||||
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
|
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_spec_round_trips_as_externally_tagged_components() {
|
||||||
|
// Serde field names ARE the shipped builders' ParamSpec names
|
||||||
|
// (cost_per_trade / slip_vol_mult / carry_per_cycle) — the doc
|
||||||
|
// vocabulary conforms to the node vocabulary, not the other way round.
|
||||||
|
for (spec, wire) in [
|
||||||
|
(
|
||||||
|
CostSpec::Constant { cost_per_trade: 2.0 },
|
||||||
|
r#"{"constant":{"cost_per_trade":2.0}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CostSpec::VolSlippage { slip_vol_mult: 0.5 },
|
||||||
|
r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CostSpec::Carry { carry_per_cycle: 0.1 },
|
||||||
|
r#"{"carry":{"carry_per_cycle":0.1}}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(serde_json::to_string(&spec).unwrap(), wire);
|
||||||
|
let back: CostSpec = serde_json::from_str(wire).unwrap();
|
||||||
|
assert_eq!(back, spec);
|
||||||
|
}
|
||||||
|
// deny_unknown_fields: a misspelled knob refuses to parse
|
||||||
|
assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
|
||||||
|
// The risk precedent's sibling: cost-less documents keep their content ids.
|
||||||
|
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
|
assert!(doc.cost.is_empty(), "an absent cost key deserializes to empty");
|
||||||
|
let absent = campaign_to_json(&doc);
|
||||||
|
assert!(!absent.contains("\"cost\""), "absent cost omits from serialization: {absent}");
|
||||||
|
let mut empty = doc.clone();
|
||||||
|
empty.cost = vec![];
|
||||||
|
assert_eq!(
|
||||||
|
content_id_of(&campaign_to_json(&empty)),
|
||||||
|
content_id_of(&absent),
|
||||||
|
"an explicit empty cost hashes identically to an absent one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
let out = campaign_to_json(&doc);
|
||||||
|
assert!(
|
||||||
|
out.contains(r#""cost":[{"constant":{"cost_per_trade":2.0}},{"vol_slippage":{"slip_vol_mult":0.5}}]"#),
|
||||||
|
"cost serializes in place as a tagged list: {out}"
|
||||||
|
);
|
||||||
|
let back: CampaignDoc = serde_json::from_str(&out).unwrap();
|
||||||
|
assert_eq!(back.cost, doc.cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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)
|
||||||
|
];
|
||||||
|
let faults = validate_campaign(&doc);
|
||||||
|
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
|
||||||
|
assert!(faults.contains(&DocFault::BadCost { index: 2 }), "{faults:?}");
|
||||||
|
assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {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 }];
|
||||||
|
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_block_is_describable_beside_risk() {
|
||||||
|
let block = describe_block("std::cost").expect("std::cost describable");
|
||||||
|
assert_eq!(block.slots.len(), 1);
|
||||||
|
assert_eq!(block.slots[0].name, "cost");
|
||||||
|
assert!(!block.slots[0].required);
|
||||||
|
assert_eq!(
|
||||||
|
slot_kind_label(block.slots[0].kind),
|
||||||
|
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The intrinsic tier does NOT constrain where a population-transforming
|
/// The intrinsic tier does NOT constrain where a population-transforming
|
||||||
/// stage sits relative to the annotators (terminal_seen gates only Gate
|
/// stage sits relative to the annotators (terminal_seen gates only Gate
|
||||||
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
|
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
|
||||||
@@ -1782,14 +1927,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn open_slots_report_partial_documents_by_path() {
|
fn open_slots_report_partial_documents_by_path() {
|
||||||
assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new());
|
assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new());
|
||||||
// #216: a complete risk-less campaign has exactly ONE open slot — the
|
// #216/#234: a complete but risk- and cost-less campaign has exactly
|
||||||
// optional risk axis (the probe's one optional line).
|
// TWO open slots — the two optional axes, in section order.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
|
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
|
||||||
vec![open(
|
vec![
|
||||||
"risk",
|
open(
|
||||||
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
|
"risk",
|
||||||
)]
|
"optional, list of stop regimes { vol: { length, k } }; absent = one default regime",
|
||||||
|
),
|
||||||
|
open(
|
||||||
|
"cost",
|
||||||
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
let partial = r#"{ "format_version": 1, "kind": "process",
|
let partial = r#"{ "format_version": 1, "kind": "process",
|
||||||
@@ -1848,19 +1999,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #216: the optional risk slot is listed only while unbound — a document
|
/// #216/#234: each optional slot is listed only while unbound — binding
|
||||||
/// with a non-empty risk list closes it (a complete bound document has no
|
/// risk leaves exactly the cost slot open; binding both closes the probe
|
||||||
/// open slots at all; the risk-less complement is pinned in
|
/// (a complete bound document has no open slots at all).
|
||||||
/// `open_slots_report_partial_documents_by_path`).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_slots_omit_a_bound_risk_slot() {
|
fn open_slots_omit_bound_risk_and_cost_slots() {
|
||||||
let with_risk = CAMPAIGN_FIXTURE.replacen(
|
let with_risk = CAMPAIGN_FIXTURE.replacen(
|
||||||
"\"seed\": 1,",
|
"\"seed\": 1,",
|
||||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
|
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
|
assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
|
||||||
assert_eq!(open_slots_campaign(&with_risk).unwrap(), Vec::new());
|
assert_eq!(
|
||||||
|
open_slots_campaign(&with_risk).unwrap(),
|
||||||
|
vec![open(
|
||||||
|
"cost",
|
||||||
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
let with_both = CAMPAIGN_FIXTURE.replacen(
|
||||||
|
"\"seed\": 1,",
|
||||||
|
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_ne!(with_both, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
|
||||||
|
assert_eq!(open_slots_campaign(&with_both).unwrap(), Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user