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:
@@ -59,6 +59,9 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> 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
|
||||
|
||||
+101
-14
@@ -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
|
||||
/// (`stop_length`/`stop_k`, stamped by `run_blueprint_member` — the stop rides the
|
||||
/// risk regime OUTSIDE the wrapped param_space, so `point_from_params` cannot
|
||||
/// recover it). Falls back to the default vol-stop
|
||||
/// regime when the manifest carries no stop knobs (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.
|
||||
/// (`stop_length`/`stop_k`, or `stop_period_minutes`/`stop_length`/`stop_k` for
|
||||
/// the #262 timescale-matched variant, stamped by `run_blueprint_member` — the
|
||||
/// stop rides the risk regime OUTSIDE the wrapped param_space, so
|
||||
/// `point_from_params` cannot recover it). `stop_period_minutes` present
|
||||
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
|
||||
/// 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 {
|
||||
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].<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}");
|
||||
}
|
||||
|
||||
/// #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 {
|
||||
|
||||
@@ -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<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
|
||||
/// the component and instruments — never mid-run, never a Debug struct.
|
||||
#[test]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<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,
|
||||
/// 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
|
||||
|
||||
@@ -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<DocFault> {
|
||||
}
|
||||
}
|
||||
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<Vec<OpenSlot>, 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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -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:** —
|
||||
|
||||
Reference in New Issue
Block a user