Files
foxhunt/crates/ml/tests/dqn_hyperopt_json_export_test.rs
jgrusewski 3d7d9c3d03 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>
2026-03-31 01:30:33 +02:00

429 lines
17 KiB
Rust

#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! Integration tests for DQN hyperopt JSON export functionality
//!
//! Tests the automatic export of best trial hyperparameters to JSON
//! during hyperopt campaigns for production deployment.
//!
//! NOTE: These tests require GPU and training data, so they are marked
//! with #[ignore]. Run with: cargo test --test dqn_hyperopt_json_export_test -- --ignored
use anyhow::Result;
use serde_json;
use std::fs;
use std::path::{Path, PathBuf};
use ml::hyperopt::adapters::dqn::{BestTrialExport, DQNTrainer};
use ml::hyperopt::ArgminOptimizer;
use tracing::info;
#[cfg(test)]
mod hyperopt_json_export_tests {
use super::*;
/// Helper: Clean up any test JSON files
fn cleanup_test_jsons(pattern: &str) {
let results_dir = Path::new("ml/hyperopt_results");
if !results_dir.exists() {
return;
}
if let Ok(entries) = fs::read_dir(results_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(pattern) && name.ends_with(".json") {
let _ = fs::remove_file(&path);
info!(path = ?path, "Cleaned up test file");
}
}
}
}
}
/// Helper: Find JSON file matching pattern in hyperopt_results/
fn find_json_file(pattern: &str) -> Option<PathBuf> {
let results_dir = Path::new("ml/hyperopt_results");
if !results_dir.exists() {
return None;
}
if let Ok(entries) = fs::read_dir(results_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(pattern) && name.ends_with(".json") {
return Some(path);
}
}
}
}
None
}
/// Test 1: Mini hyperopt saves best trial JSON
///
/// Runs 3-trial hyperopt with 10 epochs each, verifies JSON export
#[test]
#[ignore] // Requires GPU and training data
fn test_hyperopt_saves_best_trial_json() -> Result<()> {
info!("TEST 1: Hyperopt saves best trial JSON");
// Cleanup any previous test files
cleanup_test_jsons("test_mini_");
// Create DQN trainer with minimal epochs for speed
let trainer = DQNTrainer::new(
"test_data/ES_FUT_180d.parquet",
10, // epochs per trial
)?;
// Run mini hyperopt (3 trials)
info!("Running 3-trial hyperopt campaign");
let optimizer = ArgminOptimizer::with_trials(3, 1);
let result = optimizer.optimize(trainer)?;
info!(best_objective = result.best_objective, "Hyperopt complete");
// Verify JSON file was created
let json_file = find_json_file("best_trial_sharpe_")
.expect("Best trial JSON should be created");
info!(path = ?json_file, "Found JSON file");
// Load and verify the JSON
let json_content = fs::read_to_string(&json_file)?;
let trial: BestTrialExport = serde_json::from_str(&json_content)?;
// Verify metadata exists
assert!(trial.trial_number > 0, "Trial number should be > 0");
assert!(trial.trial_number <= 3, "Trial number should be <= 3 (only 3 trials)");
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 (14 fields)
let params = trial.hyperparameters;
assert!(params.gamma > 0.0 && params.gamma < 1.0, "Gamma should be in (0, 1)");
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");
// Cleanup
cleanup_test_jsons("best_trial_sharpe_");
Ok(())
}
/// Test 2: Hyperopt updates JSON on new best trial
///
/// Verifies that when a better trial is found, the JSON is updated
#[test]
#[ignore] // Requires GPU and training data
fn test_hyperopt_updates_json_on_new_best() -> Result<()> {
info!("TEST 2: Hyperopt updates JSON on new best");
cleanup_test_jsons("best_trial_sharpe_");
let trainer = DQNTrainer::new(
"test_data/ES_FUT_180d.parquet",
10, // epochs per trial
)?;
// Run 5 trials to increase chance of finding better trial
info!("Running 5-trial hyperopt campaign");
let optimizer = ArgminOptimizer::with_trials(5, 1);
let _ = optimizer.optimize(trainer)?;
// Load the final JSON
let json_file = find_json_file("best_trial_sharpe_")
.expect("Best trial JSON should exist");
let json_content = fs::read_to_string(&json_file)?;
let final_trial: BestTrialExport = serde_json::from_str(&json_content)?;
// Verify the JSON contains the best trial from the campaign
// (Note: We can't predict which trial number will be best, but we can
// verify the data structure is valid and updated)
assert!(final_trial.trial_number > 0 && final_trial.trial_number <= 5,
"Best trial number should be in range [1, 5]");
info!(trial_number = final_trial.trial_number, sharpe = final_trial.sharpe, "Final best trial");
// Verify the file was written at least once
let metadata = fs::metadata(&json_file)?;
assert!(metadata.len() > 100, "JSON file should have substantial content");
cleanup_test_jsons("best_trial_sharpe_");
Ok(())
}
/// Test 3: JSON roundtrip - hyperopt output can be reloaded
///
/// Saves a trial, loads it back, verifies all parameters match exactly
#[test]
#[ignore] // Requires GPU and training data
fn test_hyperopt_json_roundtrip() -> Result<()> {
info!("TEST 3: JSON roundtrip consistency");
cleanup_test_jsons("best_trial_sharpe_");
let trainer = DQNTrainer::new(
"test_data/ES_FUT_180d.parquet",
10,
)?;
info!("Running 2-trial hyperopt campaign");
let optimizer = ArgminOptimizer::with_trials(2, 1);
let _ = optimizer.optimize(trainer)?;
// Load the JSON that was saved
let json_file = find_json_file("best_trial_sharpe_")
.expect("Best trial JSON should exist");
let json_content = fs::read_to_string(&json_file)?;
let saved_trial: BestTrialExport = serde_json::from_str(&json_content)?;
// Serialize it again
let roundtrip_json = serde_json::to_string_pretty(&saved_trial)?;
// Deserialize the roundtrip version
let roundtrip_trial: BestTrialExport = serde_json::from_str(&roundtrip_json)?;
// Verify exact match on all fields
assert_eq!(saved_trial.trial_number, roundtrip_trial.trial_number,
"Trial number should match exactly");
assert_eq!(saved_trial.sharpe, roundtrip_trial.sharpe,
"Sharpe should match exactly");
assert_eq!(saved_trial.win_rate, roundtrip_trial.win_rate,
"Win rate should match exactly");
assert_eq!(saved_trial.max_drawdown, roundtrip_trial.max_drawdown,
"Max drawdown should match exactly");
assert_eq!(saved_trial.total_return, roundtrip_trial.total_return,
"Total return should match exactly");
assert_eq!(saved_trial.gradient_clip_norm, roundtrip_trial.gradient_clip_norm,
"Gradient clip norm should match exactly");
// 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.gamma, roundtrip_params.gamma);
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 14 hyperparameters + metadata survive roundtrip with perfect fidelity");
cleanup_test_jsons("best_trial_sharpe_");
Ok(())
}
/// Test 4: JSON contains all required metadata fields
///
/// Validates that exported JSON has comprehensive metadata for production use
#[test]
#[ignore] // Requires GPU and training data
fn test_hyperopt_json_contains_all_metadata() -> Result<()> {
info!("TEST 4: JSON metadata completeness");
cleanup_test_jsons("best_trial_sharpe_");
let trainer = DQNTrainer::new(
"test_data/ES_FUT_180d.parquet",
10,
)?;
info!("Running 2-trial hyperopt campaign");
let optimizer = ArgminOptimizer::with_trials(2, 1);
let _ = optimizer.optimize(trainer)?;
let json_file = find_json_file("best_trial_sharpe_")
.expect("Best trial JSON should exist");
// Load as raw JSON to verify field presence
let json_content = fs::read_to_string(&json_file)?;
let raw_json: serde_json::Value = serde_json::from_str(&json_content)?;
// Verify top-level fields
assert!(raw_json.get("trial_number").is_some(), "Should have trial_number");
assert!(raw_json.get("sharpe").is_some(), "Should have sharpe");
assert!(raw_json.get("win_rate").is_some(), "Should have win_rate");
assert!(raw_json.get("max_drawdown").is_some(), "Should have max_drawdown");
assert!(raw_json.get("total_return").is_some(), "Should have total_return");
assert!(raw_json.get("timestamp").is_some(), "Should have timestamp");
assert!(raw_json.get("gradient_clip_norm").is_some(), "Should have gradient_clip_norm");
assert!(raw_json.get("hyperparameters").is_some(), "Should have hyperparameters");
// Verify timestamp is ISO 8601 format
let timestamp = raw_json["timestamp"].as_str()
.expect("Timestamp should be a string");
assert!(timestamp.contains('T') && timestamp.contains('Z'),
"Timestamp should be ISO 8601 format: {}", timestamp);
// Verify hyperparameters object has all 14 fields
let hyperparams = raw_json["hyperparameters"].as_object()
.expect("Hyperparameters should be an object");
let required_fields = vec![
"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 {
assert!(hyperparams.contains_key(field),
"Hyperparameters should contain field: {}", field);
}
info!("JSON contains all 8 metadata fields + 14 hyperparameter fields");
cleanup_test_jsons("best_trial_sharpe_");
Ok(())
}
/// Test 5: Verify filename contains Sharpe ratio
#[test]
#[ignore] // Requires GPU and training data
fn test_json_filename_contains_sharpe() -> Result<()> {
info!("TEST 5: JSON filename includes Sharpe");
cleanup_test_jsons("best_trial_sharpe_");
let trainer = DQNTrainer::new(
"test_data/ES_FUT_180d.parquet",
10,
)?;
info!("Running 2-trial hyperopt campaign");
let optimizer = ArgminOptimizer::with_trials(2, 1);
let _ = optimizer.optimize(trainer)?;
let json_file = find_json_file("best_trial_sharpe_")
.expect("Best trial JSON should exist");
// Verify filename format
let filename = json_file.file_name()
.and_then(|n| n.to_str())
.expect("Should have valid filename");
assert!(filename.starts_with("best_trial_sharpe_"),
"Filename should start with 'best_trial_sharpe_': {}", filename);
assert!(filename.ends_with(".json"),
"Filename should end with '.json': {}", filename);
// Load JSON to verify Sharpe in filename matches content
let json_content = fs::read_to_string(&json_file)?;
let trial: BestTrialExport = serde_json::from_str(&json_content)?;
// Extract Sharpe from filename (format: best_trial_sharpe_X.XXXX.json)
let sharpe_str = filename
.strip_prefix("best_trial_sharpe_")
.and_then(|s| s.strip_suffix(".json"))
.expect("Should be able to extract Sharpe from filename");
let filename_sharpe: f64 = sharpe_str.parse()
.expect(&format!("Should be able to parse Sharpe from filename: {}", sharpe_str));
// Verify filename Sharpe matches JSON content (within floating point tolerance)
let sharpe_diff = (filename_sharpe - trial.sharpe).abs();
assert!(sharpe_diff < 0.0001,
"Filename Sharpe ({}) should match JSON Sharpe ({}) within 0.0001",
filename_sharpe, trial.sharpe);
info!(filename_sharpe, trial_sharpe = trial.sharpe, "Filename Sharpe matches JSON Sharpe");
cleanup_test_jsons("best_trial_sharpe_");
Ok(())
}
}