Files
foxhunt/crates/ml-backtesting/src/order.rs
jgrusewski 0b8dfa946f 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>
2026-05-18 08:11:50 +02:00

253 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"),
}
}
}