Remove use_double_dqn, use_dueling, use_per, use_branching,
use_distributional, use_noisy_nets, use_huber_loss, and use_cql
from DQNConfig, DQNHyperparameters, and DqnParams structs.
These features are always enabled (Rainbow DQN standard). The boolean
flags were dead code — every constructor set them to true, and the
only code paths that set them to false were in tests that disabled
features for simplicity. With the fields removed, the features are
unconditionally active, eliminating ~490 lines of dead configuration.
Key changes:
- Struct field declarations removed from 3 core config structs
- Conditional branches (if use_X { ... } else { ... }) simplified:
dueling/branching/PER network creation is now unconditional
- Checkpoint metadata hardcodes "true" for backward compatibility
- Hyperopt search space index 11 (use_branching) fixed at 1.0
- TOML/YAML config files cleaned of removed fields
- Tests that toggled these flags updated or rewritten
45 files changed, -487 net lines. Zero new test failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
437 lines
17 KiB
Rust
437 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 (all 21 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");
|
|
|
|
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 21 hyperparameters match
|
|
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);
|
|
|
|
info!("All 21 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 21 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",
|
|
];
|
|
|
|
for field in required_fields {
|
|
assert!(hyperparams.contains_key(field),
|
|
"Hyperparameters should contain field: {}", field);
|
|
}
|
|
|
|
info!("JSON contains all 8 metadata fields + 21 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(())
|
|
}
|
|
}
|