feat(ml): add asset_selection module with AssetUniverse config types

Introduces the asset_selection module with AssetClass enum (Equity/ETF/Future),
UniverseAsset struct, and AssetUniverse with filtering, lookup, and a us_starter()
preset of 7 highly liquid US assets. Includes scorer/selector placeholder stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 13:23:42 +01:00
parent 766e16e724
commit d2fe4c4e98
4 changed files with 212 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
//! Asset universe configuration and selection for the trading pipeline.
//!
//! This module defines which assets are eligible for trading, including
//! their classification, exchange, sector, and minimum volume requirements.
use serde::{Deserialize, Serialize};
pub mod scorer;
pub mod selector;
/// Classification of a tradable asset.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetClass {
/// Individual equity (stock).
Equity,
/// Exchange-traded fund.
ETF,
/// Futures contract.
Future,
}
/// A single asset in the trading universe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniverseAsset {
/// Ticker symbol (e.g. "AAPL").
pub symbol: String,
/// MIC exchange code (e.g. "XNAS").
pub exchange: String,
/// Asset classification.
pub asset_class: AssetClass,
/// Optional GICS sector.
pub sector: Option<String>,
/// Minimum average daily volume required for eligibility.
pub min_daily_volume: f64,
/// Whether this asset is enabled for trading.
pub enabled: bool,
}
/// The full set of assets available for trading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetUniverse {
/// All configured assets.
pub assets: Vec<UniverseAsset>,
}
impl AssetUniverse {
/// Create an empty universe.
#[must_use]
pub fn new() -> Self {
Self { assets: Vec::new() }
}
/// Return only enabled assets.
#[must_use]
pub fn eligible(&self) -> Vec<&UniverseAsset> {
self.assets.iter().filter(|a| a.enabled).collect()
}
/// Count of enabled assets.
#[must_use]
pub fn eligible_count(&self) -> usize {
self.assets.iter().filter(|a| a.enabled).count()
}
/// Find an asset by symbol (case-sensitive).
#[must_use]
pub fn find(&self, symbol: &str) -> Option<&UniverseAsset> {
self.assets.iter().find(|a| a.symbol == symbol)
}
/// List all asset symbols.
#[must_use]
pub fn symbols(&self) -> Vec<&str> {
self.assets.iter().map(|a| a.symbol.as_str()).collect()
}
/// Preset: US large-cap starter universe (7 highly liquid assets).
#[must_use]
pub fn us_starter() -> Self {
Self {
assets: vec![
UniverseAsset {
symbol: "SPY".to_string(),
exchange: "XNYS".to_string(),
asset_class: AssetClass::ETF,
sector: None,
min_daily_volume: 50_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "QQQ".to_string(),
exchange: "XNAS".to_string(),
asset_class: AssetClass::ETF,
sector: None,
min_daily_volume: 30_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "AAPL".to_string(),
exchange: "XNAS".to_string(),
asset_class: AssetClass::Equity,
sector: Some("Technology".to_string()),
min_daily_volume: 50_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "MSFT".to_string(),
exchange: "XNAS".to_string(),
asset_class: AssetClass::Equity,
sector: Some("Technology".to_string()),
min_daily_volume: 20_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "NVDA".to_string(),
exchange: "XNAS".to_string(),
asset_class: AssetClass::Equity,
sector: Some("Technology".to_string()),
min_daily_volume: 30_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "AMZN".to_string(),
exchange: "XNAS".to_string(),
asset_class: AssetClass::Equity,
sector: Some("Consumer".to_string()),
min_daily_volume: 20_000_000.0,
enabled: true,
},
UniverseAsset {
symbol: "IWM".to_string(),
exchange: "XNYS".to_string(),
asset_class: AssetClass::ETF,
sector: None,
min_daily_volume: 20_000_000.0,
enabled: true,
},
],
}
}
}
impl Default for AssetUniverse {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_us_starter_universe() {
let universe = AssetUniverse::us_starter();
assert_eq!(universe.assets.len(), 7);
assert_eq!(universe.eligible_count(), 7);
}
#[test]
fn test_eligible_filters_disabled() {
let mut universe = AssetUniverse::us_starter();
if let Some(first) = universe.assets.get_mut(0) {
first.enabled = false;
}
assert_eq!(universe.eligible_count(), 6);
}
#[test]
fn test_find_symbol() {
let universe = AssetUniverse::us_starter();
let aapl = universe.find("AAPL");
assert!(aapl.is_some());
assert_eq!(
aapl.map(|a| a.exchange.as_str()).unwrap_or_default(),
"XNAS"
);
}
#[test]
fn test_find_missing_symbol() {
let universe = AssetUniverse::us_starter();
assert!(universe.find("DOESNOTEXIST").is_none());
}
#[test]
fn test_symbols_list() {
let universe = AssetUniverse::us_starter();
let symbols = universe.symbols();
assert_eq!(symbols.len(), 7);
assert!(symbols.contains(&"SPY"));
assert!(symbols.contains(&"AAPL"));
}
#[test]
fn test_empty_universe() {
let universe = AssetUniverse::new();
assert!(universe.eligible().is_empty());
}
#[test]
fn test_serde_roundtrip() {
let universe = AssetUniverse::us_starter();
let json = serde_json::to_string(&universe).unwrap_or_default();
let restored: AssetUniverse =
serde_json::from_str(&json).unwrap_or_else(|_| AssetUniverse::new());
assert_eq!(restored.assets.len(), 7);
}
}

View File

@@ -0,0 +1 @@
//! Scorer module - TBD

View File

@@ -0,0 +1 @@
//! Selector module - TBD

View File

@@ -833,6 +833,7 @@ pub mod data_validation;
pub mod random_model;
pub mod model_registry;
pub mod data_pipeline;
pub mod asset_selection;
// ========== MISSING TYPES STUBS ==========