From 4c03beb514e42395dc5f4b5f105a5cd61a60cfb9 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 5 Mar 2026 17:06:53 +0100 Subject: [PATCH] chore: track logo, hyperopt design docs, gitignore playwright-mcp Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + ...26-03-05-hyperopt-degenerate-fix-design.md | 70 +++ .../2026-03-05-hyperopt-degenerate-fix.md | 590 ++++++++++++++++++ fxhnt.png | Bin 0 -> 1500169 bytes 4 files changed, 661 insertions(+) create mode 100644 docs/plans/2026-03-05-hyperopt-degenerate-fix-design.md create mode 100644 docs/plans/2026-03-05-hyperopt-degenerate-fix.md create mode 100644 fxhnt.png diff --git a/.gitignore b/.gitignore index 43a7b8a90..903564d19 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,4 @@ test_data/databento/samples/ services/*/load_tests/results/ services/*/load_tests/*.json !services/*/load_tests/package.json +.playwright-mcp/ diff --git a/docs/plans/2026-03-05-hyperopt-degenerate-fix-design.md b/docs/plans/2026-03-05-hyperopt-degenerate-fix-design.md new file mode 100644 index 000000000..668f06089 --- /dev/null +++ b/docs/plans/2026-03-05-hyperopt-degenerate-fix-design.md @@ -0,0 +1,70 @@ +# Hyperopt Degenerate Trial Fix + +## Problem + +7/9 DQN hyperopt trials produce degenerate policies (all-buy or all-hold) that generate 0-1 trades in backtest evaluation. All degenerates receive identical objective=1.25, creating a flat plateau that gives PSO zero gradient signal. + +Root cause chain: +1. 45-action factored space collapses to 3 legacy actions (Buy/Sell/Hold) at eval time +2. EvaluationEngine treats consecutive same-direction actions as no-ops +3. Objective function can't distinguish between failure modes (all-buy vs all-hold vs insufficient trades) + +## Solution: Three-Part Fix + +### Part 1: Exposure-Aware Evaluation Engine + +Add `process_bar_factored(&FactoredAction)` to EvaluationEngine that tracks continuous position exposure (-1.0 to +1.0): + +- Long100→Long50 generates a partial close trade (delta=-0.5) +- Long50→Short100 generates a reversal trade (delta=-1.5) +- Long100→Long100 = no trade (delta=0) +- Transaction cost uses FactoredAction's order type (Market=0.15%, Limit=0.05%, IoC=0.10%) +- Position sizing: `effective_size = target_exposure * kelly_fraction` (multiplicative) + +File: `crates/ml/src/evaluation/engine.rs` (~50 lines) + +### Part 2: Graduated Trade Insufficiency Penalty + +Replace flat 1.25 plateau with smooth, differentiated penalties in `extract_objective()`: + +``` +min_expected_trades = total_bars / 500 (~448 for 224K bars) + +penalty = match total_trades { + 0 => 10.0, // Model does nothing + 1..10 => 5.0 + 5.0 * (1.0 - trades/10.0), // Near-degenerate + 10..min => 2.0 * (1.0 - trades/min_expected), // Insufficient activity + _ => 0.0, // Rely on Sharpe/Sortino +} +``` + +Additive on objective (higher=worse for PSO minimization). Each failure mode gets a unique value → PSO gets gradient signal. + +File: `crates/ml/src/hyperopt/adapters/dqn.rs` (~25 lines in `extract_objective()`) + +### Part 3: Wire Factored Eval into Hyperopt Backtest + +Replace legacy action collapse in backtest loop: +```rust +// Before: factored → legacy → Action::Buy/Sell/Hold → engine.process_bar() +// After: factored → engine.process_bar_factored() +``` + +File: `crates/ml/src/hyperopt/adapters/dqn.rs` (~3 lines in backtest loop) + +## Expected Impact + +- Degenerate trials now produce different objectives (10.0 vs 5.0 vs 2.0 vs 1.25) → PSO can navigate +- Factored eval generates more trades from partial position changes → richer performance metrics +- Models that learn Long100/Long50 switching get credit instead of being penalized +- Combined: PSO should find viable configs in 5-8 initial trials instead of relying on luck + +## Files Changed + +| File | Change | ~Lines | +|------|--------|--------| +| evaluation/engine.rs | process_bar_factored() | 50 | +| hyperopt/adapters/dqn.rs | extract_objective() penalty | 25 | +| hyperopt/adapters/dqn.rs | Backtest loop wiring | 3 | +| Tests | Unit tests | 80 | +| **Total** | | **~160** | diff --git a/docs/plans/2026-03-05-hyperopt-degenerate-fix.md b/docs/plans/2026-03-05-hyperopt-degenerate-fix.md new file mode 100644 index 000000000..f05cd5bdf --- /dev/null +++ b/docs/plans/2026-03-05-hyperopt-degenerate-fix.md @@ -0,0 +1,590 @@ +# Hyperopt Degenerate Trial Fix — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the flat objective plateau (1.25) that makes 7/9 hyperopt trials indistinguishable, giving PSO zero gradient signal in 26D space. + +**Architecture:** Three surgical changes: (1) Add exposure-aware eval to EvaluationEngine so factored actions generate trades from partial position changes, (2) Add graduated trade-count penalty to `extract_objective()` so different failure modes produce unique objective values, (3) Wire the factored eval into the hyperopt backtest loop (replace legacy 3-action collapse). + +**Tech Stack:** Rust, candle ML framework, EvaluationEngine, DQN hyperopt adapter, PSO optimizer + +--- + +### Task 1: Add `process_bar_factored()` to EvaluationEngine + +**Files:** +- Modify: `crates/ml/src/evaluation/engine.rs` (add new fields + method after line 184) + +**Step 1: Add new fields to EvaluationEngine struct** + +Add `current_exposure` field to track continuous position exposure. The existing `current_position` + `trades` + `kelly_fraction` remain unchanged. + +In `crates/ml/src/evaluation/engine.rs`, add after line 60 (`pub kelly_fraction: f64,`): + +```rust + /// Current position exposure for factored evaluation (-1.0 to +1.0) + /// None = not using factored eval, Some(f64) = active factored tracking + pub current_exposure: f64, + /// Entry price for current exposure-based position + pub exposure_entry_price: f32, + /// Bar index where current exposure was first entered + pub exposure_entry_bar: usize, +``` + +**Step 2: Initialize new fields in constructors** + +In `new_with_kelly()` (line 69), add after `kelly_fraction,`: + +```rust + current_exposure: 0.0, + exposure_entry_price: 0.0, + exposure_entry_bar: 0, +``` + +**Step 3: Add `process_bar_factored()` method** + +Add after `close_position()` (after line 184): + +```rust + /// Process a bar using the full 45-action factored space. + /// + /// Tracks continuous exposure (-1.0 to +1.0) and generates trades on + /// any exposure change (including partial: Long100→Long50 = sell 0.5). + /// Transaction costs use the FactoredAction's order type. + pub fn process_bar_factored( + &mut self, + bar_idx: usize, + bar: &OHLCVBarF32, + action: &crate::common::action::FactoredAction, + ) { + let target = action.target_exposure(); // -1.0, -0.5, 0.0, +0.5, +1.0 + let delta = target - self.current_exposure; + + // Update legacy action counts for compatibility with metrics + if target > 0.0 { + self.action_counts[0] += 1; // buy + } else if target < 0.0 { + self.action_counts[2] += 1; // sell + } else { + self.action_counts[1] += 1; // hold + } + + const EPSILON: f64 = 1e-6; + if delta.abs() < EPSILON { + return; // No position change + } + + // Record trade for the exposure change + let effective_delta = delta.abs() * self.kelly_fraction; + let fee_rate = action.transaction_cost() as f64; // 0.0015 Market, 0.0005 Limit, 0.001 IoC + + // PnL from the portion being closed (if reducing or reversing) + let closing_size = if delta.signum() != self.current_exposure.signum() && self.current_exposure.abs() > EPSILON { + // Closing part (or all) of existing position + self.current_exposure.abs().min(delta.abs()) + } else if delta.abs() < self.current_exposure.abs() && delta.signum() == -self.current_exposure.signum() { + delta.abs() + } else { + 0.0 + }; + + if closing_size > EPSILON { + let price_diff = bar.close - self.exposure_entry_price; + let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 }; + let gross_pnl = price_diff * direction_sign * (closing_size * self.kelly_fraction) as f32; + let tx_cost = (self.exposure_entry_price.abs() + bar.close.abs()) as f64 + * 0.5 * closing_size * self.kelly_fraction * fee_rate; + + self.trades.push(Trade { + entry_bar_idx: self.exposure_entry_bar, + exit_bar_idx: bar_idx, + entry_price: self.exposure_entry_price, + exit_price: bar.close, + direction: if self.current_exposure > 0.0 { + "long".to_owned() + } else { + "short".to_owned() + }, + pnl: gross_pnl - tx_cost as f32, + }); + } + + // Update exposure state + if (target.abs()) > EPSILON { + // Opening or adjusting — reset entry if crossing zero or first entry + if self.current_exposure.abs() < EPSILON || target.signum() != self.current_exposure.signum() { + self.exposure_entry_price = bar.close; + self.exposure_entry_bar = bar_idx; + } + } + self.current_exposure = target; + } + + /// Close any remaining factored exposure at end of backtest + pub fn close_factored_position(&mut self, bar_idx: usize, bar: &OHLCVBarF32) { + const EPSILON: f64 = 1e-6; + if self.current_exposure.abs() < EPSILON { + return; + } + let price_diff = bar.close - self.exposure_entry_price; + let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 }; + let size = self.current_exposure.abs() * self.kelly_fraction; + let gross_pnl = price_diff * direction_sign * size as f32; + let tx_cost = bar.close.abs() as f64 * size * 0.0015; // Market order for forced close + + self.trades.push(Trade { + entry_bar_idx: self.exposure_entry_bar, + exit_bar_idx: bar_idx, + entry_price: self.exposure_entry_price, + exit_price: bar.close, + direction: if self.current_exposure > 0.0 { + "long".to_owned() + } else { + "short".to_owned() + }, + pnl: gross_pnl - tx_cost as f32, + }); + self.current_exposure = 0.0; + } +``` + +**Step 4: Run `cargo check -p ml`** + +Run: `SQLX_OFFLINE=true cargo check -p ml` +Expected: compiles with 0 errors + +**Step 5: Commit** + +```bash +git add crates/ml/src/evaluation/engine.rs +git commit -m "feat(ml): add process_bar_factored() for exposure-aware backtesting + +Tracks continuous position exposure (-1.0 to +1.0) instead of binary +Buy/Sell/Hold. Partial position changes (Long100→Long50) now generate +trades, giving hyperopt richer evaluation signal." +``` + +--- + +### Task 2: Add unit tests for `process_bar_factored()` + +**Files:** +- Modify: `crates/ml/src/evaluation/engine.rs` (add `#[cfg(test)] mod tests` at bottom) + +**Step 1: Write tests** + +Add at the end of `crates/ml/src/evaluation/engine.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::common::action::{ExposureLevel, FactoredAction, OrderType, Urgency}; + + fn bar(close: f32) -> OHLCVBarF32 { + OHLCVBarF32 { timestamp: 0, open: close, high: close, low: close, close, volume: 0.0 } + } + + fn market_action(exposure: ExposureLevel) -> FactoredAction { + FactoredAction::new(exposure, OrderType::Market, Urgency::Normal) + } + + #[test] + fn factored_same_exposure_no_trade() { + let mut engine = EvaluationEngine::new(10000.0); + let b = bar(100.0); + engine.process_bar_factored(0, &b, &market_action(ExposureLevel::Long100)); + engine.process_bar_factored(1, &b, &market_action(ExposureLevel::Long100)); + assert_eq!(engine.trades.len(), 0, "Same exposure should generate no trades"); + } + + #[test] + fn factored_partial_close_generates_trade() { + let mut engine = EvaluationEngine::new(10000.0); + engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); + engine.process_bar_factored(1, &bar(110.0), &market_action(ExposureLevel::Long50)); + assert_eq!(engine.trades.len(), 1, "Long100→Long50 should generate 1 trade"); + assert!(engine.trades[0].pnl > 0.0, "Price went up on long = profit"); + assert_eq!(engine.current_exposure, 0.5); + } + + #[test] + fn factored_reversal_generates_trade() { + let mut engine = EvaluationEngine::new(10000.0); + engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); + engine.process_bar_factored(1, &bar(105.0), &market_action(ExposureLevel::Short100)); + assert!(engine.trades.len() >= 1, "Reversal should generate at least 1 trade"); + assert_eq!(engine.current_exposure, -1.0); + } + + #[test] + fn factored_flat_from_long_closes() { + let mut engine = EvaluationEngine::new(10000.0); + engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); + engine.process_bar_factored(1, &bar(95.0), &market_action(ExposureLevel::Flat)); + assert_eq!(engine.trades.len(), 1); + assert!(engine.trades[0].pnl < 0.0, "Price went down on long = loss"); + assert_eq!(engine.current_exposure, 0.0); + } + + #[test] + fn factored_close_at_end() { + let mut engine = EvaluationEngine::new(10000.0); + engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Short50)); + engine.close_factored_position(1, &bar(90.0)); + assert_eq!(engine.trades.len(), 1); + assert!(engine.trades[0].pnl > 0.0, "Price down on short = profit"); + assert_eq!(engine.current_exposure, 0.0); + } + + #[test] + fn factored_all_buy_still_one_trade_at_close() { + let mut engine = EvaluationEngine::new(10000.0); + for i in 0..100 { + engine.process_bar_factored(i, &bar(100.0 + i as f32), &market_action(ExposureLevel::Long100)); + } + // All same exposure → 0 trades during loop + assert_eq!(engine.trades.len(), 0); + // But close at end generates 1 + engine.close_factored_position(100, &bar(200.0)); + assert_eq!(engine.trades.len(), 1); + } + + #[test] + fn factored_alternating_generates_many_trades() { + let mut engine = EvaluationEngine::new(10000.0); + for i in 0..10 { + let action = if i % 2 == 0 { + market_action(ExposureLevel::Long100) + } else { + market_action(ExposureLevel::Short100) + }; + engine.process_bar_factored(i, &bar(100.0), &action); + } + // Each reversal closes + opens = 1 trade per transition after first + assert!(engine.trades.len() >= 9, "Alternating should generate many trades: got {}", engine.trades.len()); + } +} +``` + +**Step 2: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib evaluation::engine::tests -- --nocapture` +Expected: all 7 tests pass + +**Step 3: Commit** + +```bash +git add crates/ml/src/evaluation/engine.rs +git commit -m "test(ml): add unit tests for process_bar_factored()" +``` + +--- + +### Task 3: Add graduated trade insufficiency penalty to `extract_objective()` + +**Files:** +- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs:3105-3258` (`extract_objective()` method) + +**Step 1: Add `calculate_trade_insufficiency_penalty()` function** + +Add before `extract_objective()` (before line 3105): + +```rust +/// Calculate graduated penalty for insufficient trade count. +/// +/// Produces different objective values for different failure modes so PSO +/// gets gradient signal instead of a flat plateau at 1.25. +/// +/// Returns penalty in [0.0, 10.0] (additive on objective, higher = worse). +fn calculate_trade_insufficiency_penalty(total_trades: usize, total_bars: usize) -> f64 { + // Expected minimum trades: ~1 trade per 500 bars (8-hour avg hold at 1min) + let min_expected = (total_bars / 500).max(10); + + if total_trades == 0 { + 10.0 // Model does nothing at all + } else if total_trades < 10 { + 5.0 + 5.0 * (1.0 - total_trades as f64 / 10.0) // 5.0-10.0 range + } else if total_trades < min_expected { + 2.0 * (1.0 - total_trades as f64 / min_expected as f64) // 0.0-2.0 smooth + } else { + 0.0 // Sufficient trades → rely on Sharpe/Sortino + } +} +``` + +**Step 2: Wire penalty into `extract_objective()`** + +In `extract_objective()`, replace lines 3168-3230 (the `if let Some(backtest)` branch) with: + +```rust + let objective_total = if let Some(backtest) = &metrics.backtest_metrics { + // TRADE INSUFFICIENCY PENALTY: gives PSO gradient across degenerate plateau + let trade_penalty = calculate_trade_insufficiency_penalty( + backtest.total_trades, + backtest.total_trades.max(1) * 500, // Approximate total_bars from trade count context + ); + + // Short-circuit: if too few trades, skip composite score (it's all zeros anyway) + if backtest.total_trades < 10 { + info!( + "DEGENERATE TRIAL: {} trades → trade_penalty={:.2} (objective={:.2})", + backtest.total_trades, trade_penalty, trade_penalty + ); + trade_penalty + } else { + // Component 1: Multi-objective composite score (60% weight) + let composite_score = + 0.4 * backtest.sortino_ratio + + 0.3 * backtest.calmar_ratio + + 0.2 * backtest.sharpe_ratio + + 0.1 * backtest.omega_ratio; + + // Tail risk penalty + let cvar_penalty = if backtest.cvar_95 < -0.05 { 10.0 } else { 0.0 }; + + // Component 2: HFT activity score (25% weight) + let hft_activity = calculate_hft_activity_score_wave10(buy_pct, sell_pct, hold_pct); + + // Combine: base objective + trade insufficiency penalty + let base_objective = + -0.60 * composite_score + cvar_penalty + -0.25 * hft_activity + 0.15 * stability_penalty_raw; + + let objective = base_objective + trade_penalty; + + info!( + "OBJECTIVE: {:.4} = base {:.4} + trade_penalty {:.4} | trades={} composite={:.4}", + objective, base_objective, trade_penalty, backtest.total_trades, composite_score + ); + + objective + } +``` + +**Important:** We need `total_bars` in `extract_objective()`. Since it's not in `DQNMetrics`, we approximate from trade context. Alternatively, we can use a constant (224K bars is the standard dataset). Let me check if we can just use a constant: + +The training data is always the full validation set (~224K bars for ES.FUT). Rather than passing `total_bars` through metrics, use the simpler approach: hardcode `min_expected = 100` (conservative — even 100 trades on 224K bars means the model is barely active, but at least it's distinguishable from 0). + +Revised `calculate_trade_insufficiency_penalty()`: + +```rust +fn calculate_trade_insufficiency_penalty(total_trades: usize) -> f64 { + const MIN_VIABLE_TRADES: usize = 100; + + if total_trades == 0 { + 10.0 + } else if total_trades < 10 { + 5.0 + 5.0 * (1.0 - total_trades as f64 / 10.0) + } else if total_trades < MIN_VIABLE_TRADES { + 2.0 * (1.0 - total_trades as f64 / MIN_VIABLE_TRADES as f64) + } else { + 0.0 + } +} +``` + +**Step 3: Run check** + +Run: `SQLX_OFFLINE=true cargo check -p ml` +Expected: compiles with 0 errors + +**Step 4: Commit** + +```bash +git add crates/ml/src/hyperopt/adapters/dqn.rs +git commit -m "feat(ml): add graduated trade insufficiency penalty to hyperopt objective + +Different failure modes now produce unique objective values: +- 0 trades → 10.0 (model does nothing) +- 1 trade → 9.5 (single-direction collapse) +- 50 trades → 1.0 (insufficient activity) +- 100+ trades → 0.0 (rely on Sharpe/Sortino) + +This breaks the flat 1.25 plateau that gave PSO zero gradient signal." +``` + +--- + +### Task 4: Add unit tests for trade insufficiency penalty + +**Files:** +- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs` (add tests to existing `mod tests`) + +**Step 1: Write tests** + +Add to the existing `mod tests` block (after the last `#[test]` fn): + +```rust + #[test] + fn test_trade_insufficiency_penalty_zero_trades() { + let penalty = calculate_trade_insufficiency_penalty(0); + assert!((penalty - 10.0).abs() < 1e-6, "0 trades = max penalty: {}", penalty); + } + + #[test] + fn test_trade_insufficiency_penalty_one_trade() { + let penalty = calculate_trade_insufficiency_penalty(1); + assert!(penalty > 9.0 && penalty < 10.0, "1 trade near max: {}", penalty); + } + + #[test] + fn test_trade_insufficiency_penalty_graduated() { + let p0 = calculate_trade_insufficiency_penalty(0); + let p1 = calculate_trade_insufficiency_penalty(1); + let p5 = calculate_trade_insufficiency_penalty(5); + let p50 = calculate_trade_insufficiency_penalty(50); + let p100 = calculate_trade_insufficiency_penalty(100); + let p500 = calculate_trade_insufficiency_penalty(500); + + // Strictly decreasing (key property for PSO gradient) + assert!(p0 > p1, "0 > 1: {} > {}", p0, p1); + assert!(p1 > p5, "1 > 5: {} > {}", p1, p5); + assert!(p5 > p50, "5 > 50: {} > {}", p5, p50); + assert!(p50 > p100, "50 > 100: {} > {}", p50, p100); + assert!((p100 - 0.0).abs() < 1e-6, "100+ = no penalty: {}", p100); + assert!((p500 - 0.0).abs() < 1e-6, "500 = no penalty: {}", p500); + } + + #[test] + fn test_trade_insufficiency_no_flat_plateau() { + // The whole point: different trade counts → different penalties + let penalties: Vec = (0..20).map(|t| calculate_trade_insufficiency_penalty(t)).collect(); + for i in 0..19 { + assert!( + penalties[i] > penalties[i + 1] || (penalties[i] - penalties[i + 1]).abs() < 1e-6, + "penalty[{}]={} should be >= penalty[{}]={}", + i, penalties[i], i + 1, penalties[i + 1] + ); + } + // Verify no two adjacent values are the same (no plateaus in 0-10 range) + for i in 0..9 { + assert!( + (penalties[i] - penalties[i + 1]).abs() > 0.01, + "PLATEAU at trades={}: penalty[{}]={:.4} == penalty[{}]={:.4}", + i, i, penalties[i], i + 1, penalties[i + 1] + ); + } + } +``` + +**Step 2: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib hyperopt::adapters::dqn::tests::test_trade_insufficiency -- --nocapture` +Expected: all 4 tests pass + +**Step 3: Commit** + +```bash +git add crates/ml/src/hyperopt/adapters/dqn.rs +git commit -m "test(ml): add unit tests for trade insufficiency penalty gradient" +``` + +--- + +### Task 5: Wire factored eval into hyperopt backtest loop + +**Files:** +- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs:2764-2790` (backtest loop) + +**Step 1: Replace legacy action collapse with factored eval** + +Replace lines 2772-2788: + +```rust + let factored = crate::dqn::FactoredAction::from_index(action_idx)?; + let legacy = factored.to_legacy_action(); + let action = match legacy { + crate::dqn::TradingAction::Buy => Action::Buy, + crate::dqn::TradingAction::Sell => Action::Sell, + crate::dqn::TradingAction::Hold => Action::Hold, + }; + + let bar = OHLCVBarF32 { + timestamp: bar_idx as i64, + open: close, + high: close, + low: close, + close, + volume: 0.0, + }; + engine.process_bar(bar_idx, &bar, action); + ohlcv_bars.push(bar); +``` + +With: + +```rust + let factored = crate::dqn::FactoredAction::from_index(action_idx)?; + + let bar = OHLCVBarF32 { + timestamp: bar_idx as i64, + open: close, + high: close, + low: close, + close, + volume: 0.0, + }; + engine.process_bar_factored(bar_idx, &bar, &factored); + ohlcv_bars.push(bar); +``` + +**Step 2: Replace `close_position` with `close_factored_position` at backtest end** + +Replace line 2818 (`engine.close_position(ohlcv_bars.len() - 1, last_bar);`) with: + +```rust + engine.close_factored_position(ohlcv_bars.len() - 1, last_bar); +``` + +**Step 3: Run check** + +Run: `SQLX_OFFLINE=true cargo check -p ml` +Expected: compiles with 0 errors + +**Step 4: Run full test suite** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib -- --nocapture 2>&1 | tail -5` +Expected: all tests pass (2500+) + +**Step 5: Run clippy** + +Run: `SQLX_OFFLINE=true cargo clippy -p ml --lib -- -D warnings 2>&1 | tail -5` +Expected: 0 warnings + +**Step 6: Commit** + +```bash +git add crates/ml/src/hyperopt/adapters/dqn.rs +git commit -m "feat(ml): wire factored eval into hyperopt backtest loop + +Replace legacy Buy/Sell/Hold collapse with exposure-aware evaluation. +Long100→Long50 now generates a partial-close trade instead of being +a no-op. Combined with graduated trade penalty, PSO now gets gradient +signal across the entire 26D search space." +``` + +--- + +### Task 6: Verify full workspace builds and run integration tests + +**Files:** +- No files modified (verification only) + +**Step 1: Full workspace check** + +Run: `SQLX_OFFLINE=true cargo check --workspace` +Expected: compiles with 0 errors + +**Step 2: Run ml lib tests** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -3` +Expected: `test result: ok. 2500+ passed; 0 failed` + +**Step 3: Run clippy on workspace** + +Run: `SQLX_OFFLINE=true cargo clippy --workspace -- -D warnings 2>&1 | tail -5` +Expected: 0 errors, 0 warnings + +**Step 4: Commit (no changes, verification only)** + +No commit needed — this is verification only. diff --git a/fxhnt.png b/fxhnt.png new file mode 100644 index 0000000000000000000000000000000000000000..ebc67beb55c9b088c0dfe508ebecef916e47c88e GIT binary patch literal 1500169 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