af0191884d
Public-API field test of the random param-sweep surface, from a standalone downstream-consumer crate (path-deps only; the public interface = ledger + glossary + spec 0049 + cargo doc rustdoc; no crates/*/src read). Four bins, each built from HEAD and run: continuous tuning (200-point random tune ranked by total_pips), the typed validation gate (all five reachable SweepError variants pre-run), reproducibility + seed-sensitivity + the full i64::MIN..=MAX sampler edge, and Space-trait interchangeability (one tune_and_rank<S: Space> over both GridSpace and RandomSpace). Findings: 0 bugs, 4 working, 2 spec_gap, 1 friction. The four working findings confirm the cycle's acceptance criterion empirically — the headline tune reads as the code a researcher would write, the gate is precise and fires before any run, the C1 reproducibility promise is checkable in one line, and the Space trait delivers one-consumer/both-enumerations. Triage of the actionable findings: - friction (no named-axis builder for RandomSpace — positional Vec<ParamRange> must align with param_space() by hand, and a same-kind transposition passes validation silently): filed as a feature for a future cycle, refs #79 (a RandomBinder sibling to the grid's SweepBinder). - spec_gap (SweepError rustdoc summary named only GridSpace): fixed inline in a follow-up doc commit. - spec_gap (NonNumericRange / Bool-slot ranges unreachable with the shipped aura-std node roster — no node declares a Bool/Timestamp knob): RATIFIED as intentional. The variant is a forward-looking structural guard for the C16 "author your own node" path (a Bool/Timestamp param-slot a custom node may declare); its current untriggerability with the standard roster is expected, not drift. refs #79
212 lines
8.2 KiB
Rust
212 lines
8.2 KiB
Rust
// Cycle-0049 fieldtest — example 1: the HEADLINE task.
|
|
//
|
|
// Tune a 3-param SMA-cross strategy by drawing N random points over declared
|
|
// continuous `ParamRange`s and running the SAME `sweep` a grid would use — then
|
|
// pick the best point by total_pips. A grid would explode here (fast x slow x
|
|
// scale over continuous ranges is the curse of dimensionality); random sampling
|
|
// over declared ranges is the standard tool. This is the code a researcher
|
|
// writes (axis-hint 1).
|
|
//
|
|
// PUBLIC INTERFACE ONLY: API discovered from the design ledger
|
|
// (docs/design/INDEX.md C12/C12.1), the glossary, spec 0049, and
|
|
// `cargo doc --workspace --no-deps` rustdoc. No crates/*/src was read.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Cell, Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, RandomSpace,
|
|
Role, RunManifest, RunReport, SweepFamily, Target, VecSource,
|
|
};
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
|
|
/// The reusable 2-SMA-cross composite (legs named so their knobs surface as
|
|
/// `sma_cross.fast.length` / `sma_cross.slow.length`).
|
|
fn sma_cross() -> Composite {
|
|
Composite::new(
|
|
"sma_cross",
|
|
vec![
|
|
Sma::builder().named("fast").into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
)
|
|
}
|
|
|
|
/// A fresh root harness Composite plus the equity + exposure receivers it
|
|
/// records to. A fresh build per point gives each sweep member its OWN
|
|
/// drainable channels.
|
|
///
|
|
/// param_space() = [sma_cross.fast.length: I64, sma_cross.slow.length: I64,
|
|
/// exposure.scale: F64].
|
|
fn harness_with_sinks() -> (
|
|
Composite,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
) {
|
|
let (tx_eq, rx_eq) = mpsc::channel();
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let bp = Composite::new(
|
|
"harness",
|
|
vec![
|
|
BlueprintNode::Composite(sma_cross()),
|
|
Exposure::builder().into(),
|
|
SimBroker::builder(1e-4).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), // sink 0: equity
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(), // sink 1: exposure
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross -> Exposure
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Exposure -> broker.exposure
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // broker -> equity sink
|
|
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // Exposure -> exposure sink
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
(bp, rx_eq, rx_ex)
|
|
}
|
|
|
|
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
|
// A trending-then-reverting series so different (fast, slow, scale) actually
|
|
// produce different pip outcomes.
|
|
let prices = [
|
|
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.11, 1.10, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07, 1.12,
|
|
1.15, 1.13, 1.09, 1.05, 1.02,
|
|
];
|
|
prices
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::f64(p)))
|
|
.collect()
|
|
}
|
|
|
|
/// The author's per-point closure: build fresh, bootstrap by the point's cells,
|
|
/// run, drain, summarize into a RunReport. `Fn(&[Cell]) -> RunReport`.
|
|
fn run_one(point: &[Cell], prices: &[(Timestamp, Scalar)]) -> RunReport {
|
|
let (bp, rx_eq, rx_ex) = harness_with_sinks();
|
|
let mut h = bp
|
|
.bootstrap_with_cells(point)
|
|
.expect("random point is kind-checked against the param-space");
|
|
h.run(vec![Box::new(VecSource::new(prices.to_vec()))]);
|
|
drop(h);
|
|
let eq_rows: Vec<_> = rx_eq.try_iter().collect();
|
|
let ex_rows: Vec<_> = rx_ex.try_iter().collect();
|
|
let equity = f64_field(&eq_rows, 0);
|
|
let exposure = f64_field(&ex_rows, 0);
|
|
let metrics = summarize(&equity, &exposure);
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "fieldtest".into(),
|
|
params: vec![],
|
|
window: (Timestamp(0), Timestamp(60_000_000_000 * 20)),
|
|
seed: 0,
|
|
broker: "sim-optimal".into(),
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let prices = synthetic_prices();
|
|
|
|
// The param-space the random ranges are declared against (positional-parallel).
|
|
let space = harness_with_sinks().0.param_space();
|
|
println!("param-space ({} slots):", space.len());
|
|
for (i, ps) in space.iter().enumerate() {
|
|
println!(" slot {i}: {} : {:?}", ps.name, ps.kind);
|
|
}
|
|
|
|
// ONE declared continuous range per slot, in param_space() order.
|
|
let ranges = vec![
|
|
ParamRange::i64(2, 8), // sma_cross.fast.length in [2, 8] (inclusive)
|
|
ParamRange::i64(10, 30), // sma_cross.slow.length in [10, 30] (inclusive)
|
|
ParamRange::f64(0.5, 4.0), // exposure.scale in [0.5, 4.0) (half-open)
|
|
];
|
|
|
|
// Draw 200 seeded points; validated against the param-space IN new().
|
|
let count = 200;
|
|
let seed = 0xC0FFEE;
|
|
let rand_space = RandomSpace::new(&space, ranges, count, seed)
|
|
.expect("ranges are well-formed for the space");
|
|
println!("\nRandomSpace: {} points, seed {:#x}", rand_space.len(), seed);
|
|
|
|
// SAME execution layer as the grid sweep — sweep is generic over impl Space.
|
|
let family: SweepFamily = sweep(&rand_space, |point: &[Cell]| run_one(point, &prices));
|
|
assert_eq!(family.points.len(), count, "one point per draw");
|
|
|
|
// Pick the best point by total_pips.
|
|
let mut best: Option<(usize, f64)> = None;
|
|
for (i, pt) in family.points.iter().enumerate() {
|
|
let p = pt.report.metrics.total_pips;
|
|
if best.is_none() || p > best.unwrap().1 {
|
|
best = Some((i, p));
|
|
}
|
|
assert!(p.is_finite(), "every drawn point produces finite metrics");
|
|
}
|
|
let (bi, bp) = best.unwrap();
|
|
|
|
// Readable named view of the winning coordinate (named_params reuses zip).
|
|
let named = family.named_params(bi);
|
|
println!("\nbest point #{bi} = {:.4} pips at:", bp);
|
|
for (name, val) in &named {
|
|
println!(" {name} = {}", scalar_str(val));
|
|
}
|
|
|
|
// Confirm the family genuinely spread: more than one distinct metric.
|
|
let distinct: std::collections::BTreeSet<String> = family
|
|
.points
|
|
.iter()
|
|
.map(|p| format!("{:.6}", p.report.metrics.total_pips))
|
|
.collect();
|
|
println!(
|
|
"\ndistinct total_pips across {} points: {}",
|
|
family.points.len(),
|
|
distinct.len()
|
|
);
|
|
assert!(distinct.len() > 1, "a random sweep over real ranges must not collapse");
|
|
|
|
// Show every drawn point landed inside its declared range (in-range draws).
|
|
let mut fast_min = i64::MAX;
|
|
let mut fast_max = i64::MIN;
|
|
let mut scale_min = f64::INFINITY;
|
|
let mut scale_max = f64::NEG_INFINITY;
|
|
for pt in &family.points {
|
|
fast_min = fast_min.min(pt.params[0].i64());
|
|
fast_max = fast_max.max(pt.params[0].i64());
|
|
scale_min = scale_min.min(pt.params[2].f64());
|
|
scale_max = scale_max.max(pt.params[2].f64());
|
|
}
|
|
println!(
|
|
"\nsampled ranges: sma_cross.fast.length in [{fast_min}, {fast_max}] (declared [2, 8]); \
|
|
exposure.scale in [{scale_min:.4}, {scale_max:.4}) (declared [0.5, 4.0))"
|
|
);
|
|
assert!((2..=8).contains(&fast_min) && (2..=8).contains(&fast_max));
|
|
assert!(scale_min >= 0.5 && scale_max < 4.0);
|
|
|
|
println!("\nOK: random continuous-range tuning produced a comparable, spread family.");
|
|
}
|
|
|
|
fn scalar_str(s: &Scalar) -> String {
|
|
match s {
|
|
Scalar::I64(v) => v.to_string(),
|
|
Scalar::F64(v) => format!("{v:.4}"),
|
|
Scalar::Bool(v) => v.to_string(),
|
|
Scalar::Timestamp(t) => t.0.to_string(),
|
|
}
|
|
}
|