77ad977623
Per-cycle fieldtest of the aura-ingest ingestion boundary (issue #7), from the public interface only. 3 examples (transpose/normalize core; close_stream -> deterministic harness run with epoch-ns end-to-end; load_m1_window over real AAPL.US 2006-08 bars + None paths), all green. 0 bugs, 0 friction. Two spec_gaps named for follow-up (neither a code defect; both filed to the tracker): - load_m1_window returns Some(empty M1Columns) — not None — for an in-coverage but bar-empty window. The rustdoc's "None if no data in [from_ms,to_ms]" reads bar-level but the realized boundary is file-level (it inherits data-server's file-skip None). A consumer matching None == "no bars" is wrong for the in-coverage-empty case. -> tighten the load_m1_window rustdoc (one line). - AAPL.US (the carrier/integration-test symbol) has only ~21 M1 bars/month, so the SMA-cross never warms and the end-to-end run yields all-zero (degenerate but valid) metrics. The boundary is correct (C2 warm-up); the milestone's shipped demo data is too thin to show a non-degenerate trace. -> weigh against C22's "newcomer sees a populated trace" before the Walking-skeleton close (document expected density or name a denser demo symbol/window).
179 lines
8.3 KiB
Rust
179 lines
8.3 KiB
Rust
//! Fieldtest c0011 #2 — close_stream -> a real engine run (axis b).
|
|
//!
|
|
//! Question under test: a project author transposes recorded M1 bars to
|
|
//! `M1Columns`, takes `close_stream()`, and feeds it straight into a Harness
|
|
//! (SMA-cross -> Exposure -> SimBroker -> Recorder, the cycle-0007/0009 pattern
|
|
//! over the shipped aura_std::Recorder), runs it, drains the recorded equity
|
|
//! curve, and the recorded stream is what a downstream consumer expects — the
|
|
//! ingestion boundary connects to the engine without a hand-authored stream.
|
|
//!
|
|
//! This is the C3 -> C1 hand-off: the (Timestamp, Scalar) shape `close_stream`
|
|
//! returns is exactly `Harness::run`'s source-stream input shape.
|
|
//!
|
|
//! Public-surface facts used (rustdoc + design ledger):
|
|
//! - aura_ingest::{M1Columns via transpose_m1, close_stream}.
|
|
//! - aura_std::{Sma, Sub, Exposure, SimBroker, Recorder}.
|
|
//! - aura_engine::{Harness, Edge, SourceSpec, Target, RunManifest, RunReport,
|
|
//! f64_field, summarize}.
|
|
//! - SimBroker slots: 0 = exposure, 1 = price (ledger C10 Realization 0007).
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{Edge, Harness, RunManifest, RunReport, SourceSpec, Target, f64_field, summarize};
|
|
use aura_ingest::transpose_m1;
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
use data_server::records::M1Parsed;
|
|
|
|
/// Build a deterministic synthetic set of M1 bars (close rises then reverses),
|
|
/// the way a project author would after recording a real window. We only use
|
|
/// the close column downstream, but transpose_m1 carries the full OHLCV bundle.
|
|
fn synthetic_bars() -> Vec<M1Parsed> {
|
|
// close: 100,102,104,106,108,106,104 — a rise then a reversal, so the
|
|
// SMA-cross flips sign once (exercises exposure_sign_flips like the CLI demo).
|
|
let closes = [100.0, 102.0, 104.0, 106.0, 108.0, 106.0, 104.0];
|
|
closes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &c)| M1Parsed {
|
|
time_ms: (i as i64 + 1) * 60_000, // one M1 bar per minute, Unix-ms
|
|
open: c,
|
|
high: c + 0.5,
|
|
low: c - 0.5,
|
|
close: c,
|
|
spread: 0.1,
|
|
volume: 1_000,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn main() {
|
|
// --- Ingestion boundary: AoS bars -> SoA columns -> close source stream ---
|
|
let bars = synthetic_bars();
|
|
let cols = transpose_m1(&bars);
|
|
let price_stream: Vec<(Timestamp, Scalar)> = cols.close_stream();
|
|
println!("close source stream from ingestion = {price_stream:?}");
|
|
|
|
// The C3 precondition close_stream documents: ascending in timestamp.
|
|
for w in price_stream.windows(2) {
|
|
assert!(w[0].0 < w[1].0, "close_stream ascending in timestamp (C3 precondition)");
|
|
}
|
|
|
|
// --- Wire the signal-quality harness over the ingested close stream. ---
|
|
let (eq_tx, eq_rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
let (exp_tx, exp_rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
|
|
// nodes: 0=SMA(2) fast, 1=SMA(4) slow, 2=Sub, 3=Exposure(4), 4=SimBroker(1.0),
|
|
// 5=equity sink (taps broker), 6=exposure sink (taps Exposure).
|
|
let mut h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Exposure::new(4.0)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, eq_tx)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, exp_tx)),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 }, // -> SMA fast
|
|
Target { node: 1, slot: 0 }, // -> SMA slow
|
|
Target { node: 4, slot: 1 }, // -> SimBroker price (slot 1)
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // SMA2 -> Sub.in0
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // SMA4 -> Sub.in1
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // Sub -> Exposure
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
|
|
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // Exposure -> exposure sink
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // SimBroker -> equity sink
|
|
],
|
|
)
|
|
.expect("valid signal-quality DAG over the ingested close stream");
|
|
|
|
// Run #1 over the ingested stream.
|
|
h.run(vec![price_stream.clone()]);
|
|
let equity_rows: Vec<(Timestamp, Vec<Scalar>)> = eq_rx.try_iter().collect();
|
|
let exposure_rows: Vec<(Timestamp, Vec<Scalar>)> = exp_rx.try_iter().collect();
|
|
println!("equity rows = {equity_rows:?}");
|
|
println!("exposure rows = {exposure_rows:?}");
|
|
|
|
let equity = f64_field(&equity_rows, 0);
|
|
let exposure = f64_field(&exposure_rows, 0);
|
|
let metrics = summarize(&equity, &exposure);
|
|
println!("metrics over ingested bars = {metrics:?}");
|
|
|
|
// The recorded equity curve must be non-empty (the source stream warmed the
|
|
// chain) and the metrics finite — the downstream "did the ingested data
|
|
// actually drive a sim?" check.
|
|
assert!(!equity_rows.is_empty(), "ingested close stream produced a recorded equity curve");
|
|
assert!(metrics.total_pips.is_finite(), "total_pips finite");
|
|
assert!(metrics.max_drawdown.is_finite() && metrics.max_drawdown >= 0.0, "drawdown finite, >= 0");
|
|
// The reversal in the close series must register at least one exposure flip.
|
|
assert!(metrics.exposure_sign_flips >= 1, "the rise-then-reverse close flips exposure at least once");
|
|
|
|
// The recorded equity timestamps are exactly the ingested (normalized) bar
|
|
// timestamps — confirming epoch-ns flowed end to end, unchanged, through the
|
|
// run loop (the broker fires on every price-fresh cycle, C10 Realization).
|
|
let recorded_ts: Vec<Timestamp> = equity_rows.iter().map(|(t, _)| *t).collect();
|
|
let ingested_ts: Vec<Timestamp> = price_stream.iter().map(|(t, _)| *t).collect();
|
|
assert_eq!(recorded_ts, ingested_ts, "recorded equity ts == ingested normalized bar ts (epoch-ns end to end)");
|
|
|
|
// --- Determinism (C1): a second disjoint run over the same ingested stream
|
|
// yields a bit-identical report. ---
|
|
let (eq_tx2, eq_rx2) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
let (exp_tx2, exp_rx2) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
let mut h2 = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Exposure::new(4.0)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, eq_tx2)),
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, exp_tx2)),
|
|
],
|
|
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 second DAG");
|
|
h2.run(vec![price_stream]);
|
|
let equity2 = f64_field(&eq_rx2.try_iter().collect::<Vec<_>>(), 0);
|
|
let exposure2 = f64_field(&exp_rx2.try_iter().collect::<Vec<_>>(), 0);
|
|
let metrics2 = summarize(&equity2, &exposure2);
|
|
|
|
let report1 = RunReport {
|
|
manifest: RunManifest {
|
|
commit: "c0011-ingested-synthetic".to_string(),
|
|
params: vec![("sma_fast".to_string(), 2.0), ("sma_slow".to_string(), 4.0)],
|
|
window: (ingested_ts[0], *ingested_ts.last().unwrap()),
|
|
seed: 0,
|
|
broker: "sim-optimal(pip_size=1)".to_string(),
|
|
},
|
|
metrics,
|
|
};
|
|
let report2 = RunReport {
|
|
manifest: report1.manifest.clone(),
|
|
metrics: metrics2,
|
|
};
|
|
assert_eq!(report1.to_json(), report2.to_json(), "two runs over the same ingested stream are bit-identical (C1)");
|
|
println!("c0011_2 OK: ingested close_stream drove a deterministic end-to-end signal-quality run");
|
|
}
|