fix: update 9 test files for 14D DQNParams restructure

Tests referenced old DQNParams fields (learning_rate, batch_size,
ensemble_size, etc.) that were absorbed into family intensity scalars.
Rewrote all affected tests to validate the 14D search space layout,
intensity bounds [0.0, 2.0], and round-trip serialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-31 01:30:33 +02:00
parent 78fd699946
commit 3d7d9c3d03
10 changed files with 373 additions and 363 deletions

View File

@@ -2991,65 +2991,6 @@ mod tests {
assert!(params.validate_for_hft_trendfollowing().is_ok());
}
#[test]
fn test_validate_rejects_invalid_gamma() {
let params = DQNParams {
gamma: 1.5,
..DQNParams::default()
};
let err = params.validate_for_hft_trendfollowing().unwrap_err();
assert!(err.contains("gamma"), "error should mention gamma: {err}");
// Zero atoms
let params_zero = DQNParams {
num_atoms: 0,
..DQNParams::default()
};
assert!(params_zero.validate_for_hft_trendfollowing().is_err());
}
#[test]
fn test_validate_rejects_zero_batch_size() {
let params = DQNParams {
batch_size: 0,
..DQNParams::default()
};
let err = params.validate_for_hft_trendfollowing().unwrap_err();
assert!(err.contains("batch_size"), "error should mention batch_size: {err}");
}
#[test]
fn test_validate_rejects_invalid_learning_rate() {
// Zero LR
let params_zero = DQNParams {
learning_rate: 0.0,
..DQNParams::default()
};
assert!(params_zero.validate_for_hft_trendfollowing().is_err());
// Negative LR
let params_neg = DQNParams {
learning_rate: -1e-4,
..DQNParams::default()
};
assert!(params_neg.validate_for_hft_trendfollowing().is_err());
// LR too high (> 0.1)
let params_high = DQNParams {
learning_rate: 0.2,
..DQNParams::default()
};
let err = params_high.validate_for_hft_trendfollowing().unwrap_err();
assert!(err.contains("learning_rate"), "error should mention learning_rate: {err}");
// LR at upper bound (0.1) should pass
let params_boundary = DQNParams {
learning_rate: 0.1,
..DQNParams::default()
};
assert!(params_boundary.validate_for_hft_trendfollowing().is_ok());
}
#[test]
fn test_validate_rejects_invalid_gamma() {
// Zero gamma

View File

@@ -575,42 +575,37 @@ fn test_c3_search_space_is_14d() {
assert!(!names.contains(&"cvar_alpha"), "cvar_alpha should be fixed, not in search space");
}
/// Verify noisy_epsilon_floor is fixed to 0.10 (prevents action collapse).
/// Verify exploration_intensity defaults to 1.0 (neutral scaling of exploration params).
/// noisy_epsilon_floor and count_bonus_coefficient are now fixed in DQNHyperparameters,
/// not in DQNParams.
#[test]
fn test_noisy_epsilon_floor_fixed() {
fn test_exploration_intensity_default() {
let params = crate::hyperopt::adapters::dqn::DQNParams::default();
assert!(
(params.noisy_epsilon_floor - 0.10).abs() < 1e-6,
"noisy_epsilon_floor must default to 0.10 (prevents action collapse)"
(params.exploration_intensity - 1.0).abs() < 1e-6,
"exploration_intensity must default to 1.0 (neutral)"
);
}
/// Verify exploration params are fixed after C2 cleanup.
/// Verify DQNParams 14D roundtrip preserves all family intensities.
#[test]
fn test_c2_exploration_params_fixed() {
let params = crate::hyperopt::adapters::dqn::DQNParams::default();
assert!(
params.curiosity_weight.abs() < f64::EPSILON,
"curiosity_weight must be fixed at 0.0"
);
assert!(
(params.noisy_epsilon_floor - 0.10).abs() < 1e-6,
"noisy_epsilon_floor must be fixed at 0.10"
);
assert!(
(params.count_bonus_coefficient - 0.05).abs() < 1e-6,
"count_bonus_coefficient should be 0.05 (UCB exploration enabled)"
);
fn test_14d_roundtrip_preserves_intensities() {
use crate::hyperopt::traits::ParameterSpace;
// Roundtrip through from_continuous should preserve fixed values
let params = crate::hyperopt::adapters::dqn::DQNParams::default();
let continuous = params.to_continuous();
let recovered = crate::hyperopt::adapters::dqn::DQNParams::from_continuous(&continuous).unwrap();
assert!(
recovered.curiosity_weight.abs() < f64::EPSILON,
"curiosity_weight must remain 0.0 after roundtrip"
(recovered.exploration_intensity - params.exploration_intensity).abs() < 1e-6,
"exploration_intensity must survive roundtrip"
);
assert!(
(recovered.count_bonus_coefficient - 0.05).abs() < 1e-6,
"count_bonus_coefficient must remain 0.05 after roundtrip"
(recovered.learning_intensity - params.learning_intensity).abs() < 1e-6,
"learning_intensity must survive roundtrip"
);
assert!(
(recovered.ensemble_intensity - params.ensemble_intensity).abs() < 1e-6,
"ensemble_intensity must survive roundtrip"
);
}

View File

@@ -212,14 +212,14 @@ fn test_idle_penalty_in_gpu_composite_reward() {
// ── 4. Hyperopt 26D search space includes cql_alpha ────────────────────────
#[test]
fn test_hyperopt_22d_search_space() {
fn test_hyperopt_14d_search_space() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let bounds = DQNParams::continuous_bounds();
assert_eq!(
bounds.len(), 22,
"Search space should be 22D (consolidated from 46D), got {}D",
bounds.len(), 14,
"Search space should be 14D (3 breakouts + 5 core + 6 gen families), got {}D",
bounds.len()
);
@@ -231,28 +231,27 @@ fn test_hyperopt_22d_search_space() {
names.len(), bounds.len()
);
// cql_alpha is now FIXED at default (not in search space)
// cql_alpha is fixed at TOML default (not in search space)
assert!(
!names.contains(&"cql_alpha"),
"cql_alpha should be fixed (not in 22D search space)"
"cql_alpha should be fixed (not in 14D search space)"
);
}
#[test]
fn test_hyperopt_v_max_in_search_space() {
fn test_hyperopt_architecture_intensity_in_search_space() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
let bounds = DQNParams::continuous_bounds();
let names = DQNParams::param_names();
// v_max is at index 14 in the 22D space with range [5, 300]
let v_max_idx = names.iter().position(|&n| n == "v_max")
.expect("v_max should be in param_names");
let (lo, hi) = bounds[v_max_idx];
assert!(lo >= 1.0 && hi <= 500.0,
"v_max bounds should be reasonable, got ({}, {})", lo, hi);
assert!(lo < hi, "v_max should have a range, got ({}, {})", lo, hi);
// v_max is now absorbed into the architecture family. Verify architecture_intensity exists.
let arch_idx = names.iter().position(|&n| n == "architecture_intensity")
.expect("architecture_intensity should be in param_names");
let (lo, hi) = bounds[arch_idx];
assert_eq!(lo, 0.0, "architecture_intensity lower bound should be 0.0");
assert_eq!(hi, 2.0, "architecture_intensity upper bound should be 2.0");
}
#[test]
@@ -261,12 +260,11 @@ fn test_hyperopt_iqn_always_enabled() {
let params = DQNParams::default();
// IQN is always enabled (iqn_lambda > 0 by default)
assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default");
assert!(params.num_quantiles >= 32, "Should have at least 32 quantiles");
assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default (iqn_lambda > 0)");
}
#[test]
fn test_hyperopt_22d_round_trip() {
fn test_hyperopt_14d_round_trip() {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
@@ -278,12 +276,12 @@ fn test_hyperopt_22d_round_trip() {
let reconstructed =
DQNParams::from_continuous(&continuous).expect("from_continuous should succeed");
// cql_alpha is fixed at default in 22D space
let diff = (reconstructed.cql_alpha - params.cql_alpha).abs();
// gamma should survive round-trip
let diff = (reconstructed.gamma - params.gamma).abs();
assert!(
diff < 0.01,
"cql_alpha should preserve default: expected {}, got {} (diff={})",
params.cql_alpha, reconstructed.cql_alpha, diff
"gamma should preserve default: expected {}, got {} (diff={})",
params.gamma, reconstructed.gamma, diff
);
}

View File

@@ -173,12 +173,11 @@ mod hyperopt_json_export_tests {
assert!(!trial.timestamp.is_empty(), "Timestamp should not be empty");
assert!(trial.gradient_clip_norm > 0.0, "Gradient clip norm should be > 0");
// Verify hyperparameters are present (all 21 fields)
// Verify hyperparameters are present (14 fields)
let params = trial.hyperparameters;
assert!(params.learning_rate > 0.0, "Learning rate should be > 0");
assert!(params.batch_size > 0, "Batch size should be > 0");
assert!(params.gamma > 0.0 && params.gamma < 1.0, "Gamma should be in (0, 1)");
assert!(params.buffer_size > 0, "Buffer size should be > 0");
assert!(params.learning_intensity >= 0.0 && params.learning_intensity <= 2.0, "learning_intensity should be in [0, 2]");
assert!(params.iqn_lambda >= 0.0, "iqn_lambda should be >= 0");
info!(trial_number = trial.trial_number, sharpe = trial.sharpe, "JSON file created with best trial");
@@ -278,30 +277,26 @@ mod hyperopt_json_export_tests {
assert_eq!(saved_trial.gradient_clip_norm, roundtrip_trial.gradient_clip_norm,
"Gradient clip norm should match exactly");
// Verify all 21 hyperparameters match
// Verify all 14 hyperparameters match (3 breakouts + 11 family intensities)
let saved_params = saved_trial.hyperparameters;
let roundtrip_params = roundtrip_trial.hyperparameters;
assert_eq!(saved_params.learning_rate, roundtrip_params.learning_rate);
assert_eq!(saved_params.batch_size, roundtrip_params.batch_size);
assert_eq!(saved_params.gamma, roundtrip_params.gamma);
assert_eq!(saved_params.buffer_size, roundtrip_params.buffer_size);
assert_eq!(saved_params.max_position_absolute, roundtrip_params.max_position_absolute);
assert_eq!(saved_params.huber_delta, roundtrip_params.huber_delta);
assert_eq!(saved_params.entropy_coefficient, roundtrip_params.entropy_coefficient);
assert_eq!(saved_params.transaction_cost_multiplier, roundtrip_params.transaction_cost_multiplier);
assert_eq!(saved_params.per_alpha, roundtrip_params.per_alpha);
assert_eq!(saved_params.per_beta_start, roundtrip_params.per_beta_start);
assert_eq!(saved_params.dueling_hidden_dim, roundtrip_params.dueling_hidden_dim);
assert_eq!(saved_params.n_steps, roundtrip_params.n_steps);
assert_eq!(saved_params.tau, roundtrip_params.tau);
assert_eq!(saved_params.num_atoms, roundtrip_params.num_atoms);
assert_eq!(saved_params.v_min, roundtrip_params.v_min);
assert_eq!(saved_params.v_max, roundtrip_params.v_max);
assert_eq!(saved_params.noisy_sigma_init, roundtrip_params.noisy_sigma_init);
assert_eq!(saved_params.minimum_profit_factor, roundtrip_params.minimum_profit_factor);
assert_eq!(saved_params.iqn_lambda, roundtrip_params.iqn_lambda);
assert_eq!(saved_params.c51_warmup_epochs, roundtrip_params.c51_warmup_epochs);
assert_eq!(saved_params.learning_intensity, roundtrip_params.learning_intensity);
assert_eq!(saved_params.exploration_intensity, roundtrip_params.exploration_intensity);
assert_eq!(saved_params.replay_intensity, roundtrip_params.replay_intensity);
assert_eq!(saved_params.architecture_intensity, roundtrip_params.architecture_intensity);
assert_eq!(saved_params.risk_intensity, roundtrip_params.risk_intensity);
assert_eq!(saved_params.adversarial_intensity, roundtrip_params.adversarial_intensity);
assert_eq!(saved_params.regularization_intensity, roundtrip_params.regularization_intensity);
assert_eq!(saved_params.augmentation_intensity, roundtrip_params.augmentation_intensity);
assert_eq!(saved_params.loss_shaping_intensity, roundtrip_params.loss_shaping_intensity);
assert_eq!(saved_params.ensemble_intensity, roundtrip_params.ensemble_intensity);
assert_eq!(saved_params.causal_intensity, roundtrip_params.causal_intensity);
info!("All 21 hyperparameters + metadata survive roundtrip with perfect fidelity");
info!("All 14 hyperparameters + metadata survive roundtrip with perfect fidelity");
cleanup_test_jsons("best_trial_sharpe_");
@@ -350,20 +345,17 @@ mod hyperopt_json_export_tests {
assert!(timestamp.contains('T') && timestamp.contains('Z'),
"Timestamp should be ISO 8601 format: {}", timestamp);
// Verify hyperparameters object has all 21 fields
// Verify hyperparameters object has all 14 fields
let hyperparams = raw_json["hyperparameters"].as_object()
.expect("Hyperparameters should be an object");
let required_fields = vec![
"learning_rate", "batch_size", "gamma", "buffer_size",
"max_position_absolute", "huber_delta",
"entropy_coefficient", "transaction_cost_multiplier",
"use_per", "per_alpha", "per_beta_start",
"use_dueling", "dueling_hidden_dim", "n_steps", "tau",
"use_distributional", "num_atoms", "v_min", "v_max",
"use_noisy_nets", "noisy_sigma_init", "minimum_profit_factor",
"w_dsr", "w_pnl", "w_dd", "w_idle",
"dd_threshold", "loss_aversion", "time_decay_rate",
"gamma", "iqn_lambda", "c51_warmup_epochs",
"learning_intensity", "exploration_intensity", "replay_intensity",
"architecture_intensity", "risk_intensity",
"adversarial_intensity", "regularization_intensity",
"augmentation_intensity", "loss_shaping_intensity",
"ensemble_intensity", "causal_intensity",
];
for field in required_fields {
@@ -371,7 +363,7 @@ mod hyperopt_json_export_tests {
"Hyperparameters should contain field: {}", field);
}
info!("JSON contains all 8 metadata fields + 21 hyperparameter fields");
info!("JSON contains all 8 metadata fields + 14 hyperparameter fields");
cleanup_test_jsons("best_trial_sharpe_");

View File

@@ -209,28 +209,27 @@ fn test_dqn_hyperopt_10_trials() -> Result<()> {
);
}
// 7. Best params should have sane learning rate (log-scale, typically 1e-5 to 1e-2)
let best_lr = result.best_params.learning_rate;
// 7. Best params should have sane gamma (in [0.95, 0.999])
let best_gamma = result.best_params.gamma;
assert!(
best_lr > 1e-7 && best_lr < 1.0,
"Best learning rate out of sane range: {best_lr}"
best_gamma >= 0.95 && best_gamma <= 0.999,
"Best gamma out of sane range: {best_gamma}"
);
// 8. Best params batch size should be positive and bounded
let best_bs = result.best_params.batch_size;
// 8. Best params learning_intensity should be in [0.0, 2.0]
let best_li = result.best_params.learning_intensity;
assert!(
best_bs > 0 && best_bs <= 512,
"Best batch size out of range: {best_bs}"
best_li >= 0.0 && best_li <= 2.0,
"Best learning_intensity out of range: {best_li}"
);
// --- Report ---
info!(
trials_completed,
best_objective = result.best_objective,
best_lr = result.best_params.learning_rate,
best_batch_size = result.best_params.batch_size,
best_gamma = result.best_params.gamma,
best_buffer_size = result.best_params.buffer_size,
best_learning_intensity = result.best_params.learning_intensity,
best_exploration_intensity = result.best_params.exploration_intensity,
total_secs = elapsed.as_secs_f64(),
avg_secs_per_trial = elapsed.as_secs_f64() / trials_completed as f64,
"DQN HYPEROPT REPORT"
@@ -240,8 +239,8 @@ fn test_dqn_hyperopt_10_trials() -> Result<()> {
trial_num = trial.trial_num,
objective = trial.objective,
duration_secs = trial.duration_secs,
lr = trial.params.learning_rate,
batch_size = trial.params.batch_size,
gamma = trial.params.gamma,
learning_intensity = trial.params.learning_intensity,
"Trial history"
);
}

View File

@@ -129,27 +129,25 @@ fn test_cost_weight_is_full_weight() {
info!("cost_weight = 1.0 (Bug #2 fix implemented): before=0.05 (5% of actual costs), after=1.0 (100% of actual costs)");
}
/// Test that transaction_cost_multiplier parameter still exists (NOT removed yet)
/// Test that transaction_cost_multiplier has been removed from DQNParams.
///
/// **Current**: DQNParams DOES have transaction_cost_multiplier field
/// **Expected (after fix)**: Field should be removed
/// **Status**: Bug #2 fix NOT YET IMPLEMENTED
/// After the 14D family-intensity restructuring, transaction_cost_multiplier
/// is no longer a tunable hyperparameter. Transaction costs are fixed market
/// rates from OrderType enum.
#[test]
fn test_transaction_cost_multiplier_removed() {
// Verify field still exists in current implementation
let params = DQNParams::default();
// DQNParams now only has 14 fields (3 breakouts + 11 family intensities).
// transaction_cost_multiplier was removed -- verify the search space is 14D.
let bounds = DQNParams::continuous_bounds();
assert_eq!(bounds.len(), 14, "Search space should be 14D after family consolidation");
// Access the field to prove it exists
let tx_cost = params.transaction_cost_multiplier;
// Verify default value is 1.0
assert_eq!(
tx_cost,
1.0,
"transaction_cost_multiplier should default to 1.0"
let names = DQNParams::param_names();
assert!(
!names.contains(&"transaction_cost_multiplier"),
"transaction_cost_multiplier should NOT be in search space"
);
info!(tx_cost, "transaction_cost_multiplier field still exists in DQNParams; Bug #2 fix will remove this field");
info!("transaction_cost_multiplier removed from DQNParams; transaction costs are fixed market rates from OrderType");
}
/// Test that LimitMaker orders have lowest cost (0.05%)
@@ -182,33 +180,26 @@ fn test_order_type_transaction_costs() {
/// Integration test: Verify hyperopt search space dimensions
///
/// **Current State**: Search space is 18D (11D base + 6D Rainbow + 1D Bug #7)
/// **Bug #2 STATUS**: Transaction cost multiplier NOT YET REMOVED (still at dimension #8)
/// **Note**: Future Bug #2 fix will remove transaction_cost_multiplier → 17D
/// Search space is now 14D (3 breakouts + 5 core families + 6 gen families).
/// transaction_cost_multiplier was removed during family-intensity consolidation.
#[test]
fn test_hyperopt_search_space_reduced() {
let bounds = DQNParams::continuous_bounds();
// Verify current search space is 18D
// Breakdown: 11D base (including transaction_cost_multiplier) + 6D Rainbow + 1D minimum_profit_factor
assert_eq!(
bounds.len(),
18,
"Current hyperopt search space should be 18D\n\
Got: {}D\n\
Breakdown: 11D base + 6D Rainbow + 1D minimum_profit_factor",
14,
"Hyperopt search space should be 14D (3 breakouts + 5 core + 6 gen families), got {}D",
bounds.len()
);
// Verify transaction_cost_multiplier is STILL in search space at dimension #8
let tx_cost_bounds = bounds[8];
assert_eq!(
tx_cost_bounds,
(0.5, 2.0),
"Transaction cost multiplier should be at dimension #8 with bounds (0.5, 2.0)"
let names = DQNParams::param_names();
assert!(
!names.contains(&"transaction_cost_multiplier"),
"transaction_cost_multiplier should not be in 14D search space"
);
info!(dims = bounds.len(), "Hyperopt search space is 18D (11D base + 6D Rainbow + 1D profit); dimension #8: transaction_cost_multiplier (0.5-2.0) still present; Bug #2 fix not yet implemented");
info!(dims = bounds.len(), "Hyperopt search space is 14D; transaction_cost_multiplier removed (fixed market rates)");
}
/// Test that transaction costs correctly reflect factored action order types

View File

@@ -72,11 +72,12 @@
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! TDD Tests for Ensemble Uncertainty Fields in Hyperopt
//! TDD Tests for Ensemble Intensity in the 14D Hyperopt Search Space
//!
//! Validates that ensemble uncertainty parameters are properly set as fixed defaults
//! in the current 39D search space (ensemble params were removed from search space,
//! they are now fixed in from_continuous).
//! Validates that ensemble_intensity is properly included in the 14D search space
//! and that from_continuous / to_continuous round-trip correctly.
//! Individual ensemble params (ensemble_size, beta_variance, etc.) are now fixed
//! in DQNHyperparameters, not in DQNParams.
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::ParameterSpace;
@@ -84,10 +85,9 @@ use ml::trainers::dqn::DQNHyperparameters;
#[test]
fn test_dqn_hyperparameters_has_ensemble_fields() {
// WAVE 26 P1.4: Verify DQNHyperparameters struct has 6 new ensemble fields
// Verify DQNHyperparameters struct has ensemble fields (these live here, not in DQNParams)
let hyperparams = DQNHyperparameters::conservative();
// Check default values exist (compilation test - if fields don't exist, this won't compile)
let _use_ensemble = hyperparams.use_ensemble_uncertainty;
let _ensemble_size = hyperparams.ensemble_size;
let _beta_variance = hyperparams.beta_variance;
@@ -95,7 +95,6 @@ fn test_dqn_hyperparameters_has_ensemble_fields() {
let _beta_entropy = hyperparams.beta_entropy;
let _variance_cap = hyperparams.variance_cap;
// Verify conservative defaults are reasonable
assert!(!hyperparams.use_ensemble_uncertainty, "Default should be disabled for conservative config");
assert_eq!(hyperparams.ensemble_size, 5, "Default ensemble size should be 5");
assert!(hyperparams.beta_variance >= 0.0 && hyperparams.beta_variance <= 1.0, "Beta variance should be in [0, 1]");
@@ -105,119 +104,103 @@ fn test_dqn_hyperparameters_has_ensemble_fields() {
}
#[test]
fn test_dqn_params_has_ensemble_fields() {
// WAVE 26 P1.4: Verify DQNParams struct has 5 new ensemble fields
fn test_dqn_params_has_ensemble_intensity() {
// DQNParams now has ensemble_intensity (a single scalar) instead of individual ensemble fields
let params = DQNParams::default();
// Check default values exist (compilation test)
let _use_ensemble = params.use_ensemble_uncertainty;
let _ensemble_size = params.ensemble_size;
let _beta_variance = params.beta_variance;
let _beta_disagreement = params.beta_disagreement;
let _beta_entropy = params.beta_entropy;
// Verify defaults match conservative config
assert!(!params.use_ensemble_uncertainty, "Default should be disabled");
assert_eq!(params.ensemble_size, 5.0, "Default ensemble size should be 5.0");
assert!(params.beta_variance > 0.0, "Beta variance should be positive");
assert!(params.beta_disagreement > 0.0, "Beta disagreement should be positive");
assert!(params.beta_entropy > 0.0, "Beta entropy should be positive");
assert!(
params.ensemble_intensity >= 0.0 && params.ensemble_intensity <= 2.0,
"ensemble_intensity should be in [0.0, 2.0], got {}",
params.ensemble_intensity
);
assert!(
(params.ensemble_intensity - 1.0).abs() < 1e-6,
"Default ensemble_intensity should be 1.0 (neutral)"
);
}
#[test]
fn test_ensemble_search_space_bounds() {
// Ensemble params (ensemble_size, beta_variance, etc.) are no longer in the
// continuous search space — they are fixed in from_continuous(). Verify that
// the 39D search space is properly sized and ensemble defaults are set.
// 14D search space: 3 breakouts + 5 core families + 6 gen families
let bounds = DQNParams::continuous_bounds();
assert_eq!(bounds.len(), 39, "Search space should have 39 continuous parameters total (C8: +8 reward/policy weights)");
assert_eq!(bounds.len(), 14, "Search space should have 14 continuous parameters");
// Ensemble params are NOT in bounds — they're fixed in from_continuous.
// Verify a DQNParams default still has correct ensemble defaults.
let params = DQNParams::default();
assert!(!params.use_ensemble_uncertainty, "Default: ensemble disabled");
assert_eq!(params.ensemble_size, 5.0, "Default ensemble size should be 5.0");
assert!(params.beta_variance > 0.0, "Beta variance should be positive");
assert!(params.beta_disagreement > 0.0, "Beta disagreement should be positive");
assert!(params.beta_entropy > 0.0, "Beta entropy should be positive");
// ensemble_intensity is at index 12
let names = DQNParams::param_names();
let ens_idx = names.iter().position(|&n| n == "ensemble_intensity")
.expect("ensemble_intensity should be in param_names");
assert_eq!(ens_idx, 12, "ensemble_intensity should be at index 12");
let (lo, hi) = bounds[ens_idx];
assert_eq!(lo, 0.0, "ensemble_intensity lower bound should be 0.0");
assert_eq!(hi, 2.0, "ensemble_intensity upper bound should be 2.0");
}
#[test]
fn test_from_continuous_validates_ensemble_params() {
// Ensemble params are now fixed in from_continuous() (not in 39D search space).
// Verify that from_continuous produces correct fixed ensemble defaults.
fn test_from_continuous_sets_ensemble_intensity() {
// from_continuous with midpoint values
let bounds = DQNParams::continuous_bounds();
let x: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
assert_eq!(x.len(), 39, "Should produce 39D midpoint vector");
assert_eq!(x.len(), 14, "Should produce 14D midpoint vector");
let params = DQNParams::from_continuous(&x).expect("Should convert valid parameters");
// Ensemble parameters are fixed to conservative defaults
assert_eq!(params.ensemble_size, 5.0, "Ensemble size should be fixed to 5.0");
assert_eq!(params.beta_variance, 0.5, "Beta variance should be fixed to 0.5");
assert_eq!(params.beta_disagreement, 0.5, "Beta disagreement should be fixed to 0.5");
assert_eq!(params.beta_entropy, 0.2, "Beta entropy should be fixed to 0.2");
assert!(!params.use_ensemble_uncertainty, "Ensemble uncertainty should be disabled");
// ensemble_intensity should be the midpoint of [0.0, 2.0] = 1.0
assert!(
(params.ensemble_intensity - 1.0).abs() < 1e-6,
"ensemble_intensity should be 1.0 at midpoint, got {}",
params.ensemble_intensity
);
}
#[test]
fn test_from_continuous_clamps_ensemble_params() {
// Ensemble parameters are now fixed (not in search space), so clamping
// is not relevant. Instead verify that from_continuous rejects wrong-length input.
let x = vec![0.0; 40]; // Wrong length (should be 39)
fn test_from_continuous_rejects_wrong_length() {
// from_continuous rejects wrong-length input
let x = vec![0.0; 15]; // Wrong length (should be 14)
let result = DQNParams::from_continuous(&x);
assert!(result.is_err(), "Should reject wrong-length (40) input");
assert!(result.is_err(), "Should reject wrong-length (15) input");
let x2 = vec![0.0; 31]; // Also wrong length
let x2 = vec![0.0; 13]; // Also wrong length
let result2 = DQNParams::from_continuous(&x2);
assert!(result2.is_err(), "Should reject wrong-length (31) input");
assert!(result2.is_err(), "Should reject wrong-length (13) input");
// Correct length (39) with valid midpoints should succeed
// Correct length (14) with valid midpoints should succeed
let bounds = DQNParams::continuous_bounds();
let x38: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
let params = DQNParams::from_continuous(&x38).expect("Should accept 39D input");
// Ensemble params are fixed regardless of input
assert_eq!(params.ensemble_size, 5.0, "Ensemble size fixed to 5.0");
let x14: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
let params = DQNParams::from_continuous(&x14).expect("Should accept 14D input");
assert!(
(params.ensemble_intensity - 1.0).abs() < 1e-6,
"ensemble_intensity should be 1.0 at midpoint"
);
}
#[test]
fn test_to_continuous_includes_reward_weights() {
// C8: Verify that to_continuous() includes 7 composite reward weights (indices 31-37)
fn test_to_continuous_roundtrip() {
// Verify to_continuous includes all 14 fields and round-trips correctly
let mut params = DQNParams::default();
// Set reward weights to specific values
params.w_dsr = 1.5;
params.w_pnl = 0.5;
params.w_dd = 2.0;
params.w_idle = 0.05;
params.dd_threshold = 0.04;
params.loss_aversion = 2.0;
params.time_decay_rate = 0.001;
params.ensemble_intensity = 1.5;
params.causal_intensity = 0.8;
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 39, "Continuous representation should have 39 values");
assert_eq!(continuous.len(), 14, "Continuous representation should have 14 values");
// Verify reward weight positions (indices 31-37)
assert_eq!(continuous[31], 1.5, "w_dsr should be at position 31");
assert_eq!(continuous[32], 0.5, "w_pnl should be at position 32");
assert_eq!(continuous[33], 2.0, "w_dd should be at position 33");
assert_eq!(continuous[34], 0.05, "w_idle should be at position 34");
assert_eq!(continuous[35], 0.04, "dd_threshold should be at position 35");
assert_eq!(continuous[36], 2.0, "loss_aversion should be at position 36");
assert_eq!(continuous[37], 0.001, "time_decay_rate should be at position 37");
// Ensemble params are NOT in the continuous vector (they're fixed)
// Verify the vector doesn't include them
assert_eq!(continuous.len(), 39);
let recovered = DQNParams::from_continuous(&continuous).expect("Should recover from continuous");
assert!(
(recovered.ensemble_intensity - 1.5).abs() < 1e-6,
"ensemble_intensity should survive roundtrip: expected 1.5, got {}",
recovered.ensemble_intensity
);
assert!(
(recovered.causal_intensity - 0.8).abs() < 1e-6,
"causal_intensity should survive roundtrip: expected 0.8, got {}",
recovered.causal_intensity
);
}
#[test]
fn test_ensemble_size_fixed_in_from_continuous() {
// Ensemble size is now fixed at 5.0 in from_continuous() — not in search space.
// Verify that from_continuous always produces ensemble_size=5.0 regardless of input.
let bounds = DQNParams::continuous_bounds();
let x: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
let params = DQNParams::from_continuous(&x).expect("Should convert");
assert_eq!(params.ensemble_size, 5.0, "Ensemble size should always be fixed to 5.0");
assert!(!params.use_ensemble_uncertainty, "Ensemble uncertainty should be disabled");
fn test_ensemble_intensity_in_param_names() {
// Verify ensemble_intensity is listed in param_names at position 12
let names = DQNParams::param_names();
assert_eq!(names.len(), 14, "param_names should have 14 entries");
assert_eq!(names[12], "ensemble_intensity", "Index 12 should be ensemble_intensity");
assert_eq!(names[13], "causal_intensity", "Index 13 should be causal_intensity");
}

View File

@@ -72,79 +72,81 @@
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! Hyperopt search space dimension tests (updated for 14D family-intensity structure).
//!
//! Kelly params (kelly_fractional, kelly_max_fraction, etc.) are now fixed at TOML
//! defaults and absorbed into the risk_intensity family. The search space is 14D.
#[cfg(test)]
mod hyperopt_kelly_params_tests {
use ml::hyperopt::adapters::dqn::DQNParams;
use ml::hyperopt::traits::ParameterSpace;
#[test]
fn test_search_space_has_22_dimensions() {
fn test_search_space_has_14_dimensions() {
let bounds = DQNParams::continuous_bounds();
assert_eq!(bounds.len(), 22, "Search space should be 22D after adding Kelly params");
assert_eq!(bounds.len(), 14, "Search space should be 14D (3 breakouts + 5 core + 6 gen families)");
}
#[test]
fn test_kelly_fractional_bounds() {
fn test_risk_intensity_bounds() {
let bounds = DQNParams::continuous_bounds();
let (min, max) = bounds[18]; // Kelly fractional at index 18
assert_eq!(min, 0.25, "Kelly fractional min should be 0.25 (quarter-Kelly)");
assert_eq!(max, 1.0, "Kelly fractional max should be 1.0 (full-Kelly)");
let names = DQNParams::param_names();
let risk_idx = names.iter().position(|&n| n == "risk_intensity")
.expect("risk_intensity should be in param_names");
let (min, max) = bounds[risk_idx];
assert_eq!(min, 0.0, "risk_intensity min should be 0.0");
assert_eq!(max, 2.0, "risk_intensity max should be 2.0");
}
#[test]
fn test_kelly_max_fraction_bounds() {
fn test_from_continuous_accepts_14d() {
let bounds = DQNParams::continuous_bounds();
let (min, max) = bounds[19];
assert_eq!(min, 0.1, "Kelly max_fraction min should be 0.1 (10%)");
assert_eq!(max, 0.5, "Kelly max_fraction max should be 0.5 (50%)");
}
#[test]
fn test_kelly_min_trades_bounds() {
let bounds = DQNParams::continuous_bounds();
let (min, max) = bounds[20];
assert_eq!(min, 10.0, "Kelly min_trades min should be 10");
assert_eq!(max, 50.0, "Kelly min_trades max should be 50");
}
#[test]
fn test_volatility_window_bounds() {
let bounds = DQNParams::continuous_bounds();
let (min, max) = bounds[21];
assert_eq!(min, 10.0, "Volatility window min should be 10");
assert_eq!(max, 30.0, "Volatility window max should be 30");
}
#[test]
fn test_from_continuous_accepts_22d() {
let x = vec![
// Existing 18 params (use mid-range values)
(-4.5_f64).ln(), 112.0, 0.97, (75000.0_f64).ln(),
1.5, 6.0, (25.0_f64).ln(), 0.05, 1.25, 0.6, 0.4,
-2.0, 2.0, (0.5_f64).ln(), 320.0, 3.0, 126.0, 1.55,
// New Kelly params
0.5, 0.25, 30.0, 20.0
];
let x: Vec<f64> = bounds.iter().map(|(lo, hi)| (lo + hi) / 2.0).collect();
assert_eq!(x.len(), 14);
let result = DQNParams::from_continuous(&x);
assert!(result.is_ok(), "Should accept 22D parameter vector");
assert!(result.is_ok(), "Should accept 14D parameter vector");
let params = result.unwrap();
assert!((params.kelly_fractional - 0.5).abs() < 0.01);
assert!((params.kelly_max_fraction - 0.25).abs() < 0.01);
assert_eq!(params.kelly_min_trades, 30);
assert_eq!(params.volatility_window, 20);
assert!((params.gamma - 0.9745).abs() < 0.001, "gamma should be midpoint of [0.95, 0.999]");
assert!((params.risk_intensity - 1.0).abs() < 1e-6, "risk_intensity should be 1.0 at midpoint");
}
#[test]
fn test_from_continuous_rejects_18d() {
let x = vec![
(-4.5_f64).ln(), 112.0, 0.97, (75000.0_f64).ln(),
1.5, 6.0, (25.0_f64).ln(), 0.05, 1.25, 0.6, 0.4,
-2.0, 2.0, (0.5_f64).ln(), 320.0, 3.0, 126.0, 1.55,
];
let result = DQNParams::from_continuous(&x);
assert!(result.is_err(), "Should reject old 18D parameter vector");
fn test_from_continuous_rejects_wrong_dimensions() {
// Too few dimensions
let x_short = vec![0.97; 13];
let result_short = DQNParams::from_continuous(&x_short);
assert!(result_short.is_err(), "Should reject 13D parameter vector");
// Too many dimensions
let x_long = vec![0.97; 15];
let result_long = DQNParams::from_continuous(&x_long);
assert!(result_long.is_err(), "Should reject 15D parameter vector");
}
#[test]
fn test_all_intensity_bounds_are_0_to_2() {
let bounds = DQNParams::continuous_bounds();
let names = DQNParams::param_names();
for (i, name) in names.iter().enumerate() {
if name.ends_with("_intensity") {
let (lo, hi) = bounds[i];
assert_eq!(lo, 0.0, "{name} lower bound should be 0.0");
assert_eq!(hi, 2.0, "{name} upper bound should be 2.0");
}
}
}
#[test]
fn test_breakout_params_have_specific_bounds() {
let bounds = DQNParams::continuous_bounds();
// gamma at index 0
assert_eq!(bounds[0], (0.95, 0.999), "gamma bounds should be (0.95, 0.999)");
// iqn_lambda at index 1
assert_eq!(bounds[1], (0.0, 1.0), "iqn_lambda bounds should be (0.0, 1.0)");
// c51_warmup_epochs at index 2
assert_eq!(bounds[2], (3.0, 15.0), "c51_warmup_epochs bounds should be (3.0, 15.0)");
}
}

View File

@@ -550,3 +550,111 @@ fn test_performance_benchmark() {
avg_time
);
}
/// Verify that OFI features integrate correctly with the state vector layout.
///
/// State layout: [42 market + 8 portfolio + 16 multi-tf + 8 OFI] = 74 raw, 80 aligned.
/// Without OFI: [42 market + 8 portfolio + 16 multi-tf] = 66 raw, 72 aligned.
#[test]
fn test_ofi_features_state_integration() {
// Simulate the state-building path from crates/ml/src/trainers/dqn/trainer/state.rs
// OFI features are appended as `regime_features` in TradingState::from_normalized().
let price_features: Vec<f32> = vec![0.01, 0.02, -0.01, 0.005]; // 4 OHLC log returns
let technical_indicators: Vec<f32> = vec![]; // empty in 42-feature architecture
let market_features: Vec<f32> = vec![0.5; 38]; // indices 4..42
let portfolio_features: Vec<f32> = vec![0.0, 0.0, 0.0]; // 3 portfolio features
// Compute OFI features from test snapshots
let mut calc = OFICalculator::new();
let snap1 = create_snapshot(1000, 150_000_000_000_000, 150_010_000_000_000, 100, 120);
let snap2 = create_snapshot(2000, 150_005_000_000_000, 150_010_000_000_000, 110, 115);
calc.calculate(&snap1).unwrap();
let ofi = calc.calculate(&snap2).unwrap();
let regime_features: Vec<f32> = ofi.to_array().iter().map(|&v| v as f32).collect();
assert_eq!(regime_features.len(), 8, "OFI must produce 8 features");
// Build state vector (same as TradingState::to_vector())
let mut state_vec = Vec::new();
state_vec.extend_from_slice(&price_features); // 4
state_vec.extend_from_slice(&technical_indicators); // 0
state_vec.extend_from_slice(&market_features); // 38
state_vec.extend_from_slice(&portfolio_features); // 3
state_vec.extend_from_slice(&regime_features); // 8
// 4 + 38 + 3 + 8 = 53 (raw state before multi-tf expansion; multi-tf is added in GPU pipeline)
// The full pipeline adds 8 portfolio features (not 3) + 16 multi-tf:
// 42 market + 8 portfolio + 16 MTF + 8 OFI = 74 raw, 80 aligned.
// Here we verify the raw OFI portion is correctly positioned.
assert_eq!(state_vec.len(), 4 + 38 + 3 + 8); // = 53
assert_eq!(state_vec.len() - 8, 45, "Without OFI the state would be 45-dim");
// Verify OFI features are non-zero (calculated from actual snapshots)
let ofi_start = state_vec.len() - 8;
let ofi_slice = &state_vec[ofi_start..];
assert!(
ofi_slice.iter().any(|&v| v != 0.0),
"OFI features should be non-zero when computed from real snapshots"
);
// Verify all features are finite
for (i, &v) in state_vec.iter().enumerate() {
assert!(v.is_finite(), "Feature at index {} is not finite: {}", i, v);
}
}
/// Verify zero-fill behavior when no MBP-10 data is available.
#[test]
fn test_ofi_features_graceful_degradation() {
let regime_features: Vec<f32> = OFIFeatures::zeros().to_array().iter().map(|&v| v as f32).collect();
assert_eq!(regime_features.len(), 8);
assert!(
regime_features.iter().all(|&v| v == 0.0),
"Zero-filled OFI features should all be 0.0"
);
// State dim should still be valid — no NaN or Inf
let mut state_vec = vec![0.0_f32; 42]; // market features
state_vec.extend_from_slice(&[0.0; 3]); // portfolio
state_vec.extend_from_slice(&regime_features); // 8 OFI zeros
assert_eq!(state_vec.len(), 53);
assert!(state_vec.iter().all(|v| v.is_finite()));
}
/// Verify OFI feature names at indices 42-49 in the unified feature space.
#[test]
fn test_ofi_feature_names_ordering() {
// The 8 OFI features occupy indices [42..50) in the full 50-dim feature space
// when appended to the 42-dim base market features.
let ofi_names = [
"ofi_level1",
"ofi_level5",
"depth_imbalance",
"vpin",
"kyles_lambda",
"bid_slope",
"ask_slope",
"trade_imbalance",
];
assert_eq!(ofi_names.len(), 8, "Must have exactly 8 OFI feature names");
// Verify the to_array() order matches the name ordering
let features = OFIFeatures {
ofi_level1: 1.0,
ofi_level5: 2.0,
depth_imbalance: 3.0,
vpin: 4.0,
kyle_lambda: 5.0,
bid_slope: 6.0,
ask_slope: 7.0,
trade_imbalance: 8.0,
};
let arr = features.to_array();
for (i, &val) in arr.iter().enumerate() {
assert_eq!(val, (i + 1) as f64, "Feature '{}' at index {} has wrong value", ofi_names[i], i);
}
}

View File

@@ -94,44 +94,45 @@ use ml::hyperopt::early_stopping::{
use ml::hyperopt::traits::ParameterSpace;
use tracing::{info, warn};
/// Test 1: Verify DQNParams::default() has IQN disabled and CQL alpha=0.1
/// (IQN disabled to fix train/inference network mismatch, CQL reduced from 1.0)
/// Test 1: Verify DQNParams::default() has correct 14D defaults
/// iqn_lambda > 0 means IQN is enabled; num_quantiles and cql_alpha are now
/// fixed in DQNHyperparameters (not in DQNParams).
#[test]
fn test_qr_dqn_defaults() -> Result<()> {
let params = DQNParams::default();
// IQN is always enabled by default
assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default");
assert_eq!(
params.num_quantiles, 32,
"DQNParams::default() should have num_quantiles: 32, got {}",
params.num_quantiles
);
let cql_diff = (params.cql_alpha - 0.1).abs();
// IQN is always enabled by default (iqn_lambda > 0)
assert!(params.iqn_lambda > 0.0, "IQN should be enabled by default (iqn_lambda > 0)");
assert!(
cql_diff < 1e-10,
"DQNParams::default() should have cql_alpha: 0.1, got {}",
params.cql_alpha
(params.gamma - 0.99).abs() < 1e-6,
"DQNParams::default() should have gamma: 0.99, got {}",
params.gamma
);
assert!(
(params.learning_intensity - 1.0).abs() < 1e-6,
"DQNParams::default() should have learning_intensity: 1.0, got {}",
params.learning_intensity
);
info!(
cql_alpha = params.cql_alpha,
num_quantiles = params.num_quantiles,
"Test 1 PASSED: DQN defaults verified"
gamma = params.gamma,
iqn_lambda = params.iqn_lambda,
learning_intensity = params.learning_intensity,
"Test 1 PASSED: DQN 14D defaults verified"
);
Ok(())
}
/// Test 2: Verify 28D parameter space and round-trip preservation of CQL alpha
/// Test 2: Verify 14D parameter space and round-trip preservation
#[test]
fn test_28d_parameter_space_round_trip() -> Result<()> {
// Verify dimensionality (28D search space including sharpe_weight)
fn test_14d_parameter_space_round_trip() -> Result<()> {
// Verify dimensionality (14D search space: 3 breakouts + 5 core + 6 gen families)
let bounds = DQNParams::continuous_bounds();
assert_eq!(
bounds.len(),
28,
"DQNParams::continuous_bounds() should return 28 dimensions, got {}",
14,
"DQNParams::continuous_bounds() should return 14 dimensions, got {}",
bounds.len()
);
@@ -149,34 +150,34 @@ fn test_28d_parameter_space_round_trip() -> Result<()> {
let continuous = original.to_continuous();
assert_eq!(
continuous.len(),
28,
"to_continuous() should return 28 values, got {}",
14,
"to_continuous() should return 14 values, got {}",
continuous.len()
);
let reconstructed = DQNParams::from_continuous(&continuous)
.map_err(|e| anyhow::anyhow!("from_continuous failed: {}", e))?;
// Verify CQL alpha survives round trip
let cql_diff = (reconstructed.cql_alpha - original.cql_alpha).abs();
// Verify gamma survives round trip
let gamma_diff = (reconstructed.gamma - original.gamma).abs();
assert!(
cql_diff < 0.01,
"Round-trip should preserve cql_alpha: expected {}, got {} (diff={})",
original.cql_alpha, reconstructed.cql_alpha, cql_diff
gamma_diff < 0.01,
"Round-trip should preserve gamma: expected {}, got {} (diff={})",
original.gamma, reconstructed.gamma, gamma_diff
);
// Verify other key params survive the round trip
let lr_diff = (reconstructed.learning_rate - original.learning_rate).abs();
// Verify learning_intensity survives the round trip
let li_diff = (reconstructed.learning_intensity - original.learning_intensity).abs();
assert!(
lr_diff < 1e-8,
"Round-trip should preserve learning_rate: expected {}, got {}",
original.learning_rate, reconstructed.learning_rate
li_diff < 1e-8,
"Round-trip should preserve learning_intensity: expected {}, got {}",
original.learning_intensity, reconstructed.learning_intensity
);
info!(
dimensions = bounds.len(),
cql_alpha = reconstructed.cql_alpha,
"Test 2 PASSED: 26D parameter space round-trip verified"
gamma = reconstructed.gamma,
"Test 2 PASSED: 14D parameter space round-trip verified"
);
Ok(())