feat(ml-backtesting): policy tree + bytecode flatten
Strategy (recursive Leaf | Ensemble | Portfolio | RegimeSwitch) +
StrategyConfig (horizon_idx + sizing_policy + sl_tp_rules +
max_concurrent_lots). Strategy::default_for(max_lots) returns the v1
default: per-horizon adaptive Ensemble with WeightedByRealizedSharpe
aggregator. No static horizon mask — capital auto-shifts via per-horizon
ISV-Kelly recent_sharpe weights (see spec §5 + §6).
Strategy::flatten() walks the tree → linear bytecode Program
(repr(C) Pod Instruction { op, arg0, arg1, arg2 }, 8 bytes/instruction)
for upload to device-global memory at slot blockIdx.x. RegimeSwitch
emits BranchIfRegime chains with arg2 patched to the post-child jump
offset.
Tests cover: 5-horizon default tree shape, flatten output for default
+ single-leaf + 2-leaf Portfolio (verifying ApplyConflict op emission),
Instruction layout size pin (8 bytes), LatencyConfig default 100ms
(IBKR+Scaleway baseline), Empirical wrap-around + empty case,
Decomposed summation.
IsvKellyStateHost host-mirror of cuda IsvKellyState (24 bytes, Pod)
defined in policy::sizing for warm-start file I/O in later commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ pub use ml_core::MLError;
|
||||
pub mod action_loader;
|
||||
pub mod barrier_backtest;
|
||||
pub mod order;
|
||||
pub mod policy;
|
||||
pub mod report;
|
||||
|
||||
pub use action_loader::{load_actions_from_csv, DQNActionRecord};
|
||||
|
||||
29
crates/ml-backtesting/src/policy/composition.rs
Normal file
29
crates/ml-backtesting/src/policy/composition.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! Aggregator + conflict + regime enums for the multi-strategy
|
||||
//! composition tree. See spec §6.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EnsembleAggregator {
|
||||
Mean,
|
||||
MaxConfidence,
|
||||
WeightedByValAuc,
|
||||
/// ADAPTIVE — per-horizon recent_sharpe weights. Auto-shifts capital
|
||||
/// toward empirically winning horizons. See spec §5 + §6.
|
||||
WeightedByRealizedSharpe,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictPolicy {
|
||||
NetOut,
|
||||
FirstSubmitWins,
|
||||
BlockOpposing,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Regime {
|
||||
SpreadQ1,
|
||||
SpreadQ2,
|
||||
SpreadQ3,
|
||||
SpreadQ4,
|
||||
}
|
||||
88
crates/ml-backtesting/src/policy/latency.rs
Normal file
88
crates/ml-backtesting/src/policy/latency.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Latency model. Default: Constant(100ms) reflects IBKR + Scaleway.
|
||||
//! See spec §4.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum LatencyConfig {
|
||||
Constant {
|
||||
total_ns: u32,
|
||||
},
|
||||
/// Per-decision latency drawn from a host-uploaded empirical
|
||||
/// distribution. Indices wrap modulo `samples.len()`.
|
||||
Empirical {
|
||||
samples: Vec<f32>,
|
||||
},
|
||||
Decomposed {
|
||||
inference_ns: u32,
|
||||
decision_ns: u32,
|
||||
wire_ns: u32,
|
||||
match_ns: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for LatencyConfig {
|
||||
fn default() -> Self {
|
||||
LatencyConfig::Constant { total_ns: 100_000_000 }
|
||||
}
|
||||
}
|
||||
|
||||
impl LatencyConfig {
|
||||
/// Sample a latency in ns for the given decision-event index.
|
||||
pub fn sample_ns(&self, decision_idx: u64) -> u32 {
|
||||
match self {
|
||||
LatencyConfig::Constant { total_ns } => *total_ns,
|
||||
LatencyConfig::Empirical { samples } => {
|
||||
if samples.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let i = (decision_idx as usize) % samples.len();
|
||||
samples[i].max(0.0) as u32
|
||||
}
|
||||
LatencyConfig::Decomposed {
|
||||
inference_ns,
|
||||
decision_ns,
|
||||
wire_ns,
|
||||
match_ns,
|
||||
} => inference_ns + decision_ns + wire_ns + match_ns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_100ms() {
|
||||
assert!(matches!(
|
||||
LatencyConfig::default(),
|
||||
LatencyConfig::Constant { total_ns: 100_000_000 }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empirical_wraps() {
|
||||
let cfg = LatencyConfig::Empirical { samples: vec![10.0, 20.0, 30.0] };
|
||||
assert_eq!(cfg.sample_ns(0), 10);
|
||||
assert_eq!(cfg.sample_ns(3), 10);
|
||||
assert_eq!(cfg.sample_ns(7), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empirical_empty_returns_zero() {
|
||||
let cfg = LatencyConfig::Empirical { samples: vec![] };
|
||||
assert_eq!(cfg.sample_ns(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposed_sums() {
|
||||
let cfg = LatencyConfig::Decomposed {
|
||||
inference_ns: 1_000_000,
|
||||
decision_ns: 100_000,
|
||||
wire_ns: 50_000_000,
|
||||
match_ns: 5_000_000,
|
||||
};
|
||||
assert_eq!(cfg.sample_ns(0), 56_100_000);
|
||||
}
|
||||
}
|
||||
287
crates/ml-backtesting/src/policy/mod.rs
Normal file
287
crates/ml-backtesting/src/policy/mod.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
//! Multi-strategy composition tree + flat bytecode program for the
|
||||
//! decision-policy kernel.
|
||||
//!
|
||||
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §6.
|
||||
|
||||
pub mod composition;
|
||||
pub mod latency;
|
||||
pub mod sizing;
|
||||
pub mod stop_rules;
|
||||
|
||||
pub use composition::{ConflictPolicy, EnsembleAggregator, Regime};
|
||||
pub use latency::LatencyConfig;
|
||||
pub use sizing::{IsvKellyStateHost, SizingPolicyId};
|
||||
pub use stop_rules::StopRules;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Mirrors `crates/ml-alpha/src/heads.rs::N_HORIZONS`.
|
||||
pub const N_HORIZONS: usize = 5;
|
||||
|
||||
/// One horizon-anchored leaf strategy. See spec §5 (per-horizon ISV-Kelly) + §6.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct StrategyConfig {
|
||||
pub horizon_idx: u8, // 0..N_HORIZONS
|
||||
pub sizing_policy: SizingPolicyId,
|
||||
pub sl_tp_rules: StopRules,
|
||||
pub max_concurrent_lots: u16,
|
||||
}
|
||||
|
||||
/// Recursive composition tree. Default constructor `default_for(max_lots)`
|
||||
/// emits the adaptive 5-horizon Ensemble with WeightedByRealizedSharpe.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum Strategy {
|
||||
Leaf(StrategyConfig),
|
||||
Ensemble {
|
||||
children: Vec<Strategy>,
|
||||
aggregator: EnsembleAggregator,
|
||||
},
|
||||
Portfolio {
|
||||
children: Vec<Strategy>,
|
||||
capital_allocation: Vec<f32>,
|
||||
conflict_policy: ConflictPolicy,
|
||||
},
|
||||
RegimeSwitch {
|
||||
regime_to_child: HashMap<Regime, Strategy>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Strategy {
|
||||
/// Default v1 policy: per-horizon adaptive Ensemble. No static
|
||||
/// horizon mask; WeightedByRealizedSharpe auto-shifts capital from
|
||||
/// in-run evidence. See spec §6 `default_strategy(...)`.
|
||||
pub fn default_for(max_lots: u16) -> Self {
|
||||
let children = (0..N_HORIZONS as u8)
|
||||
.map(|h| {
|
||||
Strategy::Leaf(StrategyConfig {
|
||||
horizon_idx: h,
|
||||
sizing_policy: SizingPolicyId::IsvKelly,
|
||||
sl_tp_rules: StopRules::default(),
|
||||
max_concurrent_lots: max_lots,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Strategy::Ensemble {
|
||||
children,
|
||||
aggregator: EnsembleAggregator::WeightedByRealizedSharpe,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the tree and emit a flat bytecode Program. See spec §6
|
||||
/// host-side flattening; layout must stay in sync with
|
||||
/// cuda/decision_policy.cu (added in C7).
|
||||
pub fn flatten(&self) -> Program {
|
||||
let mut prog = Program::default();
|
||||
flatten_into(self, &mut prog);
|
||||
prog.finalize();
|
||||
prog
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_into(s: &Strategy, prog: &mut Program) {
|
||||
match s {
|
||||
Strategy::Leaf(cfg) => {
|
||||
prog.instructions.push(Instruction {
|
||||
op: OpCode::EmitPerHorizonSize as u8,
|
||||
arg0: cfg.horizon_idx,
|
||||
arg1: cfg.max_concurrent_lots,
|
||||
arg2: 0,
|
||||
});
|
||||
}
|
||||
Strategy::Ensemble { children, aggregator } => {
|
||||
for c in children {
|
||||
flatten_into(c, prog);
|
||||
}
|
||||
let agg_op = match aggregator {
|
||||
EnsembleAggregator::Mean => OpCode::AggMean,
|
||||
EnsembleAggregator::MaxConfidence => OpCode::AggMaxConfidence,
|
||||
// v1: treat ValAuc as Mean (no static-weights upload yet).
|
||||
EnsembleAggregator::WeightedByValAuc => OpCode::AggMean,
|
||||
EnsembleAggregator::WeightedByRealizedSharpe => OpCode::AggWeightedSharpe,
|
||||
};
|
||||
prog.instructions.push(Instruction {
|
||||
op: agg_op as u8,
|
||||
arg0: children.len() as u8,
|
||||
arg1: 0,
|
||||
arg2: 0,
|
||||
});
|
||||
}
|
||||
Strategy::Portfolio { children, capital_allocation: _, conflict_policy } => {
|
||||
for c in children {
|
||||
flatten_into(c, prog);
|
||||
}
|
||||
prog.instructions.push(Instruction {
|
||||
op: OpCode::AggMean as u8,
|
||||
arg0: children.len() as u8,
|
||||
arg1: 0,
|
||||
arg2: 0,
|
||||
});
|
||||
prog.instructions.push(Instruction {
|
||||
op: OpCode::ApplyConflict as u8,
|
||||
arg0: *conflict_policy as u8,
|
||||
arg1: 0,
|
||||
arg2: 0,
|
||||
});
|
||||
}
|
||||
Strategy::RegimeSwitch { regime_to_child } => {
|
||||
let mut branch_indices: Vec<usize> = Vec::with_capacity(regime_to_child.len());
|
||||
for (regime, child) in regime_to_child {
|
||||
let branch_idx = prog.instructions.len();
|
||||
prog.instructions.push(Instruction {
|
||||
op: OpCode::BranchIfRegime as u8,
|
||||
arg0: *regime as u8,
|
||||
arg1: 0,
|
||||
arg2: 0, // patched below
|
||||
});
|
||||
flatten_into(child, prog);
|
||||
branch_indices.push(branch_idx);
|
||||
}
|
||||
let end = prog.instructions.len() as u32;
|
||||
for branch_idx in branch_indices {
|
||||
prog.instructions[branch_idx].arg2 = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytecode opcodes. The decision-policy kernel (C7) interprets these
|
||||
/// against current alpha probs + per-horizon ISV-Kelly state to mutate
|
||||
/// shared-mem orders[]. Layout MUST stay in sync with
|
||||
/// `cuda/decision_policy.cu`. See spec §6.
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpCode {
|
||||
NoOp = 0,
|
||||
EvalRegime = 1,
|
||||
EmitPerHorizonSize = 2,
|
||||
AggWeightedSharpe = 3,
|
||||
AggMean = 4,
|
||||
AggMaxConfidence = 5,
|
||||
ApplyConflict = 6,
|
||||
WriteOrder = 7,
|
||||
PushScalar = 8,
|
||||
BranchIfRegime = 9,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Instruction {
|
||||
pub op: u8,
|
||||
pub arg0: u8,
|
||||
pub arg1: u16,
|
||||
pub arg2: u32,
|
||||
}
|
||||
|
||||
/// Flat device-uploadable program. One per backtest cell; indexed by
|
||||
/// blockIdx.x in device-global memory (NOT __constant__, see spec §6
|
||||
/// "Why not __constant__").
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Program {
|
||||
pub instructions: Vec<Instruction>,
|
||||
}
|
||||
|
||||
impl Program {
|
||||
/// Append the top-level WriteOrder. Called once by Strategy::flatten().
|
||||
pub(crate) fn finalize(&mut self) {
|
||||
self.instructions.push(Instruction {
|
||||
op: OpCode::WriteOrder as u8,
|
||||
arg0: 0,
|
||||
arg1: 0,
|
||||
arg2: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_strategy_has_5_horizon_leaves() {
|
||||
let s = Strategy::default_for(3);
|
||||
match s {
|
||||
Strategy::Ensemble { children, aggregator } => {
|
||||
assert_eq!(children.len(), 5);
|
||||
assert!(matches!(
|
||||
aggregator,
|
||||
EnsembleAggregator::WeightedByRealizedSharpe
|
||||
));
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
match c {
|
||||
Strategy::Leaf(cfg) => {
|
||||
assert_eq!(cfg.horizon_idx as usize, i);
|
||||
assert_eq!(cfg.max_concurrent_lots, 3);
|
||||
}
|
||||
_ => panic!("expected Leaf at index {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => panic!("expected top-level Ensemble"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_strategy_flattens_to_5_emits_plus_agg_plus_write() {
|
||||
let s = Strategy::default_for(3);
|
||||
let p = s.flatten();
|
||||
assert_eq!(p.instructions.len(), 7);
|
||||
for i in 0..5 {
|
||||
assert_eq!(p.instructions[i].op, OpCode::EmitPerHorizonSize as u8);
|
||||
assert_eq!(p.instructions[i].arg0, i as u8);
|
||||
assert_eq!(p.instructions[i].arg1, 3);
|
||||
}
|
||||
assert_eq!(p.instructions[5].op, OpCode::AggWeightedSharpe as u8);
|
||||
assert_eq!(p.instructions[5].arg0, 5);
|
||||
assert_eq!(p.instructions[6].op, OpCode::WriteOrder as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_leaf_flattens_to_emit_plus_write() {
|
||||
let leaf = Strategy::Leaf(StrategyConfig {
|
||||
horizon_idx: 4,
|
||||
sizing_policy: SizingPolicyId::IsvKelly,
|
||||
sl_tp_rules: StopRules::default(),
|
||||
max_concurrent_lots: 1,
|
||||
});
|
||||
let p = leaf.flatten();
|
||||
assert_eq!(p.instructions.len(), 2);
|
||||
assert_eq!(p.instructions[0].op, OpCode::EmitPerHorizonSize as u8);
|
||||
assert_eq!(p.instructions[0].arg0, 4);
|
||||
assert_eq!(p.instructions[1].op, OpCode::WriteOrder as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_flattens_with_conflict_op() {
|
||||
let p = Strategy::Portfolio {
|
||||
children: vec![
|
||||
Strategy::Leaf(StrategyConfig {
|
||||
horizon_idx: 0,
|
||||
sizing_policy: SizingPolicyId::IsvKelly,
|
||||
sl_tp_rules: StopRules::default(),
|
||||
max_concurrent_lots: 1,
|
||||
}),
|
||||
Strategy::Leaf(StrategyConfig {
|
||||
horizon_idx: 1,
|
||||
sizing_policy: SizingPolicyId::IsvKelly,
|
||||
sl_tp_rules: StopRules::default(),
|
||||
max_concurrent_lots: 1,
|
||||
}),
|
||||
],
|
||||
capital_allocation: vec![0.6, 0.4],
|
||||
conflict_policy: ConflictPolicy::NetOut,
|
||||
}
|
||||
.flatten();
|
||||
// 2 × EmitPerHorizonSize + AggMean + ApplyConflict + WriteOrder
|
||||
assert_eq!(p.instructions.len(), 5);
|
||||
assert_eq!(p.instructions[2].op, OpCode::AggMean as u8);
|
||||
assert_eq!(p.instructions[3].op, OpCode::ApplyConflict as u8);
|
||||
assert_eq!(p.instructions[3].arg0, ConflictPolicy::NetOut as u8);
|
||||
assert_eq!(p.instructions[4].op, OpCode::WriteOrder as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_is_8_bytes_pod() {
|
||||
assert_eq!(std::mem::size_of::<Instruction>(), 8);
|
||||
}
|
||||
}
|
||||
27
crates/ml-backtesting/src/policy/sizing.rs
Normal file
27
crates/ml-backtesting/src/policy/sizing.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Per-leaf sizing policy id. See spec §5.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SizingPolicyId {
|
||||
/// Per-horizon adaptive Kelly with ISV-driven cap (see spec §5).
|
||||
IsvKelly,
|
||||
/// Constant lot count regardless of signal magnitude. Useful for
|
||||
/// fixture testing and as a baseline sanity check vs IsvKelly.
|
||||
FixedLots(u16),
|
||||
}
|
||||
|
||||
/// Host-side mirror of cuda/lob_state.cuh IsvKellyState.
|
||||
/// Used by the harness for warm-start file I/O + tests.
|
||||
#[repr(C)]
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct IsvKellyStateHost {
|
||||
pub pnl_ema_win: f32,
|
||||
pub pnl_ema_loss: f32,
|
||||
pub win_rate_ema: f32,
|
||||
pub n_trades_seen: u32,
|
||||
pub realised_return_var: f32,
|
||||
pub recent_sharpe: f32,
|
||||
}
|
||||
14
crates/ml-backtesting/src/policy/stop_rules.rs
Normal file
14
crates/ml-backtesting/src/policy/stop_rules.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Per-leaf stop / take-profit / max-hold config. See spec §6 StrategyConfig.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct StopRules {
|
||||
/// Hard stop-loss distance in price-unit ticks (i32). `0` = disabled.
|
||||
pub stop_loss_ticks: i32,
|
||||
/// Take-profit distance in ticks. `0` = disabled.
|
||||
pub take_profit_ticks: i32,
|
||||
/// Max holding time in nanoseconds. `0` = disabled (hold until
|
||||
/// counter-signal closes).
|
||||
pub max_hold_ns: u64,
|
||||
}
|
||||
Reference in New Issue
Block a user