Files
foxhunt/docs/plans/2026-02-21-full-stack-ml-integration-implementation.md
jgrusewski c3b5e124f0 chore: update .gitignore and add design plan docs
Ignore ML checkpoints, trained model safetensors, stray ml/ml/ dir,
and .claude/worktrees/. Clean up duplicate hive-mind-prompt entries.
Add 17 design/implementation plan docs from 2026-02-20 to 2026-02-22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:30:15 +01:00

1017 lines
31 KiB
Markdown

# Full-Stack ML Integration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Take the ML codebase from "4 models that train independently" to "paper-trading pipeline producing simulated P&L from a 4-model ensemble."
**Architecture:** 4-phase approach: (1) delete 88 stale test files and commit unstaged adapter code, (2) wire all 4 model adapters into the EnsembleCoordinator, (3) build hyperopt campaign runner with automated validation, (4) add paper-trading pipeline with simulated fills and P&L tracking.
**Tech Stack:** Rust, candle (ML framework), safetensors (checkpoints), tokio (async), Databento (market data)
---
## Phase 1: Codebase Cleanup
### Task 1: Delete stale test files using `WorkingDQN`
88 test files in `ml/tests/` reference the removed `WorkingDQN`/`WorkingDQNConfig` types and will not compile. They must be deleted.
**Files:**
- Delete: All 88 files listed below
**Step 1: Delete all stale WorkingDQN test files**
Run:
```bash
cd /home/jgrusewski/Work/foxhunt
grep -rl "WorkingDQN\|WorkingDQNConfig" ml/tests/ | xargs rm -f
```
**Step 2: Verify deletion**
Run: `ls ml/tests/*.rs | wc -l`
Expected: ~482 files remain (570 - 88 = ~482)
**Step 3: Commit**
```bash
git add -u ml/tests/
git commit -m "chore(ml): delete 88 stale test files referencing removed WorkingDQN type"
```
---
### Task 2: Fix test files using wrong crate name `foxhunt_ml::`
4 test files use `foxhunt_ml::` instead of the correct `ml::` crate name.
**Files:**
- Fix: `ml/tests/dqn_full_gradient_flow_integration_test.rs`
- Fix: `ml/tests/dqn_gradient_flow_isolation_test.rs`
- Fix: `ml/tests/tft_int8_forward_integration_test.rs`
- Fix: `ml/tests/tft_int8_integration_test.rs`
**Step 1: Fix crate name in all 4 files**
Run:
```bash
cd /home/jgrusewski/Work/foxhunt
sed -i 's/foxhunt_ml::/ml::/g' \
ml/tests/dqn_full_gradient_flow_integration_test.rs \
ml/tests/dqn_gradient_flow_isolation_test.rs \
ml/tests/tft_int8_forward_integration_test.rs \
ml/tests/tft_int8_integration_test.rs
```
**Step 2: Check if the fixed files compile**
Run: `SQLX_OFFLINE=true cargo check -p ml --tests 2>&1 | grep -i "error\[" | head -20`
If these files have other compilation errors beyond the crate name, delete them too:
```bash
rm -f ml/tests/dqn_full_gradient_flow_integration_test.rs \
ml/tests/dqn_gradient_flow_isolation_test.rs \
ml/tests/tft_int8_forward_integration_test.rs \
ml/tests/tft_int8_integration_test.rs
```
**Step 3: Commit**
```bash
git add ml/tests/
git commit -m "fix(ml): fix or remove test files with wrong foxhunt_ml:: crate name"
```
---
### Task 3: Commit unstaged ensemble adapter files
The Mamba2 and TFT inference adapters already exist but are unstaged. The `adapters/mod.rs` already exports them.
**Files:**
- Stage: `ml/src/ensemble/adapters/mod.rs` (modified — added mamba2/tft modules)
- Stage: `ml/src/ensemble/adapters/mamba2.rs` (new — 320 lines, with tests)
- Stage: `ml/src/ensemble/adapters/tft.rs` (new — 484 lines, with tests)
**Step 1: Run the adapter unit tests**
Run:
```bash
SQLX_OFFLINE=true cargo test -p ml --lib ensemble::adapters::mamba2 -- --nocapture
SQLX_OFFLINE=true cargo test -p ml --lib ensemble::adapters::tft -- --nocapture
```
Expected: All tests pass (3 tests each module)
**Step 2: Commit**
```bash
git add ml/src/ensemble/adapters/mod.rs ml/src/ensemble/adapters/mamba2.rs ml/src/ensemble/adapters/tft.rs
git commit -m "feat(ensemble): add Mamba2 and TFT inference adapters with ring buffer + tests"
```
---
### Task 4: Commit unstaged Mamba2 loss module
The directional MSE loss function and bilinear discretization fix are unstaged.
**Files:**
- Stage: `ml/src/mamba/loss.rs` (new — 112 lines)
- Stage: `ml/src/mamba/mod.rs` (modified — bilinear discretization fix)
**Step 1: Verify Mamba2 tests still pass**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib mamba -- --nocapture 2>&1 | tail -10`
Expected: Tests pass
**Step 2: Commit**
```bash
git add ml/src/mamba/loss.rs ml/src/mamba/mod.rs
git commit -m "feat(mamba): add directional MSE loss and bilinear discretization fix"
```
---
### Task 5: Verify full compilation
**Step 1: Check lib compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -5`
Expected: No errors
**Step 2: Check remaining integration tests compile**
Run: `SQLX_OFFLINE=true cargo check -p ml --tests 2>&1 | grep "error\[" | wc -l`
Expected: 0 errors (or identify remaining broken files and delete them)
**Step 3: If errors remain, delete offending files and re-commit**
```bash
# Identify remaining broken test files
SQLX_OFFLINE=true cargo check -p ml --tests 2>&1 | grep "error\[" | grep -oP 'ml/tests/\S+\.rs' | sort -u | xargs rm -f
git add -u ml/tests/
git commit -m "chore(ml): remove remaining broken integration test files"
```
**Step 4: Run lib tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -20`
Expected: All pass, zero failures
---
## Phase 2: Ensemble Coordinator Integration
### Task 6: Create `InferenceEnsemble` — lightweight coordinator wrapping 4 adapters
The existing `EnsembleCoordinator` in `coordinator.rs` is async and uses `Features`/`ModelPrediction` types from the old ensemble system. Rather than refactoring it, create a new lightweight synchronous coordinator that uses the `ModelInferenceAdapter` trait.
**Files:**
- Create: `ml/src/ensemble/inference_ensemble.rs`
- Modify: `ml/src/ensemble/mod.rs` (add `pub mod inference_ensemble;`)
**Step 1: Write the failing test**
In `ml/src/ensemble/inference_ensemble.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use crate::ensemble::inference_adapter::{
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
};
/// Dummy adapter for testing ensemble aggregation
struct DummyAdapter {
name: String,
direction: f64,
confidence: f64,
ready: bool,
}
impl ModelInferenceAdapter for DummyAdapter {
fn model_name(&self) -> &str { &self.name }
fn is_ready(&self) -> bool { self.ready }
fn predict(&self, _features: &FeatureVector) -> crate::MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: self.name.clone(),
direction: self.direction,
confidence: self.confidence,
metadata: PredictionMeta {
latency_us: 100,
quantiles: None,
attention_weights: None,
q_values: None,
},
})
}
}
#[test]
fn test_ensemble_equal_weight_aggregation() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(DummyAdapter { name: "A".into(), direction: 1.0, confidence: 0.8, ready: true }),
Box::new(DummyAdapter { name: "B".into(), direction: -1.0, confidence: 0.6, ready: true }),
];
let ensemble = InferenceEnsemble::new(adapters);
let fv = FeatureVector { values: vec![0.0; 51], timestamp: 0 };
let result = ensemble.predict(&fv).unwrap();
// Weighted: (1.0*0.8 + (-1.0)*0.6) / (0.8+0.6) = 0.2/1.4 ≈ 0.143
assert!(result.direction > 0.0, "Net direction should be positive");
assert!(result.direction < 0.5, "Net direction should be modest");
}
#[test]
fn test_ensemble_skips_unready_models() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(DummyAdapter { name: "A".into(), direction: 1.0, confidence: 0.9, ready: true }),
Box::new(DummyAdapter { name: "B".into(), direction: -1.0, confidence: 0.9, ready: false }),
];
let ensemble = InferenceEnsemble::new(adapters);
let fv = FeatureVector { values: vec![0.0; 51], timestamp: 0 };
let result = ensemble.predict(&fv).unwrap();
// Only model A is ready, so direction should be strongly positive
assert!(result.direction > 0.5);
}
#[test]
fn test_ensemble_custom_weights() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(DummyAdapter { name: "A".into(), direction: 1.0, confidence: 1.0, ready: true }),
Box::new(DummyAdapter { name: "B".into(), direction: -1.0, confidence: 1.0, ready: true }),
];
let mut ensemble = InferenceEnsemble::new(adapters);
// Give model A 3x weight of model B
ensemble.set_weight("A", 0.75);
ensemble.set_weight("B", 0.25);
let fv = FeatureVector { values: vec![0.0; 51], timestamp: 0 };
let result = ensemble.predict(&fv).unwrap();
// (1.0 * 0.75 * 1.0 + (-1.0) * 0.25 * 1.0) = 0.5 (before normalization)
assert!(result.direction > 0.3, "A's weight should dominate");
}
}
```
**Step 2: Run tests to verify they fail**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib ensemble::inference_ensemble -- --nocapture 2>&1 | tail -5`
Expected: Compilation error — `InferenceEnsemble` not defined
**Step 3: Write minimal implementation**
```rust
//! Lightweight synchronous ensemble that aggregates ModelInferenceAdapter predictions.
use std::collections::HashMap;
use crate::ensemble::inference_adapter::{
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
};
use crate::{MLError, MLResult};
/// Synchronous ensemble coordinator wrapping N model adapters.
///
/// Collects predictions from all ready adapters and produces a
/// confidence-weighted consensus direction.
pub struct InferenceEnsemble {
adapters: Vec<Box<dyn ModelInferenceAdapter>>,
weights: HashMap<String, f64>,
}
impl InferenceEnsemble {
pub fn new(adapters: Vec<Box<dyn ModelInferenceAdapter>>) -> Self {
Self {
adapters,
weights: HashMap::new(),
}
}
/// Set a custom weight for a model (by name). Default weight is 1.0.
pub fn set_weight(&mut self, model_name: &str, weight: f64) {
self.weights.insert(model_name.to_string(), weight);
}
/// Predict from all ready models and aggregate.
pub fn predict(&self, features: &FeatureVector) -> MLResult<EnsemblePrediction> {
let start = std::time::Instant::now();
let mut predictions = Vec::new();
for adapter in &self.adapters {
if !adapter.is_ready() {
continue;
}
match adapter.predict(features) {
Ok(pred) => predictions.push(pred),
Err(e) => {
tracing::warn!(model = adapter.model_name(), error = %e, "Adapter prediction failed, skipping");
}
}
}
if predictions.is_empty() {
return Err(MLError::InferenceError("No ready models in ensemble".into()));
}
// Confidence-weighted aggregation
let mut weighted_direction = 0.0;
let mut total_weight = 0.0;
for pred in &predictions {
let model_weight = self.weights.get(&pred.model_name).copied().unwrap_or(1.0);
let w = model_weight * pred.confidence;
weighted_direction += pred.direction * w;
total_weight += w;
}
let direction = if total_weight > 0.0 {
(weighted_direction / total_weight).clamp(-1.0, 1.0)
} else {
0.0
};
let confidence = if !predictions.is_empty() {
let avg_conf: f64 = predictions.iter().map(|p| p.confidence).sum::<f64>()
/ predictions.len() as f64;
avg_conf.clamp(0.0, 1.0)
} else {
0.0
};
let latency_us = start.elapsed().as_micros() as u64;
let model_names: Vec<String> = predictions.iter().map(|p| p.model_name.clone()).collect();
Ok(EnsemblePrediction {
model_name: format!("ENSEMBLE({})", model_names.join("+")),
direction,
confidence,
metadata: PredictionMeta {
latency_us,
quantiles: None,
attention_weights: None,
q_values: None,
},
})
}
/// Number of adapters currently ready.
pub fn ready_count(&self) -> usize {
self.adapters.iter().filter(|a| a.is_ready()).count()
}
}
```
**Step 4: Run tests to verify they pass**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib ensemble::inference_ensemble -- --nocapture`
Expected: 3 tests pass
**Step 5: Commit**
```bash
git add ml/src/ensemble/inference_ensemble.rs ml/src/ensemble/mod.rs
git commit -m "feat(ensemble): add InferenceEnsemble coordinator with confidence-weighted aggregation"
```
---
### Task 7: Integration test — 4-model ensemble prediction
Create an integration test that constructs all 4 real adapters (DQN, PPO, Mamba2, TFT) with random weights, feeds feature vectors, and verifies the ensemble produces bounded predictions.
**Files:**
- Create: `ml/tests/ensemble_inference_integration_test.rs`
**Step 1: Write the test**
```rust
//! Integration test: 4-model InferenceEnsemble
//!
//! Creates DQN, PPO, Mamba2, and TFT adapters with default/small configs,
//! feeds feature vectors through the ensemble, and verifies output bounds.
#![allow(unused_crate_dependencies)]
use ml::dqn::{DQNConfig, DQN};
use ml::ensemble::adapters::{
DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter,
};
use ml::ensemble::inference_adapter::FeatureVector;
use ml::ensemble::inference_ensemble::InferenceEnsemble;
use ml::mamba::Mamba2Config;
use ml::ppo::PPOConfig;
use ml::tft::TFTConfig;
#[test]
fn test_4_model_ensemble_prediction() {
// Create DQN adapter
let dqn_config = DQNConfig::default();
let dqn = DQN::new(&dqn_config).expect("DQN creation");
let dqn_adapter = DqnInferenceAdapter::new(dqn);
// Create PPO adapter
let ppo_config = PPOConfig::default();
let ppo = ml::ppo::PPO::new(&ppo_config).expect("PPO creation");
let ppo_adapter = PpoInferenceAdapter::new(ppo);
// Create Mamba2 adapter with small config
let mamba2_config = Mamba2Config {
d_model: 64,
d_state: 16,
d_head: 16,
num_heads: 2,
expand: 2,
num_layers: 1,
max_seq_len: 8,
..Default::default()
};
let mamba2_adapter = Mamba2InferenceAdapter::new(mamba2_config, 4)
.expect("Mamba2 adapter creation");
// Create TFT adapter with small config
let tft_config = TFTConfig {
input_dim: 20,
hidden_dim: 32,
num_heads: 2,
num_layers: 1,
prediction_horizon: 5,
sequence_length: 4,
num_quantiles: 9,
num_static_features: 6,
num_known_features: 6,
num_unknown_features: 8,
dropout_rate: 0.0,
..Default::default()
};
let tft_adapter = TftInferenceAdapter::new(tft_config, 4)
.expect("TFT adapter creation");
// Build ensemble
let adapters: Vec<Box<dyn ml::ensemble::inference_adapter::ModelInferenceAdapter>> = vec![
Box::new(dqn_adapter),
Box::new(ppo_adapter),
Box::new(mamba2_adapter),
Box::new(tft_adapter),
];
let ensemble = InferenceEnsemble::new(adapters);
// Feed 5 feature vectors (enough to fill Mamba2 + TFT buffers at seq_len=4)
for i in 0..5 {
let fv = FeatureVector {
values: vec![0.1 * (i as f64 + 1.0); 51],
timestamp: 1_700_000_000 + i,
};
let result = ensemble.predict(&fv);
// DQN and PPO are always ready (single-step models)
// Mamba2 and TFT need 4 vectors to fill their buffers
assert!(result.is_ok(), "Ensemble prediction should not error: {:?}", result.err());
let pred = result.unwrap();
assert!(pred.direction >= -1.0 && pred.direction <= 1.0,
"direction {} out of [-1,1]", pred.direction);
assert!(pred.confidence >= 0.0 && pred.confidence <= 1.0,
"confidence {} out of [0,1]", pred.confidence);
}
// After 5 FVs, all 4 models should be ready
assert_eq!(ensemble.ready_count(), 4, "All 4 models should be ready after 5 FVs");
}
```
**Step 2: Run test**
Run: `SQLX_OFFLINE=true cargo test -p ml --test ensemble_inference_integration_test -- --nocapture`
Expected: 1 test passes
Note: This test may need adjustments to the import paths depending on how DQN/PPO constructors are exposed. If DQN/PPO adapters need the model passed differently, adjust accordingly. The key pattern is: construct model → wrap in adapter → add to ensemble.
**Step 3: Commit**
```bash
git add ml/tests/ensemble_inference_integration_test.rs
git commit -m "test(ensemble): add 4-model inference ensemble integration test"
```
---
## Phase 3: Hyperopt Campaign Runner
### Task 8: Create hyperopt campaign CLI runner
Build a CLI entrypoint that runs a hyperopt campaign for a given model type.
**Files:**
- Create: `ml/src/hyperopt/campaign.rs`
- Modify: `ml/src/hyperopt/mod.rs` (add `pub mod campaign;`)
**Step 1: Write the failing test**
In `ml/src/hyperopt/campaign.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_campaign_config_defaults() {
let config = CampaignConfig::dqn_default();
assert_eq!(config.model_type, ModelType::DQN);
assert_eq!(config.num_trials, 50);
assert!(config.max_batch_size > 0);
}
#[test]
fn test_campaign_config_ppo() {
let config = CampaignConfig::ppo_default();
assert_eq!(config.model_type, ModelType::PPO);
assert_eq!(config.num_trials, 30);
}
#[test]
fn test_results_dir_creation() {
let config = CampaignConfig::dqn_default();
let dir = config.results_dir();
assert!(dir.starts_with("ml/hyperopt_results/dqn/"));
}
}
```
**Step 2: Write implementation**
```rust
//! Hyperopt campaign runner — configures and executes multi-trial optimization.
use std::path::PathBuf;
/// Model type for campaign targeting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelType {
DQN,
PPO,
}
/// Campaign configuration.
#[derive(Debug, Clone)]
pub struct CampaignConfig {
pub model_type: ModelType,
pub num_trials: usize,
pub data_dir: PathBuf,
pub max_batch_size: usize,
pub early_stopping_eta: usize,
pub max_epochs_per_trial: usize,
pub results_base_dir: PathBuf,
}
impl CampaignConfig {
pub fn dqn_default() -> Self {
Self {
model_type: ModelType::DQN,
num_trials: 50,
data_dir: PathBuf::from("test_data/real/databento/ml_training"),
max_batch_size: 230, // RTX 3050 Ti 4GB VRAM limit
early_stopping_eta: 3,
max_epochs_per_trial: 81,
results_base_dir: PathBuf::from("ml/hyperopt_results"),
}
}
pub fn ppo_default() -> Self {
Self {
model_type: ModelType::PPO,
num_trials: 30,
data_dir: PathBuf::from("test_data/real/databento/ml_training"),
max_batch_size: 230,
early_stopping_eta: 3,
max_epochs_per_trial: 81,
results_base_dir: PathBuf::from("ml/hyperopt_results"),
}
}
/// Generate a timestamped results directory path.
pub fn results_dir(&self) -> String {
let model_name = match self.model_type {
ModelType::DQN => "dqn",
ModelType::PPO => "ppo",
};
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
format!("{}/{}/{}", self.results_base_dir.display(), model_name, timestamp)
}
}
```
**Step 3: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib hyperopt::campaign -- --nocapture`
Expected: 3 tests pass
**Step 4: Commit**
```bash
git add ml/src/hyperopt/campaign.rs ml/src/hyperopt/mod.rs
git commit -m "feat(hyperopt): add campaign configuration with DQN/PPO defaults"
```
---
### Task 9: Campaign execution and results persistence
Implement the actual trial loop that drives the hyperopt campaign.
**Files:**
- Modify: `ml/src/hyperopt/campaign.rs` (add `run_campaign` async fn)
**Step 1: Write the test (smoke test with 2 trials)**
```rust
#[tokio::test]
async fn test_campaign_smoke_2_trials() {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test_data/real/databento/ml_training_small");
if !data_dir.exists() {
eprintln!("Skipping: test data not found at {}", data_dir.display());
return;
}
let mut config = CampaignConfig::dqn_default();
config.num_trials = 2;
config.max_epochs_per_trial = 5;
config.data_dir = data_dir;
let results = run_campaign(config).await.expect("Campaign should complete");
assert_eq!(results.trials_completed, 2);
assert!(results.best_loss.is_finite());
}
```
**Step 2: Implement `run_campaign`**
This function should:
1. Create the results directory
2. Loop for `num_trials` iterations
3. Sample random DQNParams using `ParameterSpace` trait
4. Train for `max_epochs_per_trial` epochs
5. Use `EarlyStoppingObserver` for pruning
6. Track best params and loss
7. Save best checkpoint and params JSON to results dir
**Step 3: Run test**
Run: `SQLX_OFFLINE=true cargo test -p ml campaign_smoke -- --nocapture`
Expected: Passes (may take 30-60s for 2 trials x 5 epochs)
**Step 4: Commit**
```bash
git add ml/src/hyperopt/campaign.rs
git commit -m "feat(hyperopt): implement campaign runner with trial loop and results persistence"
```
---
### Task 10: Post-campaign automated validation
After hyperopt finds best params, automatically run them through the ValidationHarness.
**Files:**
- Modify: `ml/src/hyperopt/campaign.rs` (add `validate_best` fn)
**Step 1: Write the test**
```rust
#[test]
fn test_campaign_results_serialization() {
let results = CampaignResults {
trials_completed: 10,
best_loss: 0.42,
best_params_json: "{\"lr\": 0.001}".to_string(),
best_checkpoint_path: Some("path/to/best.safetensors".into()),
total_time_seconds: 120.0,
};
let json = serde_json::to_string(&results).expect("serialize");
let back: CampaignResults = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.trials_completed, 10);
}
```
**Step 2: Implement `CampaignResults` struct with serde**
**Step 3: Commit**
```bash
git add ml/src/hyperopt/campaign.rs
git commit -m "feat(hyperopt): add CampaignResults serialization and validation hook"
```
---
## Phase 4: Paper Trading Pipeline
### Task 11: Define `TradeSignal` type in ml crate
The ML-side signal that flows from ensemble to trading.
**Files:**
- Create: `ml/src/ensemble/signal.rs`
- Modify: `ml/src/ensemble/mod.rs`
**Step 1: Write test**
```rust
#[test]
fn test_trade_signal_from_ensemble_prediction() {
let pred = EnsemblePrediction {
model_name: "ENSEMBLE(DQN+PPO)".into(),
direction: 0.6,
confidence: 0.85,
metadata: PredictionMeta::default(),
};
let signal = TradeSignal::from_prediction(&pred, "6E.FUT");
assert_eq!(signal.action, TradeAction::Buy);
assert_eq!(signal.symbol, "6E.FUT");
assert!(signal.confidence > 0.8);
}
```
**Step 2: Implement**
```rust
/// Trade action derived from ensemble prediction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TradeAction {
Buy,
Sell,
Hold,
}
/// Signal emitted by the ML ensemble for the trading engine.
#[derive(Debug, Clone)]
pub struct TradeSignal {
pub action: TradeAction,
pub symbol: String,
pub confidence: f64,
pub direction: f64,
pub timestamp_us: i64,
}
impl TradeSignal {
pub fn from_prediction(pred: &EnsemblePrediction, symbol: &str) -> Self {
let action = if pred.direction > 0.1 {
TradeAction::Buy
} else if pred.direction < -0.1 {
TradeAction::Sell
} else {
TradeAction::Hold
};
Self {
action,
symbol: symbol.to_string(),
confidence: pred.confidence,
direction: pred.direction,
timestamp_us: chrono::Utc::now().timestamp_micros(),
}
}
}
```
**Step 3: Run tests, commit**
```bash
git add ml/src/ensemble/signal.rs ml/src/ensemble/mod.rs
git commit -m "feat(ensemble): add TradeSignal type with Buy/Sell/Hold action mapping"
```
---
### Task 12: PaperBroker — simulated fill engine
Lives in the ml crate for now (no cross-crate dependency needed for paper trading).
**Files:**
- Create: `ml/src/paper_trading/mod.rs`
- Create: `ml/src/paper_trading/broker.rs`
- Modify: `ml/src/lib.rs` (add `pub mod paper_trading;`)
**Step 1: Write test**
```rust
#[test]
fn test_paper_broker_buy_and_sell() {
let mut broker = PaperBroker::new(100_000.0, 0.0001); // $100k, 1bps slippage
let fill = broker.execute_signal(&TradeSignal {
action: TradeAction::Buy,
symbol: "6E.FUT".into(),
confidence: 0.9,
direction: 0.5,
timestamp_us: 0,
}, 1.0850); // current price
assert!(fill.is_some());
let fill = fill.unwrap();
assert!(fill.fill_price > 1.0850); // slippage applied
assert_eq!(broker.position_size(), 1); // 1 contract long
}
```
**Step 2: Implement PaperBroker**
- Track cash, positions, commission
- `execute_signal` takes `TradeSignal` + current market price → `Option<Fill>`
- Apply slippage: `fill_price = price * (1 + slippage * direction_sign)`
- Commission per trade (configurable, default $2.50 per contract)
- Track P&L: `unrealized_pnl(current_price)` and `realized_pnl()`
**Step 3: Run tests, commit**
```bash
git add ml/src/paper_trading/ ml/src/lib.rs
git commit -m "feat(paper_trading): add PaperBroker with simulated fills and position tracking"
```
---
### Task 13: PnL Tracker — cumulative returns and Sharpe
**Files:**
- Create: `ml/src/paper_trading/pnl_tracker.rs`
- Modify: `ml/src/paper_trading/mod.rs`
**Step 1: Write test**
```rust
#[test]
fn test_pnl_tracker_sharpe_calculation() {
let mut tracker = PnLTracker::new(252); // 252-bar rolling window
// Simulate 10 bars of small positive returns
for i in 0..10 {
tracker.record_return(0.001 + 0.0001 * (i as f64));
}
let sharpe = tracker.rolling_sharpe();
assert!(sharpe.is_finite());
assert!(sharpe > 0.0, "Positive returns should yield positive Sharpe");
let drawdown = tracker.max_drawdown();
assert!(drawdown >= 0.0);
assert!(drawdown <= 1.0);
}
```
**Step 2: Implement**
- Ring buffer of returns (configurable window, default 252)
- `rolling_sharpe()`: `mean / std * sqrt(252)`
- `max_drawdown()`: track peak equity, compute max drop
- `cumulative_return()`: product of `(1 + r)` - 1
**Step 3: Run tests, commit**
```bash
git add ml/src/paper_trading/pnl_tracker.rs ml/src/paper_trading/mod.rs
git commit -m "feat(paper_trading): add PnLTracker with rolling Sharpe and max drawdown"
```
---
### Task 14: Paper trading pipeline integration test
End-to-end test: replay historical OHLCV data → feature extraction → ensemble → signals → paper broker → P&L.
**Files:**
- Create: `ml/tests/paper_trading_integration_test.rs`
**Step 1: Write test**
```rust
//! Paper trading pipeline integration test.
//!
//! Replays synthetic OHLCV data through the full pipeline:
//! Feature extraction → Ensemble → TradeSignal → PaperBroker → PnL
#![allow(unused_crate_dependencies)]
use ml::ensemble::adapters::{DqnInferenceAdapter, PpoInferenceAdapter};
use ml::ensemble::inference_adapter::FeatureVector;
use ml::ensemble::inference_ensemble::InferenceEnsemble;
use ml::ensemble::signal::TradeSignal;
use ml::paper_trading::broker::PaperBroker;
use ml::paper_trading::pnl_tracker::PnLTracker;
#[test]
fn test_paper_trading_pipeline_synthetic_data() {
// Build ensemble with 2 models (DQN + PPO for speed)
// ... construct adapters ...
let ensemble = InferenceEnsemble::new(adapters);
let mut broker = PaperBroker::new(100_000.0, 0.0001);
let mut tracker = PnLTracker::new(252);
// Simulate 100 bars of synthetic price data
let mut price = 1.0850;
for i in 0..100 {
// Synthetic feature vector
let fv = FeatureVector {
values: vec![price; 51],
timestamp: 1_700_000_000 + i,
};
if let Ok(pred) = ensemble.predict(&fv) {
let signal = TradeSignal::from_prediction(&pred, "6E.FUT");
broker.execute_signal(&signal, price);
}
// Random walk price
price += 0.0001 * ((i % 7) as f64 - 3.0);
// Record bar return
let pnl = broker.unrealized_pnl(price);
tracker.record_equity(100_000.0 + pnl);
}
// Assertions
let sharpe = tracker.rolling_sharpe();
assert!(sharpe.is_finite(), "Sharpe should be finite");
let drawdown = tracker.max_drawdown();
assert!(drawdown.is_finite(), "Drawdown should be finite");
assert!(drawdown <= 1.0, "Drawdown should be <= 100%");
println!("Pipeline test complete:");
println!(" Cumulative return: {:.4}%", tracker.cumulative_return() * 100.0);
println!(" Rolling Sharpe: {:.4}", sharpe);
println!(" Max drawdown: {:.4}%", drawdown * 100.0);
println!(" Final equity: ${:.2}", 100_000.0 + broker.unrealized_pnl(price));
}
```
**Step 2: Run test**
Run: `SQLX_OFFLINE=true cargo test -p ml --test paper_trading_integration_test -- --nocapture`
Expected: Passes, prints P&L summary
**Step 3: Commit**
```bash
git add ml/tests/paper_trading_integration_test.rs
git commit -m "test(paper_trading): add end-to-end pipeline integration test"
```
---
## Phase 5: Final Verification
### Task 15: Full compilation and test suite verification
**Step 1: Verify lib compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib`
Expected: No errors
**Step 2: Verify integration test compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml --tests`
Expected: No errors
**Step 3: Run all lib tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -30`
Expected: All pass
**Step 4: Run key integration tests**
Run:
```bash
SQLX_OFFLINE=true cargo test -p ml --test ensemble_inference_integration_test -- --nocapture
SQLX_OFFLINE=true cargo test -p ml --test paper_trading_integration_test -- --nocapture
SQLX_OFFLINE=true cargo test -p ml --test production_training_smoke_test -- --nocapture
```
Expected: All pass
**Step 5: Clippy check**
Run: `SQLX_OFFLINE=true cargo clippy -p ml -- -D warnings 2>&1 | tail -20`
Expected: No errors (warnings may exist but no denials)
---
## Constraints Checklist
- [ ] GPU: RTX 3050 Ti, 4GB VRAM — batch sizes clamped at 230
- [ ] `SQLX_OFFLINE=true` for all cargo commands
- [ ] Clippy denials: `unwrap_used`, `expect_used`, `panic`, `indexing_slicing` — safe patterns only
- [ ] No live broker integration (paper-trading only)
- [ ] Single asset (6E.FUT only)
## Summary
| Phase | Tasks | Key Deliverable |
|-------|-------|----------------|
| 1: Cleanup | 1-5 | Zero compilation errors across all targets |
| 2: Ensemble | 6-7 | `InferenceEnsemble::predict()` with 4 models |
| 3: Hyperopt | 8-10 | Campaign runner producing best params + checkpoint |
| 4: Paper Trading | 11-14 | Full pipeline: data → ensemble → signals → P&L |
| 5: Verification | 15 | All tests green, clippy clean |