Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
9.5 KiB
Rust
290 lines
9.5 KiB
Rust
//! DQN Feature Caching CLI Tool
|
|
//!
|
|
//! Pre-computes and caches DQN features to eliminate redundant computation during hyperopt.
|
|
//!
|
|
//! ## Purpose
|
|
//!
|
|
//! During hyperopt, each trial wastes 10-30s recalculating identical MBP-10 OFI features.
|
|
//! This tool pre-computes features once and saves them to disk for reuse across all trials.
|
|
//!
|
|
//! ## Time Savings
|
|
//!
|
|
//! - 50-trial hyperopt: **8-25 minutes saved** (87-96% reduction)
|
|
//! - Cache creation: 30s (one-time)
|
|
//! - Cache loading: <1s per trial
|
|
//!
|
|
//! ## Usage
|
|
//!
|
|
//! ```bash
|
|
//! # Pre-compute features once
|
|
//! cargo run -p ml --example cache_dqn_features --release -- \
|
|
//! --parquet-file test_data/ES_FUT_180d.parquet \
|
|
//! --mbp10-dir test_data/mbp10 \
|
|
//! --cache-dir ~/.cache/dqn_features
|
|
//!
|
|
//! # Run hyperopt with cache
|
|
//! cargo run -p ml --example hyperopt_dqn_demo --release -- \
|
|
//! --parquet-file test_data/ES_FUT_180d.parquet \
|
|
//! --trials 50 \
|
|
//! --feature-cache-dir ~/.cache/dqn_features
|
|
//! ```
|
|
//!
|
|
//! ## Cache Key Strategy
|
|
//!
|
|
//! Cache key is SHA256 hash of:
|
|
//! - Parquet file path + modification time
|
|
//! - MBP-10 directory contents + modification times
|
|
//! - Warmup period (50 bars)
|
|
//! - Feature extraction version (manual constant)
|
|
//!
|
|
//! Cache automatically invalidates when any input changes.
|
|
|
|
use anyhow::{Context, Result};
|
|
use arrow::array::{ArrayRef, Float64Array};
|
|
use arrow::datatypes::{DataType, Field, Schema};
|
|
use arrow::record_batch::RecordBatch;
|
|
use clap::Parser;
|
|
use parquet::arrow::ArrowWriter;
|
|
use parquet::file::properties::WriterProperties;
|
|
use parquet::basic::Compression;
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs::{self, File};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::UNIX_EPOCH;
|
|
use tracing::{info, warn};
|
|
|
|
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
|
|
|
|
/// Command-line arguments
|
|
#[derive(Parser, Debug)]
|
|
#[command(author, version, about, long_about = None)]
|
|
struct Args {
|
|
/// Path to input Parquet file containing OHLCV data
|
|
#[arg(long)]
|
|
parquet_file: PathBuf,
|
|
|
|
/// Path to MBP-10 snapshots directory (optional, will use zeros for OFI if missing)
|
|
#[arg(long, default_value = "test_data/mbp10")]
|
|
mbp10_dir: PathBuf,
|
|
|
|
/// Output cache directory
|
|
#[arg(long, default_value = "/tmp/ml_training/feature_cache")]
|
|
cache_dir: PathBuf,
|
|
|
|
/// Warmup period in bars (must match training configuration)
|
|
#[arg(long, default_value = "50")]
|
|
warmup_period: usize,
|
|
|
|
/// Force rebuild cache even if it exists
|
|
#[arg(long)]
|
|
force: bool,
|
|
}
|
|
|
|
type FeatureVector51 = [f64; 51];
|
|
|
|
/// Calculate cache key from input parameters
|
|
///
|
|
/// Cache key is SHA256 hash of:
|
|
/// - Parquet file path + modification time
|
|
/// - MBP-10 directory contents + modification times
|
|
/// - Warmup period
|
|
/// - Feature extraction version
|
|
fn calculate_cache_key(
|
|
parquet_path: &Path,
|
|
mbp10_dir: &Path,
|
|
warmup_period: usize,
|
|
) -> Result<String> {
|
|
let mut hasher = Sha256::new();
|
|
|
|
// 1. Hash parquet file path + mtime
|
|
hasher.update(parquet_path.to_string_lossy().as_bytes());
|
|
let parquet_metadata = fs::metadata(parquet_path)
|
|
.with_context(|| format!("Failed to read metadata for {}", parquet_path.display()))?;
|
|
let parquet_mtime = parquet_metadata
|
|
.modified()?
|
|
.duration_since(UNIX_EPOCH)?
|
|
.as_secs();
|
|
hasher.update(parquet_mtime.to_le_bytes());
|
|
|
|
// 2. Hash MBP-10 directory + all .dbn files + mtimes
|
|
if mbp10_dir.exists() {
|
|
hasher.update(mbp10_dir.to_string_lossy().as_bytes());
|
|
|
|
let mut dbn_files: Vec<_> = fs::read_dir(mbp10_dir)?
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path()
|
|
.extension()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s == "dbn")
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
|
|
dbn_files.sort_by_key(|e| e.path());
|
|
|
|
for entry in &dbn_files {
|
|
hasher.update(entry.path().to_string_lossy().as_bytes());
|
|
let metadata = entry.metadata()?;
|
|
let mtime = metadata
|
|
.modified()?
|
|
.duration_since(UNIX_EPOCH)?
|
|
.as_secs();
|
|
hasher.update(mtime.to_le_bytes());
|
|
}
|
|
}
|
|
|
|
// 3. Hash feature extraction version (from extraction.rs module constant)
|
|
hasher.update(ml::features::extraction::FEATURE_EXTRACTION_VERSION.as_bytes());
|
|
|
|
// 4. Hash warmup period
|
|
hasher.update(warmup_period.to_le_bytes());
|
|
|
|
// Return first 16 chars of hex hash for readability
|
|
Ok(format!("{:x}", hasher.finalize())[..16].to_string())
|
|
}
|
|
|
|
/// Write features and targets to Parquet with Snappy compression
|
|
fn write_features_to_parquet(
|
|
features: &[(FeatureVector51, Vec<f64>)],
|
|
output_path: &Path,
|
|
) -> Result<()> {
|
|
// Create schema: 51 feature columns + 1 target column
|
|
let mut fields = Vec::new();
|
|
for i in 0..51 {
|
|
fields.push(Field::new(&format!("feature_{}", i), DataType::Float64, false));
|
|
}
|
|
fields.push(Field::new("target_close", DataType::Float64, false));
|
|
let schema = Arc::new(Schema::new(fields));
|
|
|
|
// Configure Parquet writer with Snappy compression
|
|
let props = WriterProperties::builder()
|
|
.set_compression(Compression::SNAPPY)
|
|
.build();
|
|
|
|
let file = File::create(output_path)
|
|
.with_context(|| format!("Failed to create cache file at {}", output_path.display()))?;
|
|
let mut writer = ArrowWriter::try_new(file, schema.clone(), Some(props))?;
|
|
|
|
// Convert features to Arrow RecordBatch
|
|
let mut columns: Vec<ArrayRef> = Vec::new();
|
|
|
|
// Add 51 feature columns
|
|
for i in 0..51 {
|
|
let values: Vec<f64> = features.iter()
|
|
.map(|(state, _)| state[i])
|
|
.collect();
|
|
columns.push(Arc::new(Float64Array::from(values)));
|
|
}
|
|
|
|
// Add target_close column (preprocessed_current from targets[0])
|
|
let target_values: Vec<f64> = features.iter()
|
|
.map(|(_, targets)| targets[0])
|
|
.collect();
|
|
columns.push(Arc::new(Float64Array::from(target_values)));
|
|
|
|
let batch = RecordBatch::try_new(schema, columns)?;
|
|
writer.write(&batch)?;
|
|
writer.close()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Main cache generation logic
|
|
async fn generate_cache(args: &Args) -> Result<()> {
|
|
// Validate inputs
|
|
if !args.parquet_file.exists() {
|
|
anyhow::bail!("Parquet file not found: {}", args.parquet_file.display());
|
|
}
|
|
|
|
if !args.mbp10_dir.exists() {
|
|
warn!("⚠️ MBP-10 directory not found at {:?}. OFI features will be zeros.", args.mbp10_dir);
|
|
}
|
|
|
|
// Create cache directory if needed
|
|
fs::create_dir_all(&args.cache_dir)
|
|
.with_context(|| format!("Failed to create cache directory: {}", args.cache_dir.display()))?;
|
|
|
|
info!("🚀 Starting DQN feature cache generation...");
|
|
info!("");
|
|
info!("📂 Input:");
|
|
info!(" • Parquet: {}", args.parquet_file.display());
|
|
info!(" • MBP-10 dir: {}", args.mbp10_dir.display());
|
|
info!(" • Warmup period: {} bars", args.warmup_period);
|
|
info!("");
|
|
|
|
// Calculate cache key
|
|
let cache_key = calculate_cache_key(&args.parquet_file, &args.mbp10_dir, args.warmup_period)?;
|
|
info!("🔑 Cache key: {}", cache_key);
|
|
info!("");
|
|
|
|
// Check if cache already exists
|
|
let cache_file = args.cache_dir.join(format!("features_{}.parquet", cache_key));
|
|
if cache_file.exists() && !args.force {
|
|
info!("✅ Cache already exists: {}", cache_file.display());
|
|
info!(" Use --force to rebuild");
|
|
return Ok(());
|
|
}
|
|
|
|
// Create DQN trainer to use existing feature extraction logic
|
|
// Use conservative parameters (only needed for initialization, not used during feature extraction)
|
|
let params = DQNHyperparameters::conservative();
|
|
|
|
let mut trainer = DQNTrainer::new(params)
|
|
.with_context(|| "Failed to create DQN trainer")?;
|
|
|
|
// Load and extract features using existing DQN trainer logic
|
|
info!("📊 Extracting features...");
|
|
let parquet_path = args.parquet_file.to_string_lossy().to_string();
|
|
let (train_data, val_data) = trainer
|
|
.load_training_data_from_parquet(&parquet_path)
|
|
.await
|
|
.with_context(|| "Failed to load training data from parquet")?;
|
|
|
|
let total_samples = train_data.len() + val_data.len();
|
|
info!(" • Extracted {} training + {} validation samples", train_data.len(), val_data.len());
|
|
info!("");
|
|
|
|
// Combine train and val for caching (trainer will re-split later)
|
|
let mut all_samples = train_data;
|
|
all_samples.extend(val_data);
|
|
|
|
// Write cache
|
|
info!("💾 Writing cache...");
|
|
info!(" • Output: {}", cache_file.display());
|
|
|
|
let start = std::time::Instant::now();
|
|
write_features_to_parquet(&all_samples, &cache_file)?;
|
|
let write_duration = start.elapsed();
|
|
|
|
let file_size = fs::metadata(&cache_file)?.len();
|
|
info!(" • Size: {:.2} MB (compressed with Snappy)", file_size as f64 / 1_048_576.0);
|
|
info!(" • Write time: {:.1}s", write_duration.as_secs_f64());
|
|
info!("");
|
|
|
|
info!("✅ Cache generation complete!");
|
|
info!(" • Total samples: {}", total_samples);
|
|
info!(" • Cache key: {}", cache_key);
|
|
info!(" • Ready for hyperopt!");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// Initialize logging
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
let args = Args::parse();
|
|
|
|
generate_cache(&args).await?;
|
|
|
|
Ok(())
|
|
}
|