Closes the literal/const drift gap that F.5 introduced. Per
feedback_isv_for_adaptive_bounds + feedback_single_source_of_truth_no_duplicates:
adaptive bounds belong in ISV (or in a single canonical const that
ISV references), never duplicated as literals across modules.
Single canonical source: `crate::rl::common::FRD_HORIZON_TICKS` +
`FRD_BUCKET_RANGE_SIGMA` (already declared in F.5).
Producer-side fixes:
* Trainer ISV bootstrap (integrated.rs): the seed values for slots
500-503 now dereference the canonical consts instead of hardcoded
60.0/300.0/1800.0/3.0 literals. Future tuning of the consts
automatically propagates to both ISV seeds and loader-side
labels — no manual sync required, no drift possible.
* compute_frd_labels (loader.rs): takes `horizon_ticks` and
`range_sigma` as parameters instead of reading consts directly.
Caller (the file-load closure) sources them from the new
MultiHorizonLoaderConfig fields.
Consumer-side fixes — 8 MultiHorizonLoaderConfig literal sites now
provide the two new fields, all defaulting to the canonical consts:
* crates/ml-alpha/src/data/loader.rs (2 internal test-fixture sites)
* crates/ml-alpha/tests/multi_horizon_loader.rs (2 sites)
* crates/ml-alpha/examples/alpha_train.rs (2 sites)
* crates/ml-alpha/examples/alpha_rl_train.rs (2 sites)
* crates/ml-backtesting/src/harness.rs (1 site)
* crates/ml-backtesting/tests/{trainer_parity,ring3_replay}.rs (2 sites)
The "optimal by default" property is preserved: every caller that
doesn't explicitly override gets the spec-recommended 60/300/1800
ticks + ±3σ. Callers that need to retune set the config fields, and
the trainer's ISV slots provide a runtime knob for the same numerics.
Verification (RTX 3050 Ti):
* cargo check -p ml-alpha -p ml-backtesting --examples --tests → clean
* cargo test --lib (6/6 unit tests for FRD label gen + loss_balance) → pass
* frd_head 10/10 + integrated_trainer_smoke 1/1 + trade_mgmt 5/5 → pass
* audit-rust-consts → 0 flags
The two new MultiHorizonLoaderConfig fields are required (no Default
impl) — callers MUST opt in to the FRD label-generation contract by
naming the fields. This is the same discipline applied across other
config consumers; making them Option<...> would silently default to
"no FRD labels" and break F.4's expected supervised signal.
184 lines
7.1 KiB
Rust
184 lines
7.1 KiB
Rust
//! Ring 3 — production-day replay validation. See real-LOB spec §8 Ring 3.
|
|
//!
|
|
//! Two scenarios:
|
|
//!
|
|
//! buy_and_hold_full_day
|
|
//! Walk a populated MBP-10 fixture from first event to last, submit
|
|
//! a market buy of 1 lot at open, close with a market sell at the
|
|
//! last event. Assert simulated realized P&L matches
|
|
//! (close_mid - open_mid) within a slippage budget (a few ticks).
|
|
//! This proves the book-walking + position accounting + step-loop
|
|
//! orchestration are wire-level correct against real data, not
|
|
//! just hand-crafted JSON fixtures.
|
|
//!
|
|
//! walk_book_one_full_day
|
|
//! Same fixture; no trades. Just iterates every event through
|
|
//! apply_snapshot + step_resting_orders and asserts the LOB never
|
|
//! violates the bid/ask monotonicity + no-crossed-book invariants
|
|
//! at any point in the day. A stress test of the kernel against
|
|
//! real market microstructure (gaps, locked markets, etc).
|
|
//!
|
|
//! Both skip gracefully if FOXHUNT_TEST_DATA lacks populated predecoded
|
|
//! sidecars — the placeholder-empty 40-byte sidecars committed in the
|
|
//! test fixture tree won't drive these tests, but a real Databento
|
|
//! decode will. The Ring 3 gate is operational, not CI-mandatory.
|
|
|
|
use anyhow::Result;
|
|
use ml_alpha::data::loader::{
|
|
discover_mbp10_files_sorted, InstrumentFilter, MultiHorizonLoader, MultiHorizonLoaderConfig,
|
|
};
|
|
use ml_backtesting::sim::LobSimCuda;
|
|
use ml_core::device::MlDevice;
|
|
use std::path::PathBuf;
|
|
|
|
fn try_loader() -> Option<MultiHorizonLoader> {
|
|
let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
|
|
let mbp10 = PathBuf::from(&root).join("ES.FUT");
|
|
if !mbp10.exists() {
|
|
return None;
|
|
}
|
|
let files = discover_mbp10_files_sorted(&mbp10).ok()?;
|
|
// Take just the first file to keep the test bounded.
|
|
let files = files.into_iter().take(1).collect::<Vec<_>>();
|
|
let cfg = MultiHorizonLoaderConfig {
|
|
files,
|
|
predecoded_dir: mbp10.clone(),
|
|
multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::single_scale_32(),
|
|
horizons: ml_alpha::heads::HORIZONS,
|
|
n_max_sequences: 0,
|
|
seed: 0xCAFE_F00D,
|
|
inference_only: true,
|
|
outcome_label_cost: ml_alpha::data::loader::DEFAULT_OUTCOME_LABEL_COST_ES,
|
|
instrument_filter: InstrumentFilter::All,
|
|
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
|
|
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
|
|
};
|
|
match MultiHorizonLoader::new(&cfg) {
|
|
Ok(l) => Some(l),
|
|
Err(e) => {
|
|
eprintln!("skipping: fixture data not usable ({e})");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_device() -> Option<MlDevice> {
|
|
match MlDevice::cuda(0) {
|
|
Ok(d) => Some(d),
|
|
Err(e) => {
|
|
eprintln!("skipping: cuda device unavailable ({e})");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mid_of(raw: &ml_alpha::cfc::snap_features::Mbp10RawInput) -> f32 {
|
|
(raw.bid_px[0] + raw.ask_px[0]) * 0.5
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
|
|
fn buy_and_hold_full_day() -> Result<()> {
|
|
let Some(mut loader) = try_loader() else { return Ok(()); };
|
|
let Some(dev) = try_device() else { return Ok(()); };
|
|
let mut sim = LobSimCuda::new(1, &dev)?;
|
|
|
|
// Snapshot the OPENING price as soon as we see the first event.
|
|
let first = match loader.next_inference_input()? {
|
|
Some(r) => r,
|
|
None => {
|
|
eprintln!("skipping: empty stream");
|
|
return Ok(());
|
|
}
|
|
};
|
|
sim.apply_snapshot(&first.bid_px, &first.bid_sz, &first.ask_px, &first.ask_sz)?;
|
|
let open_mid = mid_of(&first);
|
|
// Aggressive buy 1 lot at the open's best ask.
|
|
sim.submit_market(0, 0 /*buy*/, 1, first.ts_ns)?;
|
|
|
|
let pos_after_open = sim.read_pos(0)?;
|
|
assert_eq!(
|
|
pos_after_open.position_lots, 1,
|
|
"open buy didn't fill (likely fixture data has zero ask depth)"
|
|
);
|
|
let entry_vwap = pos_after_open.vwap_entry;
|
|
|
|
// Walk every subsequent event through book_update + resting_orders.
|
|
let mut last_raw = first;
|
|
let mut event_count = 1u64;
|
|
while let Some(raw) = loader.next_inference_input()? {
|
|
sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;
|
|
sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?;
|
|
event_count += 1;
|
|
last_raw = raw;
|
|
}
|
|
|
|
// Close at the last event: aggressive sell 1 lot.
|
|
sim.submit_market(0, 1 /*sell*/, 1, last_raw.ts_ns)?;
|
|
let final_pos = sim.read_pos(0)?;
|
|
assert_eq!(
|
|
final_pos.position_lots, 0,
|
|
"close sell didn't fully unwind position"
|
|
);
|
|
|
|
let close_mid = mid_of(&last_raw);
|
|
let expected_pnl_price_units = close_mid - open_mid;
|
|
let realized = final_pos.realized_pnl;
|
|
let drift_ticks = (realized - expected_pnl_price_units).abs() / 0.25;
|
|
|
|
eprintln!(
|
|
"ring3 buy_and_hold: events={} entry_vwap={:.2} open_mid={:.2} close_mid={:.2} expected_pnl={:.4} realized={:.4} drift_ticks={:.2}",
|
|
event_count, entry_vwap, open_mid, close_mid, expected_pnl_price_units, realized, drift_ticks
|
|
);
|
|
|
|
// Slippage budget: ±2 ticks (= 0.5 price units) per spec §8 Ring 3.
|
|
// The simulator's market-order path walks the book at both ends, so
|
|
// the difference vs mid-to-mid pricing accumulates the open-side
|
|
// half-spread + the close-side half-spread, plus any in-day book
|
|
// movement we observed via realized_pnl bookkeeping.
|
|
assert!(
|
|
drift_ticks <= 2.0,
|
|
"realized P&L drift {drift_ticks:.2} ticks > 2-tick budget — sim books vs hand-computed mid diverge"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
|
|
fn walk_book_one_full_day() -> Result<()> {
|
|
let Some(mut loader) = try_loader() else { return Ok(()); };
|
|
let Some(dev) = try_device() else { return Ok(()); };
|
|
let mut sim = LobSimCuda::new(1, &dev)?;
|
|
|
|
let mut event_count = 0u64;
|
|
while let Some(raw) = loader.next_inference_input()? {
|
|
sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;
|
|
sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?;
|
|
event_count += 1;
|
|
|
|
// Spot-check invariants every 10_000 events to bound test time.
|
|
if event_count % 10_000 == 0 {
|
|
let books = sim.read_books()?;
|
|
for (b, book) in books.iter().enumerate() {
|
|
for k in 1..10 {
|
|
assert!(
|
|
book.bid_px[k] <= book.bid_px[k - 1] + 1e-4,
|
|
"event {event_count} backtest {b}: bid monotonicity violated at level {k}"
|
|
);
|
|
assert!(
|
|
book.ask_px[k] >= book.ask_px[k - 1] - 1e-4,
|
|
"event {event_count} backtest {b}: ask monotonicity violated at level {k}"
|
|
);
|
|
}
|
|
assert!(
|
|
book.ask_px[0] > book.bid_px[0],
|
|
"event {event_count} backtest {b}: crossed book"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
eprintln!("ring3 walk_book: walked {event_count} events without invariant violation");
|
|
assert!(event_count > 0, "fixture produced no events");
|
|
Ok(())
|
|
}
|