diff --git a/crates/ml-backtesting/Cargo.toml b/crates/ml-backtesting/Cargo.toml index ca908f077..701ac1da7 100644 --- a/crates/ml-backtesting/Cargo.toml +++ b/crates/ml-backtesting/Cargo.toml @@ -11,14 +11,19 @@ documentation.workspace = true publish.workspace = true keywords.workspace = true categories.workspace = true -description = "ML backtesting framework for barrier optimization and report generation" +description = "ML backtesting framework + LOB simulator + per-horizon execution policy" [dependencies] ml-core.workspace = true +ml-alpha = { path = "../ml-alpha" } # loader + trunk reuse — see real-LOB spec §1 serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true chrono.workspace = true anyhow.workspace = true csv.workspace = true +bytemuck = { workspace = true, features = ["derive"] } # SlotTag / OrderEvent / TradeRecord Pod casts +tracing.workspace = true +thiserror.workspace = true [dev-dependencies] tempfile = "3.12" diff --git a/crates/ml-backtesting/src/lib.rs b/crates/ml-backtesting/src/lib.rs index 53f652a68..8e6d3db83 100644 --- a/crates/ml-backtesting/src/lib.rs +++ b/crates/ml-backtesting/src/lib.rs @@ -17,6 +17,7 @@ pub use ml_core::MLError; pub mod action_loader; pub mod barrier_backtest; +pub mod order; pub mod report; pub use action_loader::{load_actions_from_csv, DQNActionRecord}; diff --git a/crates/ml-backtesting/src/order.rs b/crates/ml-backtesting/src/order.rs new file mode 100644 index 000000000..73bf4bf31 --- /dev/null +++ b/crates/ml-backtesting/src/order.rs @@ -0,0 +1,252 @@ +//! Host-side order representation for the LOB backtest harness. +//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §3. +//! +//! - `OrderIntent` is the ergonomic enum used by policy YAML config + the +//! trade-log audit reconstruction. +//! - `OrderEvent` is the flat 24-byte device-side record written by the +//! decision-policy kernel into the per-block mapped-pinned audit ring. +//! - `TradeRecord` is the flat 40-byte device-side record written by the +//! pnl_track kernel into the per-block mapped-pinned trade-log buffer +//! when a position returns to flat. +//! - `SlotTag` packs `{kind, index, gen_counter}` into a u32 so the +//! device-side kernel can emit it as a single field; the gen_counter +//! distinguishes a new slot occupant from stale handles after reuse. + +use serde::{Deserialize, Serialize}; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Side { + Buy = 0, + Sell = 1, +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LimitKind { + Limit = 0, + Ioc = 1, + Fok = 2, +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SlotKind { + Limit = 0, + Stop = 1, +} + +/// Stable handle for cancel/modify. Packed into u32 (host) so the +/// device-side decision kernel can emit it as a single field. +/// +/// Layout (LSB → MSB): +/// bits 0.. 7 : slot_kind (SlotKind as u8) +/// bits 8.. 15 : slot_index (u8) +/// bits 16.. 31 : gen_counter (u16) — incremented on every slot reuse +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlotTag(pub u32); + +impl SlotTag { + pub fn pack(kind: SlotKind, idx: u8, generation: u16) -> Self { + SlotTag((kind as u32) | ((idx as u32) << 8) | ((generation as u32) << 16)) + } + pub fn kind(self) -> SlotKind { + match (self.0 & 0xFF) as u8 { + 0 => SlotKind::Limit, + 1 => SlotKind::Stop, + other => panic!("SlotTag.kind() out of range: {other}"), + } + } + pub fn index(self) -> u8 { + ((self.0 >> 8) & 0xFF) as u8 + } + pub fn generation(self) -> u16 { + ((self.0 >> 16) & 0xFFFF) as u16 + } +} + +/// Limit-order parameters; reused as both legs of an OCO submission. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct LimitParams { + pub side: Side, + pub size: u16, + pub px: i32, // price in ticks + pub kind: LimitKind, +} + +/// Host-side ergonomic enum. Used for configuration sources (policy YAML) +/// AND for the trade-log audit representation reconstructed from the +/// on-device `OrderEvent` records. +/// +/// OCO is two limit legs (not recursive); on-device they are stored as +/// two slots linked by `oco_pair`. See spec §3. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum OrderIntent { + SubmitMarket { + side: Side, + size: u16, + }, + SubmitLimit { + side: Side, + size: u16, + px: i32, + kind: LimitKind, + }, + SubmitStop { + side: Side, + size: u16, + trigger_px: i32, + limit_px: Option, + }, + SubmitOcoLimits { + leg_a: LimitParams, + leg_b: LimitParams, + }, + Cancel { + slot_tag: SlotTag, + }, + Modify { + slot_tag: SlotTag, + new_size: Option, + new_px: Option, + }, +} + +/// Flat 24-byte record emitted by the device-side decision kernel into +/// the per-block mapped-pinned audit ring. Host reads + reconstructs +/// `OrderIntent` for the trade log. +#[repr(C)] +#[derive( + Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize, +)] +pub struct OrderEvent { + pub ts_ns: u64, // 8 + pub slot_tag: u32, // 4 — SlotTag.0 + pub kind: u8, // 1 — OrderEventKind discriminant + pub side: u8, // 1 + pub size: u16, // 2 + pub px: i32, // 4 (ticks; 0 for market) + pub trigger_px: i32, // 4 (ticks; 0 if not stop) + // total: 24 bytes +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderEventKind { + SubmitMarket = 0, + SubmitLimit = 1, + SubmitIoc = 2, + SubmitFok = 3, + SubmitStopMarket = 4, + SubmitStopLimit = 5, + Cancel = 6, + Modify = 7, +} + +/// Closed-trade record emitted when a position returns to flat (by +/// counter-fill or stop). Written by the pnl_track kernel into the +/// per-block trade-log mapped-pinned buffer. Pinned at 40 bytes. +/// +/// USD amounts are fixed-point ×100 (e.g. $1.23 → 123) to keep the +/// device-side write purely integer-aligned. +#[repr(C)] +#[derive( + Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize, +)] +pub struct TradeRecord { + pub entry_ts_ns: u64, // 8 + pub exit_ts_ns: u64, // 8 + pub entry_px_ticks: i32, // 4 + pub exit_px_ticks: i32, // 4 + pub size_lots: i32, // 4 signed (+long, −short) + pub fees_usd_fp: i32, // 4 + pub realised_pnl_usd_fp: i32, // 4 + pub horizon_idx: u8, // 1 + pub strategy_id: u8, // 1 + pub _pad: [u8; 2], + // total: 40 bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_tag_roundtrips() { + let t = SlotTag::pack(SlotKind::Stop, 7, 1234); + assert_eq!(t.kind(), SlotKind::Stop); + assert_eq!(t.index(), 7); + assert_eq!(t.generation(), 1234); + } + + #[test] + fn slot_tag_max_gen_fits() { + let t = SlotTag::pack(SlotKind::Limit, 31, u16::MAX); + assert_eq!(t.generation(), u16::MAX); + assert_eq!(t.index(), 31); + assert_eq!(t.kind(), SlotKind::Limit); + } + + #[test] + fn slot_tag_packs_distinct_kinds() { + let a = SlotTag::pack(SlotKind::Limit, 0, 0); + let b = SlotTag::pack(SlotKind::Stop, 0, 0); + assert_ne!(a.0, b.0); + } + + #[test] + fn order_event_is_24_bytes_pod() { + assert_eq!(std::mem::size_of::(), 24); + // Pod requires the type be Copy + 'static + no-padding; bytemuck + // already enforces this at derive time. This test pins the size + // so a future field-add forces the kernel-side struct to change too. + } + + #[test] + fn trade_record_is_40_bytes_pod() { + assert_eq!(std::mem::size_of::(), 40); + } + + #[test] + fn intent_serde_roundtrip_for_oco() { + let oco = OrderIntent::SubmitOcoLimits { + leg_a: LimitParams { + side: Side::Buy, + size: 1, + px: 550_000, + kind: LimitKind::Limit, + }, + leg_b: LimitParams { + side: Side::Sell, + size: 1, + px: 551_000, + kind: LimitKind::Limit, + }, + }; + let j = serde_json::to_string(&oco).expect("ser"); + let back: OrderIntent = serde_json::from_str(&j).expect("de"); + match back { + OrderIntent::SubmitOcoLimits { leg_a, leg_b } => { + assert_eq!(leg_a.px, 550_000); + assert_eq!(leg_b.px, 551_000); + } + _ => panic!("wrong variant after roundtrip"), + } + } + + #[test] + fn intent_serde_roundtrip_for_cancel() { + let tag = SlotTag::pack(SlotKind::Stop, 5, 42); + let intent = OrderIntent::Cancel { slot_tag: tag }; + let j = serde_json::to_string(&intent).expect("ser"); + let back: OrderIntent = serde_json::from_str(&j).expect("de"); + match back { + OrderIntent::Cancel { slot_tag } => { + assert_eq!(slot_tag.kind(), SlotKind::Stop); + assert_eq!(slot_tag.index(), 5); + assert_eq!(slot_tag.generation(), 42); + } + _ => panic!("wrong variant"), + } + } +}