Merge PR #270: tracker sweep + executor robustness (12 issues)
Autonomous /boss sweep over the 2026-07-13 quadriga harvest plus two owner-decided executor-robustness follow-ups. Closes #247 #256 #258 #259 #260 #261 #262 #264 #265 #266 #269 #272.
This commit was merged in pull request #270.
This commit is contained in:
@@ -100,12 +100,15 @@ pub struct RMetrics {
|
|||||||
pub sqn_normalized: f64,
|
pub sqn_normalized: f64,
|
||||||
pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty
|
pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty
|
||||||
pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0]
|
pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0]
|
||||||
/// Realised R per closed trade, in trade order — an in-memory conduit for the
|
/// Cost-netted realised R per closed trade (`r − cost_in_r`), in trade
|
||||||
/// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire
|
/// order — an in-memory conduit for the OOS R-series bootstrap. Equals
|
||||||
/// shape is unchanged, and from `PartialEq` (below) so every existing
|
/// the gross series bit-for-bit when no cost model is bound (an empty
|
||||||
|
/// cost stream is the documented gross-R baseline: cost `0.0` per
|
||||||
|
/// trade). Excluded from serde (`skip`) so the C18 wire shape is
|
||||||
|
/// unchanged, and from `PartialEq` (below) so every existing
|
||||||
/// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
|
/// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub trade_rs: Vec<f64>,
|
pub net_trade_rs: Vec<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for RMetrics {
|
impl PartialEq for RMetrics {
|
||||||
@@ -122,7 +125,7 @@ impl PartialEq for RMetrics {
|
|||||||
&& self.sqn_normalized == o.sqn_normalized
|
&& self.sqn_normalized == o.sqn_normalized
|
||||||
&& self.net_expectancy_r == o.net_expectancy_r
|
&& self.net_expectancy_r == o.net_expectancy_r
|
||||||
&& self.conviction_terciles_r == o.conviction_terciles_r
|
&& self.conviction_terciles_r == o.conviction_terciles_r
|
||||||
// trade_rs deliberately excluded
|
// net_trade_rs deliberately excluded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +232,7 @@ pub fn summarize_r(
|
|||||||
sqn_normalized: 0.0,
|
sqn_normalized: 0.0,
|
||||||
net_expectancy_r: 0.0,
|
net_expectancy_r: 0.0,
|
||||||
conviction_terciles_r: [0.0; 3],
|
conviction_terciles_r: [0.0; 3],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
|
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
|
||||||
@@ -277,6 +280,10 @@ pub fn summarize_r(
|
|||||||
// an empty stream is the gross-R baseline — every trade's cost is 0.0).
|
// an empty stream is the gross-R baseline — every trade's cost is 0.0).
|
||||||
let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
|
let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
|
||||||
let net_expectancy_r = net_sum / n as f64;
|
let net_expectancy_r = net_sum / n as f64;
|
||||||
|
// The per-trade net series the OOS bootstrap conduit carries (#259). A
|
||||||
|
// separate materialization, deliberately NOT factored through `net_sum`:
|
||||||
|
// that expression's tokens are byte-pinned (see the SQN note above).
|
||||||
|
let net_rs: Vec<f64> = trades.iter().map(|t| t.r - t.cost).collect();
|
||||||
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
|
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
|
||||||
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
|
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
|
||||||
let conviction_terciles_r = if n < 3 {
|
let conviction_terciles_r = if n < 3 {
|
||||||
@@ -311,7 +318,7 @@ pub fn summarize_r(
|
|||||||
sqn_normalized,
|
sqn_normalized,
|
||||||
net_expectancy_r,
|
net_expectancy_r,
|
||||||
conviction_terciles_r,
|
conviction_terciles_r,
|
||||||
trade_rs: rs,
|
net_trade_rs: net_rs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +331,8 @@ pub fn summarize_r(
|
|||||||
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
|
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
|
||||||
/// without the other and that test is the sole tripwire. The
|
/// without the other and that test is the sole tripwire. The
|
||||||
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
|
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
|
||||||
/// `net_expectancy_r = expectancy_r` (exact under the cost = 0 invariant),
|
/// `net_expectancy_r = expectancy_r` (exact: the input series is already
|
||||||
|
/// cost-netted — the conduit carries `r − cost_in_r`),
|
||||||
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
|
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
|
||||||
/// input -> a well-defined all-zero `RMetrics`.
|
/// input -> a well-defined all-zero `RMetrics`.
|
||||||
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
||||||
@@ -334,7 +342,7 @@ pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
|||||||
expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
|
expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
|
||||||
avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0,
|
avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0,
|
||||||
n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0,
|
n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0,
|
||||||
conviction_terciles_r: [0.0; 3], trade_rs: Vec::new(),
|
conviction_terciles_r: [0.0; 3], net_trade_rs: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let sum: f64 = rs.iter().sum();
|
let sum: f64 = rs.iter().sum();
|
||||||
@@ -375,9 +383,9 @@ pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
|||||||
n_open_at_end: 0,
|
n_open_at_end: 0,
|
||||||
sqn,
|
sqn,
|
||||||
sqn_normalized,
|
sqn_normalized,
|
||||||
net_expectancy_r: mean, // cost = 0 -> net == gross (frictionless)
|
net_expectancy_r: mean, // input series is already net -> net == mean
|
||||||
conviction_terciles_r: [0.0; 3],
|
conviction_terciles_r: [0.0; 3],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,6 +840,30 @@ mod tests {
|
|||||||
assert!((m.net_expectancy_r - 0.0).abs() < 1e-12);
|
assert!((m.net_expectancy_r - 0.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#259): the bootstrap conduit `net_trade_rs` carries the
|
||||||
|
/// COST-NETTED per-trade R (`r − cost_in_r`) in trade order — the same
|
||||||
|
/// per-trade values `net_expectancy_r` averages — while every gross
|
||||||
|
/// scalar stays cost-free. With an empty cost stream the conduit equals
|
||||||
|
/// the gross series bit-for-bit (cost 0.0 per trade), which is what
|
||||||
|
/// keeps uncosted campaigns byte-identical.
|
||||||
|
#[test]
|
||||||
|
fn summarize_r_net_trade_rs_is_the_cost_netted_series_in_trade_order() {
|
||||||
|
let c = 2.0_f64;
|
||||||
|
let record = vec![
|
||||||
|
(Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)),
|
||||||
|
(Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)),
|
||||||
|
];
|
||||||
|
let cost = vec![
|
||||||
|
(Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]),
|
||||||
|
(Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]),
|
||||||
|
];
|
||||||
|
let m = summarize_r(&record, &cost);
|
||||||
|
assert_eq!(m.net_trade_rs, vec![1.0 - 0.5, 0.5 - 1.0], "net conduit = r − cost, trade order");
|
||||||
|
assert!((m.expectancy_r - 0.75).abs() < 1e-12, "gross scalars stay cost-free");
|
||||||
|
let gross = summarize_r(&record, &[]);
|
||||||
|
assert_eq!(gross.net_trade_rs, vec![1.0, 0.5], "empty cost stream ⇒ conduit == gross series");
|
||||||
|
}
|
||||||
|
|
||||||
// One PositionManagement dense record row for the derive tests: only the columns
|
// One PositionManagement dense record row for the derive tests: only the columns
|
||||||
// derive_position_events reads (closed, direction, size, open) are set; the rest
|
// derive_position_events reads (closed, direction, size, open) are set; the rest
|
||||||
// default to 0. Distinct per-row timestamps (the derive keys events on the row's
|
// default to 0. Distinct per-row timestamps (the derive keys events on the row's
|
||||||
@@ -949,7 +981,7 @@ mod tests {
|
|||||||
sqn_normalized: 1.0,
|
sqn_normalized: 1.0,
|
||||||
net_expectancy_r: 0.4,
|
net_expectancy_r: 0.4,
|
||||||
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&m).expect("serialize");
|
let json = serde_json::to_string(&m).expect("serialize");
|
||||||
@@ -997,25 +1029,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summarize_r_populates_trade_rs_in_trade_order() {
|
fn summarize_r_populates_net_trade_rs_in_trade_order() {
|
||||||
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
|
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
|
||||||
// record; trade_rs must carry [2.0, -1.0] in trade order.
|
// record; net_trade_rs must carry [2.0, -1.0] in trade order.
|
||||||
let rec = pm_record_two_closed_trades(); // helper below
|
let rec = pm_record_two_closed_trades(); // helper below
|
||||||
let m = summarize_r(&rec, &[]);
|
let m = summarize_r(&rec, &[]);
|
||||||
assert_eq!(m.trade_rs, vec![2.0, -1.0]);
|
assert_eq!(m.net_trade_rs, vec![2.0, -1.0]);
|
||||||
assert_eq!(m.n_trades, 2);
|
assert_eq!(m.n_trades, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summarize_r_empty_record_has_empty_trade_rs() {
|
fn summarize_r_empty_record_has_empty_net_trade_rs() {
|
||||||
let m = summarize_r(&[], &[]);
|
let m = summarize_r(&[], &[]);
|
||||||
assert!(m.trade_rs.is_empty());
|
assert!(m.net_trade_rs.is_empty());
|
||||||
assert_eq!(m.n_trades, 0);
|
assert_eq!(m.n_trades, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Property: a position still open on the last row is folded into the trade
|
/// Property: a position still open on the last row is folded into the trade
|
||||||
/// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
|
/// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
|
||||||
/// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
|
/// `net_trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
|
||||||
/// This is the one case where the two reducers' inputs differ in meaning — it
|
/// This is the one case where the two reducers' inputs differ in meaning — it
|
||||||
/// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
|
/// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
|
||||||
/// so the two must agree on the R-distribution arithmetic for an
|
/// so the two must agree on the R-distribution arithmetic for an
|
||||||
@@ -1024,12 +1056,12 @@ mod tests {
|
|||||||
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
|
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
|
||||||
let rec = pm_record_closed_then_open_at_end();
|
let rec = pm_record_closed_then_open_at_end();
|
||||||
let m = summarize_r(&rec, &[]);
|
let m = summarize_r(&rec, &[]);
|
||||||
assert_eq!(m.trade_rs, vec![2.0, 0.5]);
|
assert_eq!(m.net_trade_rs, vec![2.0, 0.5]);
|
||||||
assert_eq!(m.n_trades, 2);
|
assert_eq!(m.n_trades, 2);
|
||||||
assert_eq!(m.n_open_at_end, 1);
|
assert_eq!(m.n_open_at_end, 1);
|
||||||
// Feed the open-at-end pooled series through the flat reducer: the
|
// Feed the open-at-end pooled series through the flat reducer: the
|
||||||
// R-distribution fields (the verbatim-copied arithmetic) must agree.
|
// R-distribution fields (the verbatim-copied arithmetic) must agree.
|
||||||
let pooled = r_metrics_from_rs(&m.trade_rs);
|
let pooled = r_metrics_from_rs(&m.net_trade_rs);
|
||||||
assert_eq!(pooled.n_trades, m.n_trades);
|
assert_eq!(pooled.n_trades, m.n_trades);
|
||||||
assert_eq!(pooled.expectancy_r, m.expectancy_r);
|
assert_eq!(pooled.expectancy_r, m.expectancy_r);
|
||||||
assert_eq!(pooled.win_rate, m.win_rate);
|
assert_eq!(pooled.win_rate, m.win_rate);
|
||||||
@@ -1042,25 +1074,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rmetrics_partial_eq_ignores_trade_rs() {
|
fn rmetrics_partial_eq_ignores_net_trade_rs() {
|
||||||
// two RMetrics equal in every metric but differing in trade_rs compare EQUAL
|
// two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL
|
||||||
// (trade_rs is an in-memory conduit, excluded from equality) — this is what
|
// (net_trade_rs is an in-memory conduit, excluded from equality) — this is what
|
||||||
// keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped,
|
// keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped,
|
||||||
// so it deserializes empty).
|
// so it deserializes empty).
|
||||||
let a = summarize_r(&pm_record_two_closed_trades(), &[]);
|
let a = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||||||
let mut b = a.clone();
|
let mut b = a.clone();
|
||||||
b.trade_rs = Vec::new();
|
b.net_trade_rs = Vec::new();
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn populated_trade_rs_is_absent_from_serialized_json() {
|
fn populated_net_trade_rs_is_absent_from_serialized_json() {
|
||||||
// the in-memory conduit must never reach the wire: a populated trade_rs is
|
// the in-memory conduit must never reach the wire: a populated net_trade_rs is
|
||||||
// dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
|
// dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
|
||||||
let m = summarize_r(&pm_record_two_closed_trades(), &[]);
|
let m = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||||||
assert_eq!(m.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated");
|
assert_eq!(m.net_trade_rs, vec![2.0, -1.0], "precondition: net_trade_rs is populated");
|
||||||
let json = serde_json::to_string(&m).expect("RMetrics serializes");
|
let json = serde_json::to_string(&m).expect("RMetrics serializes");
|
||||||
assert!(!json.contains("trade_rs"), "trade_rs must not reach the wire: {json}");
|
assert!(!json.contains("net_trade_rs"), "net_trade_rs must not reach the wire: {json}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
|
/// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ use aura_engine::{
|
|||||||
};
|
};
|
||||||
use aura_registry::{
|
use aura_registry::{
|
||||||
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
|
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
|
||||||
sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord,
|
sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind,
|
||||||
CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization,
|
PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
|
||||||
StageSelection,
|
|
||||||
};
|
};
|
||||||
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
|
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
|
||||||
|
|
||||||
@@ -232,6 +231,31 @@ pub fn execute(
|
|||||||
Ok(CampaignOutcome { record, run, cells: cells_out })
|
Ok(CampaignOutcome { record, run, cells: cells_out })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inter-stage population crossing `run_cell`'s seam: parameter points
|
||||||
|
/// only (from `std::grid`) or executed members (from `std::sweep`). Preflight
|
||||||
|
/// guarantees report-consuming stages (`std::gate`, `std::monte_carlo`'s
|
||||||
|
/// per-survivor arm) never see `Points`; the defensive faults below keep that
|
||||||
|
/// invariant loud if the shape rules ever drift.
|
||||||
|
enum StageFlow {
|
||||||
|
/// (ordinal into the nearest preceding family_id-bearing stage's family,
|
||||||
|
/// param point, member report) — the shape preflight established before
|
||||||
|
/// std::sweep populated it.
|
||||||
|
Members(Vec<(usize, Vec<Scalar>, RunReport)>),
|
||||||
|
Points(Vec<Vec<Scalar>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StageFlow {
|
||||||
|
/// Every flow can cross the seam as bare points — the walk_forward need.
|
||||||
|
fn points(&self) -> Vec<Vec<Scalar>> {
|
||||||
|
match self {
|
||||||
|
StageFlow::Points(points) => points.clone(),
|
||||||
|
StageFlow::Members(members) => {
|
||||||
|
members.iter().map(|(_, point, _)| point.clone()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run one cell's pipeline: stages in doc order, a realized prefix that stops
|
/// Run one cell's pipeline: stages in doc order, a realized prefix that stops
|
||||||
/// at an empty gate (decision 8 — the cell truncates, the campaign continues).
|
/// at an empty gate (decision 8 — the cell truncates, the campaign continues).
|
||||||
fn run_cell(
|
fn run_cell(
|
||||||
@@ -247,15 +271,27 @@ fn run_cell(
|
|||||||
let mut families: Vec<StageFamily> = Vec::new();
|
let mut families: Vec<StageFamily> = Vec::new();
|
||||||
let mut selections: Vec<StageSelectionOut> = Vec::new();
|
let mut selections: Vec<StageSelectionOut> = Vec::new();
|
||||||
let mut stages: Vec<StageRealization> = Vec::new();
|
let mut stages: Vec<StageRealization> = Vec::new();
|
||||||
// The surviving population: (ordinal into the nearest preceding
|
// The surviving population crossing the stage seam (see `StageFlow`).
|
||||||
// family_id-bearing stage's family, param point, member report).
|
let mut flow = StageFlow::Members(Vec::new());
|
||||||
let mut survivors: Vec<(usize, Vec<Scalar>, RunReport)> = Vec::new();
|
|
||||||
// The cell's nominated generalize candidate: last wf window's OOS report
|
// The cell's nominated generalize candidate: last wf window's OOS report
|
||||||
// when a walk_forward ran, else the sweep winner; None when truncated.
|
// when a walk_forward ran, else the sweep winner; None when truncated.
|
||||||
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
|
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
|
||||||
|
|
||||||
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
|
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
|
||||||
match stage {
|
match stage {
|
||||||
|
StageBlock::Grid => {
|
||||||
|
// Enumerate-only (#256 fork B): the grid's parameter points
|
||||||
|
// cross the seam; nothing executes, nothing persists.
|
||||||
|
flow = StageFlow::Points(grid.points.clone());
|
||||||
|
stages.push(StageRealization {
|
||||||
|
block: "std::grid".to_string(),
|
||||||
|
family_id: None,
|
||||||
|
survivor_ordinals: None,
|
||||||
|
selection: None,
|
||||||
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
StageBlock::Sweep { selection } => {
|
StageBlock::Sweep { selection } => {
|
||||||
// Deterministic, self-describing family name (the "-{run}"
|
// Deterministic, self-describing family name (the "-{run}"
|
||||||
// suffix is appended by the registry).
|
// suffix is appended by the registry).
|
||||||
@@ -263,7 +299,14 @@ fn run_cell(
|
|||||||
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
|
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
|
||||||
cell.strategy_ordinal, cell.instrument, cell.regime_ordinal,
|
cell.strategy_ordinal, cell.instrument, cell.regime_ordinal,
|
||||||
);
|
);
|
||||||
let family = run_members(cell, &grid, runner)?;
|
let family = match run_members(cell, &grid, runner) {
|
||||||
|
Ok(family) => family,
|
||||||
|
Err(fault) => {
|
||||||
|
// Registry etc. still aborts (contain's Err arm).
|
||||||
|
let cf = contain(fault, stage_ordinal)?;
|
||||||
|
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
|
||||||
|
}
|
||||||
|
};
|
||||||
let family_id = registry
|
let family_id = registry
|
||||||
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||||
.map_err(ExecFault::Registry)?;
|
.map_err(ExecFault::Registry)?;
|
||||||
@@ -300,6 +343,7 @@ fn run_cell(
|
|||||||
survivor_ordinals: None,
|
survivor_ordinals: None,
|
||||||
selection: Some(StageSelection { winner_ordinal, params, selection }),
|
selection: Some(StageSelection { winner_ordinal, params, selection }),
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -313,17 +357,20 @@ fn run_cell(
|
|||||||
survivor_ordinals: None,
|
survivor_ordinals: None,
|
||||||
selection: None,
|
selection: None,
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The whole family flows on (decision 3: `select` names the
|
// The whole family flows on (decision 3: `select` names the
|
||||||
// recorded selection, never a filter).
|
// recorded selection, never a filter).
|
||||||
survivors = family
|
flow = StageFlow::Members(
|
||||||
.points
|
family
|
||||||
.iter()
|
.points
|
||||||
.enumerate()
|
.iter()
|
||||||
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
.enumerate()
|
||||||
.collect();
|
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
families.push(StageFamily {
|
families.push(StageFamily {
|
||||||
stage: stage_ordinal,
|
stage: stage_ordinal,
|
||||||
block: "std::sweep",
|
block: "std::sweep",
|
||||||
@@ -338,13 +385,21 @@ fn run_cell(
|
|||||||
// None`) fails the member — conservative and deterministic
|
// None`) fails the member — conservative and deterministic
|
||||||
// (the metric NAME was already preflighted against
|
// (the metric NAME was already preflighted against
|
||||||
// `PER_MEMBER_METRICS`).
|
// `PER_MEMBER_METRICS`).
|
||||||
survivors.retain(|(_, _, report)| {
|
let StageFlow::Members(members) = &mut flow else {
|
||||||
|
return Err(ExecFault::PipelineShape {
|
||||||
|
detail: format!(
|
||||||
|
"stage {stage_ordinal}: std::gate needs executed \
|
||||||
|
members (preflight admits no std::grid predecessor)"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
members.retain(|(_, _, report)| {
|
||||||
all.iter().all(|p| {
|
all.iter().all(|p| {
|
||||||
member_metric(report, &p.metric)
|
member_metric(report, &p.metric)
|
||||||
.is_some_and(|value| predicate_holds(&p.cmp, value, p.value))
|
.is_some_and(|value| predicate_holds(&p.cmp, value, p.value))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let ordinals: Vec<usize> = survivors.iter().map(|(i, _, _)| *i).collect();
|
let ordinals: Vec<usize> = members.iter().map(|(i, _, _)| *i).collect();
|
||||||
let empty = ordinals.is_empty();
|
let empty = ordinals.is_empty();
|
||||||
stages.push(StageRealization {
|
stages.push(StageRealization {
|
||||||
block: "std::gate".to_string(),
|
block: "std::gate".to_string(),
|
||||||
@@ -352,6 +407,7 @@ fn run_cell(
|
|||||||
survivor_ordinals: Some(ordinals),
|
survivor_ordinals: Some(ordinals),
|
||||||
selection: None,
|
selection: None,
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
});
|
});
|
||||||
if empty {
|
if empty {
|
||||||
nominee = None; // a truncated cell nominates no candidate
|
nominee = None; // a truncated cell nominates no candidate
|
||||||
@@ -368,9 +424,8 @@ fn run_cell(
|
|||||||
// The seam crossing is plain points: the walk-forward stage
|
// The seam crossing is plain points: the walk-forward stage
|
||||||
// re-sweeps the surviving param points and never needs the
|
// re-sweeps the surviving param points and never needs the
|
||||||
// gate bookkeeping's ordinals or reports.
|
// gate bookkeeping's ordinals or reports.
|
||||||
let survivor_points: Vec<Vec<Scalar>> =
|
let survivor_points: Vec<Vec<Scalar>> = flow.points();
|
||||||
survivors.iter().map(|(_, point, _)| point.clone()).collect();
|
match run_walk_forward_stage(
|
||||||
let fam = run_walk_forward_stage(
|
|
||||||
seed,
|
seed,
|
||||||
cell,
|
cell,
|
||||||
stage_ordinal,
|
stage_ordinal,
|
||||||
@@ -381,22 +436,31 @@ fn run_cell(
|
|||||||
&family_name,
|
&family_name,
|
||||||
runner,
|
runner,
|
||||||
registry,
|
registry,
|
||||||
)?;
|
) {
|
||||||
stages.push(StageRealization {
|
Ok(WfStageOutcome::Ran { family: fam, window_faults }) => {
|
||||||
block: "std::walk_forward".to_string(),
|
stages.push(StageRealization {
|
||||||
family_id: Some(fam.family_id.clone()),
|
block: "std::walk_forward".to_string(),
|
||||||
survivor_ordinals: None,
|
family_id: Some(fam.family_id.clone()),
|
||||||
selection: None,
|
survivor_ordinals: None,
|
||||||
bootstrap: None,
|
selection: None,
|
||||||
});
|
bootstrap: None,
|
||||||
// Nominate the LAST window's OOS report (roll order per
|
window_faults,
|
||||||
// walkforward_member_reports) with its chosen params — the wf
|
});
|
||||||
// stage stamps them on the fresh OOS report's manifest.
|
// Nominate the LAST window's OOS report (roll order,
|
||||||
nominee = fam
|
// over the surviving folds) with its chosen params —
|
||||||
.reports
|
// the wf stage stamps them on the fresh OOS report's
|
||||||
.last()
|
// manifest.
|
||||||
.map(|last| (last.manifest.params.clone(), last.clone()));
|
nominee = fam
|
||||||
families.push(fam);
|
.reports
|
||||||
|
.last()
|
||||||
|
.map(|last| (last.manifest.params.clone(), last.clone()));
|
||||||
|
families.push(fam);
|
||||||
|
}
|
||||||
|
Ok(WfStageOutcome::CellFailed(cf)) => {
|
||||||
|
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e), // Registry / global refusal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
StageBlock::MonteCarlo { resamples, block_len } => {
|
StageBlock::MonteCarlo { resamples, block_len } => {
|
||||||
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
|
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
|
||||||
@@ -407,7 +471,7 @@ fn run_cell(
|
|||||||
let bootstrap =
|
let bootstrap =
|
||||||
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
|
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
|
||||||
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
|
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
|
||||||
&pooled_trade_rs(&fam.reports),
|
&pooled_net_trade_rs(&fam.reports),
|
||||||
*resamples as usize,
|
*resamples as usize,
|
||||||
*block_len as usize,
|
*block_len as usize,
|
||||||
seed,
|
seed,
|
||||||
@@ -415,28 +479,39 @@ fn run_cell(
|
|||||||
// Zero-trade members (r: None) feed an empty slice —
|
// Zero-trade members (r: None) feed an empty slice —
|
||||||
// the engine's defined all-zero degenerate, recorded
|
// the engine's defined all-zero degenerate, recorded
|
||||||
// explicitly (a null result is a valid result).
|
// explicitly (a null result is a valid result).
|
||||||
None => StageBootstrap::PerSurvivor(
|
None => {
|
||||||
survivors
|
let StageFlow::Members(members) = &flow else {
|
||||||
.iter()
|
return Err(ExecFault::PipelineShape {
|
||||||
.map(|(ordinal, _, report)| {
|
detail: format!(
|
||||||
let rs = report
|
"stage {stage_ordinal}: std::monte_carlo \
|
||||||
.metrics
|
per-survivor bootstrap needs executed members \
|
||||||
.r
|
(preflight admits no std::grid predecessor)"
|
||||||
.as_ref()
|
),
|
||||||
.map(|r| r.trade_rs.as_slice())
|
});
|
||||||
.unwrap_or(&[]);
|
};
|
||||||
(
|
StageBootstrap::PerSurvivor(
|
||||||
*ordinal,
|
members
|
||||||
r_bootstrap(
|
.iter()
|
||||||
rs,
|
.map(|(ordinal, _, report)| {
|
||||||
*resamples as usize,
|
let rs = report
|
||||||
*block_len as usize,
|
.metrics
|
||||||
seed,
|
.r
|
||||||
),
|
.as_ref()
|
||||||
)
|
.map(|r| r.net_trade_rs.as_slice())
|
||||||
})
|
.unwrap_or(&[]);
|
||||||
.collect(),
|
(
|
||||||
),
|
*ordinal,
|
||||||
|
r_bootstrap(
|
||||||
|
rs,
|
||||||
|
*resamples as usize,
|
||||||
|
*block_len as usize,
|
||||||
|
seed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
stages.push(StageRealization {
|
stages.push(StageRealization {
|
||||||
block: "std::monte_carlo".to_string(),
|
block: "std::monte_carlo".to_string(),
|
||||||
@@ -444,6 +519,7 @@ fn run_cell(
|
|||||||
survivor_ordinals: None,
|
survivor_ordinals: None,
|
||||||
selection: None,
|
selection: None,
|
||||||
bootstrap: Some(bootstrap),
|
bootstrap: Some(bootstrap),
|
||||||
|
window_faults: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
StageBlock::Generalize { .. } => {
|
StageBlock::Generalize { .. } => {
|
||||||
@@ -462,6 +538,8 @@ fn run_cell(
|
|||||||
window_ms: cell.window_ms,
|
window_ms: cell.window_ms,
|
||||||
regime: cell.regime,
|
regime: cell.regime,
|
||||||
regime_ordinal: cell.regime_ordinal,
|
regime_ordinal: cell.regime_ordinal,
|
||||||
|
fault: None,
|
||||||
|
coverage: runner.window_coverage(cell),
|
||||||
stages,
|
stages,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -500,6 +578,54 @@ fn enumerate_grid(axes: &BTreeMap<String, Axis>) -> SweepGrid {
|
|||||||
SweepGrid { specs, axis_lens, points }
|
SweepGrid { specs, axis_lens, points }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ref-counted panic-hook silencer: while at least one `SilencedPanic` guard
|
||||||
|
/// is alive, the process's panic hook is a no-op, so a member panic CONTAINED
|
||||||
|
/// by one of this module's three `catch_unwind` sites (recorded as a
|
||||||
|
/// `MemberFault`/`WindowFault`, the run continuing past it) does not also
|
||||||
|
/// paint a crash-looking default backtrace to stderr — "recorded, campaign
|
||||||
|
/// continues" stays observably true, not just true in the record. Ref-counted
|
||||||
|
/// rather than a bare set/restore because `sweep`/`walk_forward` run these
|
||||||
|
/// closures on multiple OS threads concurrently (C1 disjoint-parallel
|
||||||
|
/// members): a naive per-closure set_hook/take_hook pair would race two
|
||||||
|
/// threads' install/restore against each other. The previous hook (whatever
|
||||||
|
/// it was — default or a caller's own) is saved on the 0→1 transition and
|
||||||
|
/// restored on the 1→0 transition, so this never permanently clobbers a
|
||||||
|
/// hook installed above this module.
|
||||||
|
struct SilencedPanic;
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
static PANIC_HOOK_STATE: Mutex<(usize, Option<Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send>>)> =
|
||||||
|
Mutex::new((0, None));
|
||||||
|
|
||||||
|
impl SilencedPanic {
|
||||||
|
/// Enter the silenced section; drop the returned guard to leave it. Hold
|
||||||
|
/// this only around the `catch_unwind` call itself, never the whole
|
||||||
|
/// member-run closure, so a genuinely uncontained escape (this module's
|
||||||
|
/// own bug, not the member's) still surfaces normally once the guard has
|
||||||
|
/// dropped and unwinding continues past `catch_unwind`.
|
||||||
|
fn enter() -> Self {
|
||||||
|
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
|
||||||
|
if state.0 == 0 {
|
||||||
|
state.1 = Some(std::panic::take_hook());
|
||||||
|
std::panic::set_hook(Box::new(|_| {}));
|
||||||
|
}
|
||||||
|
state.0 += 1;
|
||||||
|
SilencedPanic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SilencedPanic {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
|
||||||
|
state.0 -= 1;
|
||||||
|
if state.0 == 0
|
||||||
|
&& let Some(prev) = state.1.take()
|
||||||
|
{
|
||||||
|
std::panic::set_hook(prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run every grid point through the consumer's `MemberRunner` via the engine
|
/// Run every grid point through the consumer's `MemberRunner` via the engine
|
||||||
/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are
|
/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are
|
||||||
/// captured per slot (the closure must return a report), and the LOWEST
|
/// captured per slot (the closure must return a report), and the LOWEST
|
||||||
@@ -526,9 +652,15 @@ fn run_members(
|
|||||||
let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
||||||
let family = sweep(&space, |point: &[Cell]| {
|
let family = sweep(&space, |point: &[Cell]| {
|
||||||
let params = zip_params(&grid.specs, point);
|
let params = zip_params(&grid.specs, point);
|
||||||
match runner.run_member(cell, ¶ms, cell.window_ms) {
|
let called = {
|
||||||
Ok(report) => report,
|
let _silence = SilencedPanic::enter();
|
||||||
Err(fault) => {
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
runner.run_member(cell, ¶ms, cell.window_ms)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
match called {
|
||||||
|
Ok(Ok(report)) => report,
|
||||||
|
Ok(Err(fault)) => {
|
||||||
let index = cells
|
let index = cells
|
||||||
.iter()
|
.iter()
|
||||||
.position(|c| c.as_slice() == point)
|
.position(|c| c.as_slice() == point)
|
||||||
@@ -536,6 +668,17 @@ fn run_members(
|
|||||||
faults.lock().expect("fault capture lock").push((index, fault));
|
faults.lock().expect("fault capture lock").push((index, fault));
|
||||||
placeholder_report(¶ms, cell.window_ms)
|
placeholder_report(¶ms, cell.window_ms)
|
||||||
}
|
}
|
||||||
|
Err(payload) => {
|
||||||
|
let index = cells
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.as_slice() == point)
|
||||||
|
.expect("the sweep only enumerates the grid's own points");
|
||||||
|
faults
|
||||||
|
.lock()
|
||||||
|
.expect("fault capture lock")
|
||||||
|
.push((index, MemberFault::Panic(panic_message(payload))));
|
||||||
|
placeholder_report(¶ms, cell.window_ms)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let mut captured = faults.into_inner().expect("fault capture lock");
|
let mut captured = faults.into_inner().expect("fault capture lock");
|
||||||
@@ -546,6 +689,97 @@ fn run_members(
|
|||||||
Ok(family)
|
Ok(family)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: which executor faults are per-cell outcomes vs campaign-wide
|
||||||
|
/// refusals. Member and window faults are data-and-cell-local; everything else
|
||||||
|
/// (doc-shape, registry) stays a global refusal returned to the caller.
|
||||||
|
fn contain(fault: ExecFault, stage: usize) -> Result<aura_registry::CellFault, ExecFault> {
|
||||||
|
match fault {
|
||||||
|
ExecFault::Member(m) => Ok(aura_registry::CellFault {
|
||||||
|
stage,
|
||||||
|
kind: member_kind(&m),
|
||||||
|
detail: member_fault_prose(&m),
|
||||||
|
}),
|
||||||
|
ExecFault::Window { stage: s, detail } => Ok(aura_registry::CellFault {
|
||||||
|
stage: s,
|
||||||
|
kind: aura_registry::CellFaultKind::Window,
|
||||||
|
detail,
|
||||||
|
}),
|
||||||
|
other => Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member_kind(m: &MemberFault) -> aura_registry::CellFaultKind {
|
||||||
|
use aura_registry::CellFaultKind as K;
|
||||||
|
match m {
|
||||||
|
MemberFault::NoData { .. } => K::NoData,
|
||||||
|
MemberFault::Bind(_) => K::Bind,
|
||||||
|
MemberFault::Run(_) => K::Run,
|
||||||
|
MemberFault::Panic(_) => K::Panic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prose for a member fault. `pub` (re-exported at the crate root) so the
|
||||||
|
/// CLI's `exec_fault_prose` phrases the `ExecFault::Member` arm by calling
|
||||||
|
/// this directly instead of carrying its own duplicate of the wording.
|
||||||
|
/// Debug-leak-free, self-describing.
|
||||||
|
pub fn member_fault_prose(m: &MemberFault) -> String {
|
||||||
|
match m {
|
||||||
|
MemberFault::NoData { instrument, window_ms } => format!(
|
||||||
|
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
|
||||||
|
window_ms.0, window_ms.1
|
||||||
|
),
|
||||||
|
MemberFault::Bind(detail) => format!("a member failed to bind: {detail}"),
|
||||||
|
MemberFault::Run(detail) => format!("a member failed to run: {detail}"),
|
||||||
|
MemberFault::Panic(detail) => format!("a member panicked: {detail}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort panic-payload message (String / &str payloads; else a fixed
|
||||||
|
/// marker). `AssertUnwindSafe` at the call sites is a documented judgement: the
|
||||||
|
/// runner is reused for later cells; the CLI runner holds read-only state.
|
||||||
|
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||||
|
payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "member panicked (non-string payload)".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a
|
||||||
|
/// stage (#272): the realized stage prefix survives in `stages`, `families`/
|
||||||
|
/// `selections` carry whatever already-persisted stages produced (empty for a
|
||||||
|
/// sweep-first fault; the sweep family/selection for a later wf-stage fault),
|
||||||
|
/// no nominee, the fault stamped on both, coverage from the runner.
|
||||||
|
fn failed_cell(
|
||||||
|
cell: &CellSpec,
|
||||||
|
stages: Vec<StageRealization>,
|
||||||
|
families: Vec<StageFamily>,
|
||||||
|
selections: Vec<StageSelectionOut>,
|
||||||
|
fault: aura_registry::CellFault,
|
||||||
|
runner: &dyn MemberRunner,
|
||||||
|
) -> (CellOutcome, CellRealization) {
|
||||||
|
(
|
||||||
|
CellOutcome { families, selections, nominee: None },
|
||||||
|
CellRealization {
|
||||||
|
strategy: cell.strategy_id.clone(),
|
||||||
|
instrument: cell.instrument.clone(),
|
||||||
|
window_ms: cell.window_ms,
|
||||||
|
stages,
|
||||||
|
regime: cell.regime,
|
||||||
|
regime_ordinal: cell.regime_ordinal,
|
||||||
|
fault: Some(fault),
|
||||||
|
coverage: runner.window_coverage(cell),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel `RunManifest::broker` value stamped onto [`placeholder_report`]'s
|
||||||
|
/// output — the single source both the setter and the survivor filter
|
||||||
|
/// (`run_members`'s `... != FAULTED_MEMBER_PLACEHOLDER_BROKER` sibling) read,
|
||||||
|
/// so the two can never drift apart into silently pooling a faulted
|
||||||
|
/// placeholder into a real family/OOS selection.
|
||||||
|
const FAULTED_MEMBER_PLACEHOLDER_BROKER: &str = "faulted-member-placeholder";
|
||||||
|
|
||||||
/// Slot filler for a faulted member: the engine sweep contract needs one
|
/// Slot filler for a faulted member: the engine sweep contract needs one
|
||||||
/// report per slot, but a captured fault aborts `execute` before any family
|
/// report per slot, but a captured fault aborts `execute` before any family
|
||||||
/// write, so this report is never persisted and never ranked.
|
/// write, so this report is never persisted and never ranked.
|
||||||
@@ -557,7 +791,7 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
|
|||||||
defaults: Vec::new(),
|
defaults: Vec::new(),
|
||||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||||
seed: 0,
|
seed: 0,
|
||||||
broker: "faulted-member-placeholder".to_string(),
|
broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(),
|
||||||
selection: None,
|
selection: None,
|
||||||
instrument: None,
|
instrument: None,
|
||||||
topology_hash: None,
|
topology_hash: None,
|
||||||
@@ -627,15 +861,16 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
|
|||||||
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
|
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs`
|
/// The pooled walk-forward OOS trade-R series: the family reports' `net_trade_rs`
|
||||||
/// concatenated in report order — which IS roll order (the wf stage builds its
|
/// concatenated in report order — which IS roll order (the wf stage builds its
|
||||||
/// family via `walkforward_member_reports`, per-window OOS reports in roll
|
/// family via `walkforward_member_reports_of`, per-window OOS reports in roll
|
||||||
/// order). Reports without an R block contribute nothing.
|
/// order, over the surviving folds). Reports without an R block contribute
|
||||||
fn pooled_trade_rs(reports: &[RunReport]) -> Vec<f64> {
|
/// nothing.
|
||||||
|
fn pooled_net_trade_rs(reports: &[RunReport]) -> Vec<f64> {
|
||||||
let mut pooled = Vec::new();
|
let mut pooled = Vec::new();
|
||||||
for report in reports {
|
for report in reports {
|
||||||
if let Some(r) = &report.metrics.r {
|
if let Some(r) = &report.metrics.r {
|
||||||
pooled.extend_from_slice(&r.trade_rs);
|
pooled.extend_from_slice(&r.net_trade_rs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pooled
|
pooled
|
||||||
@@ -678,7 +913,7 @@ fn run_walk_forward_stage(
|
|||||||
family_name: &str,
|
family_name: &str,
|
||||||
runner: &dyn MemberRunner,
|
runner: &dyn MemberRunner,
|
||||||
registry: &Registry,
|
registry: &Registry,
|
||||||
) -> Result<StageFamily, ExecFault> {
|
) -> Result<WfStageOutcome, ExecFault> {
|
||||||
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
|
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
|
||||||
block
|
block
|
||||||
else {
|
else {
|
||||||
@@ -757,9 +992,16 @@ fn run_walk_forward_stage(
|
|||||||
// with the same member fault capture the sweep stage uses.
|
// with the same member fault capture the sweep stage uses.
|
||||||
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
||||||
let is_family = sweep(&is_space, |cells| {
|
let is_family = sweep(&is_space, |cells| {
|
||||||
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) {
|
let params = zip_params(specs, cells);
|
||||||
Ok(report) => report,
|
let called = {
|
||||||
Err(fault) => {
|
let _silence = SilencedPanic::enter();
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
runner.run_member(cell, ¶ms, (w.is.0 .0, w.is.1 .0))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
match called {
|
||||||
|
Ok(Ok(report)) => report,
|
||||||
|
Ok(Err(fault)) => {
|
||||||
let midx = is_points
|
let midx = is_points
|
||||||
.iter()
|
.iter()
|
||||||
.position(|p| p.as_slice() == cells)
|
.position(|p| p.as_slice() == cells)
|
||||||
@@ -767,6 +1009,14 @@ fn run_walk_forward_stage(
|
|||||||
is_faults.lock().unwrap().push((midx, fault));
|
is_faults.lock().unwrap().push((midx, fault));
|
||||||
placeholder_report(&[], (0, 0))
|
placeholder_report(&[], (0, 0))
|
||||||
}
|
}
|
||||||
|
Err(payload) => {
|
||||||
|
let midx = is_points
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.as_slice() == cells)
|
||||||
|
.expect("sweep enumerates exactly is_points");
|
||||||
|
is_faults.lock().unwrap().push((midx, MemberFault::Panic(panic_message(payload))));
|
||||||
|
placeholder_report(&[], (0, 0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock");
|
let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock");
|
||||||
@@ -789,29 +1039,93 @@ fn run_walk_forward_stage(
|
|||||||
// OOS: run the winner over the out-of-sample bounds; the selection is
|
// OOS: run the winner over the out-of-sample bounds; the selection is
|
||||||
// stamped on the fresh OOS report (a new record, never a mutation of
|
// stamped on the fresh OOS report (a new record, never a mutation of
|
||||||
// a stored one).
|
// a stored one).
|
||||||
let mut oos_report = match runner.run_member(
|
let oos_params = zip_params(specs, &winner.params);
|
||||||
cell,
|
let oos_called = {
|
||||||
&zip_params(specs, &winner.params),
|
let _silence = SilencedPanic::enter();
|
||||||
(w.oos.0 .0, w.oos.1 .0),
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
) {
|
runner.run_member(cell, &oos_params, (w.oos.0 .0, w.oos.1 .0))
|
||||||
Ok(report) => report,
|
}))
|
||||||
Err(fault) => {
|
};
|
||||||
|
let mut oos_report = match oos_called {
|
||||||
|
Ok(Ok(report)) => report,
|
||||||
|
Ok(Err(fault)) => {
|
||||||
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
|
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
|
||||||
return placeholder_window_run(specs);
|
return placeholder_window_run(specs);
|
||||||
}
|
}
|
||||||
|
Err(payload) => {
|
||||||
|
faults
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((widx, ExecFault::Member(MemberFault::Panic(panic_message(payload)))));
|
||||||
|
return placeholder_window_run(specs);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
oos_report.manifest.selection = Some(selection);
|
oos_report.manifest.selection = Some(selection);
|
||||||
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
|
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
|
||||||
});
|
});
|
||||||
|
|
||||||
let window_faults = faults.into_inner().expect("no thread panicked holding the lock");
|
// Registry faults (optimizer/persistence broken) stay a global abort;
|
||||||
if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) {
|
// Member/Window faults become per-fold WindowFaults via the same
|
||||||
return Err(fault);
|
// `contain` mapping the cell-level containment uses. One drain, in
|
||||||
|
// ascending window-index order — ExecFault/RegistryError are not Clone,
|
||||||
|
// so this is a single ownership-respecting pass rather than iter()-then-
|
||||||
|
// into_iter(); the first Registry fault encountered (ascending order =
|
||||||
|
// lowest index) wins, matching the existing lowest-index-fault convention.
|
||||||
|
let mut captured = faults.into_inner().expect("no thread panicked holding the lock");
|
||||||
|
captured.sort_by_key(|(idx, _)| *idx);
|
||||||
|
let mut window_faults: Vec<aura_registry::WindowFault> = Vec::with_capacity(captured.len());
|
||||||
|
for (widx, fault) in captured {
|
||||||
|
match contain(fault, stage) {
|
||||||
|
Ok(cf) => window_faults.push(aura_registry::WindowFault {
|
||||||
|
window_ordinal: widx,
|
||||||
|
kind: cf.kind,
|
||||||
|
detail: cf.detail,
|
||||||
|
}),
|
||||||
|
Err(global) => return Err(global),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reports = walkforward_member_reports(&result);
|
// Keep only real OOS reports (drop faulted-fold placeholders by the broker
|
||||||
|
// sentinel) before building the family.
|
||||||
|
let surviving: Vec<&aura_engine::WindowOutcome> = result
|
||||||
|
.windows
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.run.oos_report.manifest.broker != FAULTED_MEMBER_PLACEHOLDER_BROKER)
|
||||||
|
.collect();
|
||||||
|
if surviving.is_empty() {
|
||||||
|
// Every fold failed — the cell fails at this stage.
|
||||||
|
let first = window_faults.first().cloned();
|
||||||
|
return Ok(WfStageOutcome::CellFailed(aura_registry::CellFault {
|
||||||
|
stage,
|
||||||
|
kind: first.as_ref().map(|w| w.kind).unwrap_or(aura_registry::CellFaultKind::Run),
|
||||||
|
detail: format!(
|
||||||
|
"all {} walk_forward folds failed{}",
|
||||||
|
result.windows.len(),
|
||||||
|
first.map(|w| format!("; first: {}", w.detail)).unwrap_or_default()
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let reports = walkforward_member_reports_of(&surviving);
|
||||||
let family_id = registry
|
let family_id = registry
|
||||||
.append_family(family_name, FamilyKind::WalkForward, &reports)
|
.append_family(family_name, FamilyKind::WalkForward, &reports)
|
||||||
.map_err(ExecFault::Registry)?;
|
.map_err(ExecFault::Registry)?;
|
||||||
Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports })
|
Ok(WfStageOutcome::Ran {
|
||||||
|
family: StageFamily { stage, block: "std::walk_forward", family_id, reports },
|
||||||
|
window_faults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: a wf stage either ran (its family + any per-fold holes) or the whole
|
||||||
|
/// cell failed (every fold faulted). A Registry fault is a separate global Err
|
||||||
|
/// (returned directly from `run_walk_forward_stage`, never wrapped here).
|
||||||
|
enum WfStageOutcome {
|
||||||
|
Ran { family: StageFamily, window_faults: Vec<aura_registry::WindowFault> },
|
||||||
|
CellFailed(aura_registry::CellFault),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surviving (non-faulted) walk-forward windows' OOS reports, in roll
|
||||||
|
/// order — the same mapping `walkforward_member_reports` does over a full
|
||||||
|
/// result, applied to the filtered survivor slice.
|
||||||
|
fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome]) -> Vec<RunReport> {
|
||||||
|
windows.iter().map(|w| w.run.oos_report.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-56
@@ -12,7 +12,9 @@
|
|||||||
//! and no UI.
|
//! and no UI.
|
||||||
|
|
||||||
mod exec;
|
mod exec;
|
||||||
pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut};
|
pub use exec::{
|
||||||
|
execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut,
|
||||||
|
};
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -54,6 +56,13 @@ pub trait MemberRunner: Sync {
|
|||||||
params: &[(String, Scalar)],
|
params: &[(String, Scalar)],
|
||||||
window_ms: (i64, i64),
|
window_ms: (i64, i64),
|
||||||
) -> Result<RunReport, MemberFault>;
|
) -> Result<RunReport, MemberFault>;
|
||||||
|
|
||||||
|
/// The archive coverage of this cell's (instrument, window), when the
|
||||||
|
/// runner can know it and it deviates from the requested window (#272).
|
||||||
|
/// One call per cell — coverage is a property of the cell, not a member.
|
||||||
|
fn window_coverage(&self, _cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display-free member faults (the consumer phrases them — the RefFault
|
/// Display-free member faults (the consumer phrases them — the RefFault
|
||||||
@@ -63,6 +72,9 @@ pub enum MemberFault {
|
|||||||
NoData { instrument: String, window_ms: (i64, i64) },
|
NoData { instrument: String, window_ms: (i64, i64) },
|
||||||
Bind(String),
|
Bind(String),
|
||||||
Run(String),
|
Run(String),
|
||||||
|
/// A panic that unwound out of `MemberRunner::run_member`, caught at the
|
||||||
|
/// member boundary (#272) — the payload's best-effort message.
|
||||||
|
Panic(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preflight + runtime refusals. Display-free and by-identifier: the
|
/// Preflight + runtime refusals. Display-free and by-identifier: the
|
||||||
@@ -196,6 +208,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
|||||||
fn shape_rank(stage: &StageBlock) -> usize {
|
fn shape_rank(stage: &StageBlock) -> usize {
|
||||||
match stage {
|
match stage {
|
||||||
StageBlock::Sweep { .. } => 0,
|
StageBlock::Sweep { .. } => 0,
|
||||||
|
StageBlock::Grid => 0,
|
||||||
StageBlock::Gate { .. } => 1,
|
StageBlock::Gate { .. } => 1,
|
||||||
StageBlock::WalkForward { .. } => 2,
|
StageBlock::WalkForward { .. } => 2,
|
||||||
StageBlock::MonteCarlo { .. } => 3,
|
StageBlock::MonteCarlo { .. } => 3,
|
||||||
@@ -205,6 +218,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
|||||||
fn block_id(stage: &StageBlock) -> &'static str {
|
fn block_id(stage: &StageBlock) -> &'static str {
|
||||||
match stage {
|
match stage {
|
||||||
StageBlock::Sweep { .. } => "std::sweep",
|
StageBlock::Sweep { .. } => "std::sweep",
|
||||||
|
StageBlock::Grid => "std::grid",
|
||||||
StageBlock::Gate { .. } => "std::gate",
|
StageBlock::Gate { .. } => "std::gate",
|
||||||
StageBlock::WalkForward { .. } => "std::walk_forward",
|
StageBlock::WalkForward { .. } => "std::walk_forward",
|
||||||
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
|
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
|
||||||
@@ -216,16 +230,33 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
|||||||
// every violation: a rank drop is an out-of-order stage (an annotator
|
// every violation: a rank drop is an out-of-order stage (an annotator
|
||||||
// before a population stage, anything after std::generalize), an adjacent
|
// before a population stage, anything after std::generalize), an adjacent
|
||||||
// equal rank above the gate tier is a duplicate suffix stage.
|
// equal rank above the gate tier is a duplicate suffix stage.
|
||||||
if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) {
|
if !matches!(
|
||||||
|
process.pipeline.first(),
|
||||||
|
Some(StageBlock::Sweep { .. } | StageBlock::Grid)
|
||||||
|
) {
|
||||||
return Err(ExecFault::PipelineShape {
|
return Err(ExecFault::PipelineShape {
|
||||||
detail: "the first stage must be std::sweep".to_string(),
|
detail: "the first stage must be std::sweep or std::grid".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #256 fork B: an enumerate-only first stage yields points, not executed
|
||||||
|
// members — every stage except std::walk_forward consumes member reports,
|
||||||
|
// so std::grid must be immediately followed by std::walk_forward (in
|
||||||
|
// particular it is never terminal).
|
||||||
|
if matches!(process.pipeline.first(), Some(StageBlock::Grid))
|
||||||
|
&& !matches!(process.pipeline.get(1), Some(StageBlock::WalkForward { .. }))
|
||||||
|
{
|
||||||
|
return Err(ExecFault::PipelineShape {
|
||||||
|
detail: "a std::grid first stage must be immediately followed by \
|
||||||
|
std::walk_forward (every other stage consumes executed \
|
||||||
|
member reports)"
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut prev = &process.pipeline[0];
|
let mut prev = &process.pipeline[0];
|
||||||
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
|
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
|
||||||
if matches!(stage, StageBlock::Sweep { .. }) {
|
if matches!(stage, StageBlock::Sweep { .. } | StageBlock::Grid) {
|
||||||
return Err(ExecFault::PipelineShape {
|
return Err(ExecFault::PipelineShape {
|
||||||
detail: format!("stage {i}: only the first stage may be std::sweep"),
|
detail: format!("stage {i}: only the first stage may be {}", block_id(stage)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
|
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
|
||||||
@@ -248,6 +279,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
|||||||
// per-stage slot rules (shape already established above).
|
// per-stage slot rules (shape already established above).
|
||||||
for (i, stage) in process.pipeline.iter().enumerate() {
|
for (i, stage) in process.pipeline.iter().enumerate() {
|
||||||
match stage {
|
match stage {
|
||||||
|
StageBlock::Grid => {}
|
||||||
StageBlock::Sweep { selection } => match selection {
|
StageBlock::Sweep { selection } => match selection {
|
||||||
Some(sel) => {
|
Some(sel) => {
|
||||||
if !RANKABLE_METRICS.contains(&sel.metric.as_str()) {
|
if !RANKABLE_METRICS.contains(&sel.metric.as_str()) {
|
||||||
@@ -365,7 +397,7 @@ mod tests {
|
|||||||
sqn: 13.0,
|
sqn: 13.0,
|
||||||
net_expectancy_r: 14.0,
|
net_expectancy_r: 14.0,
|
||||||
conviction_terciles_r: [0.0; 3],
|
conviction_terciles_r: [0.0; 3],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -731,6 +763,67 @@ mod tests {
|
|||||||
assert!(preflight(&process, &c).is_ok());
|
assert!(preflight(&process, &c).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #256 fork B: the enumerate-only leading stage is accepted exactly in
|
||||||
|
/// the dissolved walkforward/mc shapes — first, immediately before
|
||||||
|
/// std::walk_forward.
|
||||||
|
#[test]
|
||||||
|
fn preflight_accepts_a_grid_first_stage_followed_by_walk_forward() {
|
||||||
|
let c = campaign();
|
||||||
|
let wf_shape =
|
||||||
|
process_of(vec![StageBlock::Grid, wf_stage("sqn_normalized", SelectRule::Argmax)]);
|
||||||
|
assert!(preflight(&wf_shape, &c).is_ok());
|
||||||
|
let mc_shape = process_of(vec![
|
||||||
|
StageBlock::Grid,
|
||||||
|
wf_stage("sqn_normalized", SelectRule::Argmax),
|
||||||
|
mc_stage(1000, 5),
|
||||||
|
]);
|
||||||
|
assert!(preflight(&mc_shape, &c).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #256 fork B: every other placement is refused — grid terminal, grid
|
||||||
|
/// before a report-consuming stage (gate / monte_carlo / generalize),
|
||||||
|
/// grid anywhere but first. Every refusal is a PipelineShape prose fault.
|
||||||
|
#[test]
|
||||||
|
fn preflight_refuses_grid_placement_violations() {
|
||||||
|
let c = campaign();
|
||||||
|
let c2 = campaign_two_instruments();
|
||||||
|
let adjacency = "immediately followed by std::walk_forward";
|
||||||
|
let cases: Vec<(Vec<StageBlock>, &str, &CampaignDoc)> = vec![
|
||||||
|
(vec![StageBlock::Grid], adjacency, &c),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
StageBlock::Grid,
|
||||||
|
gate_stage("net_expectancy_r"),
|
||||||
|
wf_stage("sqn_normalized", SelectRule::Argmax),
|
||||||
|
],
|
||||||
|
adjacency,
|
||||||
|
&c,
|
||||||
|
),
|
||||||
|
(vec![StageBlock::Grid, mc_stage(1000, 5)], adjacency, &c),
|
||||||
|
(
|
||||||
|
vec![StageBlock::Grid, generalize_stage("net_expectancy_r")],
|
||||||
|
adjacency,
|
||||||
|
&c2,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
sweep_stage("sqn_normalized", SelectRule::Argmax, false),
|
||||||
|
StageBlock::Grid,
|
||||||
|
],
|
||||||
|
"only the first stage may be std::grid",
|
||||||
|
&c,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (pipeline, needle, camp) in cases {
|
||||||
|
let err =
|
||||||
|
preflight(&process_of(pipeline), camp).expect_err("placement violation");
|
||||||
|
let ExecFault::PipelineShape { detail } = &err else {
|
||||||
|
panic!("expected PipelineShape, got {err:?}");
|
||||||
|
};
|
||||||
|
assert!(detail.contains(needle), "detail {detail:?} misses {needle:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A selection-free sweep followed by any other stage is refused: a
|
/// A selection-free sweep followed by any other stage is refused: a
|
||||||
/// downstream stage would have no selection/nominee to consume.
|
/// downstream stage would have no selection/nominee to consume.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -885,8 +978,10 @@ mod wf_tests {
|
|||||||
/// A per-test registry in a fresh temp dir (a Registry's family store
|
/// A per-test registry in a fresh temp dir (a Registry's family store
|
||||||
/// isolates per DIRECTORY, not per filename — see Registry::open).
|
/// isolates per DIRECTORY, not per filename — see Registry::open).
|
||||||
fn wf_registry(name: &str) -> Registry {
|
fn wf_registry(name: &str) -> Registry {
|
||||||
let dir = std::env::temp_dir()
|
// fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
|
||||||
.join(format!("aura-campaign-wf-{}-{}", std::process::id(), name));
|
// pre-create wipe below then genuinely reclaims the previous run.
|
||||||
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join(format!("aura-campaign-wf-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
Registry::open(dir.join("runs.jsonl"))
|
Registry::open(dir.join("runs.jsonl"))
|
||||||
@@ -1163,75 +1258,161 @@ mod wf_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A member fault raised INSIDE a window's in-sample sweep aborts the
|
/// #272: a member fault raised INSIDE one window's in-sample sweep is
|
||||||
/// walk_forward stage with that fault, and among several faulted windows
|
/// contained as that fold's `WindowFault` — the surviving windows still
|
||||||
/// the LOWEST roll index wins (the per-window `min_by_key` attribution
|
/// run, pool into the walk_forward family, and the campaign run record
|
||||||
/// mirrors the sweep stage's own lowest-enumeration-index convention) —
|
/// persists. Faulting w1's IS run out of 3 windows (w0, w1, w2) leaves
|
||||||
/// never the faulted stage's family, since a captured fault returns
|
/// exactly 2 surviving OOS reports and one recorded window fault at
|
||||||
/// before any registry write.
|
/// ordinal 1.
|
||||||
#[test]
|
#[test]
|
||||||
fn wf_is_member_fault_lowest_window_wins() {
|
fn wf_one_faulted_fold_is_contained_survivors_pool() {
|
||||||
let registry = wf_registry("is-fault");
|
let registry = wf_registry("is-fault");
|
||||||
let campaign = wf_campaign((0, 99));
|
let campaign = wf_campaign((0, 99));
|
||||||
let process = wf_process(None, 40, 20, 20);
|
let process = wf_process(None, 40, 20, 20);
|
||||||
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault both w1's and
|
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault only w1's IS
|
||||||
// w2's IS runs; the widx-1 fault must win over widx-2's.
|
// run; w0 and w2 survive and pool.
|
||||||
|
let runner = WfFakeRunner::faulty(vec![(
|
||||||
|
(20, 59),
|
||||||
|
vec![("len".to_string(), Scalar::i64(4))],
|
||||||
|
MemberFault::Run("is-w1".to_string()),
|
||||||
|
)]);
|
||||||
|
let outcome = run_wf(&campaign, &process, &runner, ®istry)
|
||||||
|
.expect("a faulted fold is contained, not a stage abort");
|
||||||
|
|
||||||
|
let fam = outcome.cells[0]
|
||||||
|
.families
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.block == "std::walk_forward")
|
||||||
|
.expect("wf family present despite the faulted fold");
|
||||||
|
assert_eq!(fam.reports.len(), 2, "only the 2 surviving windows pool");
|
||||||
|
|
||||||
|
let cell = &outcome.record.cells[0];
|
||||||
|
let wf_stage = cell
|
||||||
|
.stages
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.block == "std::walk_forward")
|
||||||
|
.expect("wf stage realized");
|
||||||
|
assert_eq!(wf_stage.window_faults.len(), 1);
|
||||||
|
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
|
||||||
|
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Run);
|
||||||
|
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
|
||||||
|
|
||||||
|
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||||
|
let members = registry.load_family_members().expect("load members");
|
||||||
|
assert!(members.iter().any(|m| m.kind == FamilyKind::WalkForward));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: every fold faulting (both the IS sweep AND the OOS run, across
|
||||||
|
/// all 3 windows) leaves zero surviving OOS reports — the whole cell
|
||||||
|
/// fails at the wf stage, with no WalkForward family persisted, while the
|
||||||
|
/// campaign run record still records the failed cell.
|
||||||
|
#[test]
|
||||||
|
fn wf_all_folds_faulted_fails_the_cell() {
|
||||||
|
let registry = wf_registry("all-folds-fault");
|
||||||
|
let campaign = wf_campaign((0, 99));
|
||||||
|
let process = wf_process(None, 40, 20, 20);
|
||||||
|
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault every window's
|
||||||
|
// IS run so no window reaches its OOS call.
|
||||||
let runner = WfFakeRunner::faulty(vec![
|
let runner = WfFakeRunner::faulty(vec![
|
||||||
|
(
|
||||||
|
(0, 39),
|
||||||
|
vec![("len".to_string(), Scalar::i64(4))],
|
||||||
|
MemberFault::Run("is-w0".to_string()),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
(20, 59),
|
(20, 59),
|
||||||
vec![("len".to_string(), Scalar::i64(3))],
|
vec![("len".to_string(), Scalar::i64(4))],
|
||||||
MemberFault::Run("is-w1".to_string()),
|
MemberFault::Run("is-w1".to_string()),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
(40, 79),
|
(40, 79),
|
||||||
vec![("len".to_string(), Scalar::i64(2))],
|
vec![("len".to_string(), Scalar::i64(4))],
|
||||||
MemberFault::Run("is-w2".to_string()),
|
MemberFault::Run("is-w2".to_string()),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let err = run_wf(&campaign, &process, &runner, ®istry)
|
let outcome = run_wf(&campaign, &process, &runner, ®istry)
|
||||||
.expect_err("a faulted IS member aborts the walk_forward stage");
|
.expect("an all-folds-faulted stage fails the cell, not the process");
|
||||||
match err {
|
|
||||||
ExecFault::Member(fault) => assert_eq!(
|
assert!(
|
||||||
fault,
|
outcome.cells[0].families.iter().all(|f| f.block != "std::walk_forward"),
|
||||||
MemberFault::Run("is-w1".to_string()),
|
"no WalkForward family is produced when every fold fails"
|
||||||
"the LOWEST window index's fault wins, not w2's later one",
|
);
|
||||||
),
|
let cell = &outcome.record.cells[0];
|
||||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
let fault = cell.fault.as_ref().expect("the cell fails at the wf stage");
|
||||||
}
|
assert_eq!(fault.stage, 1, "walk_forward is pipeline stage 1 here");
|
||||||
|
assert!(fault.detail.contains("all 3 walk_forward folds failed"));
|
||||||
|
|
||||||
|
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||||
let members = registry.load_family_members().expect("load members");
|
let members = registry.load_family_members().expect("load members");
|
||||||
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
|
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
|
||||||
assert!(registry.load_campaign_runs().expect("load campaign runs").is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A member fault raised running the IS winner over its OUT-of-sample
|
/// #272: a member PANIC inside a window's in-sample sweep is caught at the
|
||||||
/// bounds (a call distinct from every IS-sweep call, since the OOS
|
/// wf member boundary (`catch_unwind`) and contained as that fold's
|
||||||
/// window's bounds differ from every window's IS bounds) also aborts the
|
/// `WindowFault` with kind `panic` — the process does not abort, the
|
||||||
/// walk_forward stage with that fault, and — like the IS-sweep path —
|
/// surviving folds pool, exactly as an `Err` fault is contained. Guards the
|
||||||
/// never persists the faulted stage's family.
|
/// wf-path panic arm (the sweep-path arm is covered by `PanicRunner` in
|
||||||
|
/// tests/execute.rs; this is its walk_forward mirror).
|
||||||
#[test]
|
#[test]
|
||||||
fn wf_oos_member_fault_aborts_walk_forward() {
|
fn wf_member_panic_in_a_fold_is_contained() {
|
||||||
let registry = wf_registry("oos-fault");
|
// Panics on w1's IS bounds (20,59); every other call returns a report.
|
||||||
|
struct WfPanicRunner;
|
||||||
|
impl MemberRunner for WfPanicRunner {
|
||||||
|
fn run_member(
|
||||||
|
&self,
|
||||||
|
_cell: &CellSpec,
|
||||||
|
params: &[(String, Scalar)],
|
||||||
|
window_ms: (i64, i64),
|
||||||
|
) -> Result<RunReport, MemberFault> {
|
||||||
|
assert_ne!(window_ms, (20, 59), "planted panic on w1's in-sample run");
|
||||||
|
let total: f64 = params
|
||||||
|
.iter()
|
||||||
|
.map(|(_, s)| match s {
|
||||||
|
Scalar::I64(v) => *v as f64,
|
||||||
|
Scalar::F64(v) => *v,
|
||||||
|
_ => 0.0,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
Ok(RunReport {
|
||||||
|
manifest: RunManifest {
|
||||||
|
commit: "wf-panic-fake".to_string(),
|
||||||
|
params: params.to_vec(),
|
||||||
|
defaults: Vec::new(),
|
||||||
|
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "fake".to_string(),
|
||||||
|
selection: None,
|
||||||
|
instrument: None,
|
||||||
|
topology_hash: None,
|
||||||
|
project: None,
|
||||||
|
},
|
||||||
|
metrics: RunMetrics {
|
||||||
|
total_pips: total,
|
||||||
|
max_drawdown: 0.0,
|
||||||
|
bias_sign_flips: 0,
|
||||||
|
r: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let registry = wf_registry("is-panic");
|
||||||
let campaign = wf_campaign((0, 99));
|
let campaign = wf_campaign((0, 99));
|
||||||
let process = wf_process(None, 40, 20, 20);
|
let process = wf_process(None, 40, 20, 20);
|
||||||
// total_pips == len, so the IS winner is always len=4 (argmax over
|
let strategies = vec![("s".repeat(64), "{}".to_string())];
|
||||||
// [1,2,3,4]); fault only window 0's OOS run of that winner (OOS
|
let outcome = execute(&"e".repeat(64), &campaign, &process, &strategies, &WfPanicRunner, ®istry)
|
||||||
// bounds (40,59) match no window's IS bounds and no other window's
|
.expect("a member panic in a fold is contained, not a process abort");
|
||||||
// OOS bounds).
|
|
||||||
let runner = WfFakeRunner::faulty(vec![(
|
let cell = &outcome.record.cells[0];
|
||||||
(40, 59),
|
let wf_stage = cell
|
||||||
vec![("len".to_string(), Scalar::i64(4))],
|
.stages
|
||||||
MemberFault::Run("oos-w0".to_string()),
|
.iter()
|
||||||
)]);
|
.find(|s| s.block == "std::walk_forward")
|
||||||
let err = run_wf(&campaign, &process, &runner, ®istry)
|
.expect("wf stage realized despite the panicked fold");
|
||||||
.expect_err("a faulted OOS member aborts the walk_forward stage");
|
assert_eq!(wf_stage.window_faults.len(), 1);
|
||||||
match err {
|
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
|
||||||
ExecFault::Member(fault) => {
|
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Panic);
|
||||||
assert_eq!(fault, MemberFault::Run("oos-w0".to_string()))
|
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
|
||||||
}
|
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
|
||||||
}
|
|
||||||
let members = registry.load_family_members().expect("load members");
|
|
||||||
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// select_sweep_winner's Plateau arms dispatch through the public
|
/// select_sweep_winner's Plateau arms dispatch through the public
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
|
|||||||
|
|
||||||
/// The deterministic 4-trade R series a planted report carries (matches
|
/// The deterministic 4-trade R series a planted report carries (matches
|
||||||
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory
|
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory
|
||||||
/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
/// only: `net_trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
||||||
/// existing equality and round-trip assertion stays green.
|
/// existing equality and round-trip assertion stays green.
|
||||||
fn planted_trade_rs(net: f64) -> Vec<f64> {
|
fn planted_net_trade_rs(net: f64) -> Vec<f64> {
|
||||||
vec![net, -net, 2.0 * net, net]
|
vec![net, -net, 2.0 * net, net]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
|
|||||||
sqn_normalized: net,
|
sqn_normalized: net,
|
||||||
net_expectancy_r: net,
|
net_expectancy_r: net,
|
||||||
conviction_terciles_r: [0.0, 0.0, 0.0],
|
conviction_terciles_r: [0.0, 0.0, 0.0],
|
||||||
trade_rs: planted_trade_rs(net),
|
net_trade_rs: planted_net_trade_rs(net),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,22 @@ impl MemberRunner for ZeroTradeRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A runner whose `run_member` always panics — the #272 panic-containment
|
||||||
|
/// fixture (`catch_unwind` at the member boundary must turn this into a
|
||||||
|
/// `MemberFault::Panic`, never an unwind out of `execute`).
|
||||||
|
struct PanicRunner;
|
||||||
|
|
||||||
|
impl MemberRunner for PanicRunner {
|
||||||
|
fn run_member(
|
||||||
|
&self,
|
||||||
|
_cell: &CellSpec,
|
||||||
|
_params: &[(String, Scalar)],
|
||||||
|
_window_ms: (i64, i64),
|
||||||
|
) -> Result<RunReport, MemberFault> {
|
||||||
|
panic!("planted member panic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest):
|
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest):
|
||||||
/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
|
/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
|
||||||
fn axes_2x2() -> BTreeMap<String, Axis> {
|
fn axes_2x2() -> BTreeMap<String, Axis> {
|
||||||
@@ -219,8 +235,8 @@ fn strategies() -> Vec<(String, String)> {
|
|||||||
/// Per-test registry DIRECTORY (family/campaign stores are per-directory
|
/// Per-test registry DIRECTORY (family/campaign stores are per-directory
|
||||||
/// siblings of the runs path — the aura-registry temp-dir idiom).
|
/// siblings of the runs path — the aura-registry temp-dir idiom).
|
||||||
fn temp_registry(name: &str) -> Registry {
|
fn temp_registry(name: &str) -> Registry {
|
||||||
let dir = std::env::temp_dir()
|
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||||
.join(format!("aura-campaign-exec-{}-{}", std::process::id(), name));
|
.join(format!("aura-campaign-exec-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
||||||
Registry::open(dir.join("runs.jsonl"))
|
Registry::open(dir.join("runs.jsonl"))
|
||||||
@@ -400,34 +416,57 @@ fn execute_is_deterministic_twice() {
|
|||||||
assert_eq!(runs[1], second.record);
|
assert_eq!(runs[1], second.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: a member fault is contained as a failed cell, not a global abort —
|
||||||
|
/// the campaign run record still persists (the whole point of containment),
|
||||||
|
/// while a sweep-faulted cell writes no family (`run_members` returns before
|
||||||
|
/// `append_family`).
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_member_fault_aborts_with_lowest_index() {
|
fn execute_member_fault_records_a_failed_cell_and_continues() {
|
||||||
let reg = temp_registry("member_fault");
|
let reg = temp_registry("member_fault");
|
||||||
let doc = campaign(&["EURUSD"]);
|
// two instruments; the planted fault is keyed by param point, so both
|
||||||
|
// cells' sweeps hit it and both fail — the containment property under test
|
||||||
|
// is that execute returns Ok and records every cell, not a global abort.
|
||||||
|
let doc = campaign(&["AAA", "BBB"]);
|
||||||
let proc_doc = process(vec![sweep_stage(false)]);
|
let proc_doc = process(vec![sweep_stage(false)]);
|
||||||
// faults planted at enumeration indices 1 (2,9) and 2 (3,6)
|
|
||||||
let point = |fast: i64, slow: i64| {
|
let point = |fast: i64, slow: i64| {
|
||||||
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
|
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
|
||||||
};
|
};
|
||||||
|
// FakeRunner faults are matched by param point regardless of instrument;
|
||||||
|
// plant one on the grid so AAA's (and BBB's) sweep hit it — the whole
|
||||||
|
// sweep-faulted cell fails (grid hole compromises selection).
|
||||||
let runner = FakeRunner {
|
let runner = FakeRunner {
|
||||||
faults: vec![
|
faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
|
||||||
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
|
|
||||||
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||||||
.expect_err("a faulted member aborts the run");
|
.expect("a faulted member is recorded, not a global abort");
|
||||||
match err {
|
assert_eq!(out.record.cells.len(), 2, "both cells are recorded");
|
||||||
ExecFault::Member(fault) => assert_eq!(
|
for cell in &out.record.cells {
|
||||||
fault,
|
let fault = cell.fault.as_ref().expect("sweep-fault fails the cell");
|
||||||
MemberFault::Run("boom at index 1".to_string()),
|
assert_eq!(fault.stage, 0);
|
||||||
"the LOWEST enumeration index's fault is reported",
|
assert_eq!(fault.kind, aura_registry::CellFaultKind::Run);
|
||||||
),
|
|
||||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
|
||||||
}
|
}
|
||||||
// the fault aborted before any family or realization write
|
// The run record persisted (the whole point of containment); a sweep-fault
|
||||||
|
// cell writes no family (run_members returns before append_family).
|
||||||
|
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
|
||||||
assert!(reg.load_family_members().expect("load members").is_empty());
|
assert!(reg.load_family_members().expect("load members").is_empty());
|
||||||
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
|
}
|
||||||
|
|
||||||
|
/// #272: a panic that unwinds out of `MemberRunner::run_member` is caught at
|
||||||
|
/// the member boundary, not propagated — the cell is recorded failed with a
|
||||||
|
/// `Panic` kind, and the run record persists.
|
||||||
|
#[test]
|
||||||
|
fn execute_member_panic_is_contained_as_a_failed_cell() {
|
||||||
|
let reg = temp_registry("member_panic");
|
||||||
|
let doc = campaign(&["AAA", "BBB"]);
|
||||||
|
let proc_doc = process(vec![sweep_stage(false)]);
|
||||||
|
let runner = PanicRunner;
|
||||||
|
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||||||
|
.expect("a member panic is contained, not a process abort");
|
||||||
|
assert!(out.record.cells.iter().all(|c| c
|
||||||
|
.fault
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.kind == aura_registry::CellFaultKind::Panic)));
|
||||||
|
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -483,11 +522,11 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
|
|||||||
assert_eq!(mc.selection, None);
|
assert_eq!(mc.selection, None);
|
||||||
|
|
||||||
// each survivor's bootstrap == a hand-called r_bootstrap on its planted
|
// each survivor's bootstrap == a hand-called r_bootstrap on its planted
|
||||||
// trade_rs, seeded from the campaign doc (the deflation convention)
|
// net_trade_rs, seeded from the campaign doc (the deflation convention)
|
||||||
let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
|
let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
|
||||||
let expected = StageBootstrap::PerSurvivor(vec![
|
let expected = StageBootstrap::PerSurvivor(vec![
|
||||||
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)),
|
(2, r_bootstrap(&planted_net_trade_rs(net(3, 6)), 200, 2, 7)),
|
||||||
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)),
|
(3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)),
|
||||||
]);
|
]);
|
||||||
assert_eq!(mc.bootstrap, Some(expected));
|
assert_eq!(mc.bootstrap, Some(expected));
|
||||||
|
|
||||||
@@ -496,6 +535,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
|
|||||||
assert_eq!(runs[0], out.record);
|
assert_eq!(runs[0], out.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#256 fork B): a `std::grid` first stage crosses the `run_cell`
|
||||||
|
/// seam as bare parameter points (`StageFlow::Points`) rather than executed
|
||||||
|
/// members — it persists no family of its own — and the immediately-following
|
||||||
|
/// `std::walk_forward` stage still sweeps exactly the full axis grid, picking
|
||||||
|
/// the same per-window winner a selection-carrying `std::sweep` feeding the
|
||||||
|
/// same wf stage would (`execute_mc_after_wf_pools_the_oos_series`'s
|
||||||
|
/// `sweep_stage()` + `wf_stage()` shape, minus the intermediate sweep family).
|
||||||
|
/// A regression that let `StageFlow::Points` drop or mis-order a point before
|
||||||
|
/// crossing the seam would flip the wf winner or the per-window report count
|
||||||
|
/// here even though the preflight placement tests (lib.rs) stay green.
|
||||||
|
#[test]
|
||||||
|
fn execute_grid_first_stage_feeds_walk_forward_with_all_grid_points() {
|
||||||
|
let reg = temp_registry("grid_then_wf");
|
||||||
|
let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000]
|
||||||
|
let proc_doc = process(vec![StageBlock::Grid, wf_stage()]);
|
||||||
|
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||||
|
.expect("grid-then-wf campaign executes");
|
||||||
|
|
||||||
|
// std::grid persists no family of its own — only std::walk_forward's does
|
||||||
|
assert_eq!(out.cells[0].families.len(), 1);
|
||||||
|
let wf = &out.cells[0].families[0];
|
||||||
|
assert_eq!(wf.block, "std::walk_forward");
|
||||||
|
|
||||||
|
// stage realization: std::grid first (no family/selection), then wf
|
||||||
|
let cell = &out.record.cells[0];
|
||||||
|
assert_eq!(cell.stages.len(), 2);
|
||||||
|
assert_eq!(cell.stages[0].block, "std::grid");
|
||||||
|
assert_eq!(cell.stages[0].family_id, None);
|
||||||
|
assert_eq!(cell.stages[0].survivor_ordinals, None);
|
||||||
|
assert_eq!(cell.stages[0].selection, None);
|
||||||
|
assert_eq!(cell.stages[1].block, "std::walk_forward");
|
||||||
|
|
||||||
|
// the roller yields 2 windows over the FULL 2x2 grid (nothing filtered
|
||||||
|
// the population before wf); both pick the planted argmax (3,9)
|
||||||
|
assert_eq!(wf.reports.len(), 2);
|
||||||
|
let winner_params =
|
||||||
|
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))];
|
||||||
|
for report in &wf.reports {
|
||||||
|
assert_eq!(report.manifest.params, winner_params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the record round-trips through the store unchanged
|
||||||
|
let runs = reg.load_campaign_runs().expect("load campaign runs");
|
||||||
|
assert_eq!(runs[0], out.record);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_mc_after_wf_pools_the_oos_series() {
|
fn execute_mc_after_wf_pools_the_oos_series() {
|
||||||
let reg = temp_registry("mc_pooled");
|
let reg = temp_registry("mc_pooled");
|
||||||
@@ -513,15 +598,15 @@ fn execute_mc_after_wf_pools_the_oos_series() {
|
|||||||
assert_eq!(wf.block, "std::walk_forward");
|
assert_eq!(wf.block, "std::walk_forward");
|
||||||
assert_eq!(wf.reports.len(), 2);
|
assert_eq!(wf.reports.len(), 2);
|
||||||
|
|
||||||
// pooled input = the wf family reports' trade_rs concatenated in report
|
// pooled input = the wf family reports' net_trade_rs concatenated in report
|
||||||
// order (roll order per walkforward_member_reports)
|
// order (roll order per walkforward_member_reports)
|
||||||
let mut pooled: Vec<f64> = Vec::new();
|
let mut pooled: Vec<f64> = Vec::new();
|
||||||
for report in &wf.reports {
|
for report in &wf.reports {
|
||||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
|
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
|
||||||
}
|
}
|
||||||
let net = (3 * 10 + 9) as f64 / 100.0;
|
let net = (3 * 10 + 9) as f64 / 100.0;
|
||||||
let mut expected_pool = planted_trade_rs(net);
|
let mut expected_pool = planted_net_trade_rs(net);
|
||||||
expected_pool.extend(planted_trade_rs(net));
|
expected_pool.extend(planted_net_trade_rs(net));
|
||||||
assert_eq!(pooled, expected_pool);
|
assert_eq!(pooled, expected_pool);
|
||||||
|
|
||||||
let mc = &out.record.cells[0].stages[2];
|
let mc = &out.record.cells[0].stages[2];
|
||||||
@@ -677,7 +762,7 @@ fn execute_generalize_only_after_sweep() {
|
|||||||
/// annotators compose in ONE pipeline without disturbing each other or the
|
/// annotators compose in ONE pipeline without disturbing each other or the
|
||||||
/// population stages' generalize-nominee. `std::monte_carlo` sits between
|
/// population stages' generalize-nominee. `std::monte_carlo` sits between
|
||||||
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its
|
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its
|
||||||
/// per-cell bootstrap still pools exactly the wf family's OOS `trade_rs`
|
/// per-cell bootstrap still pools exactly the wf family's OOS `net_trade_rs`
|
||||||
/// (unaffected by generalize running after it), and `std::generalize`'s
|
/// (unaffected by generalize running after it), and `std::generalize`'s
|
||||||
/// campaign-scope nominee is still the wf stage's last-window winner
|
/// campaign-scope nominee is still the wf stage's last-window winner
|
||||||
/// (unaffected by mc having annotated the cell first). A regression that
|
/// (unaffected by mc having annotated the cell first). A regression that
|
||||||
@@ -706,13 +791,13 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
|
|||||||
assert_eq!(cell.stages[3].block, "std::monte_carlo");
|
assert_eq!(cell.stages[3].block, "std::monte_carlo");
|
||||||
}
|
}
|
||||||
|
|
||||||
// mc's bootstrap pools the wf family's OOS trade_rs exactly as it does
|
// mc's bootstrap pools the wf family's OOS net_trade_rs exactly as it does
|
||||||
// with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
|
// with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
|
||||||
let wf = &out.cells[0].families[1];
|
let wf = &out.cells[0].families[1];
|
||||||
assert_eq!(wf.block, "std::walk_forward");
|
assert_eq!(wf.block, "std::walk_forward");
|
||||||
let mut pooled: Vec<f64> = Vec::new();
|
let mut pooled: Vec<f64> = Vec::new();
|
||||||
for report in &wf.reports {
|
for report in &wf.reports {
|
||||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
|
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
|
||||||
}
|
}
|
||||||
let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
|
let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
|
||||||
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
|
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
|
|||||||
sqn: 13.0,
|
sqn: 13.0,
|
||||||
net_expectancy_r: 14.0,
|
net_expectancy_r: 14.0,
|
||||||
conviction_terciles_r: [0.0; 3],
|
conviction_terciles_r: [0.0; 3],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +72,11 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_compo
|
|||||||
/// cannot diverge from the run-side cost model (the #219 divergence class,
|
/// cannot diverge from the run-side cost model (the #219 divergence class,
|
||||||
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
|
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
|
||||||
/// — the `CostSpec` serde vocabulary conforms to them.
|
/// — the `CostSpec` serde vocabulary conforms to them.
|
||||||
pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec<PrimitiveBuilder> {
|
pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
|
||||||
specs
|
specs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let (knob, v) = cost_knob(s);
|
let (knob, v) = cost_knob(s, instrument);
|
||||||
match s {
|
match s {
|
||||||
CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)),
|
CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)),
|
||||||
CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)),
|
CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)),
|
||||||
@@ -88,12 +91,19 @@ pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec<PrimitiveBuilder> {
|
|||||||
/// (main.rs): the stamp key must equal the bind key for reproduce to
|
/// (main.rs): the stamp key must equal the bind key for reproduce to
|
||||||
/// re-derive a `CostSpec` from a stored manifest, so both sites read the
|
/// re-derive a `CostSpec` from a stored manifest, so both sites read the
|
||||||
/// name off this single function rather than each carrying its own literal.
|
/// name off this single function rather than each carrying its own literal.
|
||||||
pub(crate) fn cost_knob(spec: &CostSpec) -> (&'static str, f64) {
|
pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) {
|
||||||
match spec {
|
let (knob, value) = match spec {
|
||||||
CostSpec::Constant { cost_per_trade } => ("cost_per_trade", *cost_per_trade),
|
CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
|
||||||
CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", *slip_vol_mult),
|
CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
|
||||||
CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", *carry_per_cycle),
|
CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle),
|
||||||
}
|
};
|
||||||
|
let v = value.resolve(instrument).unwrap_or_else(|| {
|
||||||
|
// Unreachable after intrinsic validation (map keys ≡ instruments);
|
||||||
|
// refuse loudly rather than charging 0 silently if it ever surfaces.
|
||||||
|
eprintln!("aura: cost {knob}: no entry for instrument {instrument}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
(knob, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
|
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
|
||||||
@@ -139,16 +149,7 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String {
|
|||||||
"process stage {stage}: walk_forward windows do not fit the campaign \
|
"process stage {stage}: walk_forward windows do not fit the campaign \
|
||||||
window: {detail}"
|
window: {detail}"
|
||||||
),
|
),
|
||||||
ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!(
|
ExecFault::Member(m) => aura_campaign::member_fault_prose(m),
|
||||||
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
|
|
||||||
window_ms.0, window_ms.1
|
|
||||||
),
|
|
||||||
ExecFault::Member(MemberFault::Bind(detail)) => {
|
|
||||||
format!("a member failed to bind: {detail}")
|
|
||||||
}
|
|
||||||
ExecFault::Member(MemberFault::Run(detail)) => {
|
|
||||||
format!("a member failed to run: {detail}")
|
|
||||||
}
|
|
||||||
ExecFault::Registry(e) => e.to_string(),
|
ExecFault::Registry(e) => e.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,10 +398,67 @@ impl MemberRunner for CliMemberRunner<'_> {
|
|||||||
stop,
|
stop,
|
||||||
&binding,
|
&binding,
|
||||||
&self.cost,
|
&self.cost,
|
||||||
|
&cell.instrument,
|
||||||
);
|
);
|
||||||
report.manifest.instrument = Some(cell.instrument.clone());
|
report.manifest.instrument = Some(cell.instrument.clone());
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: derive the cell's archive coverage from the #264 primitives —
|
||||||
|
/// `None` when the archive has no file for the instrument at all (the
|
||||||
|
/// `NoData` member fault's job, not coverage) or when the effective
|
||||||
|
/// evaluated span covers the requested window exactly with no interior
|
||||||
|
/// gap month. One call per cell (not per member): coverage is a property
|
||||||
|
/// of the cell's (instrument, window), never a swept param point.
|
||||||
|
fn window_coverage(&self, cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
|
||||||
|
let data_path = PathBuf::from(self.env.data_path());
|
||||||
|
let months = aura_ingest::list_m1_months(&data_path, &cell.instrument);
|
||||||
|
if months.is_empty() {
|
||||||
|
return None; // no archive at all is the NoData fault's job, not coverage
|
||||||
|
}
|
||||||
|
let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent(
|
||||||
|
&self.server,
|
||||||
|
&data_path,
|
||||||
|
&cell.instrument,
|
||||||
|
Some(cell.window_ms.0),
|
||||||
|
Some(cell.window_ms.1),
|
||||||
|
)?;
|
||||||
|
let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts);
|
||||||
|
let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts);
|
||||||
|
let gap_months = interior_gap_months(&months, cell.window_ms);
|
||||||
|
if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() {
|
||||||
|
return None; // fully covered — nothing to annotate
|
||||||
|
}
|
||||||
|
Some(aura_registry::CellCoverage {
|
||||||
|
effective_from_ms: eff_from,
|
||||||
|
effective_to_ms: eff_to,
|
||||||
|
gap_months,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole `(year, month)`s missing INSIDE `months`' own span (mirrors the
|
||||||
|
/// interior-gap walk `data_coverage_report`, main.rs, performs over a full
|
||||||
|
/// archive listing) that also fall inside `window_ms` (Unix-ms) — one
|
||||||
|
/// `"YYYY-MM"` entry per missing month, never a collapsed range: the
|
||||||
|
/// registry's `CellCoverage::gap_months` is a flat list an aggregate counts
|
||||||
|
/// directly. `months` is assumed sorted ([`aura_ingest::list_m1_months`]'s own
|
||||||
|
/// contract).
|
||||||
|
fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
|
||||||
|
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
|
||||||
|
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for pair in months.windows(2) {
|
||||||
|
let (prev, next) = (pair[0], pair[1]);
|
||||||
|
let mut ym = crate::next_year_month(prev);
|
||||||
|
while ym != next {
|
||||||
|
if ym >= from_ym && ym <= to_ym {
|
||||||
|
out.push(crate::fmt_year_month(ym));
|
||||||
|
}
|
||||||
|
ym = crate::next_year_month(ym);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
|
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
|
||||||
@@ -417,8 +475,10 @@ pub(crate) enum RunPresentation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
|
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
|
||||||
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1.
|
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
|
||||||
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
|
/// carries the failed-cell count (#272) so the caller can exit 3 on a
|
||||||
|
/// completed-with-failures run via `exit_on_campaign_result`.
|
||||||
|
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<usize, String> {
|
||||||
// Project gate FIRST: nothing (not even the file-sugar registration)
|
// Project gate FIRST: nothing (not even the file-sugar registration)
|
||||||
// touches a store outside a project.
|
// touches a store outside a project.
|
||||||
if env.provenance().is_none() {
|
if env.provenance().is_none() {
|
||||||
@@ -480,7 +540,7 @@ pub(crate) fn run_campaign_by_id(
|
|||||||
campaign_id: &str,
|
campaign_id: &str,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
presentation: RunPresentation,
|
presentation: RunPresentation,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let run = run_campaign_returning(campaign_id, env)?;
|
let run = run_campaign_returning(campaign_id, env)?;
|
||||||
present_campaign(run, presentation, env)
|
present_campaign(run, presentation, env)
|
||||||
}
|
}
|
||||||
@@ -589,7 +649,7 @@ fn present_campaign(
|
|||||||
run: CampaignRun,
|
run: CampaignRun,
|
||||||
presentation: RunPresentation,
|
presentation: RunPresentation,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let CampaignRun { outcome, campaign, strategies, server } = run;
|
let CampaignRun { outcome, campaign, strategies, server } = run;
|
||||||
|
|
||||||
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
||||||
@@ -610,6 +670,22 @@ fn present_campaign(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #272: failed-cell notes (the gate-empty idiom's sibling) — a failed
|
||||||
|
// cell never aborts the campaign; its fault is recorded and the run
|
||||||
|
// continues over the remaining cells.
|
||||||
|
let mut failed = 0usize;
|
||||||
|
let mut fail_labels: Vec<String> = Vec::new();
|
||||||
|
for cell in &outcome.record.cells {
|
||||||
|
if let Some(f) = &cell.fault {
|
||||||
|
failed += 1;
|
||||||
|
fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind)));
|
||||||
|
eprintln!(
|
||||||
|
"aura: cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
|
||||||
|
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emission: emit-gated family/selection lines per cell, then the
|
// Emission: emit-gated family/selection lines per cell, then the
|
||||||
// always-on final record line. Matched by NAME against the two closed-
|
// always-on final record line. Matched by NAME against the two closed-
|
||||||
// vocabulary terms (self-evident at the call site, unlike positional
|
// vocabulary terms (self-evident at the call site, unlike positional
|
||||||
@@ -657,6 +733,16 @@ fn present_campaign(
|
|||||||
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
||||||
.expect("campaign run record serializes")
|
.expect("campaign run record serializes")
|
||||||
);
|
);
|
||||||
|
eprintln!(
|
||||||
|
"campaign run {} recorded: {} cells{}",
|
||||||
|
outcome.record.run,
|
||||||
|
outcome.record.cells.len(),
|
||||||
|
if failed == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(", {failed} failed ({})", fail_labels.join(", "))
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace persistence runs LAST, after the always-on record line (0109):
|
// Trace persistence runs LAST, after the always-on record line (0109):
|
||||||
@@ -674,7 +760,22 @@ fn present_campaign(
|
|||||||
env,
|
env,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: the fault-kind label the failed-cell summary line names — the same
|
||||||
|
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
|
||||||
|
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
|
||||||
|
/// frame of the enum variant).
|
||||||
|
fn cell_fault_kind_label(kind: aura_registry::CellFaultKind) -> &'static str {
|
||||||
|
use aura_registry::CellFaultKind as K;
|
||||||
|
match kind {
|
||||||
|
K::NoData => "no_data",
|
||||||
|
K::Bind => "bind",
|
||||||
|
K::Run => "run",
|
||||||
|
K::Panic => "panic",
|
||||||
|
K::Window => "window",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which drained wrap-convention channel a requested tap persists from on a
|
/// Which drained wrap-convention channel a requested tap persists from on a
|
||||||
@@ -975,7 +1076,7 @@ pub(crate) fn persist_campaign_traces(
|
|||||||
let (tx_cost, rx_cost) = mpsc::channel();
|
let (tx_cost, rx_cost) = mpsc::channel();
|
||||||
let (tx_net, rx_net) = mpsc::channel();
|
let (tx_net, rx_net) = mpsc::channel();
|
||||||
let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg {
|
let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg {
|
||||||
nodes: cost_nodes_for(&campaign.cost),
|
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument),
|
||||||
tx_cost,
|
tx_cost,
|
||||||
tx_net,
|
tx_net,
|
||||||
});
|
});
|
||||||
@@ -1157,6 +1258,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
|
||||||
@@ -1393,18 +1509,27 @@ mod tests {
|
|||||||
/// param, so the wrapped param_space stays cost-invariant (the probe/member
|
/// param, so the wrapped param_space stays cost-invariant (the probe/member
|
||||||
/// space equivalence the campaign path relies on).
|
/// space equivalence the campaign path relies on).
|
||||||
fn cost_nodes_for_maps_each_component_to_its_bound_builder() {
|
fn cost_nodes_for_maps_each_component_to_its_bound_builder() {
|
||||||
let nodes = cost_nodes_for(&[
|
let nodes = cost_nodes_for(
|
||||||
aura_research::CostSpec::Constant { cost_per_trade: 2.0 },
|
&[
|
||||||
aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 },
|
aura_research::CostSpec::Constant {
|
||||||
aura_research::CostSpec::Carry { carry_per_cycle: 0.1 },
|
cost_per_trade: aura_research::CostValue::Scalar(2.0),
|
||||||
]);
|
},
|
||||||
|
aura_research::CostSpec::VolSlippage {
|
||||||
|
slip_vol_mult: aura_research::CostValue::Scalar(0.5),
|
||||||
|
},
|
||||||
|
aura_research::CostSpec::Carry {
|
||||||
|
carry_per_cycle: aura_research::CostValue::Scalar(0.1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"GER40",
|
||||||
|
);
|
||||||
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
||||||
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
|
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
|
||||||
assert!(
|
assert!(
|
||||||
nodes.iter().all(|n| n.params().is_empty()),
|
nodes.iter().all(|n| n.params().is_empty()),
|
||||||
"every component must be fully bound (no open param leaks into param_space)"
|
"every component must be fully bound (no open param leaks into param_space)"
|
||||||
);
|
);
|
||||||
assert!(cost_nodes_for(&[]).is_empty(), "an empty model binds no nodes");
|
assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1431,4 +1556,42 @@ mod tests {
|
|||||||
dedup.dedup();
|
dedup.dedup();
|
||||||
assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}");
|
assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// #272: `interior_gap_months` names a whole calendar month missing
|
||||||
|
/// BETWEEN two present archive months (the Copper failure shape) when the
|
||||||
|
/// requested window spans across it — not just when the window's own
|
||||||
|
/// bounds fall short of full coverage. GAPSYM-shaped input (files at
|
||||||
|
/// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June
|
||||||
|
/// window must name exactly the two missing interior months.
|
||||||
|
/// #272: the summary label is a CHECKED mirror of the persisted serde form
|
||||||
|
/// — an aggregate over `campaign_runs.jsonl` counts by the serialized
|
||||||
|
/// `CellFaultKind` string, so the human-facing label must be exactly that
|
||||||
|
/// string (no second source of truth that could silently drift).
|
||||||
|
fn cell_fault_kind_label_matches_the_serialized_kind() {
|
||||||
|
use aura_registry::CellFaultKind as K;
|
||||||
|
for kind in [K::NoData, K::Bind, K::Run, K::Panic, K::Window] {
|
||||||
|
let serde_form = serde_json::to_string(&kind).expect("serialize kind");
|
||||||
|
let serde_form = serde_form.trim_matches('"');
|
||||||
|
assert_eq!(
|
||||||
|
cell_fault_kind_label(kind),
|
||||||
|
serde_form,
|
||||||
|
"the summary label must equal the persisted serde string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() {
|
||||||
|
let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)];
|
||||||
|
// 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every
|
||||||
|
// present month plus the March/April hole.
|
||||||
|
let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64);
|
||||||
|
let gaps = interior_gap_months(&months, window_ms);
|
||||||
|
assert_eq!(
|
||||||
|
gaps,
|
||||||
|
vec!["2024-03".to_string(), "2024-04".to_string()],
|
||||||
|
"both interior gap months must be named, in order"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+471
-72
@@ -125,7 +125,7 @@ fn sim_optimal_manifest(
|
|||||||
// Typed params pass straight through: the manifest carries self-describing
|
// Typed params pass straight through: the manifest carries self-describing
|
||||||
// Scalars, so a length stays `i64` and a scale `f64` in the record.
|
// Scalars, so a length stays `i64` and a scale `f64` in the record.
|
||||||
RunManifest {
|
RunManifest {
|
||||||
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
|
commit: ENGINE_COMMIT.to_string(),
|
||||||
params,
|
params,
|
||||||
defaults: Vec::new(),
|
defaults: Vec::new(),
|
||||||
window,
|
window,
|
||||||
@@ -875,11 +875,11 @@ fn select_winner(
|
|||||||
/// (window order, then within-window trade order). Windows with no `r` block
|
/// (window order, then within-window trade order). Windows with no `r` block
|
||||||
/// contribute nothing. The single home of the pooling-in-roll-order semantics —
|
/// contribute nothing. The single home of the pooling-in-roll-order semantics —
|
||||||
/// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this.
|
/// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this.
|
||||||
fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
|
fn pooled_oos_net_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
|
||||||
result
|
result
|
||||||
.windows
|
.windows
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.trade_rs.clone()).unwrap_or_default())
|
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.net_trade_rs.clone()).unwrap_or_default())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,14 +888,14 @@ fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
|
|||||||
/// (C14).
|
/// (C14).
|
||||||
fn walkforward_summary_json(result: &WalkForwardResult) -> String {
|
fn walkforward_summary_json(result: &WalkForwardResult) -> String {
|
||||||
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0);
|
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0);
|
||||||
let pooled_rs = pooled_oos_trade_rs(result);
|
let pooled_rs = pooled_oos_net_trade_rs(result);
|
||||||
let mut obj = serde_json::json!({
|
let mut obj = serde_json::json!({
|
||||||
"windows": result.windows.len(),
|
"windows": result.windows.len(),
|
||||||
"stitched_total_pips": total,
|
"stitched_total_pips": total,
|
||||||
"param_stability": param_stability(result),
|
"param_stability": param_stability(result),
|
||||||
});
|
});
|
||||||
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) {
|
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) {
|
||||||
// RMetrics serializes its scalar fields (trade_rs is serde-skipped, so the
|
// RMetrics serializes its scalar fields (net_trade_rs is serde-skipped, so the
|
||||||
// oos_r block is the clean R-metric summary of the pooled series).
|
// oos_r block is the clean R-metric summary of the pooled series).
|
||||||
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
|
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
|
||||||
.expect("RMetrics serializes");
|
.expect("RMetrics serializes");
|
||||||
@@ -914,7 +914,7 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String {
|
|||||||
/// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while
|
/// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while
|
||||||
/// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match
|
/// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match
|
||||||
/// would miss the wrapped ones) through the same `MetricStats::from_values`; the
|
/// would miss the wrapped ones) through the same `MetricStats::from_values`; the
|
||||||
/// `oos_r` block pools the per-window `trade_rs` through `r_metrics_from_rs`.
|
/// `oos_r` block pools the per-window `net_trade_rs` through `r_metrics_from_rs`.
|
||||||
/// Canonical JSON (C14).
|
/// Canonical JSON (C14).
|
||||||
///
|
///
|
||||||
/// `axes` carries the invocation's raw axis names in argv order, followed by the
|
/// `axes` carries the invocation's raw axis names in argv order, followed by the
|
||||||
@@ -954,7 +954,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String])
|
|||||||
.collect();
|
.collect();
|
||||||
let pooled_rs: Vec<f64> = reports
|
let pooled_rs: Vec<f64> = reports
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|r| r.metrics.r.as_ref().map(|m| m.trade_rs.clone()).unwrap_or_default())
|
.flat_map(|r| r.metrics.r.as_ref().map(|m| m.net_trade_rs.clone()).unwrap_or_default())
|
||||||
.collect();
|
.collect();
|
||||||
let mut obj = serde_json::json!({
|
let mut obj = serde_json::json!({
|
||||||
"windows": reports.len(),
|
"windows": reports.len(),
|
||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1175,9 +1183,15 @@ fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec<aura_research::Cos
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
specs.push(match knob {
|
specs.push(match knob {
|
||||||
"cost_per_trade" => aura_research::CostSpec::Constant { cost_per_trade: v.as_f64() },
|
"cost_per_trade" => aura_research::CostSpec::Constant {
|
||||||
"slip_vol_mult" => aura_research::CostSpec::VolSlippage { slip_vol_mult: v.as_f64() },
|
cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()),
|
||||||
"carry_per_cycle" => aura_research::CostSpec::Carry { carry_per_cycle: v.as_f64() },
|
},
|
||||||
|
"slip_vol_mult" => aura_research::CostSpec::VolSlippage {
|
||||||
|
slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()),
|
||||||
|
},
|
||||||
|
"carry_per_cycle" => aura_research::CostSpec::Carry {
|
||||||
|
carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()),
|
||||||
|
},
|
||||||
other => {
|
other => {
|
||||||
// Corrupted-on-disk manifest data — the reproduce path's clean
|
// Corrupted-on-disk manifest data — the reproduce path's clean
|
||||||
// exit register (`aura:` + exit 1), like point_from_params.
|
// exit register (`aura:` + exit 1), like point_from_params.
|
||||||
@@ -1347,6 +1361,10 @@ fn reproduce_family_in(
|
|||||||
stop,
|
stop,
|
||||||
&binding,
|
&binding,
|
||||||
&cost,
|
&cost,
|
||||||
|
// The re-run specs come from the stamp and are scalar by
|
||||||
|
// construction (`cost_specs_from_params`), so the instrument is
|
||||||
|
// inert; the fallback is never resolved against a map.
|
||||||
|
stored.manifest.instrument.as_deref().unwrap_or(""),
|
||||||
);
|
);
|
||||||
outcomes.push((label, rerun.metrics == stored.metrics));
|
outcomes.push((label, rerun.metrics == stored.metrics));
|
||||||
}
|
}
|
||||||
@@ -1730,6 +1748,14 @@ fn run_signal_r(
|
|||||||
RunReport { manifest, metrics }
|
RunReport { manifest, metrics }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
|
||||||
|
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
|
||||||
|
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
|
||||||
|
/// originate on these paths and the instrument context is genuinely inert.
|
||||||
|
/// Named once so every such call site states its intent by reference instead
|
||||||
|
/// of repeating the justifying comment.
|
||||||
|
const NO_INSTRUMENT_CONTEXT: &str = "";
|
||||||
|
|
||||||
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
|
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
|
||||||
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
|
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
|
||||||
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
|
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
|
||||||
@@ -1750,6 +1776,7 @@ fn run_blueprint_member(
|
|||||||
stop: StopRule,
|
stop: StopRule,
|
||||||
binding: &binding::ResolvedBinding,
|
binding: &binding::ResolvedBinding,
|
||||||
cost: &[aura_research::CostSpec],
|
cost: &[aura_research::CostSpec],
|
||||||
|
instrument: &str,
|
||||||
) -> RunReport {
|
) -> RunReport {
|
||||||
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
||||||
let (tx_eq, rx_eq) = mpsc::channel();
|
let (tx_eq, rx_eq) = mpsc::channel();
|
||||||
@@ -1762,7 +1789,7 @@ fn run_blueprint_member(
|
|||||||
let (tx_cost, rx_cost) = mpsc::channel();
|
let (tx_cost, rx_cost) = mpsc::channel();
|
||||||
let (tx_net, _rx_net) = mpsc::channel();
|
let (tx_net, _rx_net) = mpsc::channel();
|
||||||
let cost_leg = (!cost.is_empty()).then(|| CostLeg {
|
let cost_leg = (!cost.is_empty()).then(|| CostLeg {
|
||||||
nodes: campaign_run::cost_nodes_for(cost),
|
nodes: campaign_run::cost_nodes_for(cost, instrument),
|
||||||
tx_cost,
|
tx_cost,
|
||||||
tx_net,
|
tx_net,
|
||||||
});
|
});
|
||||||
@@ -1771,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
|
||||||
@@ -1788,7 +1823,7 @@ fn run_blueprint_member(
|
|||||||
// reads this stamp back via `cost_specs_from_params` (the #233 stop-regime
|
// reads this stamp back via `cost_specs_from_params` (the #233 stop-regime
|
||||||
// pattern), so a costed family reproduces net, not gross.
|
// pattern), so a costed family reproduces net, not gross.
|
||||||
for (k, spec) in cost.iter().enumerate() {
|
for (k, spec) in cost.iter().enumerate() {
|
||||||
let (knob, v) = campaign_run::cost_knob(spec);
|
let (knob, v) = campaign_run::cost_knob(spec, instrument);
|
||||||
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
|
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
|
||||||
}
|
}
|
||||||
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
|
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
|
||||||
@@ -1915,6 +1950,48 @@ fn override_paths(
|
|||||||
Ok(overrides)
|
Ok(overrides)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a [`BindError`] as one-line prose in `override_paths`' sibling
|
||||||
|
/// register above (#247) — never the raw Rust `Debug` struct name
|
||||||
|
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
|
||||||
|
/// terminal actually raises past `override_paths`' own pre-flight (which
|
||||||
|
/// already rejects an unresolvable axis name as prose before either call
|
||||||
|
/// site below ever reaches the terminal). `UnknownKnob` already carries a
|
||||||
|
/// fully-prosed message string (wrapped from `override_paths`' own
|
||||||
|
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
|
||||||
|
/// the walkforward path's rejection reaches stderr as bare prose too.
|
||||||
|
fn render_bind_error(e: &BindError) -> String {
|
||||||
|
match e {
|
||||||
|
BindError::MissingKnob(name) => format!(
|
||||||
|
"axis {name}: an open param with no axis and no bound default — \
|
||||||
|
bind it with `--axis {name}=<value>` — see `aura sweep <bp> --list-axes`"
|
||||||
|
),
|
||||||
|
BindError::KindMismatch { knob, expected, got } => format!(
|
||||||
|
"axis {knob}: expected {expected:?}, supplied {got:?} — \
|
||||||
|
see `aura sweep <bp> --list-axes`"
|
||||||
|
),
|
||||||
|
BindError::UnknownKnob(msg) => msg.clone(),
|
||||||
|
BindError::DuplicateBinding(name) => format!(
|
||||||
|
"axis {name}: bound twice — each param takes exactly one axis — \
|
||||||
|
see `aura sweep <bp> --list-axes`"
|
||||||
|
),
|
||||||
|
BindError::EmptyAxis(name) => format!(
|
||||||
|
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
|
||||||
|
),
|
||||||
|
BindError::EmptyRange(name) => format!(
|
||||||
|
"axis {name}: the named range is empty — give it at least one value"
|
||||||
|
),
|
||||||
|
// A blueprint defect, not an axis usage error: the point passed name
|
||||||
|
// resolution but its bootstrap failed. Prose frame with the compile
|
||||||
|
// detail explicitly labelled as internal — per-variant prose for
|
||||||
|
// CompileError belongs to the graph-build surface, not this boundary.
|
||||||
|
BindError::Compile(e) => format!(
|
||||||
|
"the resolved axis point failed to bootstrap — the blueprint is \
|
||||||
|
defective at this point, re-validate it with `aura graph build` \
|
||||||
|
(internal detail: {e:?})"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a validated override set to a freshly loaded strategy (#246).
|
/// Apply a validated override set to a freshly loaded strategy (#246).
|
||||||
/// Infallible by contract: the set was derived against this same document at
|
/// Infallible by contract: the set was derived against this same document at
|
||||||
/// the family boundary.
|
/// the family boundary.
|
||||||
@@ -2013,11 +2090,11 @@ fn blueprint_sweep_family(
|
|||||||
.sweep(|point| {
|
.sweep(|point| {
|
||||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
||||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
||||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[])
|
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||||
})
|
})
|
||||||
// render the sweep terminal's BindError to a message (the fn's String error contract),
|
// render the sweep terminal's BindError to prose (#247), the fn's String error
|
||||||
// so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper.
|
// contract — never the raw Debug struct.
|
||||||
.map_err(|e| format!("{e:?}"))
|
.map_err(|e| render_bind_error(&e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
|
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
|
||||||
@@ -2059,7 +2136,7 @@ fn blueprint_sweep_over(
|
|||||||
binder.sweep_with_lattice(|point| {
|
binder.sweep_with_lattice(|point| {
|
||||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||||
let window = window_of(&sources).expect("non-empty in-sample window");
|
let window = window_of(&sources).expect("non-empty in-sample window");
|
||||||
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[])
|
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,7 +2162,7 @@ fn run_oos_blueprint(
|
|||||||
let pip = data.pip_size();
|
let pip = data.pip_size();
|
||||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||||
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
||||||
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]);
|
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT);
|
||||||
(Vec::new(), report)
|
(Vec::new(), report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2188,7 +2265,7 @@ fn blueprint_walkforward_family(
|
|||||||
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
|
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
|
||||||
// running a single member — no IS window (or a second roller) is needed here at all.
|
// running a single member — no IS window (or a second roller) is needed here at all.
|
||||||
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
|
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
|
||||||
eprintln!("aura: {e:?}");
|
eprintln!("aura: {}", render_bind_error(&e));
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
walk_forward(roller, space.clone(), |w: WindowBounds| {
|
walk_forward(roller, space.clone(), |w: WindowBounds| {
|
||||||
@@ -2265,7 +2342,7 @@ fn blueprint_mc_family(
|
|||||||
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
|
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
|
||||||
let sources = synthetic_walk_sources(seed);
|
let sources = synthetic_walk_sources(seed);
|
||||||
let window = window_of(&sources).expect("non-empty synthetic walk");
|
let window = window_of(&sources).expect("non-empty synthetic walk");
|
||||||
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[])
|
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||||
});
|
});
|
||||||
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
|
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
|
||||||
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
|
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
|
||||||
@@ -2637,13 +2714,39 @@ fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
|
|||||||
// selects the loaded-blueprint branch. There is no second built-in grammar anymore
|
// selects the loaded-blueprint branch. There is no second built-in grammar anymore
|
||||||
// (#159 demo retirement / #220 axis generalization): without a blueprint the
|
// (#159 demo retirement / #220 axis generalization): without a blueprint the
|
||||||
// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to
|
// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to
|
||||||
// the campaign path. Usage errors (clap parse + argv-applicability guards) exit 2;
|
// the campaign path. Usage errors exit 2; runtime refusals exit 1; a run that
|
||||||
// runtime failures exit 1.
|
// completes with ≥1 failed cell exits 3 (#272); a clean run exits 0.
|
||||||
|
|
||||||
/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION`
|
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
|
||||||
/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`.
|
/// falling back to `"unknown"`): both `--version` (via `version_string`) and
|
||||||
|
/// `sim_optimal_manifest`'s `RunManifest.commit` read this one const, so a
|
||||||
|
/// stale binary is distinguishable from a fresh one at the CLI surface,
|
||||||
|
/// before any run, without a second commit-sourcing mechanism (#266).
|
||||||
|
const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `aura 0.1.0 (<commit>)` as a process-lifetime `&'static str` — computed once
|
||||||
|
/// (clap's `version` builder method wants `Into<Str>`, and `clap::builder::Str`
|
||||||
|
/// (clap 4.6) only implements `From<&'static str>`, not `From<String>`, so the
|
||||||
|
/// one owned `String` this formats is leaked for the process's lifetime, same
|
||||||
|
/// as any other CLI one-shot startup string).
|
||||||
|
fn version_string() -> &'static str {
|
||||||
|
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||||
|
VERSION.get_or_init(|| format!("{} ({ENGINE_COMMIT})", env!("CARGO_PKG_VERSION")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the
|
||||||
|
/// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so
|
||||||
|
/// `aura --version` prints `aura 0.1.0 (<commit>)`.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "aura", version, about = "Author, backtest, and validate trading strategies — research CLI", infer_long_args = true)]
|
#[command(
|
||||||
|
name = "aura",
|
||||||
|
version = version_string(),
|
||||||
|
about = "Author, backtest, and validate trading strategies — research CLI",
|
||||||
|
infer_long_args = true
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -2684,6 +2787,8 @@ enum Command {
|
|||||||
Process(research_docs::ProcessCmd),
|
Process(research_docs::ProcessCmd),
|
||||||
/// Validate, introspect, and register campaign documents (experiment intent).
|
/// Validate, introspect, and register campaign documents (experiment intent).
|
||||||
Campaign(research_docs::CampaignCmd),
|
Campaign(research_docs::CampaignCmd),
|
||||||
|
/// Inspect the project's data archive.
|
||||||
|
Data(DataCmd),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -2728,6 +2833,26 @@ struct NodesNewCmd {
|
|||||||
namespace: Option<String>,
|
namespace: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct DataCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: DataCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum DataCommand {
|
||||||
|
/// Report a symbol's monthly file-index coverage (span + interior gaps).
|
||||||
|
Coverage(DataCoverageCmd),
|
||||||
|
/// List the archive's known symbols, sorted, one per line.
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct DataCoverageCmd {
|
||||||
|
/// The symbol to inventory (e.g. `GER40`).
|
||||||
|
symbol: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
#[command(args_conflicts_with_subcommands = true)]
|
#[command(args_conflicts_with_subcommands = true)]
|
||||||
struct GraphCmd {
|
struct GraphCmd {
|
||||||
@@ -3301,6 +3426,20 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminate per a campaign-path result (#272): a String error is a refusal
|
||||||
|
/// (exit 1); an Ok carrying the failed-cell count exits 3 when any cell failed
|
||||||
|
/// ("completed with failed cells"), else returns cleanly (exit 0).
|
||||||
|
pub(crate) fn exit_on_campaign_result(r: Result<usize, String>) {
|
||||||
|
match r {
|
||||||
|
Err(m) => {
|
||||||
|
eprintln!("aura: {m}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(_) => std::process::exit(3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared campaign window in Unix-ms, clipped to the archive. `full_window`
|
/// The shared campaign window in Unix-ms, clipped to the archive. `full_window`
|
||||||
/// probes the ARCHIVE's actual first/last bar in range (never the literal ms
|
/// probes the ARCHIVE's actual first/last bar in range (never the literal ms
|
||||||
/// request) and returns aura's native epoch-ns `Timestamp` (the ms->ns crossing
|
/// request) and returns aura's native epoch-ns `Timestamp` (the ms->ns crossing
|
||||||
@@ -3544,10 +3683,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
|
|||||||
// sweep + walkforward only); trace-writing is out of scope here.
|
// sweep + walkforward only); trace-writing is out of scope here.
|
||||||
trace: false,
|
trace: false,
|
||||||
};
|
};
|
||||||
verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| {
|
exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env));
|
||||||
eprintln!("aura: {m}");
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_runs(a: RunsCmd, env: &project::Env) {
|
fn dispatch_runs(a: RunsCmd, env: &project::Env) {
|
||||||
@@ -3651,6 +3787,105 @@ fn dispatch_nodes_new(a: NodesNewCmd) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dispatch_data(cmd: DataCmd, env: &project::Env) {
|
||||||
|
match cmd.command {
|
||||||
|
DataCommand::Coverage(a) => dispatch_data_coverage(a, env),
|
||||||
|
DataCommand::List => dispatch_data_list(env),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted
|
||||||
|
/// ascending, one per line — the discovery step before `aura data coverage`
|
||||||
|
/// or scoping a campaign's instrument matrix. Resolves the archive root
|
||||||
|
/// through the normal project path ([`dispatch_data_coverage`]'s
|
||||||
|
/// `Env::data_path`), then reuses `DataServer`'s own symbol index
|
||||||
|
/// (`DataServer::symbols()`, already sorted) rather than re-deriving a
|
||||||
|
/// directory scan. An empty or absent archive is informational absence, not a
|
||||||
|
/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established
|
||||||
|
/// empty-result register — cf. `runs family`'s unknown-id empty exit 0).
|
||||||
|
fn dispatch_data_list(env: &project::Env) {
|
||||||
|
let data_path = std::path::PathBuf::from(env.data_path());
|
||||||
|
let server = aura_ingest::DataServer::new(&data_path);
|
||||||
|
for line in data_list_report(&server.symbols()) {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one
|
||||||
|
/// line per symbol, or a single `no symbols` line when the archive is empty
|
||||||
|
/// or absent — informational absence, never a fault (#264).
|
||||||
|
fn data_list_report(symbols: &[std::sync::Arc<str>]) -> Vec<String> {
|
||||||
|
if symbols.is_empty() {
|
||||||
|
return vec!["no symbols".to_string()];
|
||||||
|
}
|
||||||
|
symbols.iter().map(|s| s.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura data coverage <SYMBOL>` (#264 cut 1): print the archive's month
|
||||||
|
/// coverage for `SYMBOL` — the Copper failure mode (files present at both
|
||||||
|
/// ends of a window while an interior month is missing, so a first/last-bounds
|
||||||
|
/// check passes but a campaign run aborts mid-window) made visible up front.
|
||||||
|
/// Resolves the archive root through the normal project path (`Env::data_path`
|
||||||
|
/// — a project-local `[paths] data` override when set, the data-server default
|
||||||
|
/// otherwise), so a project-scoped archive is inventoried, not the host one.
|
||||||
|
fn dispatch_data_coverage(a: DataCoverageCmd, env: &project::Env) {
|
||||||
|
let data_path = std::path::PathBuf::from(env.data_path());
|
||||||
|
let months = aura_ingest::list_m1_months(&data_path, &a.symbol);
|
||||||
|
match data_coverage_report(&a.symbol, &months) {
|
||||||
|
Ok(lines) => {
|
||||||
|
for line in lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e} at {}", env.data_path());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: render `symbol`'s coverage report from its sorted `(year, month)` file
|
||||||
|
/// list — one `span:` line framing the first/last present month, then either a
|
||||||
|
/// `no gaps` line or one `missing: YYYY-MM..YYYY-MM` line per interior
|
||||||
|
/// contiguous gap (a ten-month hole is one line, not ten). `Err` exactly when
|
||||||
|
/// `months` is empty — no archive file exists for `symbol` at all (the
|
||||||
|
/// caller's "unknown symbol" refusal, stderr + exit 1).
|
||||||
|
fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
|
||||||
|
let Some(&first) = months.first() else {
|
||||||
|
return Err(format!("no archive files found for symbol \"{symbol}\""));
|
||||||
|
};
|
||||||
|
let last = *months.last().expect("non-empty checked above");
|
||||||
|
let mut lines = vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
|
||||||
|
let gaps: Vec<((u16, u8), (u16, u8))> = months
|
||||||
|
.windows(2)
|
||||||
|
.filter_map(|pair| {
|
||||||
|
let (prev, next) = (pair[0], pair[1]);
|
||||||
|
let expected = next_year_month(prev);
|
||||||
|
(expected != next).then(|| (expected, prev_year_month(next)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if gaps.is_empty() {
|
||||||
|
lines.push(format!("{symbol} no gaps"));
|
||||||
|
} else {
|
||||||
|
for (from, to) in gaps {
|
||||||
|
lines.push(format!("{symbol} missing: {}..{}", fmt_year_month(from), fmt_year_month(to)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_year_month((y, m): (u16, u8)) -> String {
|
||||||
|
format!("{y:04}-{m:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
|
||||||
|
if m == 12 { (y + 1, 1) } else { (y, m + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_year_month((y, m): (u16, u8)) -> (u16, u8) {
|
||||||
|
if m == 1 { (y - 1, 12) } else { (y, m - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
|
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
|
||||||
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
|
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
|
||||||
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||||
@@ -3741,10 +3976,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
|||||||
// `name_persist`'s bool (true iff `--trace` was given).
|
// `name_persist`'s bool (true iff `--trace` was given).
|
||||||
trace: persist,
|
trace: persist,
|
||||||
};
|
};
|
||||||
verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| {
|
exit_on_campaign_result(verb_sugar::run_sweep_sugar(&inv, env));
|
||||||
eprintln!("aura: {m}");
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
DataChoice::Synthetic => {
|
DataChoice::Synthetic => {
|
||||||
// The synthetic in-process family path is still reduce-only
|
// The synthetic in-process family path is still reduce-only
|
||||||
@@ -3845,7 +4077,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
|||||||
// (unchanged since 0109 — walkforward always nominates).
|
// (unchanged since 0109 — walkforward always nominates).
|
||||||
trace,
|
trace,
|
||||||
};
|
};
|
||||||
verb_sugar::run_walkforward_sugar(
|
let result = verb_sugar::run_walkforward_sugar(
|
||||||
&inv,
|
&inv,
|
||||||
WINNER_SELECTION_METRIC,
|
WINNER_SELECTION_METRIC,
|
||||||
verb_sugar::WfWindows {
|
verb_sugar::WfWindows {
|
||||||
@@ -3855,11 +4087,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
|||||||
},
|
},
|
||||||
select,
|
select,
|
||||||
env,
|
env,
|
||||||
)
|
);
|
||||||
.unwrap_or_else(|m| {
|
exit_on_campaign_result(result);
|
||||||
eprintln!("aura: {m}");
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Synthetic in-process family path — unchanged (#220 non-goal); it
|
// Synthetic in-process family path — unchanged (#220 non-goal); it
|
||||||
@@ -3960,7 +4189,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
|
|||||||
// is out of #224's scope here.
|
// is out of #224's scope here.
|
||||||
trace: false,
|
trace: false,
|
||||||
};
|
};
|
||||||
verb_sugar::run_mc_sugar(
|
let result = verb_sugar::run_mc_sugar(
|
||||||
&inv,
|
&inv,
|
||||||
WINNER_SELECTION_METRIC,
|
WINNER_SELECTION_METRIC,
|
||||||
verb_sugar::WfWindows {
|
verb_sugar::WfWindows {
|
||||||
@@ -3970,11 +4199,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
|
|||||||
},
|
},
|
||||||
verb_sugar::McKnobs { resamples, block_len, seed },
|
verb_sugar::McKnobs { resamples, block_len, seed },
|
||||||
env,
|
env,
|
||||||
)
|
);
|
||||||
.unwrap_or_else(|m| {
|
exit_on_campaign_result(result);
|
||||||
eprintln!("aura: {m}");
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Synthetic seed family (unchanged, #220 non-goal): closed
|
// Synthetic seed family (unchanged, #220 non-goal): closed
|
||||||
@@ -4053,6 +4279,7 @@ fn main() {
|
|||||||
Command::Nodes(a) => dispatch_nodes(a),
|
Command::Nodes(a) => dispatch_nodes(a),
|
||||||
Command::Process(a) => research_docs::process_cmd(a, &env),
|
Command::Process(a) => research_docs::process_cmd(a, &env),
|
||||||
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
|
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
|
||||||
|
Command::Data(a) => dispatch_data(a, &env),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4078,6 +4305,88 @@ mod tests {
|
|||||||
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
|
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audit follow-up to #247/#269: `render_bind_error` is exhaustive — the
|
||||||
|
/// axis-usage variants the old catch-all Debug-framed render as prose
|
||||||
|
/// naming the axis, with no Rust identifier on the user's stderr.
|
||||||
|
#[test]
|
||||||
|
fn render_bind_error_prose_covers_the_axis_usage_variants() {
|
||||||
|
let dup = render_bind_error(&aura_engine::BindError::DuplicateBinding("a.b".into()));
|
||||||
|
assert!(dup.contains("a.b") && dup.contains("bound twice"), "{dup}");
|
||||||
|
assert!(!dup.contains("DuplicateBinding"), "Debug leak: {dup}");
|
||||||
|
let empty = render_bind_error(&aura_engine::BindError::EmptyAxis("a.b".into()));
|
||||||
|
assert!(empty.contains("a.b") && empty.contains("no values"), "{empty}");
|
||||||
|
assert!(!empty.contains("EmptyAxis"), "Debug leak: {empty}");
|
||||||
|
let range = render_bind_error(&aura_engine::BindError::EmptyRange("a.b".into()));
|
||||||
|
assert!(range.contains("a.b") && !range.contains("EmptyRange"), "Debug leak: {range}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audit follow-up to #247/#269: a `Compile` fault at the sweep boundary —
|
||||||
|
/// a blueprint defect, not an axis usage error — renders as a prose frame
|
||||||
|
/// that names the situation and labels the embedded compile detail as
|
||||||
|
/// internal, instead of leaking the bare Debug struct as the whole message.
|
||||||
|
#[test]
|
||||||
|
fn render_bind_error_frames_a_compile_fault_as_prose_with_labeled_detail() {
|
||||||
|
let msg = render_bind_error(&aura_engine::BindError::Compile(
|
||||||
|
aura_engine::CompileError::BadInteriorIndex,
|
||||||
|
));
|
||||||
|
assert!(msg.contains("failed to bootstrap"), "{msg}");
|
||||||
|
assert!(msg.contains("internal detail"), "{msg}");
|
||||||
|
assert!(!msg.starts_with("Compile"), "the frame must lead with prose: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unknown symbol (no archive files at all — an empty month list)
|
||||||
|
/// refuses rather than reporting a bogus empty-span coverage (#264): the
|
||||||
|
/// caller eprintln's the message and exits 1.
|
||||||
|
#[test]
|
||||||
|
fn data_coverage_report_refuses_an_unknown_symbol() {
|
||||||
|
let err = data_coverage_report("GHOST", &[]).unwrap_err();
|
||||||
|
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A symbol with a fully contiguous file index reports its span plus an
|
||||||
|
/// explicit `no gaps` line — never a bare span with no gap-status line at
|
||||||
|
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
|
||||||
|
/// checked" (#264).
|
||||||
|
#[test]
|
||||||
|
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
|
||||||
|
let months = [(2024, 1), (2024, 2), (2024, 3)];
|
||||||
|
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
|
||||||
|
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Copper failure shape itself: one interior gap collapses to a single
|
||||||
|
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
|
||||||
|
/// one line per missing month (#264).
|
||||||
|
#[test]
|
||||||
|
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
|
||||||
|
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
|
||||||
|
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty (or absent) archive is informational absence, not a fault
|
||||||
|
/// (#264): `data_list_report` returns the single `no symbols` prose line
|
||||||
|
/// rather than an empty `Vec`, so the caller's exit-0 println always has
|
||||||
|
/// something to say.
|
||||||
|
#[test]
|
||||||
|
fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() {
|
||||||
|
let symbols: Vec<std::sync::Arc<str>> = vec![];
|
||||||
|
assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-empty archive renders one line per symbol, in the order handed
|
||||||
|
/// in (`DataServer::symbols()` already sorts) — no `no symbols` line
|
||||||
|
/// mixed in (#264).
|
||||||
|
#[test]
|
||||||
|
fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() {
|
||||||
|
let symbols: Vec<std::sync::Arc<str>> =
|
||||||
|
vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")];
|
||||||
|
assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Disjoint archives (no shared instant across all listed symbols) refuse
|
/// Disjoint archives (no shared instant across all listed symbols) refuse
|
||||||
/// rather than silently pooling a floor over different periods per
|
/// rather than silently pooling a floor over different periods per
|
||||||
/// instrument (#213); the error names each symbol next to its own span,
|
/// instrument (#213); the error names each symbol next to its own span,
|
||||||
@@ -4556,10 +4865,13 @@ mod tests {
|
|||||||
stop,
|
stop,
|
||||||
&binding,
|
&binding,
|
||||||
cost,
|
cost,
|
||||||
|
"GER40",
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let gross = run(&[]);
|
let gross = run(&[]);
|
||||||
let netted = run(&[aura_research::CostSpec::Constant { cost_per_trade: CPT }]);
|
let netted = run(&[aura_research::CostSpec::Constant {
|
||||||
|
cost_per_trade: aura_research::CostValue::Scalar(CPT),
|
||||||
|
}]);
|
||||||
|
|
||||||
// The trade geometry, independently: a non-reduce cost-less wrap over
|
// The trade geometry, independently: a non-reduce cost-less wrap over
|
||||||
// the same realization, draining the dense R record whose ledger
|
// the same realization, draining the dense R record whose ledger
|
||||||
@@ -4646,7 +4958,9 @@ mod tests {
|
|||||||
let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
|
let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
|
||||||
let window = data.full_window(&env);
|
let window = data.full_window(&env);
|
||||||
let pip = data.pip_size();
|
let pip = data.pip_size();
|
||||||
let cost = [aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }];
|
let cost = [aura_research::CostSpec::Constant {
|
||||||
|
cost_per_trade: aura_research::CostValue::Scalar(0.0005),
|
||||||
|
}];
|
||||||
|
|
||||||
// The recorded member: reduce-mode, cost-bound — `run_blueprint_member`,
|
// The recorded member: reduce-mode, cost-bound — `run_blueprint_member`,
|
||||||
// the exact path `CliMemberRunner::run_member` calls.
|
// the exact path `CliMemberRunner::run_member` calls.
|
||||||
@@ -4663,6 +4977,7 @@ mod tests {
|
|||||||
stop,
|
stop,
|
||||||
&binding,
|
&binding,
|
||||||
&cost,
|
&cost,
|
||||||
|
"GER40",
|
||||||
);
|
);
|
||||||
|
|
||||||
// The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg`
|
// The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg`
|
||||||
@@ -4676,7 +4991,7 @@ mod tests {
|
|||||||
let (tx_cost, rx_cost) = mpsc::channel();
|
let (tx_cost, rx_cost) = mpsc::channel();
|
||||||
let (tx_net, _rx_net) = mpsc::channel();
|
let (tx_net, _rx_net) = mpsc::channel();
|
||||||
let cost_leg =
|
let cost_leg =
|
||||||
Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost), tx_cost, tx_net });
|
Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net });
|
||||||
let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg)
|
let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg)
|
||||||
.compile_with_params(&[])
|
.compile_with_params(&[])
|
||||||
.expect("the persist-side wrap builds");
|
.expect("the persist-side wrap builds");
|
||||||
@@ -4738,10 +5053,10 @@ mod tests {
|
|||||||
let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new())
|
let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new())
|
||||||
.expect("the price role resolves");
|
.expect("the price role resolves");
|
||||||
let report_a = run_blueprint_member(
|
let report_a = run_blueprint_member(
|
||||||
signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[],
|
signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40",
|
||||||
);
|
);
|
||||||
let report_b = run_blueprint_member(
|
let report_b = run_blueprint_member(
|
||||||
r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[],
|
r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40",
|
||||||
);
|
);
|
||||||
// Guard: the run must have actually traded, else the invariant is vacuous.
|
// Guard: the run must have actually traded, else the invariant is vacuous.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -5321,7 +5636,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reproduce_family_re_derives_every_member_bit_identically() {
|
fn reproduce_family_re_derives_every_member_bit_identically() {
|
||||||
// a unique temp runs store so the on-disk family + blueprint store do not collide.
|
// a unique temp runs store so the on-disk family + blueprint store do not collide.
|
||||||
let dir = std::env::temp_dir().join(format!("aura-repro-{}", std::process::id()));
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-repro");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||||
@@ -5366,7 +5682,8 @@ mod tests {
|
|||||||
// under a NON-default vol-stop regime (a campaign risk-regime cell, or wf/mc/
|
// under a NON-default vol-stop regime (a campaign risk-regime cell, or wf/mc/
|
||||||
// generalize with --stop-length/--stop-k) must still reproduce bit-identically;
|
// generalize with --stop-length/--stop-k) must still reproduce bit-identically;
|
||||||
// silently re-running it under the default Vol{3,2.0} reports a spurious DIVERGED.
|
// silently re-running it under the default Vol{3,2.0} reports a spurious DIVERGED.
|
||||||
let dir = std::env::temp_dir().join(format!("aura-repro-stop-{}", std::process::id()));
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-repro-stop");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||||
@@ -5402,6 +5719,7 @@ mod tests {
|
|||||||
non_default,
|
non_default,
|
||||||
&binding,
|
&binding,
|
||||||
&[],
|
&[],
|
||||||
|
"GER40",
|
||||||
);
|
);
|
||||||
|
|
||||||
// persist exactly as the sweep/campaign paths do: store the blueprint, append the family.
|
// persist exactly as the sweep/campaign paths do: store the blueprint, append the family.
|
||||||
@@ -5434,7 +5752,8 @@ mod tests {
|
|||||||
/// vol proxy).
|
/// vol proxy).
|
||||||
#[test]
|
#[test]
|
||||||
fn reproduce_family_re_derives_a_costed_member_bit_identically() {
|
fn reproduce_family_re_derives_a_costed_member_bit_identically() {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-repro-cost-{}", std::process::id()));
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-repro-cost");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||||
@@ -5450,9 +5769,15 @@ mod tests {
|
|||||||
let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new())
|
let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new())
|
||||||
.expect("the price role resolves");
|
.expect("the price role resolves");
|
||||||
let cost = vec![
|
let cost = vec![
|
||||||
aura_research::CostSpec::Constant { cost_per_trade: 0.0005 },
|
aura_research::CostSpec::Constant {
|
||||||
aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 },
|
cost_per_trade: aura_research::CostValue::Scalar(0.0005),
|
||||||
aura_research::CostSpec::Carry { carry_per_cycle: 0.0001 },
|
},
|
||||||
|
aura_research::CostSpec::VolSlippage {
|
||||||
|
slip_vol_mult: aura_research::CostValue::Scalar(0.5),
|
||||||
|
},
|
||||||
|
aura_research::CostSpec::Carry {
|
||||||
|
carry_per_cycle: aura_research::CostValue::Scalar(0.0001),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let report = run_blueprint_member(
|
let report = run_blueprint_member(
|
||||||
reload(),
|
reload(),
|
||||||
@@ -5467,6 +5792,7 @@ mod tests {
|
|||||||
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
|
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
|
||||||
&binding,
|
&binding,
|
||||||
&cost,
|
&cost,
|
||||||
|
"GER40",
|
||||||
);
|
);
|
||||||
// Non-vacuity: the cost model must actually bite, else a DIVERGED
|
// Non-vacuity: the cost model must actually bite, else a DIVERGED
|
||||||
// verdict could never be observed and this pin proves nothing.
|
// verdict could never be observed and this pin proves nothing.
|
||||||
@@ -5489,7 +5815,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reproduce_family_re_derives_every_mc_member_bit_identically() {
|
fn reproduce_family_re_derives_every_mc_member_bit_identically() {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", std::process::id()));
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-repro-mc");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||||
@@ -5590,7 +5917,8 @@ mod tests {
|
|||||||
/// must show. Sweep / walk-forward labels still echo their params (covered elsewhere).
|
/// must show. Sweep / walk-forward labels still echo their params (covered elsewhere).
|
||||||
#[test]
|
#[test]
|
||||||
fn reproduce_mc_member_labels_carry_the_seed() {
|
fn reproduce_mc_member_labels_carry_the_seed() {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-repro-mc-seed-{}", std::process::id()));
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-repro-mc-seed");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||||
@@ -5818,6 +6146,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 {
|
||||||
|
|||||||
@@ -588,7 +588,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discover_walks_up_to_aura_toml() {
|
fn discover_walks_up_to_aura_toml() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-disc");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
let nested = tmp.join("a/b/c");
|
let nested = tmp.join("a/b/c");
|
||||||
std::fs::create_dir_all(&nested).unwrap();
|
std::fs::create_dir_all(&nested).unwrap();
|
||||||
assert_eq!(discover_from(&nested), None);
|
assert_eq!(discover_from(&nested), None);
|
||||||
@@ -611,7 +613,9 @@ mod tests {
|
|||||||
/// and stamps commit-only provenance.
|
/// and stamps commit-only provenance.
|
||||||
#[test]
|
#[test]
|
||||||
fn data_only_project_loads_without_a_crate() {
|
fn data_only_project_loads_without_a_crate() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-dataonly-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-dataonly");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
||||||
let p = load(&tmp, false).expect("data-only load");
|
let p = load(&tmp, false).expect("data-only load");
|
||||||
@@ -632,7 +636,9 @@ mod tests {
|
|||||||
/// changes the data location depending on the caller's working directory.
|
/// changes the data location depending on the caller's working directory.
|
||||||
#[test]
|
#[test]
|
||||||
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
|
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-datarel-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-datarel");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
|
||||||
let p = load(&tmp, false).expect("data-only load");
|
let p = load(&tmp, false).expect("data-only load");
|
||||||
@@ -643,7 +649,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_nodes_pointers_refuse_with_multi_crate() {
|
fn two_nodes_pointers_refuse_with_multi_crate() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-multi-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-multi");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
||||||
let e = load(&tmp, false).unwrap_err();
|
let e = load(&tmp, false).unwrap_err();
|
||||||
@@ -653,7 +661,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_broken_nodes_pointer_names_the_pointer() {
|
fn a_broken_nodes_pointer_names_the_pointer() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-badptr-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-badptr");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
||||||
let e = load(&tmp, false).unwrap_err();
|
let e = load(&tmp, false).unwrap_err();
|
||||||
@@ -671,7 +681,9 @@ mod tests {
|
|||||||
/// that (`load` still refuses → CLI exit 1).
|
/// that (`load` still refuses → CLI exit 1).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
|
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-missingptr-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-missingptr");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
// `missing-node-crate` is never created — the pointer target is absent.
|
// `missing-node-crate` is never created — the pointer target is absent.
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
|||||||
DocFault::BadCost { index } => {
|
DocFault::BadCost { index } => {
|
||||||
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
||||||
}
|
}
|
||||||
|
DocFault::CostInstrumentKeys { index, missing, extra } => {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
|
||||||
|
}
|
||||||
|
if !extra.is_empty() {
|
||||||
|
parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
|
||||||
|
}
|
||||||
|
format!("cost[{index}]: instrument map {}", parts.join("; "))
|
||||||
|
}
|
||||||
DocFault::NoStrategy => "strategies is empty".into(),
|
DocFault::NoStrategy => "strategies is empty".into(),
|
||||||
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
||||||
DocFault::EmptyAxis { strategy, axis } => {
|
DocFault::EmptyAxis { strategy, axis } => {
|
||||||
@@ -278,6 +288,9 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
|||||||
fn print_open_slots(slots: &[aura_research::OpenSlot]) {
|
fn print_open_slots(slots: &[aura_research::OpenSlot]) {
|
||||||
for s in slots {
|
for s in slots {
|
||||||
println!("open slot: {} ({})", s.path, s.hint);
|
println!("open slot: {} ({})", s.path, s.hint);
|
||||||
|
for note in &s.notes {
|
||||||
|
println!(" {note}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if slots.is_empty() {
|
if slots.is_empty() {
|
||||||
println!("no open slots");
|
println!("no open slots");
|
||||||
@@ -403,7 +416,14 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
|
||||||
|
/// via `exit_on_campaign_result`), so it is pulled out of the unified
|
||||||
|
/// `Result<(), String>` match the other four subcommands still share.
|
||||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||||
|
if let CampaignSub::Run { target } = &cmd.sub {
|
||||||
|
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(target, env));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let result = match &cmd.sub {
|
let result = match &cmd.sub {
|
||||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||||
CampaignSub::Introspect(i) => {
|
CampaignSub::Introspect(i) => {
|
||||||
@@ -411,8 +431,8 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
|||||||
introspect_campaign(i)
|
introspect_campaign(i)
|
||||||
}
|
}
|
||||||
CampaignSub::Register { file } => register_campaign(file, env),
|
CampaignSub::Register { file } => register_campaign(file, env),
|
||||||
CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env),
|
|
||||||
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
||||||
|
CampaignSub::Run { .. } => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
if let Err(m) = result {
|
if let Err(m) = result {
|
||||||
eprintln!("aura: {m}");
|
eprintln!("aura: {m}");
|
||||||
@@ -651,4 +671,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
|
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #260: the instrument-map key-mismatch fault is path-addressed and names
|
||||||
|
/// both directions — the campaign instruments the map misses AND the map
|
||||||
|
/// keys naming no campaign instrument — in one line, no Debug leak. The
|
||||||
|
/// extra-keys branch is otherwise unpinned (the e2e covers only missing).
|
||||||
|
#[test]
|
||||||
|
fn cost_instrument_keys_prose_names_both_directions() {
|
||||||
|
let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
|
||||||
|
index: 1,
|
||||||
|
missing: vec!["EURUSD".into()],
|
||||||
|
extra: vec!["GER40.cash".into()],
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
prose,
|
||||||
|
"cost[1]: instrument map misses campaign instrument(s) EURUSD; \
|
||||||
|
names no campaign instrument(s): GER40.cash"
|
||||||
|
);
|
||||||
|
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,7 +488,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scaffold_project_refuses_existing_destination() {
|
fn scaffold_project_refuses_existing_destination() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-pscaf");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(tmp.join("x")).unwrap();
|
std::fs::create_dir_all(tmp.join("x")).unwrap();
|
||||||
let spec = project_scaffold_spec("x", &tmp).unwrap();
|
let spec = project_scaffold_spec("x", &tmp).unwrap();
|
||||||
let e = scaffold_project(&spec).unwrap_err();
|
let e = scaffold_project(&spec).unwrap_err();
|
||||||
@@ -498,7 +500,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
|
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
|
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join("aura-scaf");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
// existing destination
|
// existing destination
|
||||||
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
|
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
|
||||||
|
|||||||
@@ -333,11 +333,13 @@ fn validate_before_register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run one dissolved sweep invocation end-to-end: register the generated
|
/// Run one dissolved sweep invocation end-to-end: register the generated
|
||||||
/// documents, then execute through the one campaign path in sugar mode.
|
/// documents, then execute through the one campaign path in sugar mode. The
|
||||||
|
/// `Ok` carries the failed-cell count (#272), propagated verbatim from the
|
||||||
|
/// one campaign executor.
|
||||||
pub(crate) fn run_sweep_sugar(
|
pub(crate) fn run_sweep_sugar(
|
||||||
inv: &SugarInvocation,
|
inv: &SugarInvocation,
|
||||||
env: &crate::project::Env,
|
env: &crate::project::Env,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let generated = translate_sweep(inv)?;
|
let generated = translate_sweep(inv)?;
|
||||||
let probe_params = probe_params_from(inv.axes);
|
let probe_params = probe_params_from(inv.axes);
|
||||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||||
@@ -349,37 +351,36 @@ pub(crate) fn run_sweep_sugar(
|
|||||||
/// Build the `CrossInstrument` family members from an executed generalize
|
/// Build the `CrossInstrument` family members from an executed generalize
|
||||||
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
|
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
|
||||||
/// the same order + shape the inline `run_generalize` built its `members` in,
|
/// the same order + shape the inline `run_generalize` built its `members` in,
|
||||||
/// each already carrying `manifest.instrument`. Debug-asserts one member per
|
/// each already carrying `manifest.instrument`. #272: a failed cell nominates
|
||||||
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
|
/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
|
||||||
/// a cell silently missing its nominee here would be a campaign-executor
|
/// outcome (the caller counts and surfaces the failed cells separately, via
|
||||||
/// regression, not a legitimate generalize outcome, and the filter_map below
|
/// `outcome.record.cells[].fault`) — no longer an invariant this function
|
||||||
/// must not swallow it unnoticed.
|
/// itself enforces.
|
||||||
fn cross_instrument_members(
|
fn cross_instrument_members(
|
||||||
outcome: &aura_campaign::CampaignOutcome,
|
outcome: &aura_campaign::CampaignOutcome,
|
||||||
) -> Vec<aura_engine::RunReport> {
|
) -> Vec<aura_engine::RunReport> {
|
||||||
let members: Vec<aura_engine::RunReport> = outcome
|
outcome
|
||||||
.cells
|
.cells
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
|
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
|
||||||
.collect();
|
.collect()
|
||||||
debug_assert_eq!(
|
|
||||||
members.len(),
|
|
||||||
outcome.cells.len(),
|
|
||||||
"generalize has no gate stage — every cell must nominate"
|
|
||||||
);
|
|
||||||
members
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run one dissolved `generalize` invocation end-to-end: register the generated
|
/// Run one dissolved `generalize` invocation end-to-end: register the generated
|
||||||
/// documents, run through the one campaign path, then reprint today's exact
|
/// documents, run through the one campaign path, then reprint today's exact
|
||||||
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
|
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
|
||||||
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
|
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
|
||||||
/// retired welded path (the committed exact-grade anchor is the gate).
|
/// retired welded path (the committed exact-grade anchor is the gate). The
|
||||||
|
/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing
|
||||||
|
/// (the existing no-nominee -> `missing` path already handles it), counted
|
||||||
|
/// from the recorded cells' own `fault` field rather than from the missing
|
||||||
|
/// nominee, so the count is right even when some OTHER member of the cell's
|
||||||
|
/// pipeline is what actually failed.
|
||||||
pub(crate) fn run_generalize_sugar(
|
pub(crate) fn run_generalize_sugar(
|
||||||
inv: &SugarInvocation,
|
inv: &SugarInvocation,
|
||||||
metric: &str,
|
metric: &str,
|
||||||
env: &crate::project::Env,
|
env: &crate::project::Env,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let generated = translate_generalize(inv, metric)?;
|
let generated = translate_generalize(inv, metric)?;
|
||||||
let probe_params = probe_params_from(inv.axes);
|
let probe_params = probe_params_from(inv.axes);
|
||||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||||
@@ -403,7 +404,8 @@ pub(crate) fn run_generalize_sugar(
|
|||||||
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
|
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
println!("{{\"family_id\":\"{family_id}\"}}");
|
println!("{{\"family_id\":\"{family_id}\"}}");
|
||||||
Ok(())
|
let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count();
|
||||||
|
Ok(failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two generated documents of one dissolved `walkforward` invocation.
|
/// The two generated documents of one dissolved `walkforward` invocation.
|
||||||
@@ -414,12 +416,13 @@ pub(crate) struct GeneratedWalkforward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Translate one `aura walkforward --real` invocation into its two generated
|
/// Translate one `aura walkforward --real` invocation into its two generated
|
||||||
/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep
|
/// documents: a `[std::grid, std::walk_forward]` process (the leading
|
||||||
/// only enumerates the IS-refit survivor grid for the wf stage; its recorded
|
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||||
/// argmax selection is not part of the summary) and a campaign running the
|
/// stage — it executes nothing and persists no family (#256)) and a campaign
|
||||||
/// invocation's axis grid over one instrument under its single risk regime.
|
/// running the invocation's axis grid over one instrument under its single
|
||||||
/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's
|
/// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical`
|
||||||
/// blueprint already stored by topology hash; its content id is the strategy ref.
|
/// is the user's blueprint already stored by topology hash; its content id is
|
||||||
|
/// the strategy ref.
|
||||||
pub(crate) fn translate_walkforward(
|
pub(crate) fn translate_walkforward(
|
||||||
inv: &SugarInvocation,
|
inv: &SugarInvocation,
|
||||||
metric: &str,
|
metric: &str,
|
||||||
@@ -432,13 +435,7 @@ pub(crate) fn translate_walkforward(
|
|||||||
name: "walkforward".to_string(),
|
name: "walkforward".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
pipeline: vec![
|
pipeline: vec![
|
||||||
StageBlock::Sweep {
|
StageBlock::Grid,
|
||||||
selection: Some(SweepSelection {
|
|
||||||
metric: metric.to_string(),
|
|
||||||
select: SelectRule::Argmax,
|
|
||||||
deflate: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
StageBlock::WalkForward {
|
StageBlock::WalkForward {
|
||||||
in_sample_ms: w.in_sample_ms,
|
in_sample_ms: w.in_sample_ms,
|
||||||
out_of_sample_ms: w.out_of_sample_ms,
|
out_of_sample_ms: w.out_of_sample_ms,
|
||||||
@@ -494,14 +491,18 @@ pub(crate) fn register_generated_wf(
|
|||||||
/// documents, run through the one campaign path, then reprint the per-window member
|
/// documents, run through the one campaign path, then reprint the per-window member
|
||||||
/// lines + the summary line from the recorded `WalkForward` family. The stdout
|
/// lines + the summary line from the recorded `WalkForward` family. The stdout
|
||||||
/// summary line is byte-identical to the retired welded path (the committed
|
/// summary line is byte-identical to the retired welded path (the committed
|
||||||
/// exact-grade anchor is the gate).
|
/// exact-grade anchor is the gate). #272: the invocation's one cell either
|
||||||
|
/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an
|
||||||
|
/// error, since a completed-with-failed-cell run is the campaign path's own
|
||||||
|
/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap"
|
||||||
|
/// refusal the retired welded path's `.ok_or` would have raised.
|
||||||
pub(crate) fn run_walkforward_sugar(
|
pub(crate) fn run_walkforward_sugar(
|
||||||
inv: &SugarInvocation,
|
inv: &SugarInvocation,
|
||||||
metric: &str,
|
metric: &str,
|
||||||
w: WfWindows,
|
w: WfWindows,
|
||||||
select: SelectRule,
|
select: SelectRule,
|
||||||
env: &crate::project::Env,
|
env: &crate::project::Env,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let generated = translate_walkforward(inv, metric, w, select)?;
|
let generated = translate_walkforward(inv, metric, w, select)?;
|
||||||
let probe_params = probe_params_from(inv.axes);
|
let probe_params = probe_params_from(inv.axes);
|
||||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||||
@@ -509,6 +510,11 @@ pub(crate) fn run_walkforward_sugar(
|
|||||||
let (_process_id, campaign_id) = register_generated_wf(®, &generated)?;
|
let (_process_id, campaign_id) = register_generated_wf(®, &generated)?;
|
||||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||||
|
|
||||||
|
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||||
|
eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
// The single cell's walk_forward StageFamily.
|
// The single cell's walk_forward StageFamily.
|
||||||
let wf = run
|
let wf = run
|
||||||
.outcome
|
.outcome
|
||||||
@@ -554,7 +560,7 @@ pub(crate) fn run_walkforward_sugar(
|
|||||||
env,
|
env,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two generated documents of one dissolved `mc` invocation.
|
/// The two generated documents of one dissolved `mc` invocation.
|
||||||
@@ -565,8 +571,9 @@ pub(crate) struct GeneratedMc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Translate one `aura mc --real` invocation into its two generated documents:
|
/// Translate one `aura mc --real` invocation into its two generated documents:
|
||||||
/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the
|
/// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading
|
||||||
/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal
|
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||||
|
/// stage — it executes nothing and persists no family (#256); the terminal
|
||||||
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps
|
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps
|
||||||
/// `E[R]`) and a campaign running the invocation's axis grid over one instrument
|
/// `E[R]`) and a campaign running the invocation's axis grid over one instrument
|
||||||
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
|
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
|
||||||
@@ -587,13 +594,7 @@ pub(crate) fn translate_mc(
|
|||||||
name: "mc".to_string(),
|
name: "mc".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
pipeline: vec![
|
pipeline: vec![
|
||||||
StageBlock::Sweep {
|
StageBlock::Grid,
|
||||||
selection: Some(SweepSelection {
|
|
||||||
metric: metric.to_string(),
|
|
||||||
select: SelectRule::Argmax,
|
|
||||||
deflate: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
StageBlock::WalkForward {
|
StageBlock::WalkForward {
|
||||||
in_sample_ms: w.in_sample_ms,
|
in_sample_ms: w.in_sample_ms,
|
||||||
out_of_sample_ms: w.out_of_sample_ms,
|
out_of_sample_ms: w.out_of_sample_ms,
|
||||||
@@ -652,13 +653,16 @@ pub(crate) fn register_generated_mc(
|
|||||||
/// byte-identical to the retired welded path (the committed exact-grade anchor is
|
/// byte-identical to the retired welded path (the committed exact-grade anchor is
|
||||||
/// the gate). mc prints ONLY the one summary line — no per-window member lines
|
/// the gate). mc prints ONLY the one summary line — no per-window member lines
|
||||||
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
|
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
|
||||||
|
/// #272: the invocation's one cell either completed (`Ok(0)`) or failed
|
||||||
|
/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s
|
||||||
|
/// twin note).
|
||||||
pub(crate) fn run_mc_sugar(
|
pub(crate) fn run_mc_sugar(
|
||||||
inv: &SugarInvocation,
|
inv: &SugarInvocation,
|
||||||
metric: &str,
|
metric: &str,
|
||||||
w: WfWindows,
|
w: WfWindows,
|
||||||
mc: McKnobs,
|
mc: McKnobs,
|
||||||
env: &crate::project::Env,
|
env: &crate::project::Env,
|
||||||
) -> Result<(), String> {
|
) -> Result<usize, String> {
|
||||||
let generated = translate_mc(inv, metric, w, mc)?;
|
let generated = translate_mc(inv, metric, w, mc)?;
|
||||||
let probe_params = probe_params_from(inv.axes);
|
let probe_params = probe_params_from(inv.axes);
|
||||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||||
@@ -666,6 +670,11 @@ pub(crate) fn run_mc_sugar(
|
|||||||
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
||||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||||
|
|
||||||
|
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||||
|
eprintln!("aura: mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
|
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
|
||||||
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
|
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
|
||||||
let boot = run
|
let boot = run
|
||||||
@@ -680,7 +689,7 @@ pub(crate) fn run_mc_sugar(
|
|||||||
})
|
})
|
||||||
.ok_or("mc produced no pooled-OOS bootstrap")?;
|
.ok_or("mc produced no pooled-OOS bootstrap")?;
|
||||||
println!("{}", crate::mc_r_bootstrap_json(boot));
|
println!("{}", crate::mc_r_bootstrap_json(boot));
|
||||||
Ok(())
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -895,10 +904,9 @@ mod tests {
|
|||||||
/// that silently, before the dispatch rewire ever exercises the path live.
|
/// that silently, before the dispatch rewire ever exercises the path live.
|
||||||
#[test]
|
#[test]
|
||||||
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
|
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
"aura-verb-sugar-generalize-registry-test-{}",
|
.join("aura-verb-sugar-generalize-registry-test");
|
||||||
std::process::id()
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||||
|
|
||||||
@@ -934,17 +942,11 @@ mod tests {
|
|||||||
content_id_of(&campaign_to_json(&b.campaign)),
|
content_id_of(&campaign_to_json(&b.campaign)),
|
||||||
"identical invocations must generate identical campaign ids"
|
"identical invocations must generate identical campaign ids"
|
||||||
);
|
);
|
||||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling)
|
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.process.pipeline,
|
a.process.pipeline,
|
||||||
vec![
|
vec![
|
||||||
StageBlock::Sweep {
|
StageBlock::Grid,
|
||||||
selection: Some(SweepSelection {
|
|
||||||
metric: "sqn_normalized".to_string(),
|
|
||||||
select: SelectRule::Argmax,
|
|
||||||
deflate: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
StageBlock::WalkForward {
|
StageBlock::WalkForward {
|
||||||
in_sample_ms: 7_776_000_000,
|
in_sample_ms: 7_776_000_000,
|
||||||
out_of_sample_ms: 2_592_000_000,
|
out_of_sample_ms: 2_592_000_000,
|
||||||
@@ -1047,10 +1049,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
|
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
"aura-verb-sugar-walkforward-registry-test-{}",
|
.join("aura-verb-sugar-walkforward-registry-test");
|
||||||
std::process::id()
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||||
let ax = wf_axes();
|
let ax = wf_axes();
|
||||||
@@ -1084,17 +1085,11 @@ mod tests {
|
|||||||
content_id_of(&campaign_to_json(&b.campaign)),
|
content_id_of(&campaign_to_json(&b.campaign)),
|
||||||
"identical invocations must generate identical campaign ids"
|
"identical invocations must generate identical campaign ids"
|
||||||
);
|
);
|
||||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo
|
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.process.pipeline,
|
a.process.pipeline,
|
||||||
vec![
|
vec![
|
||||||
StageBlock::Sweep {
|
StageBlock::Grid,
|
||||||
selection: Some(SweepSelection {
|
|
||||||
metric: "sqn_normalized".to_string(),
|
|
||||||
select: SelectRule::Argmax,
|
|
||||||
deflate: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
StageBlock::WalkForward {
|
StageBlock::WalkForward {
|
||||||
in_sample_ms: 7_776_000_000,
|
in_sample_ms: 7_776_000_000,
|
||||||
out_of_sample_ms: 2_592_000_000,
|
out_of_sample_ms: 2_592_000_000,
|
||||||
@@ -1140,10 +1135,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
|
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
"aura-verb-sugar-mc-registry-test-{}",
|
.join("aura-verb-sugar-mc-registry-test");
|
||||||
std::process::id()
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||||
let ax = wf_axes();
|
let ax = wf_axes();
|
||||||
@@ -1165,4 +1159,73 @@ mod tests {
|
|||||||
assert_eq!(stored_process, process_to_json(&generated.process));
|
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
|
||||||
|
/// members list by nothing but that stamp — the field
|
||||||
|
/// `cross_instrument_members` preserves verbatim from a nominating cell.
|
||||||
|
fn fake_report(instrument: &str) -> aura_engine::RunReport {
|
||||||
|
aura_engine::RunReport {
|
||||||
|
manifest: aura_engine::RunManifest {
|
||||||
|
commit: String::new(),
|
||||||
|
params: vec![],
|
||||||
|
defaults: vec![],
|
||||||
|
window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "test".to_string(),
|
||||||
|
selection: None,
|
||||||
|
instrument: Some(instrument.to_string()),
|
||||||
|
topology_hash: None,
|
||||||
|
project: None,
|
||||||
|
},
|
||||||
|
metrics: aura_engine::RunMetrics {
|
||||||
|
total_pips: 0.0,
|
||||||
|
max_drawdown: 0.0,
|
||||||
|
bias_sign_flips: 0,
|
||||||
|
r: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome {
|
||||||
|
aura_campaign::CellOutcome {
|
||||||
|
families: vec![],
|
||||||
|
selections: vec![],
|
||||||
|
nominee: nominee_instrument.map(|i| (vec![], fake_report(i))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: `cross_instrument_members` skips a cell with no nominee (a
|
||||||
|
/// failed cell nominates nothing) rather than padding the members list or
|
||||||
|
/// aborting — the surviving cells' reports still reach the persisted
|
||||||
|
/// `CrossInstrument` family, in cell order, with the failed cell simply
|
||||||
|
/// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC
|
||||||
|
/// nominates) must yield exactly the two survivors' reports, in order.
|
||||||
|
#[test]
|
||||||
|
fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() {
|
||||||
|
let outcome = aura_campaign::CampaignOutcome {
|
||||||
|
record: aura_registry::CampaignRunRecord {
|
||||||
|
campaign: String::new(),
|
||||||
|
process: String::new(),
|
||||||
|
run: 0,
|
||||||
|
seed: 0,
|
||||||
|
cells: vec![],
|
||||||
|
generalizations: vec![],
|
||||||
|
trace_name: None,
|
||||||
|
},
|
||||||
|
run: 0,
|
||||||
|
cells: vec![
|
||||||
|
cell_outcome(Some("AAA")),
|
||||||
|
cell_outcome(None),
|
||||||
|
cell_outcome(Some("CCC")),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let members = cross_instrument_members(&outcome);
|
||||||
|
let instruments: Vec<Option<String>> =
|
||||||
|
members.iter().map(|r| r.manifest.instrument.clone()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
instruments,
|
||||||
|
vec![Some("AAA".to_string()), Some("CCC".to_string())],
|
||||||
|
"the failed (no-nominee) cell is skipped, the two survivors keep their order"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
|||||||
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
||||||
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
||||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||||
dir
|
dir
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::path::Path;
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
use common::{fresh_project, fresh_project_with_data};
|
use common::{fresh_project, fresh_project_with_data, fresh_project_with_gapped_data};
|
||||||
|
|
||||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
||||||
/// crate; the binary is named `aura` in `Cargo.toml`).
|
/// crate; the binary is named `aura` in `Cargo.toml`).
|
||||||
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
|||||||
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
|
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
|
||||||
/// Unique per test + per process; no external tempfile dependency.
|
/// Unique per test + per process; no external tempfile dependency.
|
||||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||||
dir
|
dir
|
||||||
@@ -2181,6 +2181,84 @@ fn aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#247): every sweep-verb boundary `BindError` renders as prose in
|
||||||
|
/// the same register as its unknown-axis sibling
|
||||||
|
/// (`aura: axis <name>: names no param of this blueprint … — see `aura sweep
|
||||||
|
/// <bp> --list-axes``) — never a raw Rust Debug struct. A wrong-kind axis value
|
||||||
|
/// and an uncovered open knob are the two variants that today leak
|
||||||
|
/// `KindMismatch { knob: …, expected: …, got: … }` and
|
||||||
|
/// `MissingKnob("…")` verbatim to stderr; both must instead name the axis, carry
|
||||||
|
/// the specifics (expected vs supplied kind / the unbound knob), and point at
|
||||||
|
/// `--list-axes`, with the exit code staying 2. Data-free: both faults are
|
||||||
|
/// caught at the axis-resolution boundary before any member runs, so the test
|
||||||
|
/// needs no archive and no project (hostless synthetic sweep).
|
||||||
|
#[test]
|
||||||
|
fn sweep_boundary_bind_errors_render_as_prose_not_debug_structs() {
|
||||||
|
let dir = temp_cwd("sweep-bind-error-prose");
|
||||||
|
|
||||||
|
// (a) KindMismatch — an axis re-opens the bound I64 `fast.length` (#246) but
|
||||||
|
// supplies F64 values: the specifics are the expected vs supplied kind.
|
||||||
|
let closed = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let km = Command::new(BIN)
|
||||||
|
.args(["sweep", &closed, "--axis", "sma_signal.fast.length=2.5,3.5"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura sweep (kind-mismatched axis)");
|
||||||
|
assert_eq!(
|
||||||
|
km.status.code(),
|
||||||
|
Some(2),
|
||||||
|
"a kind-mismatched axis value stays a usage refusal: status={:?}",
|
||||||
|
km.status
|
||||||
|
);
|
||||||
|
let km_err = String::from_utf8_lossy(&km.stderr).into_owned();
|
||||||
|
assert!(
|
||||||
|
!km_err.contains("KindMismatch"),
|
||||||
|
"must not leak the raw Rust Debug struct name: {km_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
km_err.contains("sma_signal.fast.length"),
|
||||||
|
"the prose names the offending axis exactly as typed: {km_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
km_err.contains("I64") && km_err.contains("F64"),
|
||||||
|
"the prose carries the expected vs supplied kind specifics: {km_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
km_err.contains("--list-axes"),
|
||||||
|
"the prose points at the discovery surface, like the unknown-axis sibling: {km_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (b) MissingKnob — the open fixture leaves `slow.length` unbound when only
|
||||||
|
// `fast.length` is swept: the specific is the uncovered open knob.
|
||||||
|
let open = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let mk = Command::new(BIN)
|
||||||
|
.args(["sweep", &open, "--axis", "sma_signal.fast.length=2,4"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura sweep (uncovered open knob)");
|
||||||
|
assert_eq!(
|
||||||
|
mk.status.code(),
|
||||||
|
Some(2),
|
||||||
|
"an uncovered open knob stays a usage refusal: status={:?}",
|
||||||
|
mk.status
|
||||||
|
);
|
||||||
|
let mk_err = String::from_utf8_lossy(&mk.stderr).into_owned();
|
||||||
|
assert!(
|
||||||
|
!mk_err.contains("MissingKnob"),
|
||||||
|
"must not leak the raw Rust Debug struct name: {mk_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mk_err.contains("sma_signal.slow.length"),
|
||||||
|
"the prose names the unbound knob no axis supplies: {mk_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mk_err.contains("--list-axes"),
|
||||||
|
"the prose points at the discovery surface, like the unknown-axis sibling: {mk_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register
|
/// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register
|
||||||
/// their generated campaign document" — store litter, probe P3): binding only
|
/// their generated campaign document" — store litter, probe P3): binding only
|
||||||
/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving
|
/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving
|
||||||
@@ -3215,8 +3293,8 @@ fn walkforward_dissolved_refuses_name_and_trace_together() {
|
|||||||
/// generated campaign document (carrying the `--name` handle and the stop as a
|
/// generated campaign document (carrying the `--name` handle and the stop as a
|
||||||
/// non-empty single risk regime), and one campaign-run record — mirroring the
|
/// non-empty single risk regime), and one campaign-run record — mirroring the
|
||||||
/// generalize dissolution's audit trail. The persisted family set is exactly one
|
/// generalize dissolution's audit trail. The persisted family set is exactly one
|
||||||
/// `Sweep` family (the pipeline's leading `std::sweep(argmax)` stage) plus one
|
/// `WalkForward` family (the per-window OOS reports) and NO `Sweep` family —
|
||||||
/// `WalkForward` family (the per-window OOS reports) — the observable proof that
|
/// the leading `std::grid` stage only enumerates (#256) — the observable proof that
|
||||||
/// `dispatch_walkforward`'s real-data path runs through the one campaign
|
/// `dispatch_walkforward`'s real-data path runs through the one campaign
|
||||||
/// executor rather than the deleted inline roller. Gated on the shared GER40
|
/// executor rather than the deleted inline roller. Gated on the shared GER40
|
||||||
/// archive; skips cleanly on a data refusal.
|
/// archive; skips cleanly on a data refusal.
|
||||||
@@ -3285,8 +3363,8 @@ fn walkforward_dissolves_through_the_campaign_path() {
|
|||||||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||||||
1,
|
0,
|
||||||
"one Sweep family from the pipeline's leading argmax stage: {fams_out}"
|
"the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
||||||
@@ -4485,8 +4563,8 @@ fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() {
|
|||||||
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
||||||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("Knob") && stderr.contains("nope"),
|
stderr.contains("names no param") && stderr.contains("nope"),
|
||||||
"names the unresolved axis via BindError: {stderr}"
|
"names the unresolved axis via the prose payload (#269): {stderr}"
|
||||||
);
|
);
|
||||||
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
||||||
}
|
}
|
||||||
@@ -4518,7 +4596,7 @@ fn aura_walkforward_emits_an_unknown_axis_rejection_once() {
|
|||||||
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
|
||||||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||||||
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
|
||||||
let emits = stderr.matches("UnknownKnob").count();
|
let emits = stderr.matches("names no param").count();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
emits, 1,
|
emits, 1,
|
||||||
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}",
|
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}",
|
||||||
@@ -4526,6 +4604,61 @@ fn aura_walkforward_emits_an_unknown_axis_rejection_once() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#269): the walkforward verb's unknown-axis rejection reaches a
|
||||||
|
/// user's stderr as prose — the same register `aura sweep`'s boundary errors
|
||||||
|
/// adopted in #247 — never wrapped in the `UnknownKnob("…")` Rust `Debug`
|
||||||
|
/// frame. The walkforward path already carries a fully-prosed payload INSIDE
|
||||||
|
/// that wrapper (`axis <name>: names no param of this blueprint … — see …
|
||||||
|
/// --list-axes`), so the fix sheds the frame rather than authoring new prose:
|
||||||
|
/// the message that reaches stderr is exactly that payload, the axis named as
|
||||||
|
/// typed, exit still 2. Data-free — the axis is rejected at the resolution
|
||||||
|
/// pre-flight before any window runs, so no archive and no project are needed.
|
||||||
|
///
|
||||||
|
/// NB: this is the post-#269 contract. The two sibling tests above
|
||||||
|
/// (`aura_walkforward_over_a_blueprint_rejects_an_unknown_axis`,
|
||||||
|
/// `aura_walkforward_emits_an_unknown_axis_rejection_once`) still pin the
|
||||||
|
/// `UnknownKnob` substring — stale pins of the defective frame that the GREEN
|
||||||
|
/// slice repoints onto the prose fragment.
|
||||||
|
#[test]
|
||||||
|
fn walkforward_unknown_axis_rejection_renders_as_prose_not_a_debug_frame() {
|
||||||
|
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["walkforward", &bp, "--axis", "nope=1,2"])
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura walkforward (unknown axis)");
|
||||||
|
assert_eq!(
|
||||||
|
out.status.code(),
|
||||||
|
Some(2),
|
||||||
|
"an unknown axis stays a usage refusal (exit 2, not a panic): status={:?}",
|
||||||
|
out.status
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||||||
|
// The headline — the whole defect: no Rust `Debug` frame reaches the user.
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("UnknownKnob"),
|
||||||
|
"must not leak the `UnknownKnob(…)` Debug frame around the prose: {stderr}"
|
||||||
|
);
|
||||||
|
// The prose payload survives intact — the axis named as typed, the
|
||||||
|
// names-no-param wording, and the `--list-axes` pointer (the sweep
|
||||||
|
// sibling's register).
|
||||||
|
assert!(
|
||||||
|
stderr.contains("nope"),
|
||||||
|
"the prose names the offending axis exactly as typed: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("names no param"),
|
||||||
|
"the prose carries the boundary payload wording: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("--list-axes"),
|
||||||
|
"the prose points at the discovery surface, like the sweep sibling: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("panicked"),
|
||||||
|
"must reject cleanly, never panic: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// E2E (acc 2): `aura mc <open blueprint.json>` is rejected with a named error + exit 2
|
/// E2E (acc 2): `aura mc <open blueprint.json>` is rejected with a named error + exit 2
|
||||||
/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder.
|
/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4866,6 +4999,42 @@ fn version_flag_prints_version_to_stdout_and_exits_zero() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--version` carries the ENGINE COMMIT beside the semver, drawn from the SAME
|
||||||
|
/// `AURA_COMMIT` build-time provenance the run manifest stamps (the
|
||||||
|
/// `option_env!("AURA_COMMIT")` that fills `RunManifest.commit`), so a stale
|
||||||
|
/// binary is distinguishable at the CLI surface *before* any run (#266). Shape:
|
||||||
|
/// `aura 0.1.0 (<commit-ish>)`, exit 0.
|
||||||
|
///
|
||||||
|
/// Property, not a specific sha: the expected commit is read from the same
|
||||||
|
/// provenance source the built binary was compiled with — this integration test
|
||||||
|
/// receives aura-cli's build-script `rustc-env`, so it sees the identical value
|
||||||
|
/// (dirty/`unknown` suffixes included) rather than today's HEAD, keeping the
|
||||||
|
/// assertion honest against a lagging or unclean tree. The load-bearing claim is
|
||||||
|
/// that the parenthesized commit rides beside the still-present semver.
|
||||||
|
#[test]
|
||||||
|
fn version_flag_carries_the_engine_commit_from_manifest_provenance() {
|
||||||
|
// The single source of truth — the same env the manifest's commit is sourced
|
||||||
|
// from; falls back to "unknown" identically to `sim_optimal_manifest`.
|
||||||
|
let commit = option_env!("AURA_COMMIT").unwrap_or("unknown");
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura");
|
||||||
|
assert_eq!(out.status.code(), Some(0), "--version exit {:?}", out.status);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
// Semver stays present (one source of truth, CARGO_PKG_VERSION).
|
||||||
|
assert!(
|
||||||
|
stdout.contains(env!("CARGO_PKG_VERSION")),
|
||||||
|
"--version still prints the semver: {stdout:?}"
|
||||||
|
);
|
||||||
|
// ...and the engine commit rides beside it, parenthesized, from the shared
|
||||||
|
// manifest provenance — the load-bearing assertion #266 adds.
|
||||||
|
assert!(
|
||||||
|
stdout.contains(&format!("({commit})")),
|
||||||
|
"--version prints the engine commit ({commit}) beside the semver: {stdout:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is
|
/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is
|
||||||
/// NOT byte-identical to `aura run --help` — each subcommand documents its own
|
/// NOT byte-identical to `aura run --help` — each subcommand documents its own
|
||||||
/// options rather than reprinting one shared global blob (the #131 uniform-help
|
/// options rather than reprinting one shared global blob (the #131 uniform-help
|
||||||
@@ -5143,9 +5312,10 @@ fn mc_dissolves_a_non_r_sma_blueprint() {
|
|||||||
/// path — a successful run durably auto-registers exactly one generated process
|
/// path — a successful run durably auto-registers exactly one generated process
|
||||||
/// document, one generated campaign document (carrying the constant "mc" name and the
|
/// document, one generated campaign document (carrying the constant "mc" name and the
|
||||||
/// stop as a non-empty single risk regime), and one campaign-run record, and emits the
|
/// stop as a non-empty single risk regime), and one campaign-run record, and emits the
|
||||||
/// single `mc_r_bootstrap` grade line. The pipeline's leading argmax sweep and its
|
/// single `mc_r_bootstrap` grade line. The pipeline's walk_forward stage persists
|
||||||
/// walk_forward stage each persist one family; the terminal monte_carlo stage is an
|
/// the one family; the leading `std::grid` stage only enumerates (#256) and the
|
||||||
/// annotator and adds NO family. This is the observable proof the inline path is gone
|
/// terminal monte_carlo stage is an annotator — neither adds a family. This is
|
||||||
|
/// the observable proof the inline path is gone
|
||||||
/// and the dissolution runs through the executor. Gated on the GER40 2025 archive;
|
/// and the dissolution runs through the executor. Gated on the GER40 2025 archive;
|
||||||
/// skips cleanly on a data refusal.
|
/// skips cleanly on a data refusal.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5222,8 +5392,8 @@ fn mc_dissolves_through_the_campaign_path() {
|
|||||||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
|
||||||
1,
|
0,
|
||||||
"one Sweep family from the pipeline's leading argmax stage: {fams_out}"
|
"the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(),
|
||||||
@@ -5247,9 +5417,9 @@ fn mc_dissolves_through_the_campaign_path() {
|
|||||||
/// data-carrying window far shorter than the roller", which a fixed recent window
|
/// data-carrying window far shorter than the roller", which a fixed recent window
|
||||||
/// satisfies without the full-archive probe sweep a live-span derivation costs —
|
/// satisfies without the full-archive probe sweep a live-span derivation costs —
|
||||||
/// the archive only grows forward, so the window never falls off its end. The
|
/// the archive only grows forward, so the window never falls off its end. The
|
||||||
/// leading sweep stage runs first over the short window; the refusal today fires at
|
/// leading std::grid stage only enumerates (nothing runs over the short window); the
|
||||||
/// stage 1 (the walk_forward roller), so this bites only when the window genuinely has
|
/// refusal fires at stage 1 (the walk_forward roller), so this bites only when the
|
||||||
/// data. Gated on the GER40 archive; skips cleanly on a data-less host.
|
/// window genuinely has data. Gated on the GER40 archive; skips cleanly on a data-less host.
|
||||||
#[test]
|
#[test]
|
||||||
fn mc_real_fits_the_injected_roller_to_a_short_window() {
|
fn mc_real_fits_the_injected_roller_to_a_short_window() {
|
||||||
if !local_data_present() {
|
if !local_data_present() {
|
||||||
@@ -5862,3 +6032,78 @@ fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
|
|||||||
assert!(stdout.contains("\"n_trades\":0"), "{stdout}");
|
assert!(stdout.contains("\"n_trades\":0"), "{stdout}");
|
||||||
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
|
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#264, coverage cut 1): `aura data coverage <SYMBOL>` surfaces the
|
||||||
|
/// archive's month COVERAGE from the monthly file index — the first and last
|
||||||
|
/// present month AND every interior missing-month range (a gap) — so the Copper
|
||||||
|
/// failure mode is visible up front. Copper had files from 2016-08 but was
|
||||||
|
/// missing 2018-01..2018-10, so a first/last-bounds check passed while a
|
||||||
|
/// window opening in the hole had no data, aborting a multi-hour campaign
|
||||||
|
/// mid-run; a per-symbol gap report would have caught it before the run. Over a
|
||||||
|
/// hermetic synthetic archive whose `GAPSYM` has 2024-01..02 and 2024-05..06
|
||||||
|
/// present and 2024-03..04 absent, the verb exits 0, frames the span by its
|
||||||
|
/// first (2024-01) and last (2024-06) present month, and reports the interior
|
||||||
|
/// hole as a single collapsed month-range line `missing: 2024-03..2024-04`
|
||||||
|
/// (one line for the whole contiguous gap, as Copper's ten-month hole would be
|
||||||
|
/// one line — not one line per missing month).
|
||||||
|
#[test]
|
||||||
|
fn data_coverage_reports_month_span_and_interior_gap_range() {
|
||||||
|
let (cwd, _g) = fresh_project_with_gapped_data();
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["data", "coverage", "GAPSYM"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura data coverage");
|
||||||
|
assert_eq!(
|
||||||
|
out.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"exit: {:?}, stderr: {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||||
|
// The interior gap is the headline — the Copper failure the verb exists to
|
||||||
|
// surface — reported as one collapsed range for the whole contiguous hole.
|
||||||
|
assert!(
|
||||||
|
stdout.contains("missing: 2024-03..2024-04"),
|
||||||
|
"interior gap month-range reported as one line: {stdout}"
|
||||||
|
);
|
||||||
|
// The span bounds frame the gap: first and last present month.
|
||||||
|
assert!(
|
||||||
|
stdout.contains("2024-01") && stdout.contains("2024-06"),
|
||||||
|
"first (2024-01) and last (2024-06) present month reported: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#264, list cut 2): `aura data list` enumerates the symbols the
|
||||||
|
/// archive contains, one per line, sorted — the discovery step a campaign
|
||||||
|
/// author (or an agent operating through the CLI alone, the deployment
|
||||||
|
/// posture) needs before `aura data coverage <symbol>`, since without it the
|
||||||
|
/// inventory is obtainable only by listing the archive directory outside
|
||||||
|
/// aura. The archive root resolves through the same project path as `coverage`
|
||||||
|
/// (`Env::data_path` — a project-local `[paths] data` override when set). Over
|
||||||
|
/// a hermetic synthetic archive holding two symbols (`SYMA` 2024-01..08 and
|
||||||
|
/// `SYMB` 2024-03..06), the verb exits 0 and prints EXACTLY the two symbol
|
||||||
|
/// names in ascending order, one per line — `SYMA` before `SYMB`, nothing
|
||||||
|
/// else.
|
||||||
|
#[test]
|
||||||
|
fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
|
||||||
|
let (cwd, _g) = fresh_project_with_data();
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["data", "list"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura data list");
|
||||||
|
assert_eq!(
|
||||||
|
out.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"exit: {:?}, stderr: {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||||
|
// The whole contract: the two archive symbols, sorted, one per line, and
|
||||||
|
// nothing else — this is the inventory a campaign author reads before
|
||||||
|
// scoping an instrument matrix.
|
||||||
|
assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ impl Drop for FreshProject {
|
|||||||
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
|
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
|
||||||
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
|
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
|
||||||
/// concurrent `cargo test` processes never collide.
|
/// concurrent `cargo test` processes never collide.
|
||||||
|
///
|
||||||
|
/// #258 sweep note: this site keeps `std::process::id()` deliberately, unlike
|
||||||
|
/// the fixed, pid-free sandbox names the rest of #258's fix uses — it has no
|
||||||
|
/// pre-create wipe to defeat in the first place. `FreshProject::drop` (above)
|
||||||
|
/// unconditionally removes the returned root when the owning test ends (pass,
|
||||||
|
/// fail, or panic-unwind), so nothing strands across runs; the pid instead
|
||||||
|
/// disambiguates concurrent `cargo test` *processes* racing this one function
|
||||||
|
/// from dozens of call sites, which a fixed tag-keyed name cannot do without
|
||||||
|
/// plumbing a unique tag through every one of them.
|
||||||
fn mint_tempdir() -> PathBuf {
|
fn mint_tempdir() -> PathBuf {
|
||||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
@@ -118,6 +127,26 @@ pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
|
|||||||
(root.clone(), FreshProject { root })
|
(root.clone(), FreshProject { root })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`fresh_project_with_data`], but the single symbol `GAPSYM` carries a
|
||||||
|
/// DELIBERATE interior month gap: monthly files exist for 2024-01..2024-02 and
|
||||||
|
/// 2024-05..2024-06, while 2024-03..2024-04 are absent. This is the Copper
|
||||||
|
/// failure shape (#264 — files present at both ends while an interior window
|
||||||
|
/// has no data, so a first/last-bounds check passes but a campaign aborts
|
||||||
|
/// mid-run). For tests whose property is per-symbol month-gap reporting: the
|
||||||
|
/// two `write_symbol_archive` calls write disjoint contiguous segments into the
|
||||||
|
/// same `data/` dir (the second re-writes GAPSYM's identical geometry sidecar),
|
||||||
|
/// leaving March and April as a real hole in the monthly file index.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn fresh_project_with_gapped_data() -> (PathBuf, FreshProject) {
|
||||||
|
let root = mint_tempdir();
|
||||||
|
write_project_files(&root, Some("data"));
|
||||||
|
let data_dir = root.join("data");
|
||||||
|
std::fs::create_dir_all(&data_dir).expect("create synthetic gapped archive dir");
|
||||||
|
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 1), (2024, 2));
|
||||||
|
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 5), (2024, 6));
|
||||||
|
(root.clone(), FreshProject { root })
|
||||||
|
}
|
||||||
|
|
||||||
/// A scratch filesystem entry a test writes under the git-tracked
|
/// A scratch filesystem entry a test writes under the git-tracked
|
||||||
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
||||||
/// removed on drop — including during a mid-test panic — so a failed
|
/// removed on drop — including during a mid-test panic — so a failed
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
|
|||||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lines.len(),
|
lines.len(),
|
||||||
28,
|
29,
|
||||||
"the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}"
|
"the std-only (no project) vocabulary has exactly the roster's 29 entries: {stdout}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ fn graph_introspect_no_flag_is_usage_exit_2() {
|
|||||||
/// A fresh, unique working directory for a test that persists content-addressed
|
/// A fresh, unique working directory for a test that persists content-addressed
|
||||||
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
|
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
|
||||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
|
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||||
dir
|
dir
|
||||||
|
|||||||
@@ -368,7 +368,8 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
|||||||
/// "binds no node crate" hint (not the outside-project "no Aura.toml" text).
|
/// "binds no node crate" hint (not the outside-project "no Aura.toml" text).
|
||||||
#[test]
|
#[test]
|
||||||
fn data_only_project_hints_attach_for_namespaced_ids() {
|
fn data_only_project_hints_attach_for_namespaced_ids() {
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-dataonly-hint-{}", std::process::id()));
|
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||||
let bp = tmp.join("bp.json");
|
let bp = tmp.join("bp.json");
|
||||||
@@ -388,7 +389,8 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn missing_artifact_refuses_with_build_hint() {
|
fn missing_artifact_refuses_with_build_hint() {
|
||||||
// A minimal never-built project: valid cargo metadata, no dylib on disk.
|
// A minimal never-built project: valid cargo metadata, no dylib on disk.
|
||||||
let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
|
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild");
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
std::fs::create_dir_all(tmp.join("src")).unwrap();
|
std::fs::create_dir_all(tmp.join("src")).unwrap();
|
||||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||||
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
|
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
|
||||||
|
|||||||
@@ -14,8 +14,19 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
|
|||||||
.expect("run aura")
|
.expect("run aura")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()`
|
||||||
|
/// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other
|
||||||
|
/// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`)
|
||||||
|
/// require a base genuinely detached from any git work tree, and
|
||||||
|
/// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`.
|
||||||
|
/// The checkout discriminator keeps concurrently running checkouts/worktrees
|
||||||
|
/// (which share `env::temp_dir()`) off each other's fixed names while the
|
||||||
|
/// name stays deterministic per checkout, so each run still reclaims the last.
|
||||||
fn tmp(tag: &str) -> PathBuf {
|
fn tmp(tag: &str) -> PathBuf {
|
||||||
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id()));
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
env!("CARGO_MANIFEST_DIR").hash(&mut h);
|
||||||
|
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32));
|
||||||
let _ = std::fs::remove_dir_all(&d);
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
std::fs::create_dir_all(&d).unwrap();
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
d
|
d
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn temp_cwd(tag: &str) -> std::path::PathBuf {
|
fn temp_cwd(tag: &str) -> std::path::PathBuf {
|
||||||
let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id()));
|
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
|
||||||
let _ = std::fs::remove_dir_all(&d);
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
std::fs::create_dir_all(&d).unwrap();
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
d
|
d
|
||||||
@@ -18,6 +18,39 @@ fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
|
|||||||
.expect("spawn aura")
|
.expect("spawn aura")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must
|
||||||
|
/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so
|
||||||
|
/// its own pre-create `remove_dir_all` reclaims the previous run instead of
|
||||||
|
/// stranding one `aura-nodes-*` directory per test-binary invocation.
|
||||||
|
///
|
||||||
|
/// The helper today keys the name on `std::process::id()`, so every run mints a
|
||||||
|
/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs
|
||||||
|
/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling
|
||||||
|
/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This
|
||||||
|
/// pins that property at one representative site — the assertion reads the path
|
||||||
|
/// value only, so it is deterministic and does not depend on any external state.
|
||||||
|
#[test]
|
||||||
|
fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() {
|
||||||
|
let sandbox = temp_cwd("leakguard");
|
||||||
|
// Read the path value, then reclaim immediately so a red run of this very
|
||||||
|
// test does not itself add to the leak it describes.
|
||||||
|
let path = sandbox.clone();
|
||||||
|
let _ = std::fs::remove_dir_all(&sandbox);
|
||||||
|
|
||||||
|
let pid = std::process::id().to_string();
|
||||||
|
assert!(
|
||||||
|
!path.to_string_lossy().contains(&pid),
|
||||||
|
"sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \
|
||||||
|
can reclaim it — one directory leaks per run: {}",
|
||||||
|
path.display(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!path.starts_with(std::env::temp_dir()),
|
||||||
|
"sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}",
|
||||||
|
path.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
|
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
|
||||||
let cwd = temp_cwd("attach");
|
let cwd = temp_cwd("attach");
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
|
|||||||
|
|
||||||
// Archive-gated: skip cleanly on a data-less machine (the member-data
|
// Archive-gated: skip cleanly on a data-less machine (the member-data
|
||||||
// refusal, never a panic) — mirroring campaign_run_real_e2e_*.
|
// refusal, never a panic) — mirroring campaign_run_real_e2e_*.
|
||||||
if run.status.code() == Some(1)
|
if run.status.code() == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign cell");
|
eprintln!("skip: no local GER40 data for the campaign cell");
|
||||||
|
|||||||
@@ -5,14 +5,17 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
use common::{ScratchGuard, ScratchPath, fresh_project};
|
use common::{
|
||||||
|
ScratchGuard, ScratchPath, fresh_project, fresh_project_with_data,
|
||||||
|
fresh_project_with_gapped_data,
|
||||||
|
};
|
||||||
|
|
||||||
/// A fresh, unique working directory for a process test that persists
|
/// A fresh, unique working directory for a process test that persists
|
||||||
/// content-addressed documents under `./runs/` (so `aura process register`
|
/// content-addressed documents under `./runs/` (so `aura process register`
|
||||||
/// never dirties the repo). Unique per test + per process; no external
|
/// never dirties the repo). Unique per test + per process; no external
|
||||||
/// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`).
|
/// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`).
|
||||||
fn temp_cwd(name: &str) -> PathBuf {
|
fn temp_cwd(name: &str) -> PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||||
dir
|
dir
|
||||||
@@ -493,6 +496,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 +585,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 +630,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 +645,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,12 +688,81 @@ 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}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #265: the cost and risk slots name their knobs but not their UNITS or
|
||||||
|
/// SEMANTICS, and the two natural misreadings are silent (wrong by orders of
|
||||||
|
/// magnitude / change an experiment's meaning, with no error). Property: when
|
||||||
|
/// `campaign introspect --unwired` lists the open cost and risk slots, each of
|
||||||
|
/// the four silently-misreadable knobs carries a one-line unit/semantics note
|
||||||
|
/// that blocks its misreading — `constant.cost_per_trade` is in PRICE units
|
||||||
|
/// (charged per close as cost/|entry−stop| in R), `vol_slippage.slip_vol_mult`
|
||||||
|
/// is a MULTIPLIER on the per-cycle vol estimate (charged in R likewise),
|
||||||
|
/// `vol.length` SMOOTHS the estimator and does NOT set the stop's timescale,
|
||||||
|
/// and `vol.k` SCALES the stop distance. Autonomous: a bare `{}` envelope
|
||||||
|
/// (both slots open) written inline, no project, hostless. Wording stays free
|
||||||
|
/// (matched case-insensitively on load-bearing fragments, not whole lines); it
|
||||||
|
/// fails today because the annotations are absent, not on scaffolding.
|
||||||
|
#[test]
|
||||||
|
fn campaign_introspect_unwired_annotates_cost_and_risk_knob_units() {
|
||||||
|
let dir = temp_cwd("campaign-introspect-unwired-knob-units");
|
||||||
|
write_doc(&dir, "bare.json", "{}");
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]);
|
||||||
|
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||||
|
let low = out.to_lowercase();
|
||||||
|
|
||||||
|
// constant.cost_per_trade — price units, not R (the order-of-magnitude trap).
|
||||||
|
assert!(low.contains("cost_per_trade"), "the cost knob must still be named: {out}");
|
||||||
|
assert!(
|
||||||
|
low.contains("price units"),
|
||||||
|
"cost_per_trade must be annotated as a PRICE-unit numerator (its misreading is a cost in R): {out}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// vol_slippage.slip_vol_mult — a multiplier on the per-cycle vol estimate.
|
||||||
|
assert!(low.contains("slip_vol_mult"), "the vol-slippage knob must still be named: {out}");
|
||||||
|
assert!(
|
||||||
|
low.contains("multiplier on the per-cycle vol"),
|
||||||
|
"slip_vol_mult must be annotated as a MULTIPLIER on the per-cycle vol estimate: {out}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// vol.length — smooths the estimator; it does NOT set the stop's timescale.
|
||||||
|
assert!(
|
||||||
|
low.contains("smooths"),
|
||||||
|
"vol.length must be annotated as SMOOTHING the per-cycle vol estimator: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
low.contains("does not set the stop's timescale"),
|
||||||
|
"vol.length must state it does NOT set the stop's timescale (its exact misreading): {out}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// vol.k — scales the stop distance (the risk-unit lever).
|
||||||
|
assert!(
|
||||||
|
low.contains("scales the stop distance"),
|
||||||
|
"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).
|
||||||
|
assert!(low.contains("carry_per_cycle"), "the carry knob must still be named: {out}");
|
||||||
|
assert!(
|
||||||
|
low.contains("price units per held cycle"),
|
||||||
|
"carry_per_cycle must be annotated as a PRICE-unit per-held-cycle accrual: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||||||
let dir = temp_cwd("campaign-introspect-unwired");
|
let dir = temp_cwd("campaign-introspect-unwired");
|
||||||
@@ -1010,7 +1106,10 @@ fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
|
|||||||
/// the RAW `param_space` names (`fast.length` / `slow.length` — see the
|
/// the RAW `param_space` names (`fast.length` / `slow.length` — see the
|
||||||
/// naming note in the referential test above). `persist_taps`/`emit` are
|
/// naming note in the referential test above). `persist_taps`/`emit` are
|
||||||
/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.).
|
/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.).
|
||||||
fn campaign_doc_json(
|
/// [`campaign_doc_json`] with the instrument spliced — the synthetic-archive
|
||||||
|
/// e2e targets `SYMA` (tests/common/mod.rs) instead of the hardcoded GER40.
|
||||||
|
fn campaign_doc_json_for(
|
||||||
|
instrument: &str,
|
||||||
bp_id: &str,
|
bp_id: &str,
|
||||||
proc_id: &str,
|
proc_id: &str,
|
||||||
window: (i64, i64),
|
window: (i64, i64),
|
||||||
@@ -1022,7 +1121,7 @@ fn campaign_doc_json(
|
|||||||
"format_version": 1,
|
"format_version": 1,
|
||||||
"kind": "campaign",
|
"kind": "campaign",
|
||||||
"name": "run-seam",
|
"name": "run-seam",
|
||||||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||||
@@ -1035,6 +1134,47 @@ fn campaign_doc_json(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn campaign_doc_json(
|
||||||
|
bp_id: &str,
|
||||||
|
proc_id: &str,
|
||||||
|
window: (i64, i64),
|
||||||
|
persist_taps: &str,
|
||||||
|
emit: &str,
|
||||||
|
) -> String {
|
||||||
|
campaign_doc_json_for("GER40", bp_id, proc_id, window, persist_taps, emit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`campaign_doc_json_for`] over TWO instruments plus a verbatim cost block
|
||||||
|
/// (#260) — the per-instrument-cost e2es need both synthetic symbols in one
|
||||||
|
/// document. `cost` is spliced verbatim (a full `"cost": [...],` line
|
||||||
|
/// including the trailing comma, or `""` for none).
|
||||||
|
fn campaign_doc_json_two(
|
||||||
|
instruments: (&str, &str),
|
||||||
|
cost: &str,
|
||||||
|
bp_id: &str,
|
||||||
|
proc_id: &str,
|
||||||
|
window: (i64, i64),
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"format_version": 1,
|
||||||
|
"kind": "campaign",
|
||||||
|
"name": "run-seam",
|
||||||
|
"data": {{ "instruments": ["{a}", "{b}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||||
|
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||||
|
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }},
|
||||||
|
"slow.length": {{ "kind": "I64", "values": [8] }} }} }} ],
|
||||||
|
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||||
|
{cost}"seed": 7,
|
||||||
|
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||||
|
}}"#,
|
||||||
|
a = instruments.0,
|
||||||
|
b = instruments.1,
|
||||||
|
from = window.0,
|
||||||
|
to = window.1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// An mc-bearing process: intrinsically valid AND (since the v2 executor) an
|
/// An mc-bearing process: intrinsically valid AND (since the v2 executor) an
|
||||||
/// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep
|
/// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep
|
||||||
/// survivor. The two addressing-mode tests below run it over the [1, 2] 1970
|
/// survivor. The two addressing-mode tests below run it over the [1, 2] 1970
|
||||||
@@ -1361,6 +1501,137 @@ fn campaign_validate_accepts_a_terminal_selection_free_sweep() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hand-authored (non-sugar) `[std::grid, std::walk_forward]` process — the
|
||||||
|
/// #256 fork B shape the `walkforward`/`mc` verb sugar now generates
|
||||||
|
/// internally, but authored here directly as an op-script the way a data-only
|
||||||
|
/// project author would reach it.
|
||||||
|
const GRID_THEN_WF_PROCESS_DOC: &str = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"kind": "process",
|
||||||
|
"name": "grid-then-walkforward",
|
||||||
|
"pipeline": [
|
||||||
|
{ "block": "std::grid" },
|
||||||
|
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||||
|
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// The same leading `std::grid` stage, but with a `std::gate` interposed
|
||||||
|
/// before `std::walk_forward` — the one placement `campaign run`'s preflight
|
||||||
|
/// must refuse (#256 fork B: a `std::grid` first stage yields bare parameter
|
||||||
|
/// points, not executed member reports, so it is legal ONLY immediately
|
||||||
|
/// before `std::walk_forward`).
|
||||||
|
const GRID_THEN_GATE_THEN_WF_PROCESS_DOC: &str = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"kind": "process",
|
||||||
|
"name": "grid-then-gate-then-walkforward",
|
||||||
|
"pipeline": [
|
||||||
|
{ "block": "std::grid" },
|
||||||
|
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||||||
|
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||||
|
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// Property (#256 fork B): `campaign run`'s data-free preflight, exercised
|
||||||
|
/// through the real CLI binary over a hand-authored op-script (not the
|
||||||
|
/// verb-sugar translation), refuses a `std::grid` first stage that is not
|
||||||
|
/// immediately followed by `std::walk_forward` — here, a `std::gate`
|
||||||
|
/// interposed between them — before any member runs (the [1, 2] 1970-epoch-ms
|
||||||
|
/// window is never reached). A regression that let this placement through
|
||||||
|
/// would hand `std::gate` bare parameter points instead of the executed
|
||||||
|
/// member reports it retains on, silently corrupting the gate's survivor
|
||||||
|
/// filter. The detail is Debug-free prose, not the `PipelineShape`/`ExecFault`
|
||||||
|
/// identifier.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
|
||||||
|
let (dir, _fixture) = fresh_project();
|
||||||
|
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("gridgate.process.json")),
|
||||||
|
ScratchPath::File(dir.join("gridgate.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-gridgate-seed");
|
||||||
|
let proc_id = register_process_doc(
|
||||||
|
&dir,
|
||||||
|
"gridgate.process.json",
|
||||||
|
GRID_THEN_GATE_THEN_WF_PROCESS_DOC,
|
||||||
|
);
|
||||||
|
write_doc(
|
||||||
|
&dir,
|
||||||
|
"gridgate.campaign.json",
|
||||||
|
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
|
||||||
|
);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridgate.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
|
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"a std::grid first stage must be immediately followed by std::walk_forward"
|
||||||
|
),
|
||||||
|
"the detail names the adjacency rule: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("PipelineShape"), "Debug leak: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#256 fork B): a hand-authored `[std::grid, std::walk_forward]`
|
||||||
|
/// op-script — not the verb-sugar translation, which already pins this shape
|
||||||
|
/// in `cli_run.rs` — runs to completion over the synthetic SYMA archive, and
|
||||||
|
/// the persisted `CampaignRunRecord`'s leading `std::grid` stage carries no
|
||||||
|
/// `family_id`: the enumerate-only leading stage crosses the stage seam as
|
||||||
|
/// bare points and never executes or persists a family of its own, while the
|
||||||
|
/// following `std::walk_forward` stage does. (`aura runs families` is not
|
||||||
|
/// used here: `seed_blueprint` itself runs a real `aura sweep` to register
|
||||||
|
/// the blueprint, which stamps its own incidental Sweep family and would
|
||||||
|
/// confound a registry-wide family count — the record's own stage array is
|
||||||
|
/// the unconfounded source of truth.) A regression that made a hand-authored
|
||||||
|
/// `std::grid` stage execute or persist a family would flip
|
||||||
|
/// `stages[0]["family_id"]` from null here even though the sugar-generated
|
||||||
|
/// shape (a distinct code path) stayed green.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() {
|
||||||
|
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("gridwf.process.json")),
|
||||||
|
ScratchPath::File(dir.join("gridwf.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-gridwf-seed");
|
||||||
|
let proc_id = register_process_doc(&dir, "gridwf.process.json", GRID_THEN_WF_PROCESS_DOC);
|
||||||
|
// SYMA's synthetic archive spans 2024-01..08; Mar-01..Jun-30 (~17 weeks)
|
||||||
|
// gives the (14d, 7d, 7d) roller a comfortable tiling (the cost-e2e
|
||||||
|
// siblings' window).
|
||||||
|
write_doc(
|
||||||
|
&dir,
|
||||||
|
"gridwf.campaign.json",
|
||||||
|
&campaign_doc_json_for(
|
||||||
|
"SYMA",
|
||||||
|
&bp_id,
|
||||||
|
&proc_id,
|
||||||
|
(1709251200000, 1719791999999),
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridwf.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
|
|
||||||
|
let 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(line).expect("record parses");
|
||||||
|
let stages = &v["campaign_run"]["cells"][0]["stages"];
|
||||||
|
assert_eq!(stages[0]["block"], "std::grid");
|
||||||
|
assert!(stages[0]["family_id"].is_null(), "std::grid persists no family: {stages}");
|
||||||
|
assert_eq!(stages[1]["block"], "std::walk_forward");
|
||||||
|
assert!(!stages[1]["family_id"].is_null(), "std::walk_forward persists its family: {stages}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The v2 boundary, campaign side: a generalize-bearing process is an
|
/// The v2 boundary, campaign side: a generalize-bearing process is an
|
||||||
/// executable shape, but `std::generalize` needs >= 2 instruments in the
|
/// executable shape, but `std::generalize` needs >= 2 instruments in the
|
||||||
/// campaign — a STATIC preflight fact (the referential gate has run, no data
|
/// campaign — a STATIC preflight fact (the referential gate has run, no data
|
||||||
@@ -1731,7 +2002,7 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
|
|||||||
/// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` +
|
/// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` +
|
||||||
/// `doc_fault_prose` composition `campaign validate` shares), before
|
/// `doc_fault_prose` composition `campaign validate` shares), before
|
||||||
/// registration, before the referential tier, before any member runs — and
|
/// registration, before the referential tier, before any member runs — and
|
||||||
/// therefore before the (Task-3-doomed) loud-deferral note, whose absence is
|
/// therefore before the retired loud-deferral note, whose absence is
|
||||||
/// pinned here.
|
/// pinned here.
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_run_refuses_unknown_tap_at_validate() {
|
fn campaign_run_refuses_unknown_tap_at_validate() {
|
||||||
@@ -1769,14 +2040,13 @@ fn campaign_run_refuses_unknown_tap_at_validate() {
|
|||||||
|
|
||||||
/// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails
|
/// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails
|
||||||
/// through the new intrinsic gate (no UnknownTap prose) and the run proceeds
|
/// through the new intrinsic gate (no UnknownTap prose) and the run proceeds
|
||||||
/// to refuse at the member-data seam, exactly as a tap-less run over the
|
/// to the member-data seam, exactly as a tap-less run over the [1, 2] 1970
|
||||||
/// [1, 2] 1970 epoch-ms window does (data-less host: missing geometry;
|
/// epoch-ms window does (data-less host: missing geometry; data-ful host: no
|
||||||
/// data-ful host: no archive file overlaps) — exit 1 either way, and no
|
/// archive file overlaps) — #272: the member-data refusal is now a CONTAINED
|
||||||
/// trace output (tracing never starts on a run that dies before members;
|
/// cell fault, not a campaign abort, so the run completes (exit 3, "completed
|
||||||
/// there is no "traces persisted" summary line). NOTE: the 0107
|
/// with failed cells") and the always-on record line still carries the
|
||||||
/// loud-deferral eprintln still fires in this task's tree and is
|
/// failed cell's fault. The retired 0107 loud-deferral note ("persist_taps not
|
||||||
/// deliberately NOT asserted either way here — Task 3 deletes it and
|
/// yet honored") must not appear — its absence is asserted below.
|
||||||
/// tightens this test to pin its absence.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
||||||
let (dir, _fixture) = fresh_project();
|
let (dir, _fixture) = fresh_project();
|
||||||
@@ -1795,7 +2065,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
|||||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
|
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
|
||||||
);
|
);
|
||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]);
|
||||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
!out.contains("unknown tap"),
|
!out.contains("unknown tap"),
|
||||||
"\"equity\" is in the closed vocabulary and must pass validate: {out}"
|
"\"equity\" is in the closed vocabulary and must pass validate: {out}"
|
||||||
@@ -1804,17 +2074,25 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
|||||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||||
"the refusal names the data condition: {out}"
|
"the refusal names the data condition: {out}"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
!out.contains("traces persisted"),
|
|
||||||
"no trace summary on a run that never reached a member: {out}"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
!out.contains("persist_taps not yet honored"),
|
!out.contains("persist_taps not yet honored"),
|
||||||
"the 0107 deferral line is retired; persist_taps is honored from 0109 on: {out}"
|
"the 0107 deferral line is retired; persist_taps is honored from 0109 on: {out}"
|
||||||
);
|
);
|
||||||
|
// The record shows the cell as failed and the summary names the count —
|
||||||
|
// not the brittle "no traces persisted" absence: a contained fault still
|
||||||
|
// reaches (and passes through) the trace-persistence tail, which honestly
|
||||||
|
// reports 0 cells written, so "traces persisted:" DOES appear now.
|
||||||
assert!(
|
assert!(
|
||||||
!out.contains("traces persisted:"),
|
out.contains("1 failed"),
|
||||||
"a member-data refusal aborts inside execute, before any tracing: {out}"
|
"the campaign-run summary names the one failed cell: {out}"
|
||||||
|
);
|
||||||
|
let record_line = out
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||||||
|
.expect("the always-on final campaign_run line");
|
||||||
|
assert!(
|
||||||
|
record_line.contains("\"fault\""),
|
||||||
|
"the recorded cell carries its fault: {record_line}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1859,7 +2137,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||||||
@@ -1990,6 +2268,340 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#259): a campaign's cost block reaches the walk-forward →
|
||||||
|
/// monte-carlo evidence chain — the pooled-OOS bootstrap resamples the
|
||||||
|
/// COST-NETTED per-trade series, so the same matrix run with and without a
|
||||||
|
/// cost block records DIFFERENT `pooled_oos` stats (today's bug: identical to
|
||||||
|
/// every digit). Hostless: runs over the synthetic SYMA archive, no
|
||||||
|
/// data-mount skip-guard.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_synthetic_e2e_cost_block_nets_the_pooled_oos_bootstrap() {
|
||||||
|
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("netmc.process.json")),
|
||||||
|
ScratchPath::File(dir.join("gross.campaign.json")),
|
||||||
|
ScratchPath::File(dir.join("net.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-netmc-seed");
|
||||||
|
let proc_id = register_process_doc(&dir, "netmc.process.json", WF_PROCESS_DOC);
|
||||||
|
// SYMA's synthetic archive spans 2024-01..08; Mar-01..Jun-30 (~17 weeks)
|
||||||
|
// gives the (14d, 7d, 7d) roller a comfortable tiling.
|
||||||
|
let base = campaign_doc_json_for(
|
||||||
|
"SYMA",
|
||||||
|
&bp_id,
|
||||||
|
&proc_id,
|
||||||
|
(1709251200000, 1719791999999),
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
// The costed twin: the SAME document plus a constant cost block — the
|
||||||
|
// only difference (the replacen pattern of the cost e2e siblings).
|
||||||
|
let with_cost = base.replacen(
|
||||||
|
"\"seed\": 7,",
|
||||||
|
"\"seed\": 7,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 0.5 } } ],",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_ne!(with_cost, base, "replacen must actually match the seed field");
|
||||||
|
write_doc(&dir, "gross.campaign.json", &base);
|
||||||
|
write_doc(&dir, "net.campaign.json", &with_cost);
|
||||||
|
|
||||||
|
// Both runs share the store (the cost-e2e sibling pattern) — the record
|
||||||
|
// line is read from each run's own stdout, so no isolation is needed.
|
||||||
|
let pooled = |doc: &str| -> serde_json::Value {
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
|
||||||
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
|
let 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(line).expect("record parses");
|
||||||
|
v["campaign_run"]["cells"][0]["stages"][3]["bootstrap"]["pooled_oos"].clone()
|
||||||
|
};
|
||||||
|
let gross = pooled("gross.campaign.json");
|
||||||
|
let net = pooled("net.campaign.json");
|
||||||
|
|
||||||
|
let n_gross = gross["n_trades"].as_u64().expect("gross n_trades");
|
||||||
|
let n_net = net["n_trades"].as_u64().expect("net n_trades");
|
||||||
|
assert!(n_gross > 0, "the synthetic window must produce OOS trades: {gross}");
|
||||||
|
assert_eq!(
|
||||||
|
n_gross, n_net,
|
||||||
|
"cost is a feed-forward subtraction — it must not change the trade population"
|
||||||
|
);
|
||||||
|
let mean_gross = gross["e_r"]["mean"].as_f64().expect("gross mean");
|
||||||
|
let mean_net = net["e_r"]["mean"].as_f64().expect("net mean");
|
||||||
|
assert!(
|
||||||
|
mean_net < mean_gross,
|
||||||
|
"the costed bootstrap must resample the NET series (#259): gross {mean_gross} vs net {mean_net}"
|
||||||
|
);
|
||||||
|
let p_gross = gross["prob_le_zero"].as_f64().expect("gross prob_le_zero");
|
||||||
|
let p_net = net["prob_le_zero"].as_f64().expect("net prob_le_zero");
|
||||||
|
assert!(
|
||||||
|
p_net >= p_gross,
|
||||||
|
"a strictly-lower resample mean cannot lower prob_le_zero: gross {p_gross} vs net {p_net}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#259), the OTHER mc bootstrap input shape: with no
|
||||||
|
/// `walk_forward` in the pipeline (`sweep -> monte_carlo`), each surviving
|
||||||
|
/// sweep member gets its OWN bootstrap (`PerSurvivor`, keyed by ordinal) —
|
||||||
|
/// a distinct code path from `PooledOos` above (`member_net_trade_rs` /
|
||||||
|
/// `SweepPoint` in aura-registry, not the wf-family pooling in
|
||||||
|
/// aura-campaign). That path must resample the same cost-netted
|
||||||
|
/// `net_trade_rs` conduit: a cost block must shift EVERY surviving member's
|
||||||
|
/// resampled mean down, never leave any member's bootstrap gross-identical.
|
||||||
|
/// Hostless: runs over the synthetic SYMA archive, no data-mount skip-guard.
|
||||||
|
/// The gated real-data sibling (`campaign_run_real_e2e_sweep_monte_carlo_per_survivor`)
|
||||||
|
/// exercises this shape but never with a cost block, so it cannot pin this property.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() {
|
||||||
|
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("persurvivor.process.json")),
|
||||||
|
ScratchPath::File(dir.join("gross_ps.campaign.json")),
|
||||||
|
ScratchPath::File(dir.join("net_ps.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-persurvivor-seed");
|
||||||
|
let proc_id = register_process_doc(&dir, "persurvivor.process.json", MC_PROCESS_DOC);
|
||||||
|
// Same SYMA window as the pooled-OOS sibling above — known to produce
|
||||||
|
// trades over the synthetic archive.
|
||||||
|
let base = campaign_doc_json_for(
|
||||||
|
"SYMA",
|
||||||
|
&bp_id,
|
||||||
|
&proc_id,
|
||||||
|
(1709251200000, 1719791999999),
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
let with_cost = base.replacen(
|
||||||
|
"\"seed\": 7,",
|
||||||
|
"\"seed\": 7,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 0.5 } } ],",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_ne!(with_cost, base, "replacen must actually match the seed field");
|
||||||
|
write_doc(&dir, "gross_ps.campaign.json", &base);
|
||||||
|
write_doc(&dir, "net_ps.campaign.json", &with_cost);
|
||||||
|
|
||||||
|
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> {
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
|
||||||
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
|
let 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(line).expect("record parses");
|
||||||
|
v["campaign_run"]["cells"][0]["stages"][1]["bootstrap"]["per_survivor"]
|
||||||
|
.as_array()
|
||||||
|
.expect("no walk_forward precedes: the PerSurvivor arm, not pooled_oos")
|
||||||
|
.iter()
|
||||||
|
.map(|pair| (pair[0].as_u64().expect("ordinal"), pair[1].clone()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let gross = per_survivor("gross_ps.campaign.json");
|
||||||
|
let net = per_survivor("net_ps.campaign.json");
|
||||||
|
|
||||||
|
assert_eq!(gross.len(), 4, "2x2 axes -> four surviving members: {gross:?}");
|
||||||
|
assert_eq!(gross.len(), net.len(), "cost must not change the surviving-member population");
|
||||||
|
for ((g_ord, g), (n_ord, n)) in gross.iter().zip(net.iter()) {
|
||||||
|
assert_eq!(g_ord, n_ord, "the two runs must enumerate members in the same ordinal order");
|
||||||
|
let n_trades_g = g["n_trades"].as_u64().expect("gross n_trades");
|
||||||
|
let n_trades_n = n["n_trades"].as_u64().expect("net n_trades");
|
||||||
|
assert!(n_trades_g > 0, "member {g_ord} must have produced trades: {g}");
|
||||||
|
assert_eq!(
|
||||||
|
n_trades_g, n_trades_n,
|
||||||
|
"member {g_ord}: cost is a feed-forward subtraction — it must not change the trade population"
|
||||||
|
);
|
||||||
|
let mean_g = g["e_r"]["mean"].as_f64().expect("gross mean");
|
||||||
|
let mean_n = n["e_r"]["mean"].as_f64().expect("net mean");
|
||||||
|
assert!(
|
||||||
|
mean_n < mean_g,
|
||||||
|
"member {g_ord}: the costed PerSurvivor bootstrap must resample the NET series (#259): gross {mean_g} vs net {mean_n}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#260): an instrument-keyed cost knob resolves PER CELL — each
|
||||||
|
/// emitted member's manifest stamps the value for ITS instrument under the
|
||||||
|
/// unchanged cost[k].<knob> key. Hostless over the synthetic SYMA+SYMB
|
||||||
|
/// archive (SYMB's 2024-03..06 span contains the window).
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
|
||||||
|
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("percost.process.json")),
|
||||||
|
ScratchPath::File(dir.join("percost.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-percost-seed");
|
||||||
|
let proc_id = register_process_doc(&dir, "percost.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||||
|
let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25 } } } ],\n ";
|
||||||
|
let doc = campaign_doc_json_two(
|
||||||
|
("SYMA", "SYMB"),
|
||||||
|
cost,
|
||||||
|
&bp_id,
|
||||||
|
&proc_id,
|
||||||
|
(1709251200000, 1717199999999),
|
||||||
|
);
|
||||||
|
write_doc(&dir, "percost.campaign.json", &doc);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||||
|
|
||||||
|
// Member emit lines carry report.manifest; group the stamped resolved
|
||||||
|
// value by the member's instrument stamp (the #220-era assertion idiom).
|
||||||
|
let mut seen = std::collections::BTreeMap::new();
|
||||||
|
for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses");
|
||||||
|
let inst = v["report"]["manifest"]["instrument"]
|
||||||
|
.as_str()
|
||||||
|
.expect("instrument stamped")
|
||||||
|
.to_string();
|
||||||
|
let params = v["report"]["manifest"]["params"].as_array().expect("params array");
|
||||||
|
let cost_param = params
|
||||||
|
.iter()
|
||||||
|
.find(|p| p[0].as_str() == Some("cost[0].cost_per_trade"))
|
||||||
|
.unwrap_or_else(|| panic!("cost stamp missing on {inst} member: {line}"));
|
||||||
|
let val = cost_param[1]["F64"].as_f64().expect("stamped as F64");
|
||||||
|
seen.insert(inst, val);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
std::collections::BTreeMap::from([
|
||||||
|
("SYMA".to_string(), 2.5),
|
||||||
|
("SYMB".to_string(), 0.25),
|
||||||
|
]),
|
||||||
|
"each cell must stamp ITS instrument's resolved cost: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
||||||
|
fn campaign_validate_refuses_a_cost_map_missing_an_instrument() {
|
||||||
|
let dir = temp_cwd("campaign-validate-percost-missing");
|
||||||
|
let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5 } } } ],\n ";
|
||||||
|
let doc = campaign_doc_json_two(
|
||||||
|
("SYMA", "SYMB"),
|
||||||
|
cost,
|
||||||
|
"9f3a",
|
||||||
|
"9f3a",
|
||||||
|
(1709251200000, 1717199999999),
|
||||||
|
);
|
||||||
|
write_doc(&dir, "bad.campaign.json", &doc);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(1), "a key-set mismatch is a validate fault: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("cost[0]") && out.contains("SYMB"),
|
||||||
|
"prose names the component and the missing instrument: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#260): an instrument map naming a key that is NOT one of the
|
||||||
|
/// campaign's instruments (a typo'd symbol) is refused too — the sibling
|
||||||
|
/// defect to the missing-instrument case above, and a distinct prose branch
|
||||||
|
/// (`doc_fault_prose`'s `extra` arm) that was otherwise never exercised past
|
||||||
|
/// the raw `DocFault` value in the aura-research unit tests.
|
||||||
|
#[test]
|
||||||
|
fn campaign_validate_refuses_a_cost_map_with_an_unknown_instrument_key() {
|
||||||
|
let dir = temp_cwd("campaign-validate-percost-extra");
|
||||||
|
let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25, \"SYMA.cash\": 1.0 } } } ],\n ";
|
||||||
|
let doc = campaign_doc_json_two(
|
||||||
|
("SYMA", "SYMB"),
|
||||||
|
cost,
|
||||||
|
"9f3a",
|
||||||
|
"9f3a",
|
||||||
|
(1709251200000, 1717199999999),
|
||||||
|
);
|
||||||
|
write_doc(&dir, "bad_extra.campaign.json", &doc);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad_extra.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(1), "an unmatched map key is a validate fault: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains("cost[0]") && out.contains("SYMA.cash"),
|
||||||
|
"prose names the component and the unmatched key: {out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("names no campaign instrument"),
|
||||||
|
"prose uses the extra-key phrasing, not the missing-instrument one: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The campaign `data.bindings` override end to end (#231, 6b): the same
|
/// The campaign `data.bindings` override end to end (#231, 6b): the same
|
||||||
/// seeded strategy, window, and seed run twice — bare (price<-close default)
|
/// seeded strategy, window, and seed run twice — bare (price<-close default)
|
||||||
/// and with `price` rebound to the open column. Both exit 0; the realized
|
/// and with `price` rebound to the open column. Both exit 0; the realized
|
||||||
@@ -2025,7 +2637,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
|||||||
write_doc(&dir, "open.campaign.json", &rebound);
|
write_doc(&dir, "open.campaign.json", &rebound);
|
||||||
|
|
||||||
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]);
|
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]);
|
||||||
if code_close == Some(1)
|
if code_close == Some(3)
|
||||||
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the binding-override e2e");
|
eprintln!("skip: no local GER40 data for the binding-override e2e");
|
||||||
@@ -2089,7 +2701,7 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
|
|||||||
write_doc(&dir, "regime.campaign.json", &with_regime);
|
write_doc(&dir, "regime.campaign.json", &with_regime);
|
||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]);
|
||||||
|
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign regime e2e");
|
eprintln!("skip: no local GER40 data for the campaign regime e2e");
|
||||||
@@ -2159,7 +2771,7 @@ fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() {
|
|||||||
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
|
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
|
||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]);
|
||||||
|
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign two-regime e2e");
|
eprintln!("skip: no local GER40 data for the campaign two-regime e2e");
|
||||||
@@ -2247,7 +2859,7 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign regime-persist e2e");
|
eprintln!("skip: no local GER40 data for the campaign regime-persist e2e");
|
||||||
@@ -2327,7 +2939,7 @@ fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign cost-persist e2e");
|
eprintln!("skip: no local GER40 data for the campaign cost-persist e2e");
|
||||||
@@ -2422,7 +3034,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign net-persist e2e");
|
eprintln!("skip: no local GER40 data for the campaign net-persist e2e");
|
||||||
@@ -2536,7 +3148,7 @@ fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign vol-slippage-persist e2e");
|
eprintln!("skip: no local GER40 data for the campaign vol-slippage-persist e2e");
|
||||||
@@ -2625,7 +3237,7 @@ fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the two-regime persist e2e");
|
eprintln!("skip: no local GER40 data for the two-regime persist e2e");
|
||||||
@@ -2703,7 +3315,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||||||
@@ -2817,7 +3429,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
|
|||||||
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
|
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
|
||||||
// the remedy notice while equity still persists.
|
// the remedy notice while equity still persists.
|
||||||
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]);
|
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]);
|
||||||
if code_gross == Some(1)
|
if code_gross == Some(3)
|
||||||
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
|
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the cost e2e");
|
eprintln!("skip: no local GER40 data for the cost e2e");
|
||||||
@@ -2940,7 +3552,7 @@ fn campaign_run_real_e2e_carry_cost_persists_without_drift_alarm() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign carry-cost-persist e2e");
|
eprintln!("skip: no local GER40 data for the campaign carry-cost-persist e2e");
|
||||||
@@ -3012,7 +3624,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
|
|||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]);
|
||||||
|
|
||||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(1)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
eprintln!("skip: no local GER40 data for the campaign e2e");
|
eprintln!("skip: no local GER40 data for the campaign e2e");
|
||||||
@@ -3060,10 +3672,12 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
|
|||||||
/// by FILE and once by its own resulting content ID must refuse with the
|
/// by FILE and once by its own resulting content ID must refuse with the
|
||||||
/// byte-identical prose — proof the two addressing modes are not two
|
/// byte-identical prose — proof the two addressing modes are not two
|
||||||
/// independently drifting code paths. No real data needed: with the [1, 2]
|
/// independently drifting code paths. No real data needed: with the [1, 2]
|
||||||
/// 1970 window, both runs pass the (now mc-permitting) preflight and refuse
|
/// 1970 window, both runs pass the (now mc-permitting) preflight and the one
|
||||||
/// at the member-data seam — no recorded geometry on a data-less host, no
|
/// cell fails at the member-data seam — no recorded geometry on a data-less
|
||||||
/// overlapping archive window on a data-ful one — exit 1 and byte-identical
|
/// host, no overlapping archive window on a data-ful one — #272: the failed
|
||||||
/// prose either way (the message depends only on env/instrument/window).
|
/// cell is contained, so the campaign run still completes (exit 3, "completed
|
||||||
|
/// with failed cells") and byte-identical prose either way (the message
|
||||||
|
/// depends only on env/instrument/window).
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
||||||
let (dir, _fixture) = fresh_project();
|
let (dir, _fixture) = fresh_project();
|
||||||
@@ -3079,7 +3693,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
|||||||
write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||||
|
|
||||||
let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]);
|
let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]);
|
||||||
assert_eq!(file_code, Some(1), "stdout/stderr: {file_out}");
|
assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
|
||||||
|
|
||||||
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
|
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
|
||||||
assert_eq!(reg_code, Some(0), "register failed: {reg_out}");
|
assert_eq!(reg_code, Some(0), "register failed: {reg_out}");
|
||||||
@@ -3094,7 +3708,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
|||||||
.trim_start_matches("content:")
|
.trim_start_matches("content:")
|
||||||
.to_string();
|
.to_string();
|
||||||
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]);
|
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]);
|
||||||
assert_eq!(id_code, Some(1), "stdout/stderr: {id_out}");
|
assert_eq!(id_code, Some(3), "stdout/stderr: {id_out}");
|
||||||
|
|
||||||
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
|
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
|
||||||
let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line");
|
let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line");
|
||||||
@@ -3195,7 +3809,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() {
|
|||||||
let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
|
let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||||
write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]);
|
||||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||||
"referencing + axis-binding must succeed, reaching the member-data \
|
"referencing + axis-binding must succeed, reaching the member-data \
|
||||||
@@ -3239,3 +3853,76 @@ fn campaign_run_invalid_file_refuses_before_touching_store() {
|
|||||||
"an invalid document must not create a store entry"
|
"an invalid document must not create a store entry"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: a two-window campaign over GAPSYM where one window falls inside the
|
||||||
|
/// March-April hole records that cell as failed (no_data) while the covered
|
||||||
|
/// window's cell completes and persists — exit 3, both cells in one run record.
|
||||||
|
#[test]
|
||||||
|
fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
|
||||||
|
let (dir, _fixture) = fresh_project_with_gapped_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("gap.process.json")),
|
||||||
|
ScratchPath::File(dir.join("gap.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(&dir, "campaign-run-gap-seed");
|
||||||
|
let proc_id = register_process_doc(&dir, "gap.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||||
|
// Window 0 (2024-01, Unix-ms [1704067200000, 1706745600000)) is fully
|
||||||
|
// archived by `fresh_project_with_gapped_data` (GAPSYM 2024-01..02);
|
||||||
|
// window 1 (2024-03, [1709251200000, 1711929600000)) falls entirely
|
||||||
|
// inside the deliberate March-April hole.
|
||||||
|
let doc = format!(
|
||||||
|
r#"{{
|
||||||
|
"format_version": 1,
|
||||||
|
"kind": "campaign",
|
||||||
|
"name": "gap-cells",
|
||||||
|
"data": {{ "instruments": ["GAPSYM"],
|
||||||
|
"windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745600000 }},
|
||||||
|
{{ "from_ms": 1709251200000, "to_ms": 1711929600000 }} ] }},
|
||||||
|
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||||
|
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||||
|
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||||
|
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||||
|
"seed": 7,
|
||||||
|
"presentation": {{ "persist_taps": [], "emit": [] }}
|
||||||
|
}}"#,
|
||||||
|
);
|
||||||
|
write_doc(&dir, "gap.campaign.json", &doc);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "run", "gap.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(3), "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("campaign_run line parses as JSON");
|
||||||
|
let cells = v["campaign_run"]["cells"].as_array().expect("cells array");
|
||||||
|
assert_eq!(cells.len(), 2, "one cell per window, both in one run record: {record_line}");
|
||||||
|
|
||||||
|
let jan_cell = &cells[0];
|
||||||
|
let mar_cell = &cells[1];
|
||||||
|
assert!(jan_cell["fault"].is_null(), "the January cell completes: {record_line}");
|
||||||
|
assert!(
|
||||||
|
!jan_cell["stages"].as_array().expect("stages").is_empty(),
|
||||||
|
"the January cell realizes a walk-through stage set: {record_line}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
jan_cell["coverage"].is_object(),
|
||||||
|
"the covered window's cell is still only trading-hours-covered inside \
|
||||||
|
a calendar-month request, so it carries a coverage annotation: {record_line}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mar_cell["fault"]["kind"].as_str(),
|
||||||
|
Some("no_data"),
|
||||||
|
"the March cell (entirely inside the gap) fails as no_data: {record_line}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
out.contains("1 failed"),
|
||||||
|
"the campaign-run summary names the one failed cell: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -659,6 +659,51 @@ mod tests {
|
|||||||
assert_eq!(names, ["length"]);
|
assert_eq!(names, ["length"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #261 — the Frankfurt `Session` preset is reachable from a blueprint. The
|
||||||
|
/// property this pins: a data-only op-script (a blueprint) that names the
|
||||||
|
/// preset's closed-roster type-id resolves it through `std_vocabulary`,
|
||||||
|
/// carries its one scalar `period_minutes` knob and the `bars_since_open:
|
||||||
|
/// i64` session-index output, wires its `trigger` input through the ordinary
|
||||||
|
/// edge convention, and builds to a finished `Composite`. Reachability is the
|
||||||
|
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
|
||||||
|
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
|
||||||
|
/// id is absent from the roster (the base `Session` builder takes structural
|
||||||
|
/// construction args, so the zero-arg macro cannot host it), so the lookup
|
||||||
|
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
|
||||||
|
#[test]
|
||||||
|
fn session_frankfurt_preset_is_reachable_from_a_blueprint() {
|
||||||
|
// (a) the preset resolves through the closed roster as the Session preset
|
||||||
|
// — its one scalar knob and its i64 session-index output, not some
|
||||||
|
// other node parked under the id.
|
||||||
|
let preset = std_vocabulary("SessionFrankfurt")
|
||||||
|
.expect("the Frankfurt Session preset is in the std vocabulary roster");
|
||||||
|
assert_eq!(preset.label(), "SessionFrankfurt");
|
||||||
|
let schema = preset.schema();
|
||||||
|
assert_eq!(schema.params.len(), 1, "exactly one scalar knob");
|
||||||
|
assert_eq!(schema.params[0].name, "period_minutes");
|
||||||
|
assert_eq!(schema.params[0].kind, ScalarKind::I64);
|
||||||
|
assert_eq!(schema.inputs.len(), 1);
|
||||||
|
assert_eq!(schema.inputs[0].name, "trigger");
|
||||||
|
assert_eq!(schema.output[0].name, "bars_since_open");
|
||||||
|
assert_eq!(schema.output[0].kind, ScalarKind::I64);
|
||||||
|
|
||||||
|
// (b) an op-script naming that id builds: a bound trigger source feeds the
|
||||||
|
// preset's `trigger`, its `period_minutes` knob binds, and the i64 bar
|
||||||
|
// index exposes — a data-only blueprint reaching the session stream.
|
||||||
|
let ops = vec![
|
||||||
|
Op::Source { role: "trigger".into(), kind: ScalarKind::F64 },
|
||||||
|
Op::Add {
|
||||||
|
type_id: "SessionFrankfurt".into(),
|
||||||
|
as_name: Some("session".into()),
|
||||||
|
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||||
|
},
|
||||||
|
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
||||||
|
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
||||||
|
];
|
||||||
|
super::replay("session_blueprint", ops, &std_vocabulary)
|
||||||
|
.expect("the preset resolves, wires and builds through an op-script");
|
||||||
|
}
|
||||||
|
|
||||||
/// Eager refusals: unknown member param (a member bound at `Add` has left the
|
/// Eager refusals: unknown member param (a member bound at `Add` has left the
|
||||||
/// open schema), kind mismatch across members, a doubly-ganged member, a
|
/// open schema), kind mismatch across members, a doubly-ganged member, a
|
||||||
/// single-member gang, and an unknown node identifier.
|
/// single-member gang, and an unknown node identifier.
|
||||||
|
|||||||
@@ -453,8 +453,11 @@ fn month_end_ms_exclusive(year: u16, month: u8) -> i64 {
|
|||||||
/// needs no general symbol-boundary regex and reads no private index (#252:
|
/// needs no general symbol-boundary regex and reads no private index (#252:
|
||||||
/// no new data-server surface). Empty when `data_path` doesn't exist or holds
|
/// no new data-server surface). Empty when `data_path` doesn't exist or holds
|
||||||
/// no matching file for `symbol` — the same "no archive for this symbol"
|
/// no matching file for `symbol` — the same "no archive for this symbol"
|
||||||
/// condition an absent `DataServer` symbol represents.
|
/// condition an absent `DataServer` symbol represents. `pub` (#264): unlike
|
||||||
fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> {
|
/// [`archive_extent`], which deliberately walks *past* an interior gap to find
|
||||||
|
/// the window's first/last bar, a coverage report needs the raw month list
|
||||||
|
/// itself to detect and render that gap.
|
||||||
|
pub fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> {
|
||||||
let prefix = format!("{symbol}_");
|
let prefix = format!("{symbol}_");
|
||||||
let mut months = Vec::new();
|
let mut months = Vec::new();
|
||||||
let Ok(entries) = std::fs::read_dir(data_path) else {
|
let Ok(entries) = std::fs::read_dir(data_path) else {
|
||||||
@@ -551,6 +554,12 @@ mod tests {
|
|||||||
/// mirrors `aura-cli`'s own `tests/common::mint_tempdir` pattern (unique
|
/// mirrors `aura-cli`'s own `tests/common::mint_tempdir` pattern (unique
|
||||||
/// name via pid + an atomic counter) so this crate's tests need no
|
/// name via pid + an atomic counter) so this crate's tests need no
|
||||||
/// `tempfile` dev-dependency for a handful of throwaway fixture dirs.
|
/// `tempfile` dev-dependency for a handful of throwaway fixture dirs.
|
||||||
|
///
|
||||||
|
/// #258 sweep note: deliberately kept pid-suffixed like the sibling it
|
||||||
|
/// mirrors — there is no pre-create wipe here for a stale pid to defeat;
|
||||||
|
/// `Drop` (below) unconditionally reclaims the directory when the owning
|
||||||
|
/// test ends, so nothing strands across runs. The pid instead
|
||||||
|
/// disambiguates concurrent `cargo test` processes.
|
||||||
struct ScratchDir(std::path::PathBuf);
|
struct ScratchDir(std::path::PathBuf);
|
||||||
|
|
||||||
impl ScratchDir {
|
impl ScratchDir {
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ mod compat;
|
|||||||
mod lineage;
|
mod lineage;
|
||||||
pub use lineage::{
|
pub use lineage::{
|
||||||
derive_trace_name, group_families, mc_member_reports, sweep_member_reports,
|
derive_trace_name, group_families, mc_member_reports, sweep_member_reports,
|
||||||
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization,
|
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellCoverage,
|
||||||
Family, FamilyKind, FamilyRunRecord, StageBootstrap, StageRealization, StageSelection,
|
CellFault, CellFaultKind, CellRealization, Family, FamilyKind, FamilyRunRecord,
|
||||||
|
StageBootstrap, StageRealization, StageSelection, WindowFault,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod trace_store;
|
mod trace_store;
|
||||||
@@ -505,8 +506,8 @@ fn closed_neighbourhood(i: usize, axis_lens: &[usize]) -> Vec<usize> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member_trade_rs(rep: &RunReport) -> &[f64] {
|
fn member_net_trade_rs(rep: &RunReport) -> &[f64] {
|
||||||
rep.metrics.r.as_ref().map(|r| r.trade_rs.as_slice()).unwrap_or(&[])
|
rep.metrics.r.as_ref().map(|r| r.net_trade_rs.as_slice()).unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the selected R metric from a trade-R slice (reuses the engine's
|
/// Recompute the selected R metric from a trade-R slice (reuses the engine's
|
||||||
@@ -522,13 +523,13 @@ fn member_metric_from_rs(rs: &[f64], m: Metric) -> f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The centred best-of-K null-max distribution: each member's `trade_rs` is
|
/// The centred best-of-K null-max distribution: each member's `net_trade_rs` is
|
||||||
/// mean-subtracted (the no-edge null), then for each resample every member is
|
/// mean-subtracted (the no-edge null), then for each resample every member is
|
||||||
/// moving-block-resampled (in odometer order, from a per-iteration seed) and the
|
/// moving-block-resampled (in odometer order, from a per-iteration seed) and the
|
||||||
/// max recomputed metric across members is taken. Deterministic given `seed` (C1).
|
/// max recomputed metric across members is taken. Deterministic given `seed` (C1).
|
||||||
fn null_best_of_k(family: &SweepFamily, m: Metric, n_resamples: usize, block_len: usize, seed: u64) -> Vec<f64> {
|
fn null_best_of_k(family: &SweepFamily, m: Metric, n_resamples: usize, block_len: usize, seed: u64) -> Vec<f64> {
|
||||||
let centred: Vec<Vec<f64>> = family.points.iter().map(|p| {
|
let centred: Vec<Vec<f64>> = family.points.iter().map(|p| {
|
||||||
let rs = member_trade_rs(&p.report);
|
let rs = member_net_trade_rs(&p.report);
|
||||||
if rs.is_empty() { return Vec::new(); }
|
if rs.is_empty() { return Vec::new(); }
|
||||||
let mean = rs.iter().sum::<f64>() / rs.len() as f64;
|
let mean = rs.iter().sum::<f64>() / rs.len() as f64;
|
||||||
rs.iter().map(|x| x - mean).collect()
|
rs.iter().map(|x| x - mean).collect()
|
||||||
@@ -657,8 +658,8 @@ pub fn optimize_deflated(
|
|||||||
let null_max = null_best_of_k(family, m, n_resamples, block_len, seed);
|
let null_max = null_best_of_k(family, m, n_resamples, block_len, seed);
|
||||||
// Keep only the finite best-of-K draws. The null is *not computable* in two
|
// Keep only the finite best-of-K draws. The null is *not computable* in two
|
||||||
// degenerate cases: zero resamples (`null_max` empty), or no member carries
|
// degenerate cases: zero resamples (`null_max` empty), or no member carries
|
||||||
// `trade_rs` (every member's centred series is empty, so each iteration's
|
// `net_trade_rs` (every member's centred series is empty, so each iteration's
|
||||||
// best-of-K folds to `NEG_INFINITY`). The latter is reachable — `trade_rs`
|
// best-of-K folds to `NEG_INFINITY`). The latter is reachable — `net_trade_rs`
|
||||||
// is `#[serde(skip)]`, so a family loaded back from the registry has empty
|
// is `#[serde(skip)]`, so a family loaded back from the registry has empty
|
||||||
// conduits even when its `r` block (hence `raw`) is finite. Filtering to
|
// conduits even when its `r` block (hence `raw`) is finite. Filtering to
|
||||||
// finite values collapses both into one "no usable null" branch, instead of
|
// finite values collapses both into one "no usable null" branch, instead of
|
||||||
@@ -847,7 +848,7 @@ mod tests {
|
|||||||
sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here
|
sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here
|
||||||
net_expectancy_r,
|
net_expectancy_r,
|
||||||
conviction_terciles_r: [0.0, 0.0, 0.0],
|
conviction_terciles_r: [0.0, 0.0, 0.0],
|
||||||
trade_rs: Vec::new(),
|
net_trade_rs: Vec::new(),
|
||||||
});
|
});
|
||||||
rep
|
rep
|
||||||
}
|
}
|
||||||
@@ -909,8 +910,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn temp_path(name: &str) -> PathBuf {
|
fn temp_path(name: &str) -> PathBuf {
|
||||||
// unique per test + per process; no external tempfile dependency
|
// fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258):
|
||||||
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
|
// no external tempfile dependency, and every call site already wipes
|
||||||
|
// before use, so the fixed name genuinely reclaims the previous run.
|
||||||
|
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join(format!("aura-registry-{name}.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1081,8 +1085,10 @@ mod tests {
|
|||||||
fn temp_family_dir(name: &str) -> PathBuf {
|
fn temp_family_dir(name: &str) -> PathBuf {
|
||||||
// a unique per-test directory so the families.jsonl sibling never collides
|
// a unique per-test directory so the families.jsonl sibling never collides
|
||||||
// across tests sharing the temp dir; the registry binds to runs.jsonl inside.
|
// across tests sharing the temp dir; the registry binds to runs.jsonl inside.
|
||||||
let dir =
|
// Fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
|
||||||
std::env::temp_dir().join(format!("aura-registry-fam-{}-{}", std::process::id(), name));
|
// pre-create wipe below then genuinely reclaims the previous run.
|
||||||
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join(format!("aura-registry-fam-{name}"));
|
||||||
let _ = fs::remove_dir_all(&dir);
|
let _ = fs::remove_dir_all(&dir);
|
||||||
fs::create_dir_all(&dir).expect("create temp family dir");
|
fs::create_dir_all(&dir).expect("create temp family dir");
|
||||||
dir.join("runs.jsonl")
|
dir.join("runs.jsonl")
|
||||||
@@ -1169,16 +1175,16 @@ mod tests {
|
|||||||
assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family
|
assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member(total_pips: f64, trade_rs: Vec<f64>) -> SweepPoint {
|
fn member(total_pips: f64, net_trade_rs: Vec<f64>) -> SweepPoint {
|
||||||
// Every RMetrics field is derived from the R slice by `r_metrics_from_rs`
|
// Every RMetrics field is derived from the R slice by `r_metrics_from_rs`
|
||||||
// (the same arithmetic production members carry), then the per-trade
|
// (the same arithmetic production members carry), then the per-trade
|
||||||
// `trade_rs` conduit is restored: `r_metrics_from_rs` empties it
|
// `net_trade_rs` conduit is restored: `r_metrics_from_rs` empties it
|
||||||
// (`trade_rs: Vec::new()`), whereas a production member is built by
|
// (`net_trade_rs: Vec::new()`), whereas a production member is built by
|
||||||
// `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm
|
// `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm
|
||||||
// resamples exactly that conduit, so a fixture without it cannot reach
|
// resamples exactly that conduit, so a fixture without it cannot reach
|
||||||
// the R-arm path under test.
|
// the R-arm path under test.
|
||||||
let mut r = aura_engine::r_metrics_from_rs(&trade_rs);
|
let mut r = aura_engine::r_metrics_from_rs(&net_trade_rs);
|
||||||
r.trade_rs = trade_rs;
|
r.net_trade_rs = net_trade_rs;
|
||||||
SweepPoint {
|
SweepPoint {
|
||||||
params: vec![],
|
params: vec![],
|
||||||
report: RunReport {
|
report: RunReport {
|
||||||
@@ -1214,7 +1220,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn optimize_deflated_winner_is_byte_identical_to_optimize() {
|
fn optimize_deflated_winner_is_byte_identical_to_optimize() {
|
||||||
let fam = fixture_family_with_r(); // helper: ≥3 members, varied sqn_normalized + trade_rs
|
let fam = fixture_family_with_r(); // helper: ≥3 members, varied sqn_normalized + net_trade_rs
|
||||||
for metric in ["total_pips", "sqn_normalized", "expectancy_r"] {
|
for metric in ["total_pips", "sqn_normalized", "expectancy_r"] {
|
||||||
let plain = optimize(&fam, metric).unwrap();
|
let plain = optimize(&fam, metric).unwrap();
|
||||||
let (defl, _) = optimize_deflated(&fam, metric, 200, 3, 7).unwrap();
|
let (defl, _) = optimize_deflated(&fam, metric, 200, 3, 7).unwrap();
|
||||||
@@ -1254,18 +1260,18 @@ mod tests {
|
|||||||
assert_eq!(sel.overfit_probability, Some(1.0), "(0+1)/(0+1) Laplace floor");
|
assert_eq!(sel.overfit_probability, Some(1.0), "(0+1)/(0+1) Laplace floor");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: a family whose members carry an `r` block but NO `trade_rs` —
|
/// Regression: a family whose members carry an `r` block but NO `net_trade_rs` —
|
||||||
/// the serde-skipped state of a family `load`ed back from the registry — must
|
/// the serde-skipped state of a family `load`ed back from the registry — must
|
||||||
/// NOT yield a `+inf` deflated score on the R arm with positive resamples. With
|
/// NOT yield a `+inf` deflated score on the R arm with positive resamples. With
|
||||||
/// no member contributing a centred series, every best-of-K draw is
|
/// no member contributing a centred series, every best-of-K draw is
|
||||||
/// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor
|
/// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor
|
||||||
/// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`).
|
/// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`).
|
||||||
#[test]
|
#[test]
|
||||||
fn optimize_deflated_no_trade_rs_floors_instead_of_infinity() {
|
fn optimize_deflated_no_net_trade_rs_floors_instead_of_infinity() {
|
||||||
let mut fam = fixture_family_with_r();
|
let mut fam = fixture_family_with_r();
|
||||||
for p in &mut fam.points {
|
for p in &mut fam.points {
|
||||||
if let Some(r) = p.report.metrics.r.as_mut() {
|
if let Some(r) = p.report.metrics.r.as_mut() {
|
||||||
r.trade_rs.clear(); // mimic a serde-loaded member (trade_rs is #[serde(skip)])
|
r.net_trade_rs.clear(); // mimic a serde-loaded member (net_trade_rs is #[serde(skip)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sel = optimize_deflated(&fam, "expectancy_r", 500, 3, 1).unwrap().1;
|
let sel = optimize_deflated(&fam, "expectancy_r", 500, 3, 1).unwrap().1;
|
||||||
@@ -1958,6 +1964,8 @@ mod tests {
|
|||||||
window_ms: (1_136_073_600_000, 1_154_390_400_000),
|
window_ms: (1_136_073_600_000, 1_154_390_400_000),
|
||||||
regime: None,
|
regime: None,
|
||||||
regime_ordinal: 0,
|
regime_ordinal: 0,
|
||||||
|
fault: None,
|
||||||
|
coverage: None,
|
||||||
stages: vec![
|
stages: vec![
|
||||||
StageRealization {
|
StageRealization {
|
||||||
block: "std::sweep".to_string(),
|
block: "std::sweep".to_string(),
|
||||||
@@ -1984,6 +1992,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
},
|
},
|
||||||
StageRealization {
|
StageRealization {
|
||||||
block: "std::gate".to_string(),
|
block: "std::gate".to_string(),
|
||||||
@@ -1991,6 +2000,7 @@ mod tests {
|
|||||||
survivor_ordinals: Some(vec![0, 3, 4, 7]),
|
survivor_ordinals: Some(vec![0, 3, 4, 7]),
|
||||||
selection: None,
|
selection: None,
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
@@ -2024,6 +2034,8 @@ mod tests {
|
|||||||
window_ms: (0, 1000),
|
window_ms: (0, 1000),
|
||||||
regime: None,
|
regime: None,
|
||||||
regime_ordinal: 0,
|
regime_ordinal: 0,
|
||||||
|
fault: None,
|
||||||
|
coverage: None,
|
||||||
stages: vec![StageRealization {
|
stages: vec![StageRealization {
|
||||||
block: "std::monte_carlo".to_string(),
|
block: "std::monte_carlo".to_string(),
|
||||||
family_id: None,
|
family_id: None,
|
||||||
@@ -2033,6 +2045,7 @@ mod tests {
|
|||||||
(0, per_a),
|
(0, per_a),
|
||||||
(2, per_b),
|
(2, per_b),
|
||||||
])),
|
])),
|
||||||
|
window_faults: vec![],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
CellRealization {
|
CellRealization {
|
||||||
@@ -2041,12 +2054,15 @@ mod tests {
|
|||||||
window_ms: (0, 1000),
|
window_ms: (0, 1000),
|
||||||
regime: None,
|
regime: None,
|
||||||
regime_ordinal: 0,
|
regime_ordinal: 0,
|
||||||
|
fault: None,
|
||||||
|
coverage: None,
|
||||||
stages: vec![StageRealization {
|
stages: vec![StageRealization {
|
||||||
block: "std::monte_carlo".to_string(),
|
block: "std::monte_carlo".to_string(),
|
||||||
family_id: None,
|
family_id: None,
|
||||||
survivor_ordinals: None,
|
survivor_ordinals: None,
|
||||||
selection: None,
|
selection: None,
|
||||||
bootstrap: Some(StageBootstrap::PooledOos(pooled)),
|
bootstrap: Some(StageBootstrap::PooledOos(pooled)),
|
||||||
|
window_faults: vec![],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2108,6 +2124,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #272: a pre-fault campaign_runs line (no fault/coverage/window_faults
|
||||||
|
/// keys) parses and re-serializes byte-identical — the additive-widening
|
||||||
|
/// guarantee for the new per-cell-fault fields.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_line_without_fault_fields_round_trips() {
|
||||||
|
let line = r#"{"campaign":"c","process":"p","run":0,"seed":7,"cells":[{"strategy":"s","instrument":"EURUSD","window_ms":[0,10],"stages":[{"block":"std::sweep","family_id":"f-0"}]}]}"#;
|
||||||
|
let rec: CampaignRunRecord = serde_json::from_str(line).expect("parse pre-fault line");
|
||||||
|
assert_eq!(rec.cells[0].fault, None);
|
||||||
|
assert_eq!(rec.cells[0].coverage, None);
|
||||||
|
assert!(rec.cells[0].stages[0].window_faults.is_empty());
|
||||||
|
assert_eq!(serde_json::to_string(&rec).expect("re-serialize"), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: a cell that failed serializes its fault, and a fold-fault list
|
||||||
|
/// serializes on the stage — both absent-by-default, present when set.
|
||||||
|
#[test]
|
||||||
|
fn cell_fault_and_window_faults_serialize_when_present() {
|
||||||
|
let fault = CellFault { stage: 0, kind: CellFaultKind::NoData, detail: "no data".into() };
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&fault).expect("fault"),
|
||||||
|
r#"{"stage":0,"kind":"no_data","detail":"no data"}"#
|
||||||
|
);
|
||||||
|
let wf = WindowFault { window_ordinal: 2, kind: CellFaultKind::Run, detail: "boom".into() };
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&wf).expect("window fault"),
|
||||||
|
r#"{"window_ordinal":2,"kind":"run","detail":"boom"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #272: a `CellCoverage` with gap months serializes them, and its
|
||||||
|
/// effective-bounds round-trip (parse-then-reserialize) is byte-identical
|
||||||
|
/// — pinning both the populated `gap_months` shape and the
|
||||||
|
/// `skip_serializing_if = "Vec::is_empty"` omission when there are none.
|
||||||
|
#[test]
|
||||||
|
fn cell_coverage_serializes_gap_months_and_round_trips() {
|
||||||
|
let with_gaps = CellCoverage {
|
||||||
|
effective_from_ms: 0,
|
||||||
|
effective_to_ms: 1000,
|
||||||
|
gap_months: vec!["2024-02".into()],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&with_gaps).expect("coverage with gaps");
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
r#"{"effective_from_ms":0,"effective_to_ms":1000,"gap_months":["2024-02"]}"#
|
||||||
|
);
|
||||||
|
let parsed: CellCoverage = serde_json::from_str(&json).expect("parse coverage");
|
||||||
|
assert_eq!(parsed, with_gaps);
|
||||||
|
|
||||||
|
let no_gaps =
|
||||||
|
CellCoverage { effective_from_ms: 0, effective_to_ms: 1000, gap_months: vec![] };
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&no_gaps).expect("coverage without gaps"),
|
||||||
|
r#"{"effective_from_ms":0,"effective_to_ms":1000}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Property (#210 T3, C14/C23 widening convention): a stored
|
/// Property (#210 T3, C14/C23 widening convention): a stored
|
||||||
/// `campaign_runs.jsonl` line whose `generalizations` entries predate the
|
/// `campaign_runs.jsonl` line whose `generalizations` entries predate the
|
||||||
/// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/
|
/// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/
|
||||||
|
|||||||
@@ -98,6 +98,48 @@ pub struct CampaignRunRecord {
|
|||||||
pub trace_name: Option<String>,
|
pub trace_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The recorded fault of a failed cell (#272): the pipeline stage it fired in,
|
||||||
|
/// a closed kind tag aggregations count on, and the prose detail. `no_data` is
|
||||||
|
/// one kind among others, not a special path.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CellFault {
|
||||||
|
pub stage: usize,
|
||||||
|
pub kind: CellFaultKind,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closed fault-kind vocabulary (#272).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CellFaultKind {
|
||||||
|
NoData,
|
||||||
|
Bind,
|
||||||
|
Run,
|
||||||
|
Panic,
|
||||||
|
Window,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One failed walk-forward fold (#272): the roll ordinal that produced no OOS
|
||||||
|
/// report, with the fault that killed it. The surviving folds still pool; an
|
||||||
|
/// aggregate over this stage must name the ratio.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowFault {
|
||||||
|
pub window_ordinal: usize,
|
||||||
|
pub kind: CellFaultKind,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell whose requested window is only partially covered by the archive
|
||||||
|
/// (#272): the effective evaluated bounds and any whole months missing inside
|
||||||
|
/// them. Recorded only when it deviates from the requested window.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CellCoverage {
|
||||||
|
pub effective_from_ms: i64,
|
||||||
|
pub effective_to_ms: i64,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub gap_months: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One realized (strategy, instrument, window) cell: the pipeline prefix that
|
/// One realized (strategy, instrument, window) cell: the pipeline prefix that
|
||||||
/// actually ran (`stages` stops after an empty gate).
|
/// actually ran (`stages` stops after an empty gate).
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -125,6 +167,13 @@ pub struct CellRealization {
|
|||||||
/// same pre-#219 byte-identical round-trip reason.
|
/// same pre-#219 byte-identical round-trip reason.
|
||||||
#[serde(default, skip_serializing_if = "is_zero_ordinal")]
|
#[serde(default, skip_serializing_if = "is_zero_ordinal")]
|
||||||
pub regime_ordinal: usize,
|
pub regime_ordinal: usize,
|
||||||
|
/// Set when the cell failed (#272): the pipeline prefix in `stages` ran,
|
||||||
|
/// the faulting stage did not complete, no nominee was produced.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fault: Option<CellFault>,
|
||||||
|
/// Set when the archive only partially covers the requested window (#272).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub coverage: Option<CellCoverage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `CellRealization::regime_ordinal`'s serialize-skip predicate: the default
|
/// `CellRealization::regime_ordinal`'s serialize-skip predicate: the default
|
||||||
@@ -151,6 +200,10 @@ pub struct StageRealization {
|
|||||||
/// serde-default widening keeps existing stored lines parseable.
|
/// serde-default widening keeps existing stored lines parseable.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub bootstrap: Option<StageBootstrap>,
|
pub bootstrap: Option<StageBootstrap>,
|
||||||
|
/// std::walk_forward stages only (#272): folds that produced no OOS report;
|
||||||
|
/// the surviving folds pooled, and aggregates must name the ratio.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub window_faults: Vec<WindowFault>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The recorded winner of a selection-bearing stage: its ordinal in the stage's
|
/// The recorded winner of a selection-bearing stage: its ordinal in the stage's
|
||||||
|
|||||||
@@ -288,8 +288,10 @@ mod tests {
|
|||||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||||
|
|
||||||
fn temp_traces_root(name: &str) -> PathBuf {
|
fn temp_traces_root(name: &str) -> PathBuf {
|
||||||
let dir =
|
// fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
|
||||||
std::env::temp_dir().join(format!("aura-tracestore-{}-{}", std::process::id(), name));
|
// pre-create wipe below then genuinely reclaims the previous run.
|
||||||
|
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||||
|
.join(format!("aura-tracestore-{name}"));
|
||||||
let _ = fs::remove_dir_all(&dir);
|
let _ = fs::remove_dir_all(&dir);
|
||||||
fs::create_dir_all(&dir).expect("create temp traces root");
|
fs::create_dir_all(&dir).expect("create temp traces root");
|
||||||
dir
|
dir
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use aura_registry::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn temp_dir(name: &str) -> PathBuf {
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
let dir = std::env::temp_dir()
|
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||||
.join(format!("aura-registry-campaign-e2e-{}-{}", std::process::id(), name));
|
.join(format!("aura-registry-campaign-e2e-{name}"));
|
||||||
let _ = fs::remove_dir_all(&dir);
|
let _ = fs::remove_dir_all(&dir);
|
||||||
fs::create_dir_all(&dir).expect("create temp dir");
|
fs::create_dir_all(&dir).expect("create temp dir");
|
||||||
dir.join("runs.jsonl")
|
dir.join("runs.jsonl")
|
||||||
@@ -55,6 +55,8 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord {
|
|||||||
window_ms: (0, 1000),
|
window_ms: (0, 1000),
|
||||||
regime: None,
|
regime: None,
|
||||||
regime_ordinal: 0,
|
regime_ordinal: 0,
|
||||||
|
fault: None,
|
||||||
|
coverage: None,
|
||||||
stages: vec![StageRealization {
|
stages: vec![StageRealization {
|
||||||
block: "std::sweep".to_string(),
|
block: "std::sweep".to_string(),
|
||||||
family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")),
|
family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")),
|
||||||
@@ -77,6 +79,7 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
bootstrap: None,
|
bootstrap: None,
|
||||||
|
window_faults: vec![],
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
generalizations: vec![],
|
generalizations: vec![],
|
||||||
|
|||||||
+341
-35
@@ -71,6 +71,11 @@ pub enum StageBlock {
|
|||||||
MonteCarlo { resamples: u32, block_len: u32 },
|
MonteCarlo { resamples: u32, block_len: u32 },
|
||||||
#[serde(rename = "std::generalize")]
|
#[serde(rename = "std::generalize")]
|
||||||
Generalize { metric: String },
|
Generalize { metric: String },
|
||||||
|
/// Enumerate-only leading stage (#256 fork B): yields the axis grid's
|
||||||
|
/// parameter points to the next stage; executes and persists nothing.
|
||||||
|
/// Fieldless — the axes live in the campaign document.
|
||||||
|
#[serde(rename = "std::grid")]
|
||||||
|
Grid,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_false(b: &bool) -> bool {
|
fn is_false(b: &bool) -> bool {
|
||||||
@@ -224,6 +229,14 @@ pub const PROCESS_BLOCKS: &[BlockSchema] = &[
|
|||||||
needs >= 2 instruments and an R-expectancy metric to execute",
|
needs >= 2 instruments and an R-expectancy metric to execute",
|
||||||
slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }],
|
slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }],
|
||||||
},
|
},
|
||||||
|
BlockSchema {
|
||||||
|
id: "std::grid",
|
||||||
|
doc: "enumerate the axis grid as parameter points for the next stage \
|
||||||
|
(enumerate-only leading stage): executes and persists nothing; \
|
||||||
|
legal only as the first stage, immediately before \
|
||||||
|
std::walk_forward",
|
||||||
|
slots: &[],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -374,6 +387,7 @@ fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
|
|||||||
"std::generalize" => Ok(StageBlock::Generalize {
|
"std::generalize" => Ok(StageBlock::Generalize {
|
||||||
metric: require_str(m, "metric", &id)?,
|
metric: require_str(m, "metric", &id)?,
|
||||||
}),
|
}),
|
||||||
|
"std::grid" => Ok(StageBlock::Grid),
|
||||||
_ => unreachable!("schema membership checked above"),
|
_ => unreachable!("schema membership checked above"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,6 +515,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
|
||||||
@@ -509,12 +524,77 @@ pub enum RiskRegime {
|
|||||||
/// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
|
/// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
|
||||||
/// vocabulary conforms to the node vocabulary. Externally tagged so a future
|
/// vocabulary conforms to the node vocabulary. Externally tagged so a future
|
||||||
/// component is additive — no content-id churn on stored components.
|
/// component is additive — no content-id churn on stored components.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||||
pub enum CostSpec {
|
pub enum CostSpec {
|
||||||
Constant { cost_per_trade: f64 },
|
Constant { cost_per_trade: CostValue },
|
||||||
VolSlippage { slip_vol_mult: f64 },
|
VolSlippage { slip_vol_mult: CostValue },
|
||||||
Carry { carry_per_cycle: f64 },
|
Carry { carry_per_cycle: CostValue },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One cost knob's value (#260): one number for every cell, or an
|
||||||
|
/// instrument-keyed map resolved per cell at member construction. Custom
|
||||||
|
/// serde keeps the scalar form byte-identical to the historical bare number
|
||||||
|
/// (content-id parity for stored docs — the `Axis` precedent); the map form
|
||||||
|
/// is a plain JSON object of numbers (BTreeMap: deterministic key order).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum CostValue {
|
||||||
|
Scalar(f64),
|
||||||
|
PerInstrument(std::collections::BTreeMap<String, f64>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostValue {
|
||||||
|
/// The per-cell value: the scalar, or the instrument's map entry. `None`
|
||||||
|
/// only for an uncovered instrument — unreachable after intrinsic
|
||||||
|
/// validation; callers refuse with prose rather than defaulting.
|
||||||
|
pub fn resolve(&self, instrument: &str) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
CostValue::Scalar(v) => Some(*v),
|
||||||
|
CostValue::PerInstrument(m) => m.get(instrument).copied(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Every numeric value the validator finiteness/sign-checks.
|
||||||
|
pub fn values(&self) -> Vec<f64> {
|
||||||
|
match self {
|
||||||
|
CostValue::Scalar(v) => vec![*v],
|
||||||
|
CostValue::PerInstrument(m) => m.values().copied().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// The map's key set (empty for a scalar) — the validate cross-check input.
|
||||||
|
pub fn instrument_keys(&self) -> Vec<&str> {
|
||||||
|
match self {
|
||||||
|
CostValue::Scalar(_) => Vec::new(),
|
||||||
|
CostValue::PerInstrument(m) => m.keys().map(String::as_str).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for CostValue {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
match self {
|
||||||
|
CostValue::Scalar(v) => v.serialize(s),
|
||||||
|
CostValue::PerInstrument(m) => m.serialize(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for CostValue {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
use serde::de::Error as _;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Raw {
|
||||||
|
Scalar(f64),
|
||||||
|
PerInstrument(std::collections::BTreeMap<String, f64>),
|
||||||
|
}
|
||||||
|
match Raw::deserialize(d)? {
|
||||||
|
Raw::Scalar(v) => Ok(CostValue::Scalar(v)),
|
||||||
|
Raw::PerInstrument(m) if m.is_empty() => {
|
||||||
|
Err(D::Error::custom("instrument map is empty"))
|
||||||
|
}
|
||||||
|
Raw::PerInstrument(m) => Ok(CostValue::PerInstrument(m)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
|
/// Bare wire value of one Scalar (strips the tagged form via Scalar's own
|
||||||
@@ -724,6 +804,7 @@ pub enum DocFault {
|
|||||||
BadWindow { index: usize },
|
BadWindow { index: usize },
|
||||||
BadRegime { index: usize },
|
BadRegime { index: usize },
|
||||||
BadCost { index: usize },
|
BadCost { index: usize },
|
||||||
|
CostInstrumentKeys { index: usize, missing: Vec<String>, extra: Vec<String> },
|
||||||
NoStrategy,
|
NoStrategy,
|
||||||
EmptyAxes { strategy: usize },
|
EmptyAxes { strategy: usize },
|
||||||
EmptyAxis { strategy: usize, axis: String },
|
EmptyAxis { strategy: usize, axis: String },
|
||||||
@@ -791,6 +872,7 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
StageBlock::MonteCarlo { .. } => {}
|
StageBlock::MonteCarlo { .. } => {}
|
||||||
|
StageBlock::Grid => {}
|
||||||
}
|
}
|
||||||
if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) {
|
if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) {
|
||||||
terminal_seen = true;
|
terminal_seen = true;
|
||||||
@@ -826,12 +908,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() {
|
||||||
@@ -839,14 +930,35 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
|||||||
// knob (`>= 0`); the doc tier refuses the same bounds earlier,
|
// knob (`>= 0`); the doc tier refuses the same bounds earlier,
|
||||||
// path-addressed (the BadRegime precedent). NaN/inf are unreachable
|
// path-addressed (the BadRegime precedent). NaN/inf are unreachable
|
||||||
// via JSON but refused defensively.
|
// via JSON but refused defensively.
|
||||||
let v = match c {
|
let value = match c {
|
||||||
CostSpec::Constant { cost_per_trade } => *cost_per_trade,
|
CostSpec::Constant { cost_per_trade } => cost_per_trade,
|
||||||
CostSpec::VolSlippage { slip_vol_mult } => *slip_vol_mult,
|
CostSpec::VolSlippage { slip_vol_mult } => slip_vol_mult,
|
||||||
CostSpec::Carry { carry_per_cycle } => *carry_per_cycle,
|
CostSpec::Carry { carry_per_cycle } => carry_per_cycle,
|
||||||
};
|
};
|
||||||
if !v.is_finite() || v < 0.0 {
|
if value.values().iter().any(|v| !v.is_finite() || *v < 0.0) {
|
||||||
faults.push(DocFault::BadCost { index: i });
|
faults.push(DocFault::BadCost { index: i });
|
||||||
}
|
}
|
||||||
|
// #260: an instrument map must cover the campaign's instruments
|
||||||
|
// exactly — a missing instrument silently un-costs its cells, an
|
||||||
|
// extra key is almost always a symbol typo; both refuse up front.
|
||||||
|
let keys = value.instrument_keys();
|
||||||
|
if !keys.is_empty() {
|
||||||
|
let missing: Vec<String> = doc
|
||||||
|
.data
|
||||||
|
.instruments
|
||||||
|
.iter()
|
||||||
|
.filter(|inst| !keys.contains(&inst.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let extra: Vec<String> = keys
|
||||||
|
.iter()
|
||||||
|
.filter(|k| !doc.data.instruments.iter().any(|inst| inst == *k))
|
||||||
|
.map(|k| k.to_string())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() || !extra.is_empty() {
|
||||||
|
faults.push(DocFault::CostInstrumentKeys { index: i, missing, extra });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if doc.strategies.is_empty() {
|
if doc.strategies.is_empty() {
|
||||||
faults.push(DocFault::NoStrategy);
|
faults.push(DocFault::NoStrategy);
|
||||||
@@ -945,7 +1057,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)"
|
||||||
@@ -978,10 +1090,22 @@ pub struct OpenSlot {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
/// Short human hint, e.g. "required, one of: content_id, identity_id".
|
/// Short human hint, e.g. "required, one of: content_id, identity_id".
|
||||||
pub hint: String,
|
pub hint: String,
|
||||||
|
/// Additional unit/semantics notes rendered as indented sub-lines under
|
||||||
|
/// the slot's main line (#265) — additive, so the `hint` string itself
|
||||||
|
/// (pinned verbatim by earlier tests) never changes.
|
||||||
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot {
|
fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot {
|
||||||
OpenSlot { path: path.into(), hint: hint.into() }
|
OpenSlot { path: path.into(), hint: hint.into(), notes: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `open`, but carries additional unit/semantics notes (#265) rendered
|
||||||
|
/// as indented sub-lines under the slot — the `hint` string stays exactly as
|
||||||
|
/// `open` would have produced it, so sibling tests pinning `hint` verbatim
|
||||||
|
/// are unaffected.
|
||||||
|
fn open_with_notes(path: impl Into<String>, hint: impl Into<String>, notes: Vec<String>) -> OpenSlot {
|
||||||
|
OpenSlot { path: path.into(), hint: hint.into(), notes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the open slots of a (possibly partial) process document. Only the
|
/// List the open slots of a (possibly partial) process document. Only the
|
||||||
@@ -1064,18 +1188,29 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
|||||||
// how it went undiscovered (#216). Absent, empty, or non-array = open.
|
// how it went undiscovered (#216). Absent, empty, or non-array = open.
|
||||||
let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty());
|
let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty());
|
||||||
if !risk_bound {
|
if !risk_bound {
|
||||||
slots.push(open(
|
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![
|
||||||
|
"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(),
|
||||||
|
],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The optional cost slot, mirroring the risk axis (#216's discoverability
|
// The optional cost slot, mirroring the risk axis (#216's discoverability
|
||||||
// lesson applied to #234): absent, empty, or non-array = open.
|
// lesson applied to #234): absent, empty, or non-array = open.
|
||||||
let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
|
let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
|
||||||
if !cost_bound {
|
if !cost_bound {
|
||||||
slots.push(open(
|
slots.push(open_with_notes(
|
||||||
"cost",
|
"cost",
|
||||||
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
vec![
|
||||||
|
"constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
|
||||||
|
"vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
|
||||||
|
"carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
|
||||||
|
"each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
|
||||||
|
],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
match v.get("strategies").and_then(|s| s.as_array()) {
|
match v.get("strategies").and_then(|s| s.as_array()) {
|
||||||
@@ -1395,6 +1530,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();
|
||||||
@@ -1438,6 +1585,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();
|
||||||
@@ -1452,15 +1614,15 @@ mod tests {
|
|||||||
// vocabulary conforms to the node vocabulary, not the other way round.
|
// vocabulary conforms to the node vocabulary, not the other way round.
|
||||||
for (spec, wire) in [
|
for (spec, wire) in [
|
||||||
(
|
(
|
||||||
CostSpec::Constant { cost_per_trade: 2.0 },
|
CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
|
||||||
r#"{"constant":{"cost_per_trade":2.0}}"#,
|
r#"{"constant":{"cost_per_trade":2.0}}"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
CostSpec::VolSlippage { slip_vol_mult: 0.5 },
|
CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
|
||||||
r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#,
|
r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
CostSpec::Carry { carry_per_cycle: 0.1 },
|
CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.1) },
|
||||||
r#"{"carry":{"carry_per_cycle":0.1}}"#,
|
r#"{"carry":{"carry_per_cycle":0.1}}"#,
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
@@ -1472,6 +1634,86 @@ mod tests {
|
|||||||
assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
|
assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#260): a cost knob's map form round-trips as a plain JSON
|
||||||
|
/// object keyed by instrument, and the scalar form stays the bare number
|
||||||
|
/// (content-id parity for stored docs is pinned by the sibling scalar
|
||||||
|
/// round-trip test, which must stay green untouched).
|
||||||
|
#[test]
|
||||||
|
fn cost_value_map_form_round_trips_and_scalar_stays_bare() {
|
||||||
|
let json = r#"{"constant":{"cost_per_trade":{"EURUSD":0.00012,"GER40":2.0}}}"#;
|
||||||
|
let spec: CostSpec = serde_json::from_str(json).expect("map form parses");
|
||||||
|
assert_eq!(serde_json::to_string(&spec).expect("serializes"), json);
|
||||||
|
let bare: CostSpec = serde_json::from_str(r#"{"constant":{"cost_per_trade":2.0}}"#)
|
||||||
|
.expect("scalar form parses");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&bare).expect("serializes"),
|
||||||
|
r#"{"constant":{"cost_per_trade":2.0}}"#,
|
||||||
|
"scalar wire form must stay the bare number byte-identically"
|
||||||
|
);
|
||||||
|
let CostSpec::Constant { cost_per_trade } = &spec else { panic!("constant") };
|
||||||
|
assert_eq!(cost_per_trade.resolve("GER40"), Some(2.0));
|
||||||
|
assert_eq!(cost_per_trade.resolve("EURUSD"), Some(0.00012));
|
||||||
|
assert_eq!(cost_per_trade.resolve("XAUUSD"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#260): an empty instrument map (`{}`) is refused at
|
||||||
|
/// deserialize time — `validate_campaign`'s cross-check skips a knob
|
||||||
|
/// whose `instrument_keys()` is empty (that shape also means "scalar"),
|
||||||
|
/// so an empty map that slipped past this guard would silently escape
|
||||||
|
/// the instrument-coverage check entirely.
|
||||||
|
#[test]
|
||||||
|
fn cost_value_empty_map_is_refused_at_deserialize() {
|
||||||
|
let err = serde_json::from_str::<CostSpec>(r#"{"constant":{"cost_per_trade":{}}}"#)
|
||||||
|
.expect_err("an empty instrument map must not parse");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("instrument map is empty"),
|
||||||
|
"error names the defect: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#260): the intrinsic tier cross-checks every instrument map
|
||||||
|
/// against the doc's own data.instruments — a missing instrument and an
|
||||||
|
/// unknown extra key are each a fault naming the component; a complete
|
||||||
|
/// map is valid. No silent default, no partial coverage.
|
||||||
|
#[test]
|
||||||
|
fn validate_campaign_cross_checks_cost_instrument_maps() {
|
||||||
|
let mk = |cost: &str| {
|
||||||
|
let doc = format!(
|
||||||
|
r#"{{ "format_version": 1, "kind": "campaign", "name": "t",
|
||||||
|
"data": {{ "instruments": ["GER40", "EURUSD"],
|
||||||
|
"windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
||||||
|
"strategies": [ {{ "ref": {{ "content_id": "9f" }},
|
||||||
|
"axes": {{ "a": {{ "kind": "I64", "values": [1] }} }} }} ],
|
||||||
|
"process": {{ "ref": {{ "content_id": "9f" }} }},
|
||||||
|
"cost": [ {cost} ],
|
||||||
|
"presentation": {{ "persist_taps": [], "emit": [] }},
|
||||||
|
"seed": 1 }}"#
|
||||||
|
);
|
||||||
|
let parsed = parse_campaign(&doc).expect("parses");
|
||||||
|
validate_campaign(&parsed)
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 0.00012 } } }"#)
|
||||||
|
.is_empty(),
|
||||||
|
"a complete map must validate"
|
||||||
|
);
|
||||||
|
let missing = mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0 } } }"#);
|
||||||
|
assert!(
|
||||||
|
missing.iter().any(|f| matches!(
|
||||||
|
f,
|
||||||
|
DocFault::CostInstrumentKeys { index: 0, .. }
|
||||||
|
)),
|
||||||
|
"fault names the component: {missing:?}"
|
||||||
|
);
|
||||||
|
let extra = mk(
|
||||||
|
r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 1.0, "GER40.cash": 3.0 } } }"#,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
extra.iter().any(|f| matches!(f, DocFault::CostInstrumentKeys { index: 0, .. })),
|
||||||
|
"fault names the component: {extra:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
|
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
|
||||||
// The risk precedent's sibling: cost-less documents keep their content ids.
|
// The risk precedent's sibling: cost-less documents keep their content ids.
|
||||||
@@ -1492,8 +1734,8 @@ mod tests {
|
|||||||
fn campaign_with_cost_serializes_and_round_trips_the_components() {
|
fn campaign_with_cost_serializes_and_round_trips_the_components() {
|
||||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
doc.cost = vec![
|
doc.cost = vec![
|
||||||
CostSpec::Constant { cost_per_trade: 2.0 },
|
CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
|
||||||
CostSpec::VolSlippage { slip_vol_mult: 0.5 },
|
CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
|
||||||
];
|
];
|
||||||
let out = campaign_to_json(&doc);
|
let out = campaign_to_json(&doc);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1508,9 +1750,9 @@ mod tests {
|
|||||||
fn validate_campaign_flags_each_bad_cost_component() {
|
fn validate_campaign_flags_each_bad_cost_component() {
|
||||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
doc.cost = vec![
|
doc.cost = vec![
|
||||||
CostSpec::Constant { cost_per_trade: 0.0 }, // 0: valid (zero cost is a legal stress point)
|
CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) }, // 0: valid (zero cost is a legal stress point)
|
||||||
CostSpec::VolSlippage { slip_vol_mult: -0.5 }, // 1: negative
|
CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) }, // 1: negative
|
||||||
CostSpec::Carry { carry_per_cycle: f64::NAN }, // 2: non-finite (unreachable via JSON; defensive)
|
CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) }, // 2: non-finite (unreachable via JSON; defensive)
|
||||||
];
|
];
|
||||||
let faults = validate_campaign(&doc);
|
let faults = validate_campaign(&doc);
|
||||||
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
|
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}");
|
||||||
@@ -1518,10 +1760,28 @@ mod tests {
|
|||||||
assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}");
|
assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#260): the finiteness/sign check applies to EVERY value
|
||||||
|
/// inside a `PerInstrument` map, not only the scalar form — a negative
|
||||||
|
/// entry nested in an otherwise fully-keyed map is refused exactly like
|
||||||
|
/// a bad scalar (quality-review gap: the map cross-check test above only
|
||||||
|
/// exercised valid map values, leaving this path unexercised).
|
||||||
|
#[test]
|
||||||
|
fn validate_campaign_flags_a_bad_value_inside_an_instrument_map() {
|
||||||
|
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
|
doc.cost = vec![CostSpec::Constant {
|
||||||
|
cost_per_trade: CostValue::PerInstrument(std::collections::BTreeMap::from([(
|
||||||
|
"GER40".to_string(),
|
||||||
|
-1.0,
|
||||||
|
)])),
|
||||||
|
}];
|
||||||
|
let faults = validate_campaign(&doc);
|
||||||
|
assert!(faults.contains(&DocFault::BadCost { index: 0 }), "{faults:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_campaign_accepts_a_valid_cost_section() {
|
fn validate_campaign_accepts_a_valid_cost_section() {
|
||||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
doc.cost = vec![CostSpec::Carry { carry_per_cycle: 0.25 }];
|
doc.cost = vec![CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.25) }];
|
||||||
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
|
assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,16 +2120,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #256 fork B: the fieldless enumerate-only stage round-trips through
|
||||||
|
/// the schema-strict parser, and any slot on it is refused (its slot
|
||||||
|
/// list is empty, so the generic unknown-slot check covers every key).
|
||||||
|
#[test]
|
||||||
|
fn grid_block_round_trips_and_refuses_slots() {
|
||||||
|
let json = serde_json::to_string(&StageBlock::Grid).expect("serialize");
|
||||||
|
assert_eq!(json, r#"{"block":"std::grid"}"#);
|
||||||
|
let back: StageBlock = serde_json::from_str(&json).expect("parse back");
|
||||||
|
assert_eq!(back, StageBlock::Grid);
|
||||||
|
let err = serde_json::from_str::<StageBlock>(
|
||||||
|
r#"{"block":"std::grid","metric":"sqn"}"#,
|
||||||
|
)
|
||||||
|
.expect_err("a slot on the fieldless std::grid must be refused");
|
||||||
|
assert!(err.to_string().contains("unknown slot"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vocabularies_enumerate_every_block_with_typed_slots() {
|
fn vocabularies_enumerate_every_block_with_typed_slots() {
|
||||||
let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect();
|
let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids,
|
ids,
|
||||||
vec!["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"]
|
vec![
|
||||||
|
"std::sweep",
|
||||||
|
"std::gate",
|
||||||
|
"std::walk_forward",
|
||||||
|
"std::monte_carlo",
|
||||||
|
"std::generalize",
|
||||||
|
"std::grid"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
|
for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
|
||||||
assert!(!b.doc.is_empty(), "block {} has no doc line", b.id);
|
assert!(!b.doc.is_empty(), "block {} has no doc line", b.id);
|
||||||
assert!(!b.slots.is_empty(), "block {} has no slots", b.id);
|
// std::grid is the one legitimate fieldless block (#256); every
|
||||||
|
// other block keeps its documented-slots guard.
|
||||||
|
assert!(
|
||||||
|
b.id == "std::grid" || !b.slots.is_empty(),
|
||||||
|
"block {} has no slots",
|
||||||
|
b.id
|
||||||
|
);
|
||||||
for s in b.slots {
|
for s in b.slots {
|
||||||
assert!(!slot_kind_label(s.kind).is_empty());
|
assert!(!slot_kind_label(s.kind).is_empty());
|
||||||
}
|
}
|
||||||
@@ -1932,13 +2221,24 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
|
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
|
||||||
vec![
|
vec![
|
||||||
open(
|
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![
|
||||||
|
"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(
|
open_with_notes(
|
||||||
"cost",
|
"cost",
|
||||||
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
vec![
|
||||||
|
"constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
|
||||||
|
"vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
|
||||||
|
"carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
|
||||||
|
"each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -2012,9 +2312,15 @@ mod tests {
|
|||||||
assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
|
assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
open_slots_campaign(&with_risk).unwrap(),
|
open_slots_campaign(&with_risk).unwrap(),
|
||||||
vec![open(
|
vec![open_with_notes(
|
||||||
"cost",
|
"cost",
|
||||||
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)",
|
||||||
|
vec![
|
||||||
|
"constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(),
|
||||||
|
"vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(),
|
||||||
|
"carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(),
|
||||||
|
"each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(),
|
||||||
|
],
|
||||||
)]
|
)]
|
||||||
);
|
);
|
||||||
let with_both = CAMPAIGN_FIXTURE.replacen(
|
let with_both = CAMPAIGN_FIXTURE.replacen(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ mod stop_rule;
|
|||||||
mod sub;
|
mod sub;
|
||||||
mod vocabulary;
|
mod vocabulary;
|
||||||
mod vol_slippage_cost;
|
mod vol_slippage_cost;
|
||||||
|
mod vol_tf_stop;
|
||||||
pub use abs::Abs;
|
pub use abs::Abs;
|
||||||
pub use add::Add;
|
pub use add::Add;
|
||||||
pub use and::And;
|
pub use and::And;
|
||||||
@@ -88,7 +89,7 @@ pub use rolling_max::RollingMax;
|
|||||||
pub use rolling_min::RollingMin;
|
pub use rolling_min::RollingMin;
|
||||||
pub use scale::Scale;
|
pub use scale::Scale;
|
||||||
pub use series_reducer::SeriesReducer;
|
pub use series_reducer::SeriesReducer;
|
||||||
pub use session::Session;
|
pub use session::{Session, SessionFrankfurt};
|
||||||
pub use sim_broker::SimBroker;
|
pub use sim_broker::SimBroker;
|
||||||
pub use sizer::Sizer;
|
pub use sizer::Sizer;
|
||||||
pub use sma::Sma;
|
pub use sma::Sma;
|
||||||
@@ -97,3 +98,4 @@ pub use stop_rule::FixedStop;
|
|||||||
pub use sub::Sub;
|
pub use sub::Sub;
|
||||||
pub use vocabulary::{std_vocabulary, std_vocabulary_types};
|
pub use vocabulary::{std_vocabulary, std_vocabulary_types};
|
||||||
pub use vol_slippage_cost::VolSlippageCost;
|
pub use vol_slippage_cost::VolSlippageCost;
|
||||||
|
pub use vol_tf_stop::VolTfStop;
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
//!
|
//!
|
||||||
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
||||||
|
|
||||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
|
use aura_core::{
|
||||||
|
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||||
|
};
|
||||||
use chrono::{TimeZone, Timelike};
|
use chrono::{TimeZone, Timelike};
|
||||||
|
|
||||||
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
||||||
@@ -76,6 +78,33 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||||||
|
/// (09:00 `Europe/Berlin`) baked as a literal, so the closed `std_vocabulary`
|
||||||
|
/// roster can reach a `Session` without a structural construction argument
|
||||||
|
/// (`Session::builder` itself stays absent from the roster — #155 scope).
|
||||||
|
/// The one remaining knob, `period_minutes` (i64, conventionally 15 — the
|
||||||
|
/// resampler's bar width), is declared as the node's sole `ParamSpec` and
|
||||||
|
/// bound through it via the single-knob `CarryCost`/`ConstantCost`/
|
||||||
|
/// `VolSlippageCost` pattern. This is additive: `Session::builder` is
|
||||||
|
/// untouched and stays the vehicle for other timezones/opens.
|
||||||
|
pub struct SessionFrankfurt;
|
||||||
|
|
||||||
|
impl SessionFrankfurt {
|
||||||
|
/// The param-generic recipe: one `period_minutes` I64 knob, the Frankfurt
|
||||||
|
/// open/timezone baked in the closure.
|
||||||
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
|
PrimitiveBuilder::new(
|
||||||
|
"SessionFrankfurt",
|
||||||
|
NodeSchema {
|
||||||
|
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||||
|
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
||||||
|
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||||||
|
},
|
||||||
|
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Node for Session {
|
impl Node for Session {
|
||||||
fn lookbacks(&self) -> Vec<usize> {
|
fn lookbacks(&self) -> Vec<usize> {
|
||||||
vec![1]
|
vec![1]
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
|
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
|
||||||
//! position-management latches the entry-cycle distance as the frozen R-denominator.
|
//! position-management latches the entry-cycle distance as the frozen R-denominator.
|
||||||
//!
|
//!
|
||||||
//! `FixedStop` is the only stop-rule PRIMITIVE: a constant distance gated on its price
|
//! `FixedStop` and `VolTfStop` (`vol_tf_stop.rs`) are the fused stop-rule
|
||||||
//! input (a source-less `Const` has no firing trigger in the push model, so the
|
//! PRIMITIVES: `FixedStop` is a constant distance gated on its price input (a
|
||||||
//! triggered-constant shape is the honest primitive). The volatility stop is NOT a
|
//! source-less `Const` has no firing trigger in the push model, so the
|
||||||
//! primitive — it is a COMPOSITION of primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))`
|
//! triggered-constant shape is the honest primitive); `VolTfStop` folds
|
||||||
//! with `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built with
|
//! `k · √EMA(Δ², length)` over completed timescale-matched buckets. The
|
||||||
//! `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`. True-range ATR
|
//! per-cycle volatility stop remains NOT a primitive — it is a COMPOSITION of
|
||||||
//! (a richer stop) is deferred — it needs OHLC.
|
//! primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))` with
|
||||||
|
//! `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built
|
||||||
|
//! with `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`.
|
||||||
|
//! True-range ATR (a richer stop) is deferred — it needs OHLC.
|
||||||
use aura_core::{
|
use aura_core::{
|
||||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||||
ScalarKind,
|
ScalarKind,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt,
|
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt,
|
||||||
Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale,
|
Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale,
|
||||||
Sizer, Sma, Sqrt, Sub, VolSlippageCost,
|
SessionFrankfurt, Sizer, Sma, Sqrt, Sub, VolSlippageCost,
|
||||||
};
|
};
|
||||||
use aura_core::PrimitiveBuilder;
|
use aura_core::PrimitiveBuilder;
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ std_vocabulary_roster! {
|
|||||||
"RollingMax" => RollingMax,
|
"RollingMax" => RollingMax,
|
||||||
"RollingMin" => RollingMin,
|
"RollingMin" => RollingMin,
|
||||||
"Scale" => Scale,
|
"Scale" => Scale,
|
||||||
|
"SessionFrankfurt" => SessionFrankfurt,
|
||||||
"Sizer" => Sizer,
|
"Sizer" => Sizer,
|
||||||
"SMA" => Sma,
|
"SMA" => Sma,
|
||||||
"Sqrt" => Sqrt,
|
"Sqrt" => Sqrt,
|
||||||
@@ -129,7 +130,7 @@ mod tests {
|
|||||||
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
|
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
|
||||||
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
||||||
assert!(!std_vocabulary_types().contains(&"nope"));
|
assert!(!std_vocabulary_types().contains(&"nope"));
|
||||||
// count guard: pins the roster at exactly 28 entries
|
// count guard: pins the roster at exactly 29 entries
|
||||||
assert_eq!(std_vocabulary_types().len(), 28);
|
assert_eq!(std_vocabulary_types().len(), 29);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! `VolTfStop` — timescale-matched volatility stop (#262):
|
||||||
|
//! `k · √EMA(Δ², length)` over completed `period_minutes` buckets — the vol
|
||||||
|
//! regime on a coarser clock. A fused primitive in the `Resample` mold: the
|
||||||
|
//! in-progress bucket lives in node state; on rollover the finished bucket's
|
||||||
|
//! close is differenced against the previous bucket's close, Δ² folds into an
|
||||||
|
//! `Ema`-convention EMA (SMA seed over the first `length` deltas,
|
||||||
|
//! `alpha = 2/(length+1)`), and the stop distance is emitted ONCE (C2: a
|
||||||
|
//! partial bucket never exists downstream). Mid-bucket the node emits
|
||||||
|
//! nothing, so the `Firing::Any` consumers (`Sizer`, `PositionManagement`)
|
||||||
|
//! hold the last completed bucket's value; the R-latch samples at entry.
|
||||||
|
|
||||||
|
/// Nanoseconds per minute — the bucket-id divisor's one scale factor.
|
||||||
|
const NS_PER_MINUTE: i64 = 60 * 1_000_000_000;
|
||||||
|
|
||||||
|
use aura_core::{
|
||||||
|
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||||
|
ScalarKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct VolTfStop {
|
||||||
|
period_ns: i64,
|
||||||
|
k: f64,
|
||||||
|
// EMA(Δ²) over completed buckets — `Ema`'s exact convention.
|
||||||
|
alpha: f64,
|
||||||
|
length: usize,
|
||||||
|
seeded: bool,
|
||||||
|
warmup_sum: f64,
|
||||||
|
count: usize,
|
||||||
|
ema: f64,
|
||||||
|
// Bucket accumulator (`Resample`'s mold): current id, its running close,
|
||||||
|
// and the previous COMPLETED bucket's close.
|
||||||
|
bucket: Option<i64>,
|
||||||
|
close_in_bucket: f64,
|
||||||
|
prev_close: Option<f64>,
|
||||||
|
out: [Cell; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolTfStop {
|
||||||
|
pub fn new(period_minutes: i64, length: i64, k: f64) -> Self {
|
||||||
|
assert!(period_minutes >= 1, "VolTfStop period_minutes must be >= 1");
|
||||||
|
assert!(length >= 1, "VolTfStop length must be >= 1");
|
||||||
|
assert!(k > 0.0, "VolTfStop k must be > 0");
|
||||||
|
Self {
|
||||||
|
period_ns: period_minutes * NS_PER_MINUTE,
|
||||||
|
k,
|
||||||
|
alpha: 2.0 / (length as f64 + 1.0),
|
||||||
|
length: length as usize,
|
||||||
|
seeded: false,
|
||||||
|
warmup_sum: 0.0,
|
||||||
|
count: 0,
|
||||||
|
ema: 0.0,
|
||||||
|
bucket: None,
|
||||||
|
close_in_bucket: 0.0,
|
||||||
|
prev_close: None,
|
||||||
|
out: [Cell::from_f64(0.0)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The param-generic recipe (#262): a graph builder adds this fused
|
||||||
|
/// primitive the same way it adds any other node — all three knobs are
|
||||||
|
/// structural (baked into the accumulator/EMA state at construction), so
|
||||||
|
/// `risk_executor`'s `VolTf` arm binds all three via `.bind(..)` (the
|
||||||
|
/// `FixedStop::builder().bind("distance", ..)` idiom, chained thrice).
|
||||||
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
|
PrimitiveBuilder::new(
|
||||||
|
"VolTfStop",
|
||||||
|
NodeSchema {
|
||||||
|
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
|
||||||
|
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
|
||||||
|
params: vec![
|
||||||
|
ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 },
|
||||||
|
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
|
||||||
|
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|p| Box::new(VolTfStop::new(p[0].i64(), p[1].i64(), p[2].f64())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node for VolTfStop {
|
||||||
|
// The in-progress bucket lives in node state, not the input columns.
|
||||||
|
fn lookbacks(&self) -> Vec<usize> {
|
||||||
|
vec![1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||||
|
let w = ctx.f64_in(0);
|
||||||
|
if w.is_empty() {
|
||||||
|
return None; // fire with price (warm-up filter)
|
||||||
|
}
|
||||||
|
let price = w[0];
|
||||||
|
let bucket = ctx.now().0 / self.period_ns;
|
||||||
|
match self.bucket {
|
||||||
|
// First ever sample: open the accumulator, nothing complete yet.
|
||||||
|
None => {
|
||||||
|
self.bucket = Some(bucket);
|
||||||
|
self.close_in_bucket = price;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// Same bucket: the running close advances (last close wins).
|
||||||
|
Some(b) if bucket == b => {
|
||||||
|
self.close_in_bucket = price;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// Rollover: the finished bucket's close is final — difference it
|
||||||
|
// against the previous completed close, fold, emit once.
|
||||||
|
Some(_) => {
|
||||||
|
let finished_close = self.close_in_bucket;
|
||||||
|
self.bucket = Some(bucket);
|
||||||
|
self.close_in_bucket = price;
|
||||||
|
let Some(prev) = self.prev_close.replace(finished_close) else {
|
||||||
|
return None; // first completed bucket: no delta yet
|
||||||
|
};
|
||||||
|
let d = finished_close - prev;
|
||||||
|
let d2 = d * d;
|
||||||
|
if !self.seeded {
|
||||||
|
// SMA-seed over the first `length` deltas (Ema's convention).
|
||||||
|
self.warmup_sum += d2;
|
||||||
|
self.count += 1;
|
||||||
|
if self.count < self.length {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.ema = self.warmup_sum / self.length as f64;
|
||||||
|
self.seeded = true;
|
||||||
|
} else {
|
||||||
|
// O(1) recurrence, exactly Ema's.
|
||||||
|
self.ema += self.alpha * (d2 - self.ema);
|
||||||
|
}
|
||||||
|
self.out[0] = Cell::from_f64(self.k * self.ema.sqrt());
|
||||||
|
Some(&self.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"VolTfStop({}m,{},{})",
|
||||||
|
self.period_ns / NS_PER_MINUTE,
|
||||||
|
self.length,
|
||||||
|
self.k
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use aura_core::{AnyColumn, Scalar, ScalarKind, Timestamp};
|
||||||
|
|
||||||
|
// Drive the node like the engine does: one eval per (minute, price) sample,
|
||||||
|
// mirroring the `feed`/`step` eval-harness idiom of `stop_rule.rs` /
|
||||||
|
// `resample.rs` — a capacity-1 ring column whose push overwrites the single
|
||||||
|
// slot, re-presenting the newest sample each cycle.
|
||||||
|
fn drive(node: &mut VolTfStop, samples: &[(i64, f64)]) -> Vec<Option<f64>> {
|
||||||
|
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.map(|&(ts_min, price)| {
|
||||||
|
col[0].push(Scalar::f64(price)).unwrap();
|
||||||
|
node.eval(Ctx::new(&col, Timestamp(ts_min * NS_PER_MINUTE)))
|
||||||
|
.map(|c| c[0].f64())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#262): mid-bucket cycles emit NOTHING — the stop exists only
|
||||||
|
/// at bucket rollover (C2: a partial bucket never reaches downstream).
|
||||||
|
#[test]
|
||||||
|
fn emits_only_on_bucket_rollover() {
|
||||||
|
let mut n = VolTfStop::new(60, 1, 2.0);
|
||||||
|
// Minutes 0..59 are one bucket; minute 60 rolls it over; 61..119 the
|
||||||
|
// next; 120 rolls again (first delta -> first emission).
|
||||||
|
let out = drive(
|
||||||
|
&mut n,
|
||||||
|
&[(0, 100.0), (30, 101.0), (59, 102.0), (60, 103.0), (90, 104.0), (120, 105.0)],
|
||||||
|
);
|
||||||
|
assert_eq!(out[0], None, "first sample opens the accumulator");
|
||||||
|
assert_eq!(out[1], None, "mid-bucket");
|
||||||
|
assert_eq!(out[2], None, "mid-bucket");
|
||||||
|
assert_eq!(out[3], None, "first rollover: a close exists, no delta yet");
|
||||||
|
assert_eq!(out[4], None, "mid-bucket");
|
||||||
|
assert!(out[5].is_some(), "second rollover: first delta -> first stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#262): the emitted value is k · √EMA(Δ², length) over the
|
||||||
|
/// BUCKET closes. length=1 (alpha=1, the Ema identity) makes each stop
|
||||||
|
/// exactly k·|Δ| of the last completed bucket pair.
|
||||||
|
#[test]
|
||||||
|
fn stop_is_k_times_root_ema_of_bucket_close_deltas() {
|
||||||
|
let mut n = VolTfStop::new(60, 1, 2.0);
|
||||||
|
// Bucket closes: 102 (bucket 0), 104 (bucket 1), 109 (bucket 2).
|
||||||
|
let out = drive(
|
||||||
|
&mut n,
|
||||||
|
&[(0, 100.0), (59, 102.0), (61, 103.0), (119, 104.0), (121, 108.0), (179, 109.0), (181, 110.0)],
|
||||||
|
);
|
||||||
|
// Rollover at 61 (close 102, no delta), 121 (Δ=2 -> 2·|2|=4), 181 (Δ=5 -> 2·|5|=10).
|
||||||
|
assert_eq!(out[2], None, "first completed bucket seeds prev_close only");
|
||||||
|
assert_eq!(out[4], Some(4.0), "k·|Δ| with length=1: 2·(104−102)");
|
||||||
|
assert_eq!(out[6], Some(10.0), "2·(109−104)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Correspondence pin (#262 acceptance): on strictly 1-minute-spaced
|
||||||
|
/// input with CONSTANT |Δ| per minute, vol_tf{1, length, k} converges to
|
||||||
|
/// exactly what the composite vol stop computes — k·|Δ| — the constant
|
||||||
|
/// input makes the one-cycle emission lag immaterial.
|
||||||
|
#[test]
|
||||||
|
fn one_minute_periods_match_the_vol_regime_on_constant_deltas() {
|
||||||
|
let mut n = VolTfStop::new(1, 3, 2.0);
|
||||||
|
// Alternating ±1 prices: every minute-delta has |Δ| = 1, Δ² = 1.
|
||||||
|
let samples: Vec<(i64, f64)> =
|
||||||
|
(0..12).map(|m| (m, if m % 2 == 0 { 100.0 } else { 101.0 })).collect();
|
||||||
|
let out = drive(&mut n, &samples);
|
||||||
|
let last = out.last().copied().flatten().expect("steady state reached");
|
||||||
|
assert!((last - 2.0).abs() < 1e-12, "k·√EMA(1) = 2.0, got {last}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -350,6 +350,7 @@ std::gate filter survivors by a conjunction of typed metric predicates
|
|||||||
std::walk_forward rolling in-sample optimize + out-of-sample test
|
std::walk_forward rolling in-sample optimize + out-of-sample test
|
||||||
std::monte_carlo R-bootstrap over realised R (terminal annotator): ...
|
std::monte_carlo R-bootstrap over realised R (terminal annotator): ...
|
||||||
std::generalize cross-instrument worst-case floor (terminal annotator): ...
|
std::generalize cross-instrument worst-case floor (terminal annotator): ...
|
||||||
|
std::grid enumerate the axis grid as parameter points for the next stage (enumerate-only leading stage): ...
|
||||||
$ aura process introspect --block std::sweep
|
$ aura process introspect --block std::sweep
|
||||||
std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only)
|
std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only)
|
||||||
metric optional, metric name (see metric_vocabulary)
|
metric optional, metric name (see metric_vocabulary)
|
||||||
@@ -468,7 +469,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)
|
||||||
@@ -603,3 +604,9 @@ family charted the same way, by the handle the run prints (members keyed
|
|||||||
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
|
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
|
||||||
stored campaign documents; a name reused across runs refuses rather than
|
stored campaign documents; a name reused across runs refuses rather than
|
||||||
guessing which one you mean).
|
guessing which one you mean).
|
||||||
|
|
||||||
|
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
|
||||||
|
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
|
||||||
|
a run that **completes with one or more failed cells** exits 3 — the run record
|
||||||
|
and every healthy cell persist, and the failed cells are named on stderr
|
||||||
|
(#272).
|
||||||
|
|||||||
+48
-8
@@ -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
|
||||||
@@ -1564,11 +1565,13 @@ boundary is test-pinned on both sides), plus three new static guards
|
|||||||
(unanimous #200 triage): nothing flows out of them; filtering stays the gate's monopoly.
|
(unanimous #200 triage): nothing flows out of them; filtering stays the gate's monopoly.
|
||||||
`std::monte_carlo` bootstraps the stage's *incoming R-evidence* with one semantics,
|
`std::monte_carlo` bootstraps the stage's *incoming R-evidence* with one semantics,
|
||||||
input-shaped by position — after a walk_forward, ONE `r_bootstrap` over the wf family's
|
input-shaped by position — after a walk_forward, ONE `r_bootstrap` over the wf family's
|
||||||
pooled per-window OOS `trade_rs` in roll order (`PooledOos`); after sweep/gates, one
|
pooled per-window OOS `net_trade_rs` in roll order (`PooledOos`; #259 materialized this
|
||||||
`r_bootstrap` per surviving member's fresh in-memory series (`PerSurvivor`, ordinals into
|
conduit as the cost-netted per-trade series `r − cost_in_r`, equal to the gross series
|
||||||
the population family; a zero-trade member records the engine's defined all-zero
|
bit-for-bit when no cost model is bound); after sweep/gates, one `r_bootstrap` per
|
||||||
degenerate) — seeded from the campaign doc's `seed` (C1; `trade_rs` is `#[serde(skip)]`,
|
surviving member's fresh in-memory series (`PerSurvivor`, ordinals into the population
|
||||||
so annotators run in-executor or not at all). `std::generalize` executes at **campaign
|
family; a zero-trade member records the engine's defined all-zero degenerate) — seeded
|
||||||
|
from the campaign doc's `seed` (C1; `net_trade_rs` is `#[serde(skip)]`, so annotators run
|
||||||
|
in-executor or not at all). `std::generalize` executes at **campaign
|
||||||
scope**: after all cells, per (strategy, window) the per-cell *nominees* (last wf
|
scope**: after all cells, per (strategy, window) the per-cell *nominees* (last wf
|
||||||
window's OOS report, else the sweep winner; none on gate truncation) across instruments
|
window's OOS report, else the sweep winner; none on gate truncation) across instruments
|
||||||
feed the shipped `generalization()` when ≥ 2 exist — divergent per-instrument winners
|
feed the shipped `generalization()` when ≥ 2 exist — divergent per-instrument winners
|
||||||
@@ -1602,7 +1605,31 @@ the retired deferral: per-cell no-nominee skip, per-run unproducible-tap skip
|
|||||||
pure name derivation). Noted debt: `aura chart` over the campaign family ROOT (cells
|
pure name derivation). Noted debt: `aura chart` over the campaign family ROOT (cells
|
||||||
spanning instruments) is untested/semantically undefined — only per-cell read-back is
|
spanning instruments) is untested/semantically undefined — only per-cell read-back is
|
||||||
pinned.
|
pinned.
|
||||||
**Guarantee.** Construction is a distinct phase, recursive at every level. Each
|
**Realization (#272, 2026-07-14 — per-cell fault isolation).** A member
|
||||||
|
fault (no-data, bind, run, or a caught panic) is a recorded per-cell outcome,
|
||||||
|
never a global abort: `run_cell` returns a fault-annotated `CellRealization`
|
||||||
|
(`fault: Option<CellFault>`, closed `CellFaultKind`) instead of `Err`, so
|
||||||
|
`execute`'s existing accumulate-then-append-once tail persists every healthy
|
||||||
|
cell and the one run record. Containment granularity is the cell for a sweep
|
||||||
|
stage (a grid hole compromises selection) and the fold for walk_forward
|
||||||
|
(surviving folds pool; failed folds recorded as `StageRealization.
|
||||||
|
window_faults`, the summary naming the ratio). `ExecFault::Registry` and
|
||||||
|
doc-shape preflight faults stay global. The CLI declares holes (per-cell
|
||||||
|
notes + a completion summary) and a run with ≥1 failed cell exits **3**
|
||||||
|
("completed with failed cells" — distinct from 0/1/2). A partially-covered
|
||||||
|
window carries a `CellCoverage` annotation (effective bounds + interior gap
|
||||||
|
months, from the #264 archive primitives). Generalize already treats a
|
||||||
|
no-nominee cell as `missing`, so a failed cell surfaces there unchanged.
|
||||||
|
Member panics are caught with `catch_unwind`(`AssertUnwindSafe`) at the three
|
||||||
|
member-run sites and recorded as `MemberFault::Panic`; a ref-counted
|
||||||
|
`SilencedPanic` guard (a process-global panic-hook save/no-op/restore behind a
|
||||||
|
`static Mutex`, held only around each `catch_unwind`) suppresses the default
|
||||||
|
crash backtrace so "recorded, campaign continues" is observably true on stderr,
|
||||||
|
not merely true in the record. The guard's mutex serialises only the
|
||||||
|
ref-count/hook-swap (O(1)), never the member computation, so C1 disjoint-parallel
|
||||||
|
execution and determinism are preserved; ref-counting (save on 0→1, restore on
|
||||||
|
1→0) keeps concurrent sweep/walk-forward threads and any caller-installed hook
|
||||||
|
correct. **Guarantee.** Construction is a distinct phase, recursive at every level. Each
|
||||||
node type has a **factory** `params → sized concrete node` (e.g. `SMA(length)`
|
node type has a **factory** `params → sized concrete node` (e.g. `SMA(length)`
|
||||||
sizes its ring buffer). A **blueprint** is the param-generic, input-role-generic
|
sizes its ring buffer). A **blueprint** is the param-generic, input-role-generic
|
||||||
graph-as-data produced by running a Rust builder (C9); it carries *free* numeric
|
graph-as-data produced by running a Rust builder (C9); it carries *free* numeric
|
||||||
@@ -2548,6 +2575,19 @@ plan so the two halves cannot drift (C1/C4 determinism).
|
|||||||
(0 bugs; behaviour preservation, campaign-substrate reach-through, and the risk-regime axis
|
(0 bugs; behaviour preservation, campaign-substrate reach-through, and the risk-regime axis
|
||||||
all confirmed); residual findings are discoverability/ergonomics follow-ups (#216 risk-axis
|
all confirmed); residual findings are discoverability/ergonomics follow-ups (#216 risk-axis
|
||||||
discoverability, #217 verb knob asymmetry, #218 no-project store litter).
|
discoverability, #217 verb knob asymmetry, #218 no-project store litter).
|
||||||
|
**Amendment (2026-07-14, #256 fork B):** the dissolved walkforward/mc
|
||||||
|
translations' leading sweep became the enumerate-only **`std::grid`** stage —
|
||||||
|
a fieldless vocabulary block, legal only as the first stage and immediately
|
||||||
|
before `std::walk_forward`. Only the grid's parameter points ever crossed the
|
||||||
|
stage seam (the wf stage re-sweeps them per IS window itself), so the leading
|
||||||
|
stage no longer executes members or persists a `Sweep` registry family:
|
||||||
|
persisted dissolved-walkforward/mc campaign documents lose that family, while
|
||||||
|
the visible grades are byte-identical (the exact-grade E2E pins are
|
||||||
|
unchanged). The executor's inter-stage seam is now a typed two-armed flow
|
||||||
|
(points-only vs executed members); report-consuming stages are fenced by
|
||||||
|
preflight. `translate_generalize` keeps its executed `std::sweep(argmax)`
|
||||||
|
leading stage — its generalize stage consumes the argmax winner report as the
|
||||||
|
cell nominee.
|
||||||
- **Inferential honesty of the World — family-selection without false-discovery
|
- **Inferential honesty of the World — family-selection without false-discovery
|
||||||
control (tracked: milestone "Inferential validation (defend against false
|
control (tracked: milestone "Inferential validation (defend against false
|
||||||
discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's
|
discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's
|
||||||
|
|||||||
+7
-3
@@ -73,7 +73,7 @@ A feed-forward, order-independent research axis for scaling risk by bias strengt
|
|||||||
|
|
||||||
### cost model
|
### cost model
|
||||||
**Avoid:** realistic broker
|
**Avoid:** realistic broker
|
||||||
A composable downstream **C9 graph of cost nodes**, in **R**, that **approximates** (never claims) a broker's cost: each cost node reads the state it depends on (price, realized-volatility, a C11-recorded rate source, the executor's per-trade R-records) and emits a **cost-in-R** stream subtracted from gross R to yield **net R** (per-trade factors deduct at close, per-cycle-held factors accrue over the hold). It generalizes / subsumes the scalar `round_trip_cost` (its degenerate constant-per-trade case), lives in `aura-std` / `aura-composites`, is optional (zero-cost baseline), and demands every factor be a labelled stress-parameter **or** data-grounded (over-modelling is the anti-pattern).
|
A composable downstream **C9 graph of cost nodes**, in **R**, that **approximates** (never claims) a broker's cost: each cost node reads the state it depends on (price, realized-volatility, a C11-recorded rate source, the executor's per-trade R-records) and emits a **cost-in-R** stream subtracted from gross R to yield **net R** (per-trade factors deduct at close, per-cycle-held factors accrue over the hold). It generalizes / subsumes the scalar `round_trip_cost` (its degenerate constant-per-trade case — `cost_per_trade` is a **price-unit** numerator, R-normalized per trade as `cost/|entry−stop|`, not a cost in R), lives in `aura-std` / `aura-composites`, is optional (zero-cost baseline), and demands every factor be a labelled stress-parameter **or** data-grounded (over-modelling is the anti-pattern).
|
||||||
|
|
||||||
### cross-instrument generalization
|
### cross-instrument generalization
|
||||||
**Avoid:** cross-symbol pooling, pooled generalization
|
**Avoid:** cross-symbol pooling, pooled generalization
|
||||||
@@ -232,10 +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.
|
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 (`vol_tf` sets the stop's
|
||||||
|
timescale via `period_minutes`).
|
||||||
|
|
||||||
### run
|
### run
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
|
|||||||
Reference in New Issue
Block a user