400105e2e2
Tasks 7-8 of the bench-harness plan plus the orchestrator's sizing pass — this completes the phase-1 harness (closes #251). Driver: run [--surface|--quick|--reps|--out] / pin over five surfaces, report-only comparison (drift NOTICE at >=10%, never a failure), fingerprint mismatch exits 1 with a baseline-vs-measured block, infra errors exit 2, debug builds refuse to measure. The Bench project-facts line and the crate README (discipline: quiet box, warmup+median, deliberate re-pins) land with it. Workload sizing, measured then corrected: the release engine outran the plan's guesses by ~100x, leaving sub-second child walls where spawn jitter reaches the 10% threshold and flaps the report. Full-size workloads now land in whole seconds — engine 10M bars (~0.7s), ingest 24 months x 20 fresh drains (~0.4s), campaigns on a 24-month archive with a ~22-month window and a 5x5 member grid (sweep ~1.4s, heavy ~5.5s), fixed-cost as in-rep medians over spawn batches (20x help, 10x run). Quick mode keeps the small E2E-fixture shape. The campaign fingerprint folds all three realization layers — sweep winner, gate survivor ordinals, bootstrap trade counts (pooled or per-survivor sum) — so a wrong-result regression in any stage fails the bench, not just a wrong sweep winner. Baselines pinned on this host (commit-stamped), verified by a follow-up run: five fingerprints OK at <=2% drift under load. Verification: cargo test --workspace 1379 green (bench tests all quick-mode; the release-binary E2Es are #[ignore]d behind -- --ignored per the suite-wallclock discipline), clippy -D warnings clean, doc build clean.
252 lines
9.5 KiB
Rust
252 lines
9.5 KiB
Rust
//! Deterministic synthetic inputs: the seeded price walk (engine surface), the
|
|
//! 48-byte M1 zip archive writer (ingest + campaign surfaces), and the scratch
|
|
//! temp-dir guard. Everything here is a pure function of its arguments — same
|
|
//! bytes on every host, every run (C1 is what makes the engine benchmarkable).
|
|
|
|
use aura_core::{Scalar, Timestamp};
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// A seeded LCG random walk as a `VecSource` stream: 1-minute spacing in
|
|
/// epoch-ns, prices around 100. Same constants, same bytes, everywhere.
|
|
pub fn synthetic_walk(n: usize) -> Vec<(Timestamp, Scalar)> {
|
|
let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
|
|
let mut price = 100.0_f64;
|
|
(0..n)
|
|
.map(|i| {
|
|
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
|
let step = ((x >> 11) as f64 / (1u64 << 53) as f64) - 0.5;
|
|
price += step;
|
|
(Timestamp(i as i64 * 60_000_000_000), Scalar::f64(price))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// FNV-1a over raw bytes — the fingerprint hash for child-process stdout.
|
|
pub fn fnv1a(bytes: &[u8]) -> u64 {
|
|
let mut h = 0xcbf2_9ce4_8422_2325_u64;
|
|
for b in bytes {
|
|
h ^= u64::from(*b);
|
|
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
|
}
|
|
h
|
|
}
|
|
|
|
/// A self-deleting scratch directory under the system temp dir.
|
|
pub struct ScratchDir(pub PathBuf);
|
|
|
|
impl ScratchDir {
|
|
pub fn new(tag: &str) -> std::io::Result<Self> {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"aura-bench-{}-{}-{}",
|
|
std::process::id(),
|
|
tag,
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0)
|
|
));
|
|
std::fs::create_dir_all(&base)?;
|
|
Ok(Self(base))
|
|
}
|
|
}
|
|
|
|
impl Drop for ScratchDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// Days since the Unix epoch for a proleptic-Gregorian civil date
|
|
/// (Howard Hinnant's `days_from_civil` — dependency-free calendar math).
|
|
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
|
let y = i64::from(if m <= 2 { y - 1 } else { y });
|
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
|
let yoe = y - era * 400; // [0, 399]
|
|
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
|
|
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
|
|
era * 146_097 + doe - 719_468
|
|
}
|
|
|
|
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
|
|
pub fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
|
|
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
|
|
}
|
|
|
|
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
|
|
fn weekday(y: i32, m: u32, d: u32) -> i64 {
|
|
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
|
|
}
|
|
|
|
fn days_in_month(y: i32, m: u32) -> u32 {
|
|
match m {
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
4 | 6 | 9 | 11 => 30,
|
|
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
|
2 => 28,
|
|
_ => unreachable!("month out of range: {m}"),
|
|
}
|
|
}
|
|
|
|
/// A deterministic price at continuous minute offset `t` (minutes since
|
|
/// 2024-01-01T00:00 UTC): a gentle upward trend plus a 180-minute sine.
|
|
fn price_at(t_minutes: f64) -> f64 {
|
|
let hours = t_minutes / 60.0;
|
|
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
|
|
}
|
|
|
|
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
|
|
/// (Delphi `TDateTime`, days since 1899-12-30), `open/high/low/close`,
|
|
/// `spread` (f32), `volume` (i32).
|
|
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
|
|
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
|
const MS_PER_DAY: f64 = 86_400_000.0;
|
|
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
|
|
let mut rec = [0u8; 48];
|
|
rec[0..8].copy_from_slice(&dt.to_le_bytes());
|
|
rec[8..16].copy_from_slice(&open.to_le_bytes());
|
|
rec[16..24].copy_from_slice(&high.to_le_bytes());
|
|
rec[24..32].copy_from_slice(&low.to_le_bytes());
|
|
rec[32..40].copy_from_slice(&close.to_le_bytes());
|
|
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
|
|
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
|
|
rec
|
|
}
|
|
|
|
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
|
|
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
|
|
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
|
|
let mut records = Vec::new();
|
|
for day in 1..=days_in_month(year, month) {
|
|
if !(1..=5).contains(&weekday(year, month, day)) {
|
|
continue; // weekend
|
|
}
|
|
for hh in 8..16 {
|
|
for mm in 0..60 {
|
|
let ms = unix_ms(year, month, day, hh, mm);
|
|
let t = (ms - epoch_ms) as f64 / 60_000.0;
|
|
let (open, close) = (price_at(t), price_at(t + 1.0));
|
|
let high = open.max(close) + 0.2;
|
|
let low = open.min(close) - 0.2;
|
|
records.push(pack_record(ms, open, high, low, close));
|
|
}
|
|
}
|
|
}
|
|
records
|
|
}
|
|
|
|
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry).
|
|
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
|
|
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
|
|
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
|
|
let mut zip = zip::ZipWriter::new(file);
|
|
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
|
|
.expect("start synthetic m1 zip entry");
|
|
for rec in month_records(year, month) {
|
|
zip.write_all(&rec).expect("write synthetic m1 record");
|
|
}
|
|
zip.finish().expect("finish synthetic m1 zip");
|
|
}
|
|
|
|
/// Writes every month in `[from, to]` (inclusive `(year, month)` pairs) for
|
|
/// `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar (GER40-shaped).
|
|
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
|
|
let (mut y, mut m) = from;
|
|
loop {
|
|
write_month_zip(data_dir, symbol, y, m);
|
|
if (y, m) == to {
|
|
break;
|
|
}
|
|
m += 1;
|
|
if m > 12 {
|
|
m = 1;
|
|
y += 1;
|
|
}
|
|
}
|
|
std::fs::write(
|
|
data_dir.join(format!("{symbol}.meta.json")),
|
|
format!(
|
|
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
|
|
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
|
|
),
|
|
)
|
|
.expect("write synthetic geometry sidecar");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn synthetic_walk_is_deterministic_and_sized() {
|
|
let a = synthetic_walk(1000);
|
|
let b = synthetic_walk(1000);
|
|
assert_eq!(a.len(), 1000);
|
|
assert_eq!(a, b, "same seed, same bytes");
|
|
assert_eq!(a[0].0, Timestamp(0));
|
|
assert_eq!(a[1].0, Timestamp(60_000_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn write_symbol_archive_names_months_and_sidecar() {
|
|
let dir = ScratchDir::new("synthtest").expect("scratch dir");
|
|
write_symbol_archive(&dir.0, "TSYM", (2024, 11), (2025, 2));
|
|
let mut names: Vec<String> = std::fs::read_dir(&dir.0)
|
|
.expect("read scratch")
|
|
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
names.sort();
|
|
assert_eq!(
|
|
names,
|
|
vec![
|
|
"TSYM.meta.json".to_string(),
|
|
"TSYM_2024_11.m1".to_string(),
|
|
"TSYM_2024_12.m1".to_string(),
|
|
"TSYM_2025_01.m1".to_string(),
|
|
"TSYM_2025_02.m1".to_string(),
|
|
],
|
|
"month rollover + sidecar"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fnv1a_is_stable() {
|
|
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
|
|
assert_eq!(fnv1a(b"a"), fnv1a(b"a"));
|
|
assert_ne!(fnv1a(b"a"), fnv1a(b"b"));
|
|
}
|
|
|
|
/// #251: the synthetic archive is the ingest surface's benchmark input
|
|
/// (fed through the CLI wiring), so its bytes must be more than
|
|
/// internally self-consistent — they must actually parse through the
|
|
/// real production M1 reader
|
|
/// (`aura_ingest::DataServer`/`load_m1_window`), the same path a real
|
|
/// GER40 archive goes through. This is not a round trip against our own
|
|
/// `pack_record`: it is the production parser recovering `pack_record`'s
|
|
/// 48-byte layout (Delphi `TDateTime`, 4 little-endian f64 prices) and
|
|
/// the `price_at` OHLC shape (`low <= open,close <= high` on every bar) —
|
|
/// pins the record layout against future drift in either writer or
|
|
/// reader.
|
|
#[test]
|
|
fn synthetic_archive_parses_through_the_real_ingest_reader() {
|
|
let dir = ScratchDir::new("ingest-roundtrip").expect("scratch dir");
|
|
write_symbol_archive(&dir.0, "BENCHSYM", (2024, 1), (2024, 1));
|
|
let server = std::sync::Arc::new(aura_ingest::DataServer::new(&dir.0));
|
|
let cols = aura_ingest::load_m1_window(&server, "BENCHSYM", None, None)
|
|
.expect("the synthetic archive is a recognized data source");
|
|
|
|
assert!(!cols.close.is_empty(), "January 2024 has weekday 08:00-16:00 bars");
|
|
for i in 0..cols.close.len() {
|
|
assert!(
|
|
cols.high[i] >= cols.open[i] && cols.high[i] >= cols.close[i],
|
|
"bar {i}: high must dominate open/close"
|
|
);
|
|
assert!(
|
|
cols.low[i] <= cols.open[i] && cols.low[i] <= cols.close[i],
|
|
"bar {i}: low must be dominated by open/close"
|
|
);
|
|
}
|
|
}
|
|
}
|