a982b96ecc
First fieldtest of the run-metrics + manifest report surface. A standalone
downstream-consumer crate (fieldtests/cycle-0009-run-metrics/) path-depends on
the engine crates and exercises the post-0009 surface from the public interface
only (rustdoc + ledger + glossary + project layout, never crates/*/src).
Primary axis empirically met: the north-star "a run emits metrics + manifest"
move is reachable from rustdoc alone — drain two recording sinks -> f64_field ->
summarize -> RunManifest -> to_json, metrics matching the hand model on the first
run, deterministic across reruns.
Findings: 0 bugs, 1 friction, 1 spec_gap, 4 working.
- working x4: north-star reachable from rustdoc; SimBroker firing/slot docs (a
resolved 0007 gap) now carry the example; summarize metric definitions exact
on six degenerate inputs (incl. negative-curve drawdown + flat-as-sign-0);
f64_field panics precise and well-located.
- spec_gap: to_json's JSON key names + {manifest,metrics} nesting are not on
the public surface — a consumer parsing the JSON (C18 registry, the deferred
aura run printer) cannot author against it from rustdoc alone.
- friction: to_json renders whole-valued f64 without a decimal point (3.0 ->
"3"), so one f64 field appears as integer or decimal token within one schema.
Both doc-level findings are the same doc pass and matter mainly for the deferred
aura run (#8) and the C18 registry that will parse this JSON. Spec feeds the next
plan as reference.
refs #6
139 lines
5.2 KiB
Rust
139 lines
5.2 KiB
Rust
//! Fieldtest c0009 #2 — comparison + determinism over the report surface.
|
|
//!
|
|
//! Axis (carrier): run two harnesses differing in a tuning param (Exposure
|
|
//! scale), confirm each run is deterministic (bit-identical metrics across two
|
|
//! runs) and the two metrics differ as expected. This is the smallest slice of
|
|
//! the World's reason to exist (C21): compare runs by their RunMetrics.
|
|
//!
|
|
//! Same SMA-cross harness as #1, but the Exposure node's `scale` is the swept
|
|
//! tuning param. A smaller scale => larger |exposure| (clamp(signal/scale,..)),
|
|
//! so the same price move earns proportionally more pips — until the exposure
|
|
//! saturates at +1. So total_pips(scale=2) > total_pips(scale=4) here.
|
|
|
|
use std::sync::mpsc::{self, Sender};
|
|
|
|
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
Edge, Harness, RunManifest, RunMetrics, RunReport, SourceSpec, Target, f64_field, summarize,
|
|
};
|
|
use aura_std::{Exposure, SimBroker, Sma, Sub};
|
|
|
|
struct RowRecorder {
|
|
tx: Sender<(Timestamp, Vec<Scalar>)>,
|
|
}
|
|
impl Node for RowRecorder {
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
|
|
output: vec![],
|
|
}
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
|
let w = ctx.f64_in(0);
|
|
if w.is_empty() {
|
|
return None;
|
|
}
|
|
let _ = self.tx.send((ctx.now(), vec![Scalar::F64(w[0])]));
|
|
None
|
|
}
|
|
}
|
|
|
|
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
|
|
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
|
|
}
|
|
|
|
const PRICES: &[(i64, f64)] = &[
|
|
(1, 100.0),
|
|
(2, 102.0),
|
|
(3, 104.0),
|
|
(4, 106.0),
|
|
(5, 108.0),
|
|
(6, 110.0),
|
|
(7, 112.0),
|
|
];
|
|
|
|
/// One full run: bootstrap a harness with the given exposure scale, run, drain
|
|
/// the two sinks, reduce to a RunReport. This is exactly the World's per-instance
|
|
/// step in a tuning sweep (C12/C19/C20), authored in plain Rust.
|
|
fn run_one(exposure_scale: f64) -> RunReport {
|
|
let (eq_tx, eq_rx) = mpsc::channel();
|
|
let (exp_tx, exp_rx) = mpsc::channel();
|
|
|
|
let mut h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Exposure::new(exposure_scale)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(RowRecorder { tx: eq_tx }),
|
|
Box::new(RowRecorder { tx: exp_tx }),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 4, slot: 1 },
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
|
|
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
|
|
],
|
|
)
|
|
.expect("valid harness");
|
|
|
|
h.run(vec![f64_stream(PRICES)]);
|
|
|
|
let equity = f64_field(&eq_rx.try_iter().collect::<Vec<_>>(), 0);
|
|
let exposure = f64_field(&exp_rx.try_iter().collect::<Vec<_>>(), 0);
|
|
let metrics = summarize(&equity, &exposure);
|
|
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "fieldtest-c0009-synthetic".to_string(),
|
|
params: vec![("exposure_scale".to_string(), exposure_scale)],
|
|
window: (Timestamp(1), Timestamp(7)),
|
|
seed: 0,
|
|
broker: "sim-optimal(pip_size=1)".to_string(),
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
// Determinism (C1): the same instance run twice yields bit-identical metrics.
|
|
let a1 = run_one(4.0);
|
|
let a2 = run_one(4.0);
|
|
assert_eq!(a1.metrics, a2.metrics, "scale=4 deterministic across runs");
|
|
assert_eq!(a1.to_json(), a2.to_json(), "scale=4 JSON bit-identical");
|
|
println!("determinism OK: scale=4 metrics = {:?}", a1.metrics);
|
|
|
|
// Comparison: a second instance with a different tuning param.
|
|
let b = run_one(2.0);
|
|
println!("scale=4 report json = {}", a1.to_json());
|
|
println!("scale=2 report json = {}", b.to_json());
|
|
|
|
// Expectation (public model): scale=2 doubles |exposure| (0.5 -> 1.0 saturated)
|
|
// versus scale=4 (0.5), so the same +2/cycle price move earns ~2x the pips.
|
|
let RunMetrics { total_pips: p4, .. } = a1.metrics;
|
|
let RunMetrics { total_pips: p2, .. } = b.metrics;
|
|
println!("total_pips scale=4 = {p4}, scale=2 = {p2}");
|
|
assert!(p2 > p4, "smaller scale => larger exposure => more pips ({p2} > {p4})");
|
|
|
|
// Both should be drawdown-free monotonic-up curves on a steadily-rising price,
|
|
// and stay long throughout (no sign flips) — a sanity check the comparison is
|
|
// apples-to-apples, differing only on the swept axis.
|
|
assert_eq!(a1.metrics.max_drawdown, 0.0);
|
|
assert_eq!(b.metrics.max_drawdown, 0.0);
|
|
assert_eq!(a1.metrics.exposure_sign_flips, 0);
|
|
assert_eq!(b.metrics.exposure_sign_flips, 0);
|
|
|
|
println!("c0009_2 OK: deterministic + comparable two-run metrics over the report surface");
|
|
}
|