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>
429 lines
17 KiB
Rust
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(())
|
|
}
|
|
}
|