13941a24d5
Milestone-close fieldtest for "Runway — real-data ergonomics & honesty hardening". Four curated end-to-end scenarios derived top-down from the milestone promise, run as a downstream consumer against real GER40/EURUSD M1 bars (public interface only; no crates/*/src read): 1. per-instrument pip honesty (#22) — GER40 index pip 1.0 AND EURUSD forex pip 0.0001 both honest end-to-end; AAPL.US/FOOBAR un-vetted refused exit 2, no stdout leak 2. real GER40 M1 open via open_ohlc from aura-ingest ALONE (#80/#81/#92) — no data-server dep, 53173 real bars, C4 O/H/L/C order, ts-window inverse 3. multi-tap ts-join (#93) — two taps at different cadences, 27744 rows, 0 by-ts mismatches; warm-up None rows where a positional zip would corrupt 4. run-registry coherence (#73/#82) — list/rank retired exit 2, families/ family live, two-store Registry read-back + rustdoc Findings: 4 working, 1 friction, 1 spec_gap. - friction: Cell exposes only from_i64/from_f64/... while the side-by-side Scalar is a tagged enum with public I64(..)/F64(..) variants. The natural param-authoring entry point is Scalar (Cell is the hot-path buffer carrier), so the wrong reach is a self-correcting compile snag — ergonomic only. Filed as a follow-up idea. - spec_gap: unknown family id exits 0 empty — ratified into the C18 note in the preceding commit (treat-as-empty contract). Gate verdict: GREEN. All seven axes mapped to a covering scenario and exercised on real bars; both finding classifications and the one material coverage gap were adversarially re-verified. That gap — #22's FX-vetted pip face, untested by the original scenario set — was probed (EURUSD -> sim-optimal(pip_size=0.0001), FX-scaled, not index-mis-scaled) and folded into scenario 1, so the pip promise is demonstrated on both positive faces. Every scenario re-run independently on HEAD before commit.
217 lines
8.5 KiB
Rust
217 lines
8.5 KiB
Rust
// Runway milestone fieldtest — scenario 3: multi-tap trace honesty (#93).
|
|
//
|
|
// The task a downstream researcher does: "I record two taps off the same real
|
|
// GER40 run — the exposure stream and the cumulative-equity stream — and I want a
|
|
// single per-bar trace that lines each tap up against the other. The taps fire at
|
|
// DIFFERENT cadences (exposure warms up later than the raw bar clock; equity only
|
|
// fires once the broker has a prev price), so a positional zip-by-index would
|
|
// misalign. I want them joined on the recorded TIMESTAMP."
|
|
//
|
|
// What this exercises, top-down from the milestone promise: #93's `join_on_ts` /
|
|
// `JoinedRow` — exactly one joined row per spine entry, each side Some(row) where
|
|
// it fired at that ts and None where it did not. The honest fusion the milestone
|
|
// promises: traces join on timestamp, never by index.
|
|
//
|
|
// It also re-uses the #81/#92 real-data seam (open_ohlc from aura-ingest alone) so
|
|
// the join runs over REAL bars, not a synthetic toy.
|
|
//
|
|
// PUBLIC INTERFACE ONLY: API from `cargo doc` rustdoc + the design ledger
|
|
// (C8/C18 post-run reduction, C3 no in-graph join, C1 one row per ts). No
|
|
// crates/*/src was read.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Cell, Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{join_on_ts, BlueprintNode, Composite, Edge, OutField, Role, Source, Target};
|
|
use aura_ingest::{default_data_server, open_ohlc};
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
|
|
const SYMBOL: &str = "GER40";
|
|
|
|
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 single-price-role harness with TWO taps off different nodes:
|
|
// - exposure tap (off the Exposure node) — fires once SMA-cross warms up
|
|
// - equity tap (off the SimBroker) — fires once the broker has a prev price
|
|
// Their cadences differ, so their recorded streams have different lengths and
|
|
// firing instants — the exact #93 shape.
|
|
#[allow(clippy::type_complexity)]
|
|
fn harness_two_taps() -> (
|
|
Composite,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>, // exposure tap
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>, // equity tap
|
|
) {
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let (tx_eq, rx_eq) = mpsc::channel();
|
|
// 0 sma_cross, 1 Exposure, 2 SimBroker, 3 exposure-rec, 4 equity-rec
|
|
let bp = Composite::new(
|
|
"two_tap_harness",
|
|
vec![
|
|
BlueprintNode::Composite(sma_cross()),
|
|
Exposure::builder().into(),
|
|
SimBroker::builder(1.0).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // sma_cross -> exposure
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker.slot0
|
|
Edge { from: 1, to: 3, slot: 0, from_field: 0 }, // exposure -> exposure tap
|
|
Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // equity -> equity tap
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 }, // strategy
|
|
Target { node: 2, slot: 1 }, // broker price slot
|
|
],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
(bp, rx_ex, rx_eq)
|
|
}
|
|
|
|
fn ms(t: Timestamp) -> i64 {
|
|
t.0 / 1_000_000
|
|
}
|
|
|
|
fn main() {
|
|
let server = default_data_server();
|
|
|
|
// ~1 month of GER40 from 2015-01-01 (epoch-ns), close-driven price.
|
|
let day_ns: i64 = 86_400_000 * 1_000_000;
|
|
let from = Timestamp(1_420_070_400_000 * 1_000_000);
|
|
let to = Timestamp(from.0 + 31 * day_ns);
|
|
|
|
let sources: Vec<Box<dyn Source>> = match open_ohlc(&server, SYMBOL, from, to) {
|
|
Some(s) => s,
|
|
None => {
|
|
eprintln!("no local {SYMBOL} archive overlapping the window; skipping");
|
|
std::process::exit(0);
|
|
}
|
|
};
|
|
// The strategy here uses ONE price role (close). open_ohlc gives four; we take
|
|
// the close source (index 3 in the fixed O/H/L/C order #92 vouches for).
|
|
let close: Box<dyn Source> = sources.into_iter().nth(3).expect("close is source 3");
|
|
|
|
let (bp, rx_ex, rx_eq) = harness_two_taps();
|
|
let params: Vec<Cell> = vec![Cell::from_i64(3), Cell::from_i64(20), Cell::from_f64(1.0)];
|
|
let mut h = bp.bootstrap_with_cells(¶ms).expect("params kind-check");
|
|
h.run(vec![close]);
|
|
drop(h);
|
|
|
|
let exposure_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
|
let equity_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
|
|
|
println!(
|
|
"two taps, DIFFERENT cadences: exposure fired {} times, equity fired {} times",
|
|
exposure_rows.len(),
|
|
equity_rows.len(),
|
|
);
|
|
// The whole point of #93: a positional zip-by-index is WRONG when the lengths
|
|
// differ. Show the mismatch the index approach would hit:
|
|
if exposure_rows.len() != equity_rows.len() {
|
|
println!(
|
|
" -> lengths differ by {} rows; zip-by-index would silently misalign every row \
|
|
past the first divergence",
|
|
(exposure_rows.len() as i64 - equity_rows.len() as i64).abs(),
|
|
);
|
|
}
|
|
|
|
// The honest fusion: join on the recorded timestamp. Use the equity stream as
|
|
// the spine (the trace we anchor on), exposure as a side stream.
|
|
let exp_slice: &[(Timestamp, Vec<Scalar>)] = &exposure_rows;
|
|
let joined = join_on_ts(&equity_rows, &[exp_slice]);
|
|
|
|
println!(
|
|
"\njoin_on_ts: spine(equity)={} rows -> {} JoinedRows (exactly one per spine entry)",
|
|
equity_rows.len(),
|
|
joined.len(),
|
|
);
|
|
assert_eq!(
|
|
joined.len(),
|
|
equity_rows.len(),
|
|
"exactly one JoinedRow per spine entry (C1/C8)"
|
|
);
|
|
|
|
// Every joined row's ts is the spine ts, in spine order.
|
|
for (k, jr) in joined.iter().enumerate() {
|
|
assert_eq!(jr.ts, equity_rows[k].0, "row {k}: joined ts == spine ts");
|
|
}
|
|
|
|
// Where the exposure side fired at the spine ts -> Some; where it did not -> None.
|
|
// Cross-check Some-rows against the exposure stream by ts (not by index!).
|
|
use std::collections::HashMap;
|
|
let exp_by_ts: HashMap<i64, f64> =
|
|
exposure_rows.iter().map(|(t, r)| (t.0, r[0].as_f64())).collect();
|
|
|
|
let mut present = 0usize;
|
|
let mut absent = 0usize;
|
|
let mut mismatches = 0usize;
|
|
for jr in &joined {
|
|
match &jr.sides[0] {
|
|
Some(side_row) => {
|
|
present += 1;
|
|
let joined_val = side_row[0].as_f64();
|
|
match exp_by_ts.get(&jr.ts.0) {
|
|
Some(truth) if (*truth - joined_val).abs() < 1e-12 => {}
|
|
_ => mismatches += 1,
|
|
}
|
|
}
|
|
None => {
|
|
absent += 1;
|
|
// a None side MUST mean exposure genuinely did not fire at this ts.
|
|
if exp_by_ts.contains_key(&jr.ts.0) {
|
|
mismatches += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
println!(
|
|
"exposure side: present(Some)={present}, absent(None)={absent}, by-ts cross-check mismatches={mismatches}",
|
|
);
|
|
assert_eq!(mismatches, 0, "every Some/None matches the exposure stream BY TIMESTAMP");
|
|
|
|
// Show the first few divergent rows: spine bars where exposure had NOT yet
|
|
// warmed up (None) vs where both fired (Some) — proving the alignment is by ts.
|
|
println!("\nfirst 6 joined rows (equity spine | exposure side):");
|
|
for jr in joined.iter().take(6) {
|
|
let eq = jr.spine[0].as_f64();
|
|
let ex = jr.sides[0].as_ref().map(|r| r[0].as_f64());
|
|
println!(
|
|
" ts={} equity={:.4} exposure={}",
|
|
ms(jr.ts),
|
|
eq,
|
|
match ex {
|
|
Some(v) => format!("Some({v:.4})"),
|
|
None => "None (had not fired at this ts)".into(),
|
|
}
|
|
);
|
|
}
|
|
|
|
println!(
|
|
"\nOK: two real-data taps at different cadences fused HONESTLY on timestamp \
|
|
({present} aligned, {absent} side-absent), never by index."
|
|
);
|
|
}
|