feat(research,composites,cli,docs): vol_tf — the timescale-matched stop regime

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
This commit is contained in:
2026-07-14 02:55:02 +02:00
parent 528e8f33d6
commit d1b3a3dd31
9 changed files with 329 additions and 35 deletions
+18
View File
@@ -59,6 +59,9 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_compo
Some(aura_research::RiskRegime::Vol { length, k }) => { Some(aura_research::RiskRegime::Vol { length, k }) => {
aura_composites::StopRule::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)))); 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] #[test]
/// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse
/// pair of one wrapper-segment convention — stripping the wrapper off a /// pair of one wrapper-segment convention — stripping the wrapper off a
+101 -14
View File
@@ -1141,17 +1141,25 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Ce
} }
/// Re-derive the `StopRule` a member was minted under from its manifest params /// Re-derive the `StopRule` a member was minted under from its manifest params
/// (`stop_length`/`stop_k`, stamped by `run_blueprint_member` — the stop rides the /// (`stop_length`/`stop_k`, or `stop_period_minutes`/`stop_length`/`stop_k` for
/// risk regime OUTSIDE the wrapped param_space, so `point_from_params` cannot /// the #262 timescale-matched variant, stamped by `run_blueprint_member` — the
/// recover it). Falls back to the default vol-stop /// stop rides the risk regime OUTSIDE the wrapped param_space, so
/// regime when the manifest carries no stop knobs (pre-#233 members), mirroring /// `point_from_params` cannot recover it). `stop_period_minutes` present
/// `stop_rule_for_regime`'s `None` arm (campaign_run.rs) for the same one-directional /// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
/// widening `point_from_params` already applies to missing manifest params. /// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
/// members), mirroring `stop_rule_for_regime`'s `None` arm (campaign_run.rs)
/// for the same one-directional widening `point_from_params` already applies
/// to missing manifest params.
fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { fn stop_rule_from_params(params: &[(String, Scalar)]) -> 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 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()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
match (length, k) { match (period_minutes, length, k) {
(Some(length), Some(k)) => StopRule::Vol { 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 }, _ => 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)"); .expect("member bootstraps (point kind-checked against param_space)");
h.run(sources); h.run(sources);
let mut named = zip_params(space, point); // by-name params for the manifest record 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, // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`, // which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
// so the `Fixed` arm is inert here. // or `VolTf`, so the `Fixed` arm is inert here.
if let StopRule::Vol { length, k } = stop { match stop {
named.push(("stop_length".to_string(), Scalar::i64(length))); StopRule::Vol { length, k } => {
named.push(("stop_k".to_string(), Scalar::f64(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, // Stamp the cost model the member ran under, beside the stop knobs (#234,
// the #233 pattern): one `cost[k].<knob>` param per component, in // the #233 pattern): one `cost[k].<knob>` 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}"); 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 /// 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. /// `mc_args_from` refusal test below only sets the fields its scenario needs.
fn bare_mc_cmd() -> McCmd { fn bare_mc_cmd() -> McCmd {
+106 -5
View File
@@ -493,6 +493,30 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
assert!(!out.contains("BadRegime"), "Debug leak: {out}"); 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 /// #216: the intrinsic validate summary reports the realized matrix — the
/// exec cross-product strategies × instruments × windows × regimes — so an /// exec cross-product strategies × instruments × windows × regimes — so an
/// 8-cell document no longer reads as regime-less. Points (the axis grid) are /// 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"]); let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( 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}" "the block description must name the regime shape: {out}"
); );
assert!( 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}" "the risk slot must render as optional: {out}"
); );
assert!( assert!(
@@ -603,7 +627,7 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() {
assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( assert!(
out.contains( 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}" "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"]); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]);
assert_eq!(code, Some(0)); assert_eq!(code, Some(0));
assert!( 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}" "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_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( assert!(
out.contains( 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}" "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.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 // 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 // held cycle (audit follow-up to #265: the note set must cover ALL three
// cost components, not two of 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<u64> =
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 /// Property (#260): map-key mismatches refuse at validate with prose naming
/// the component and instruments — never mid-run, never a Debug struct. /// the component and instruments — never mid-run, never a Debug struct.
#[test] #[test]
+8 -1
View File
@@ -19,7 +19,7 @@ use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_std::{ use aura_std::{
cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul, 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, PM_FIELD_NAMES,
}; };
@@ -75,6 +75,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
pub enum StopRule { pub enum StopRule {
Fixed(f64), Fixed(f64),
Vol { length: i64, k: f64 }, Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
} }
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal /// 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 { Box::new(move |g| match stop {
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
StopRule::Vol { length, k } => g.add(vol_stop(length, k)), 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, risk_budget,
) )
@@ -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); 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<Scalar>)> = 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, /// 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` /// 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 /// is the positional point the open variant needs (empty for the bound one). Shared by the
+48 -9
View File
@@ -501,6 +501,7 @@ pub struct Axis {
#[serde(deny_unknown_fields, rename_all = "snake_case")] #[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime { pub enum RiskRegime {
Vol { length: i64, k: f64 }, 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 /// One component of the campaign's cost model (#234): a closed, additive
@@ -892,12 +893,21 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
} }
} }
for (i, r) in doc.risk.iter().enumerate() { for (i, r) in doc.risk.iter().enumerate() {
let RiskRegime::Vol { length, k } = r; match r {
// length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches RiskRegime::Vol { length, k } => {
// a non-positive or NaN multiplier (clippy-clean vs `!(k > 0.0)`; +inf // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()`
// is unreachable via JSON, so it need not be excluded). // catches a non-positive or NaN multiplier (clippy-clean vs
if *length < 1 || *k <= 0.0 || k.is_nan() { // `!(k > 0.0)`; +inf is unreachable via JSON, so it need not
faults.push(DocFault::BadRegime { index: i }); // 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() { 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::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
SlotKind::Regimes => { 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 => { SlotKind::Costs => {
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)" "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<Vec<OpenSlot>, DocError> {
if !risk_bound { if !risk_bound {
slots.push(open_with_notes( slots.push(open_with_notes(
"risk", "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![ vec![
"vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), "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.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); 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] #[test]
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); 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:?}"); 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] #[test]
fn validate_campaign_accepts_a_valid_risk_section() { fn validate_campaign_accepts_a_valid_risk_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -2141,10 +2179,11 @@ mod tests {
vec![ vec![
open_with_notes( open_with_notes(
"risk", "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![ vec![
"vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), "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.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( open_with_notes(
+1 -1
View File
@@ -468,7 +468,7 @@ open slot: format_version (required, must be 1)
open slot: kind (required, must be "campaign") open slot: kind (required, must be "campaign")
open slot: name (required, string) open slot: name (required, string)
open slot: data (required section: instruments + windows) 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: 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: strategies (required, non-empty list of { ref, axes })
open slot: process.ref (required, content id of a process document) open slot: process.ref (required, content id of a process document)
+3 -2
View File
@@ -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).** **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 The `StopRule{Fixed,Vol}` structural axis is realized at the campaign-document
level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable
mirror of the runtime enum — `aura-research`, sole variant `Vol{length,k}`, the mirror of the runtime enum — `aura-research`, variants `Vol{length,k}` and
fixed-stop rule additive when needed). It is a **kept-separate** matrix axis, a `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 peer of instruments and windows: the executor keys the nominee map by
`(strategy, window, regime)`, so generalize aggregates across instruments `(strategy, window, regime)`, so generalize aggregates across instruments
*within* a regime, never across regimes. Regimes are therefore **compared** at *within* a regime, never across regimes. Regimes are therefore **compared** at
+4 -3
View File
@@ -232,13 +232,14 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete
### risk regime ### risk regime
**Avoid:** risk section **Avoid:** risk section
One entry of a campaign document's structural risk axis (`risk`): a 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 runs every cell under, so cells differ by execution discipline, never by
signal. Absent or empty = one implicit default regime; the regime's stop 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 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 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 distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's
timescale-matched variant). timescale via `period_minutes`).
### run ### run
**Avoid:** — **Avoid:** —