feat(ml-backtesting): host-side order types + SlotTag
OrderIntent (ergonomic host-side enum for policy YAML config + audit
reconstruction), OrderEvent (24-byte repr(C) Pod for the device-side
audit ring), TradeRecord (40-byte repr(C) Pod for the device-side
trade-log buffer), SlotTag (u32-packed {SlotKind, index, generation}
for cancel/modify against slot reuse), plus Side / LimitKind /
SlotKind / LimitParams.
Size pinning tests (24/40 bytes) ensure any future field-add forces
the kernel-side struct to migrate in lockstep. Serde roundtrips cover
OCO + Cancel variants. Renamed `.gen()` accessor to `.generation()`
to dodge the Rust 2024 reserved-keyword clash.
OCO is two limit legs linked by oco_pair byte on-device (not recursive
in OrderIntent) per spec §3 — keeps device-side layout flat for the
matching kernel coming in C5.
Adds ml-alpha + bytemuck + serde_json + tracing + thiserror to deps.
cudarc + parquet + arrow added later when CUDA + sweep aggregator
land (C4 / C9). No CUDA dependency in this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
252
crates/ml-backtesting/src/order.rs
Normal file
252
crates/ml-backtesting/src/order.rs
Normal file
@@ -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<i32>,
|
||||
},
|
||||
SubmitOcoLimits {
|
||||
leg_a: LimitParams,
|
||||
leg_b: LimitParams,
|
||||
},
|
||||
Cancel {
|
||||
slot_tag: SlotTag,
|
||||
},
|
||||
Modify {
|
||||
slot_tag: SlotTag,
|
||||
new_size: Option<u16>,
|
||||
new_px: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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::<OrderEvent>(), 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::<TradeRecord>(), 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user