Files
foxhunt/services/ml_training_service/src/asset_parser.rs
jgrusewski cfbe7939dd safety: replace 21 unwrap/expect in trading_service and ml_training
- trading_service/metrics.rs: 16 expect→unwrap_or_else+abort on
  Prometheus metric registration (startup-only, fatal if fails)
- ml_training/asset_parser.rs: 5 expect→static Lazy<Regex> with abort
  (compiled once, eliminates per-call Regex::new overhead)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:15:39 +01:00

361 lines
11 KiB
Rust

//! Asset Parser & Validation Module
//!
//! Provides parsing and validation for trading assets (futures and equities).
//! Supports comma-separated multi-asset input with automatic normalization,
//! deduplication, and comprehensive error handling.
//!
//! # Examples
//!
//! ```
//! use ml_training_service::asset_parser::{Asset, AssetParser};
//!
//! // Parse single future
//! let assets = AssetParser::parse("ES.FUT").unwrap();
//! assert_eq!(assets.len(), 1);
//!
//! // Parse multiple mixed assets
//! let assets = AssetParser::parse("ES.FUT,AAPL,NQ.FUT").unwrap();
//! assert_eq!(assets.len(), 3);
//!
//! // Case normalization
//! let assets = AssetParser::parse("es.fut").unwrap();
//! // Returns Asset::Future { symbol: "ES.FUT" }
//! ```
use anyhow::{bail, Context, Result};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
use std::fmt;
// ============================================================================
// COMPILED REGEX PATTERNS (static, compiled once)
// ============================================================================
/// Futures pattern: 1-4 alphanumeric chars followed by `.FUT`
static FUTURES_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[A-Z0-9]{1,4}\.FUT$").unwrap_or_else(|e| {
eprintln!("FATAL: Failed to compile futures regex: {e}");
std::process::abort()
})
});
/// Equity pattern: 3-5 uppercase letters (min 3 to avoid futures ambiguity)
static EQUITY_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[A-Z]{3,5}$").unwrap_or_else(|e| {
eprintln!("FATAL: Failed to compile equity regex: {e}");
std::process::abort()
})
});
/// Short symbol pattern: 1-2 alphanumeric chars (likely missing `.FUT`)
static SHORT_SYMBOL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[A-Z0-9]{1,2}$").unwrap_or_else(|e| {
eprintln!("FATAL: Failed to compile short symbol regex: {e}");
std::process::abort()
})
});
// ============================================================================
// ASSET TYPE ENUM
// ============================================================================
/// Represents a validated trading asset
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Asset {
/// Futures contract (e.g., ES.FUT, NQ.FUT, 6E.FUT)
Future { symbol: String },
/// Equity/stock (e.g., AAPL, MSFT, GOOGL)
Equity { symbol: String },
}
impl fmt::Display for Asset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Asset::Future { symbol } => write!(f, "{}", symbol),
Asset::Equity { symbol } => write!(f, "{}", symbol),
}
}
}
impl Asset {
/// Get the symbol string for this asset
pub fn symbol(&self) -> &str {
match self {
Asset::Future { symbol } => symbol,
Asset::Equity { symbol } => symbol,
}
}
/// Check if this is a futures contract
pub fn is_future(&self) -> bool {
matches!(self, Asset::Future { .. })
}
/// Check if this is an equity
pub fn is_equity(&self) -> bool {
matches!(self, Asset::Equity { .. })
}
}
// ============================================================================
// ASSET PARSER
// ============================================================================
/// Parser for trading asset symbols with validation
pub struct AssetParser;
impl AssetParser {
/// Parse a comma-separated list of assets
///
/// # Features
/// - Supports futures: `ES.FUT`, `NQ.FUT`, `6E.FUT` (1-4 alphanumeric + `.FUT`)
/// - Supports equities: `AAPL`, `MSFT`, `GOOGL` (1-5 uppercase letters)
/// - Automatic case normalization (lowercase → uppercase)
/// - Whitespace trimming
/// - Automatic deduplication
///
/// # Arguments
/// * `input` - Comma-separated asset list (e.g., "ES.FUT,AAPL,NQ.FUT")
///
/// # Returns
/// * `Ok(Vec<Asset>)` - Parsed and validated assets
/// * `Err(anyhow::Error)` - Parse or validation error
///
/// # Performance
/// Target: <1μs per asset
///
/// # Examples
/// ```
/// use ml_training_service::asset_parser::AssetParser;
///
/// let assets = AssetParser::parse("ES.FUT,AAPL").unwrap();
/// assert_eq!(assets.len(), 2);
/// ```
pub fn parse(input: &str) -> Result<Vec<Asset>> {
// Trim and check for empty input
let trimmed = input.trim();
if trimmed.is_empty() {
bail!("Asset list cannot be empty");
}
// Split by comma, normalize, and deduplicate
let raw_assets: HashSet<String> = trimmed
.split(',')
.map(|s| s.trim().to_uppercase())
.filter(|s| !s.is_empty())
.collect();
if raw_assets.is_empty() {
bail!("Asset list contains no valid assets (only whitespace/commas)");
}
// Parse each unique asset
let mut assets = Vec::with_capacity(raw_assets.len());
for asset_str in raw_assets {
let asset = Self::parse_single(&asset_str)
.context("Invalid asset format")?;
assets.push(asset);
}
Ok(assets)
}
/// Parse a single asset string (internal helper)
fn parse_single(input: &str) -> Result<Asset> {
// Try futures first (more specific pattern)
if FUTURES_RE.is_match(input) {
return Ok(Asset::Future {
symbol: input.to_string(),
});
}
// Try equity (3-5 letters to avoid ambiguity with futures symbols like ES, NQ)
if EQUITY_RE.is_match(input) {
return Ok(Asset::Equity {
symbol: input.to_string(),
});
}
// Special error message for 1-2 character symbols (likely missing .FUT)
if SHORT_SYMBOL_RE.is_match(input) {
bail!(
"Invalid asset format: '{}' - This looks like a futures symbol. Did you mean '{}.FUT'?\n\n\
Expected formats:\n\
- Futures: SYMBOL.FUT (e.g., ES.FUT, NQ.FUT)\n\
- Equities: 3-5 letter tickers (e.g., AAPL, MSFT, GOOGL)",
input,
input
);
}
// Neither pattern matched - provide helpful error
bail!(
"Invalid asset format: '{}'\n\
Expected formats:\n\
- Futures: SYMBOL.FUT (e.g., ES.FUT, NQ.FUT, 6E.FUT, ZN.FUT)\n\
Pattern: ^[A-Z0-9]{{1,4}}\\.FUT$\n\
- Equities: 3-5 letter tickers (e.g., AAPL, MSFT, GOOGL)\n\
Pattern: ^[A-Z]{{3,5}}$",
input
);
}
/// Validate a futures contract symbol
///
/// # Format
/// - Pattern: `{SYMBOL}.FUT` where SYMBOL is 1-4 alphanumeric characters
/// - Regex: `^[A-Z0-9]{1,4}\.FUT$`
/// - Examples: ES.FUT, NQ.FUT, 6E.FUT, ZN.FUT
///
/// # Arguments
/// * `symbol` - The futures symbol to validate (already uppercased)
///
/// # Returns
/// * `Ok(Asset::Future)` - Valid futures contract
/// * `Err(anyhow::Error)` - Invalid format
pub fn validate_future(symbol: &str) -> Result<Asset> {
if FUTURES_RE.is_match(symbol) {
Ok(Asset::Future {
symbol: symbol.to_string(),
})
} else {
bail!(
"Invalid asset format: '{}'\n\
Expected futures format: SYMBOL.FUT\n\
- SYMBOL: 1-4 alphanumeric characters (A-Z, 0-9)\n\
- Examples: ES.FUT, NQ.FUT, 6E.FUT, ZN.FUT\n\
Valid patterns:\n\
- Futures: ^[A-Z0-9]{{1,4}}\\.FUT$\n\
- Equities: ^[A-Z]{{1,5}}$",
symbol
);
}
}
/// Validate an equity symbol
///
/// # Format
/// - Pattern: 3-5 uppercase letters (min 3 to avoid futures ambiguity)
/// - Regex: `^[A-Z]{3,5}$`
/// - Examples: AAPL, MSFT, GOOGL, TSLA
///
/// # Note
/// 1-2 character symbols are rejected to avoid ambiguity with futures symbols
/// like ES, NQ, CL, etc. Use .FUT suffix for futures contracts.
///
/// # Arguments
/// * `symbol` - The equity symbol to validate (already uppercased)
///
/// # Returns
/// * `Ok(Asset::Equity)` - Valid equity symbol
/// * `Err(anyhow::Error)` - Invalid format
pub fn validate_equity(symbol: &str) -> Result<Asset> {
if EQUITY_RE.is_match(symbol) {
Ok(Asset::Equity {
symbol: symbol.to_string(),
})
} else {
bail!(
"Invalid asset format: '{}'\n\
Expected equity format: TICKER\n\
- TICKER: 3-5 uppercase letters (A-Z only)\n\
- Examples: AAPL, MSFT, GOOGL, TSLA\n\
Valid patterns:\n\
- Futures: ^[A-Z0-9]{{1,4}}\\.FUT$\n\
- Equities: ^[A-Z]{{3,5}}$",
symbol
);
}
}
}
// ============================================================================
// UNIT TESTS
// ============================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_asset_display() {
let future = Asset::Future {
symbol: "ES.FUT".to_string(),
};
assert_eq!(format!("{}", future), "ES.FUT");
let equity = Asset::Equity {
symbol: "AAPL".to_string(),
};
assert_eq!(format!("{}", equity), "AAPL");
}
#[test]
fn test_asset_helpers() {
let future = Asset::Future {
symbol: "ES.FUT".to_string(),
};
assert!(future.is_future());
assert!(!future.is_equity());
assert_eq!(future.symbol(), "ES.FUT");
let equity = Asset::Equity {
symbol: "AAPL".to_string(),
};
assert!(equity.is_equity());
assert!(!equity.is_future());
assert_eq!(equity.symbol(), "AAPL");
}
#[test]
fn test_parse_basic_futures() {
let result = AssetParser::parse("ES.FUT").unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_future());
}
#[test]
fn test_parse_basic_equities() {
let result = AssetParser::parse("AAPL").unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_equity());
}
#[test]
fn test_case_normalization() {
let result = AssetParser::parse("es.fut").unwrap();
assert_eq!(result[0].symbol(), "ES.FUT");
let result = AssetParser::parse("aapl").unwrap();
assert_eq!(result[0].symbol(), "AAPL");
}
#[test]
fn test_deduplication() {
let result = AssetParser::parse("ES.FUT,ES.FUT,es.fut").unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_whitespace_handling() {
let result = AssetParser::parse(" ES.FUT , AAPL ").unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_empty_input_error() {
assert!(AssetParser::parse("").is_err());
assert!(AssetParser::parse(" ").is_err());
assert!(AssetParser::parse(",,,").is_err());
}
#[test]
fn test_invalid_format_error() {
assert!(AssetParser::parse("INVALID_FORMAT").is_err());
assert!(AssetParser::parse("ES").is_err());
assert!(AssetParser::parse("TOOLONG").is_err());
}
}