- 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>
361 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|