From d1b3a3dd3196a091b043e3a9ab61f9f0cdddd3f5 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 14 Jul 2026 02:55:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(research,composites,cli,docs):=20vol=5Ftf?= =?UTF-8?q?=20=E2=80=94=20the=20timescale-matched=20stop=20regime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second risk-regime variant vol_tf{period_minutes, length, k} computes the vol estimator over completed time buckets: an H1 signal gets an H1-matched stop in the research matrix instead of a hand-scaled minute stop (the issue's k-inflation workaround retires). Additive externally-tagged serde (stored Vol documents keep their content ids); the executor arm wires the VolTfStop primitive via the builder+bind chain; validate checks period_minutes/length/k positivity per regime. The member-manifest round-trip holds for both variants (C1): vol_tf members stamp stop_period_minutes/stop_length/stop_k and the reproduce path re-derives the VolTf stop whenever stop_period_minutes is present — never a silent default-Vol fallback. The Regimes slot label and the unit notes name the new variant (period_minutes IS the stop's timescale); glossary, authoring guide, and design ledger record the second variant. Default regime and sugar paths byte-untouched. Coverage: node units (rollover-only emission, bucket-delta math, the constant-|delta| correspondence with the per-cycle regime), executor fold, serde/validate units, the manifest round-trip unit, and a hostless two-regime e2e (distinct ordinals; vol_tf members stamp their timescale, vol members do not). closes #262 --- crates/aura-cli/src/campaign_run.rs | 18 +++ crates/aura-cli/src/main.rs | 115 +++++++++++++++--- crates/aura-cli/tests/research_docs.rs | 111 ++++++++++++++++- crates/aura-composites/src/lib.rs | 9 +- crates/aura-composites/tests/risk_executor.rs | 40 ++++++ crates/aura-research/src/lib.rs | 57 +++++++-- docs/authoring-guide.md | 2 +- docs/design/INDEX.md | 5 +- docs/glossary.md | 7 +- 9 files changed, 329 insertions(+), 35 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index a1aebc8..7fbd2dd 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -59,6 +59,9 @@ fn stop_rule_for_regime(regime: Option) -> aura_compo Some(aura_research::RiskRegime::Vol { length, k }) => { aura_composites::StopRule::Vol { length, k } } + Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => { + aura_composites::StopRule::VolTf { period_minutes, length, k } + } } } @@ -1165,6 +1168,21 @@ mod tests { assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); } + #[test] + /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to + /// `StopRule::VolTf` field-for-field — the resolve-side half of the + /// write/resolve pair the manifest round-trip test (main.rs) covers from + /// the stamp side; together the two arms this regime touches + /// (`campaign_run.rs`'s bind and `main.rs`'s stamp) are both exercised. + fn stop_rule_for_regime_binds_vol_tf_field_for_field() { + let regime = + Some(aura_research::RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }); + assert_eq!( + stop_rule_for_regime(regime), + aura_composites::StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + } + #[test] /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse /// pair of one wrapper-segment convention — stripping the wrapper off a diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 8344ab3..4b982a7 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1141,17 +1141,25 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec StopRule { + let period_minutes = + params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64()); let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64()); - match (length, k) { - (Some(length), Some(k)) => StopRule::Vol { length, k }, + match (period_minutes, length, k) { + (Some(period_minutes), Some(length), Some(k)) => { + StopRule::VolTf { period_minutes, length, k } + } + (None, Some(length), Some(k)) => StopRule::Vol { length, k }, _ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, } } @@ -1790,12 +1798,20 @@ fn run_blueprint_member( .expect("member bootstraps (point kind-checked against param_space)"); h.run(sources); let mut named = zip_params(space, point); // by-name params for the manifest record - // `if let` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, - // which stamps no vol knobs. The campaign/single-run paths only pass `Vol`, - // so the `Fixed` arm is inert here. - if let StopRule::Vol { length, k } = stop { - named.push(("stop_length".to_string(), Scalar::i64(length))); - named.push(("stop_k".to_string(), Scalar::f64(k))); + // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, + // which stamps no vol knobs. The campaign/single-run paths only pass `Vol` + // or `VolTf`, so the `Fixed` arm is inert here. + match stop { + StopRule::Vol { length, k } => { + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::VolTf { period_minutes, length, k } => { + named.push(("stop_period_minutes".to_string(), Scalar::i64(period_minutes))); + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::Fixed(_) => {} } // Stamp the cost model the member ran under, beside the stop knobs (#234, // the #233 pattern): one `cost[k].` param per component, in @@ -6128,6 +6144,77 @@ mod tests { assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}"); } + /// #262 round-trip: a manifest carrying stop_period_minutes re-derives + /// the VolTf stop; one without it keeps the Vol path — a stored VolTf + /// member must never silently reproduce under a default Vol stop. + #[test] + fn stop_rule_from_params_round_trips_the_vol_tf_stamp() { + let vol_tf = vec![ + ("stop_period_minutes".to_string(), Scalar::i64(60)), + ("stop_length".to_string(), Scalar::i64(14)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!( + stop_rule_from_params(&vol_tf), + StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + let vol = vec![ + ("stop_length".to_string(), Scalar::i64(3)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); + } + + /// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually + /// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm, + /// end-to-end over a real synthetic realization, not a hand-built value) + /// and stamps all three knobs into the manifest under the keys + /// `stop_rule_from_params`'s round-trip above reads back — the write half + /// of the manifest round-trip pair. + #[test] + fn run_blueprint_member_stamps_the_vol_tf_stop_knobs() { + let env = project::Env::std(); + let data = DataSource::Synthetic; + let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let binding = binding::resolve_binding("voltfstamp", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::VolTf { period_minutes: 60, length: 3, k: 2.0 }; + let window = data.full_window(&env); + let pip = data.pip_size(); + let run = crate::run_blueprint_member( + reload(), + &[], + &space, + data.run_sources(&env, &binding.columns()), + window, + 0, + pip, + "topo", + &env, + stop, + &binding, + &[], + "GER40", + ); + assert!( + run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))), + "manifest must stamp stop_period_minutes: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_length".to_string(), Scalar::i64(3))), + "manifest must stamp stop_length: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_k".to_string(), Scalar::f64(2.0))), + "manifest must stamp stop_k: {:?}", + run.manifest.params + ); + } + /// A bare `McCmd` with every optional field defaulted to `None`/empty, so each /// `mc_args_from` refusal test below only sets the fields its scenario needs. fn bare_mc_cmd() -> McCmd { diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 54e3126..16ff497 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -493,6 +493,30 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() { assert!(!out.contains("BadRegime"), "Debug leak: {out}"); } +/// #262: the `vol_tf` regime variant is refused end-to-end by `campaign +/// validate` on a non-positive `period_minutes`, the same CLI seam #210's +/// `vol`-regime refusal test pins — before this, `RiskRegime::VolTf`'s +/// `period_minutes < 1` check was proven only inside `aura-research`'s own +/// unit tests (`validate_campaign_flags_non_positive_period_minutes_regime`); +/// this pins that a malformed vol_tf regime is actually unreachable at the +/// CLI seam a data author drives: exit 1, no bare `BadRegime` Debug leak. +#[test] +fn campaign_validate_refuses_bad_vol_tf_regime_prose_exit_1() { + let dir = temp_cwd("campaign-validate-vol-tf-bad"); + let with_bad_risk = CAMPAIGN_DOC.replacen( + "\"seed\": 1,", + "\"seed\": 1,\n \"risk\": [ { \"vol_tf\": { \"period_minutes\": 0, \"length\": 3, \"k\": 1.5 } } ],", + 1, + ); + assert_ne!(with_bad_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field"); + write_doc(&dir, "risk-bad-vol-tf.campaign.json", &with_bad_risk); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad-vol-tf.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("risk[0]:"), "stdout/stderr: {out}"); + assert!(!out.contains("BadRegime"), "Debug leak: {out}"); + assert!(!out.contains("VolTf"), "Debug leak: {out}"); +} + /// #216: the intrinsic validate summary reports the realized matrix — the /// exec cross-product strategies × instruments × windows × regimes — so an /// 8-cell document no longer reads as regime-less. Points (the axis grid) are @@ -558,11 +582,11 @@ fn campaign_introspect_block_risk_describes_the_regime_shape() { let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( - out.contains("list of stop regimes { vol: { length, k } }; absent = one default regime"), + out.contains("list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime"), "the block description must name the regime shape: {out}" ); assert!( - out.contains("optional, list of stop regimes { vol: { length, k } }; absent = one default regime"), + out.contains("optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime"), "the risk slot must render as optional: {out}" ); assert!( @@ -603,7 +627,7 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() { assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( out.contains( - "open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)" + "open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)" ), "stdout/stderr: {out}" ); @@ -618,7 +642,7 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() { let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]); assert_eq!(code, Some(0)); assert!( - out.contains("open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)"), + out.contains("open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)"), "stdout/stderr: {out}" ); } @@ -661,7 +685,7 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_explicitly_empty() { assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( out.contains( - "open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)" + "open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)" ), "an explicit empty risk array must still count as an open slot: {out}" ); @@ -718,6 +742,14 @@ fn campaign_introspect_unwired_annotates_cost_and_risk_knob_units() { "vol.k must be annotated as SCALING the stop distance (the risk-unit lever): {out}" ); + // vol_tf — period_minutes IS the timescale (the #262 knob the vol notes + // say vol.length is NOT). + assert!(low.contains("vol_tf"), "the vol_tf regime must be annotated: {out}"); + assert!( + low.contains("period_minutes is the stop's timescale"), + "vol_tf must state that period_minutes sets the timescale: {out}" + ); + // carry.carry_per_cycle — the identical price-unit-vs-R trap, accruing per // held cycle (audit follow-up to #265: the note set must cover ALL three // cost components, not two of three). @@ -2307,6 +2339,75 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() { ); } +/// Property (#262): a two-regime risk matrix realizes one cell per regime +/// with distinct ordinals, and each cell's members stamp THEIR regime's stop +/// knobs — the vol_tf cells carry stop_period_minutes (the round-trip pin), +/// the vol cells do not. Hostless over the synthetic SYMA archive. +#[test] +fn campaign_run_synthetic_e2e_vol_tf_regime_realizes_and_stamps() { + 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("voltf.process.json")), + ScratchPath::File(dir.join("voltf.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-voltf-seed"); + let proc_id = register_process_doc(&dir, "voltf.process.json", SWEEP_ONLY_PROCESS_DOC); + let base = campaign_doc_json_for( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1717199999999), + "", + "\"family_table\"", + ); + let with_risk = base.replacen( + "\"seed\": 7,", + "\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } }, { \"vol_tf\": { \"period_minutes\": 60, \"length\": 6, \"k\": 2.0 } } ],", + 1, + ); + assert_ne!(with_risk, base, "replacen must actually match the seed field"); + write_doc(&dir, "voltf.campaign.json", &with_risk); + let (out, code) = run_code_in(&dir, &["campaign", "run", "voltf.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("record parses"); + let cells = v["campaign_run"]["cells"].as_array().expect("cells"); + assert_eq!(cells.len(), 2, "one cell per regime: {record_line}"); + // regime_ordinal 0 (the first/default-slot regime) is skip_serialize'd + // (CellRealization::is_zero_ordinal, byte-stability for pre-#219 stored + // lines) — absent means 0, not a missing field. + let ordinals: Vec = + cells.iter().map(|c| c["regime_ordinal"].as_u64().unwrap_or(0)).collect(); + assert_eq!(ordinals, vec![0, 1], "distinct regime ordinals: {record_line}"); + + // Member emit lines: vol_tf members stamp stop_period_minutes, vol members do not. + let (mut tf_members, mut vol_members) = (0usize, 0usize); + for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) { + let m: serde_json::Value = serde_json::from_str(line).expect("member line parses"); + let params = m["report"]["manifest"]["params"].as_array().expect("params"); + let has_tf = params.iter().any(|p| p[0].as_str() == Some("stop_period_minutes")); + if has_tf { + let pm = params + .iter() + .find(|p| p[0].as_str() == Some("stop_period_minutes")) + .expect("present"); + assert_eq!(pm[1]["I64"].as_i64(), Some(60), "the stamped timescale: {line}"); + tf_members += 1; + } else { + vol_members += 1; + } + } + assert!(tf_members > 0, "vol_tf members must exist and stamp their timescale: {out}"); + assert!(vol_members > 0, "vol members must exist without the vol_tf stamp: {out}"); +} + /// Property (#260): map-key mismatches refuse at validate with prose naming /// the component and instruments — never mid-run, never a Debug struct. #[test] diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index d025fda..1e9e628 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -19,7 +19,7 @@ use aura_core::{PrimitiveBuilder, Scalar}; use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_std::{ cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul, - PositionManagement, Sizer, Sqrt, Sub, COST_FIELD_NAMES, GEOMETRY_WIDTH, + PositionManagement, Sizer, Sqrt, Sub, VolTfStop, COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES, }; @@ -75,6 +75,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite { pub enum StopRule { Fixed(f64), Vol { length: i64, k: f64 }, + VolTf { period_minutes: i64, length: i64, k: f64 }, } /// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal @@ -88,6 +89,12 @@ pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite { Box::new(move |g| match stop { StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), StopRule::Vol { length, k } => g.add(vol_stop(length, k)), + StopRule::VolTf { period_minutes, length, k } => g.add( + VolTfStop::builder() + .bind("period_minutes", Scalar::i64(period_minutes)) + .bind("length", Scalar::i64(length)) + .bind("k", Scalar::f64(k)), + ), }), risk_budget, ) diff --git a/crates/aura-composites/tests/risk_executor.rs b/crates/aura-composites/tests/risk_executor.rs index 7120ffe..f5647a2 100644 --- a/crates/aura-composites/tests/risk_executor.rs +++ b/crates/aura-composites/tests/risk_executor.rs @@ -180,6 +180,46 @@ fn risk_executor_vol_stop_arm_bootstraps_and_folds() { assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn); } +/// Property (#262): the RiskExecutor embeds the `VolTfStop` primitive (the new +/// `StopRule::VolTf` arm) and folds to a finite RMetric, proving the arm's +/// `price -> stop_distance` port/kind/firing wiring actually bootstraps and +/// runs — not merely compiles. Timestamps are real epoch-ns minute ticks +/// (`i * 60s` in ns) so `period_minutes: 1` rolls a bucket every cycle, +/// matching the node's own bucket-rollover contract; a short k·σ stop over a +/// rising-then-falling path opens a trade and closes at least one. +#[test] +fn risk_executor_vol_tf_stop_arm_bootstraps_and_folds() { + const MINUTE_NS: i64 = 60 * 1_000_000_000; + let (tx, rx) = channel(); + let mut g = GraphBuilder::new("vol_tf_harness"); + let price = g.source_role("price", ScalarKind::F64); + let strat = g.add(ConstLongBias::builder()); + let exec = g.add(risk_executor( + StopRule::VolTf { period_minutes: 1, length: 1, k: 2.0 }, + 1.0, + )); + let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx)); + g.feed(price, [strat.input("price"), exec.input("price")]); + g.connect(strat.output("bias"), exec.input("bias")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + let col: &'static str = format!("col[{i}]").leak(); + g.connect(exec.output(field), rec.input(col)); + } + let mut h = g + .build() + .expect("vol_tf_harness wires") + .bootstrap_with_params(vec![]) + .expect("bootstraps"); + let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0]; + let stream: Vec<(Timestamp, Scalar)> = + path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64 * MINUTE_NS), Scalar::f64(p))).collect(); + h.run(vec![Box::new(VecSource::new(stream))]); + let ledger: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + let m = summarize_r(&ledger, &[]); + assert!(m.n_trades >= 1, "the vol_tf-stop executor must fold at least one trade"); + assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn); +} + /// Drain one `risk_executor`-variant harness over the shared rising-then-falling path, /// folding the dense R-record. `exec` is the variant under test (bound or open); `params` /// is the positional point the open variant needs (empty for the bound one). Shared by the diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 09d120b..78d3e57 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -501,6 +501,7 @@ pub struct Axis { #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum RiskRegime { Vol { length: i64, k: f64 }, + VolTf { period_minutes: i64, length: i64, k: f64 }, } /// One component of the campaign's cost model (#234): a closed, additive @@ -892,12 +893,21 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { } } for (i, r) in doc.risk.iter().enumerate() { - let RiskRegime::Vol { length, k } = r; - // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches - // a non-positive or NaN multiplier (clippy-clean vs `!(k > 0.0)`; +inf - // is unreachable via JSON, so it need not be excluded). - if *length < 1 || *k <= 0.0 || k.is_nan() { - faults.push(DocFault::BadRegime { index: i }); + match r { + RiskRegime::Vol { length, k } => { + // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` + // catches a non-positive or NaN multiplier (clippy-clean vs + // `!(k > 0.0)`; +inf is unreachable via JSON, so it need not + // be excluded). + if *length < 1 || *k <= 0.0 || k.is_nan() { + faults.push(DocFault::BadRegime { index: i }); + } + } + RiskRegime::VolTf { period_minutes, length, k } => { + if *period_minutes < 1 || *length < 1 || *k <= 0.0 || k.is_nan() { + faults.push(DocFault::BadRegime { index: i }); + } + } } } for (i, c) in doc.cost.iter().enumerate() { @@ -1032,7 +1042,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str { SlotKind::EmitKinds => "list of: family_table | selection_report", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", SlotKind::Regimes => { - "list of stop regimes { vol: { length, k } }; absent = one default regime" + "list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, 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)" @@ -1165,10 +1175,11 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { if !risk_bound { slots.push(open_with_notes( "risk", - "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime", vec![ "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), "vol.k scales the stop distance (the risk-unit lever).".to_string(), + "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(), ], )); } @@ -1504,6 +1515,18 @@ mod tests { assert_eq!(back, r); } + /// #262: the second variant's wire form is pinned the same way its Vol + /// sibling is — `vol_tf` snake_case tag, three named fields — so a stored + /// VolTf document's parse path doesn't silently rot. + #[test] + fn risk_regime_round_trips_as_externally_tagged_vol_tf() { + let r = RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }; + let j = serde_json::to_string(&r).unwrap(); + assert_eq!(j, r#"{"vol_tf":{"period_minutes":60,"length":14,"k":2.0}}"#); + let back: RiskRegime = serde_json::from_str(&j).unwrap(); + assert_eq!(back, r); + } + #[test] fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); @@ -1547,6 +1570,21 @@ mod tests { assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); } + /// #262: a non-positive `period_minutes` must be refused as a graceful + /// `DocFault::BadRegime` at the doc tier — otherwise it reaches + /// `VolTfStop::new`'s assert and panics instead of a validation error. + #[test] + fn validate_campaign_flags_non_positive_period_minutes_regime() { + let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); + doc.risk = vec![ + RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }, // 0: valid + RiskRegime::VolTf { period_minutes: 0, length: 14, k: 2.0 }, // 1: period_minutes < 1 + ]; + let faults = validate_campaign(&doc); + assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}"); + assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); + } + #[test] fn validate_campaign_accepts_a_valid_risk_section() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); @@ -2141,10 +2179,11 @@ mod tests { vec![ open_with_notes( "risk", - "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime", vec![ "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), "vol.k scales the stop distance (the risk-unit lever).".to_string(), + "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(), ], ), open_with_notes( diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 158dfd4..4aebf04 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -468,7 +468,7 @@ open slot: format_version (required, must be 1) open slot: kind (required, must be "campaign") open slot: name (required, string) open slot: data (required section: instruments + windows) -open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime) +open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime) open slot: cost (optional, list of cost models { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero cost, net = gross) open slot: strategies (required, non-empty list of { ref, axes }) open slot: process.ref (required, content id of a process document) diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index bd82773..a25dc7f 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -993,8 +993,9 @@ blueprint-data (#159, cuts 1b-4); `run` is now blueprint-driven — `aura run **Realization (2026-07-06 — the risk regime as a structural campaign axis, #210).** The `StopRule{Fixed,Vol}` structural axis is realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable -mirror of the runtime enum — `aura-research`, sole variant `Vol{length,k}`, the -fixed-stop rule additive when needed). It is a **kept-separate** matrix axis, a +mirror of the runtime enum — `aura-research`, variants `Vol{length,k}` and +`VolTf{period_minutes,length,k}` (#262), the fixed-stop rule additive when +needed). It is a **kept-separate** matrix axis, a peer of instruments and windows: the executor keys the nominee map by `(strategy, window, regime)`, so generalize aggregates across instruments *within* a regime, never across regimes. Regimes are therefore **compared** at diff --git a/docs/glossary.md b/docs/glossary.md index de3be55..3a18fca 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -232,13 +232,14 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete ### risk regime **Avoid:** risk section One entry of a campaign document's structural risk axis (`risk`): a -serializable protective-stop regime (sole variant `vol{length,k}`) the matrix +serializable protective-stop regime (variants `vol{length,k}` — per-cycle — +and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix runs every cell under, so cells differ by execution discipline, never by signal. Absent or empty = one implicit default regime; the regime's stop defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over m1 cycles) `length` only smooths the vol estimator while `k` scales the stop -distance, so the stop's timescale stays one cycle (#262 tracks a -timescale-matched variant). +distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's +timescale via `period_minutes`). ### run **Avoid:** —