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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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_");
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®ime_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(®ime_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user