Files
foxhunt/crates/ml-backtesting/src/action_loader.rs
jgrusewski 7ef92983f9 fix(clippy): apply cargo clippy --fix across workspace
Mechanical auto-fixes: redundant borrows, clone on Copy, or_insert_with,
single-char push_str, get(0) → first(), needless borrow, let_and_return.
150 files, no behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:51 +01:00

198 lines
6.2 KiB
Rust

// ml-backtesting/src/action_loader.rs
// DQN action loader for CSV-based backtesting
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
/// DQN action record from CSV (13,552 actions, 10 columns)
/// Format: `timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume`
#[derive(Debug, Clone, Deserialize)]
pub struct DQNActionRecord {
/// ISO8601 timestamp (e.g., "2024-10-20T23:31:00.000000000Z")
pub timestamp: DateTime<Utc>,
/// Action: 0=Buy, 1=Sell, 2=Hold
pub action: u8,
/// Q-value for buy action
pub q_buy: f64,
/// Q-value for sell action
pub q_sell: f64,
/// Q-value for hold action
pub q_hold: f64,
/// OHLCV data (for validation/debugging)
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: u64,
}
/// Load DQN actions from CSV file
///
/// # Arguments
/// * `csv_path` - Path to CSV file (e.g., "/`tmp/dqn_actions_wave3.csv`")
///
/// # Returns
/// * `Ok(Vec<DQNActionRecord>)` - Parsed and validated actions
/// * `Err(String)` - Validation errors (action bounds, Q-values, timestamps)
///
/// # Validations
/// 1. Action bounds: 0 <= action <= 2
/// 2. Finite Q-values: `q_buy`, `q_sell`, `q_hold` must be finite (no NaN/Inf)
/// 3. Timestamp ordering: timestamps must be monotonically increasing
///
/// # Example
/// ```ignore
/// let actions = load_actions_from_csv("/tmp/dqn_actions_wave3.csv")?;
/// assert_eq!(actions.len(), 13_552);
/// ```
pub fn load_actions_from_csv<P: AsRef<Path>>(csv_path: P) -> Result<Vec<DQNActionRecord>, String> {
let path = csv_path.as_ref();
// Open CSV file
let file = File::open(path)
.map_err(|e| format!("Failed to open CSV file '{}': {}", path.display(), e))?;
let reader = BufReader::new(file);
let mut csv_reader = csv::Reader::from_reader(reader);
let mut actions = Vec::new();
let mut prev_timestamp: Option<DateTime<Utc>> = None;
let mut row_num = 1; // Start at 1 (header is row 0)
for result in csv_reader.deserialize() {
row_num += 1;
let record: DQNActionRecord =
result.map_err(|e| format!("CSV parsing error at row {}: {}", row_num, e))?;
// Validation 1: Action bounds (0-2)
if record.action > 2 {
return Err(format!(
"Invalid action {} at row {} (timestamp {}): action must be 0 (Buy), 1 (Sell), or 2 (Hold)",
record.action,
row_num,
record.timestamp
));
}
// Validation 2: Finite Q-values
if !record.q_buy.is_finite() {
return Err(format!(
"Invalid q_buy {} at row {} (timestamp {}): Q-value must be finite",
record.q_buy, row_num, record.timestamp
));
}
if !record.q_sell.is_finite() {
return Err(format!(
"Invalid q_sell {} at row {} (timestamp {}): Q-value must be finite",
record.q_sell, row_num, record.timestamp
));
}
if !record.q_hold.is_finite() {
return Err(format!(
"Invalid q_hold {} at row {} (timestamp {}): Q-value must be finite",
record.q_hold, row_num, record.timestamp
));
}
// Validation 3: Timestamp ordering
if let Some(prev_ts) = prev_timestamp {
if record.timestamp < prev_ts {
return Err(format!(
"Timestamp ordering violation at row {}: {} is before previous timestamp {}",
row_num, record.timestamp, prev_ts
));
}
}
prev_timestamp = Some(record.timestamp);
actions.push(record);
}
if actions.is_empty() {
return Err(format!(
"CSV file '{}' contains no data rows",
path.display()
));
}
Ok(actions)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_valid_action_record() {
// Test DQNActionRecord struct initialization
let record = DQNActionRecord {
timestamp: Utc.with_ymd_and_hms(2024, 10, 20, 23, 31, 0).unwrap(),
action: 2, // Hold
q_buy: -658.8440,
q_sell: 355.0268,
q_hold: 538.5875,
open: 5914.50,
high: 5914.75,
low: 5914.25,
close: 5914.25,
volume: 27,
};
assert_eq!(record.action, 2);
assert!(record.q_buy.is_finite());
assert!(record.q_sell.is_finite());
assert!(record.q_hold.is_finite());
}
#[test]
fn test_action_bounds_validation() {
// Test action bounds: 0 <= action <= 2
// Create a temporary CSV with invalid action
use std::io::Write;
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmpfile,
"timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume"
)
.unwrap();
writeln!(tmpfile, "2024-10-20T23:31:00.000000000Z,3,-658.8440,355.0268,538.5875,5914.50,5914.75,5914.25,5914.25,27").unwrap();
tmpfile.flush().unwrap();
let result = load_actions_from_csv(tmpfile.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Invalid action 3"));
assert!(err.contains("action must be 0 (Buy), 1 (Sell), or 2 (Hold)"));
}
#[test]
fn test_finite_qvalue_validation() {
// Test finite Q-value validation (NaN/Inf detection)
use std::io::Write;
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmpfile,
"timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume"
)
.unwrap();
writeln!(tmpfile, "2024-10-20T23:31:00.000000000Z,2,NaN,355.0268,538.5875,5914.50,5914.75,5914.25,5914.25,27").unwrap();
tmpfile.flush().unwrap();
let result = load_actions_from_csv(tmpfile.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Invalid q_buy"));
assert!(err.contains("Q-value must be finite"));
}
}