Files
foxhunt/crates/ml-asset-selection/src/selector.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

307 lines
9.2 KiB
Rust

//! Active-set selector -- picks the top-N assets for ensemble prediction.
//!
//! This is the final tier of the selection funnel. After assets are scored
//! by [`super::scorer::PredictabilityScorer`], the `ActiveSetSelector`
//! filters by a minimum composite score threshold and retains only the top
//! `max_active` candidates. Only assets in the active set receive full
//! ensemble predictions each tick, keeping compute costs bounded.
use std::cmp::Ordering;
use std::fmt;
use std::time::{Duration, Instant};
use super::scorer::AssetScore;
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// A scored asset that has been promoted into the active trading set.
#[derive(Debug, Clone)]
pub struct TradingCandidate {
/// The underlying composite score.
pub score: AssetScore,
/// When this candidate was selected into the active set.
pub selected_at: Instant,
}
/// Configuration for the active-set selector.
#[derive(Debug, Clone)]
pub struct ActiveSetConfig {
/// Maximum number of assets in the active set.
pub max_active: usize,
/// Minimum composite score required for consideration.
pub min_score_threshold: f64,
/// How often the active set should be re-evaluated.
pub rebalance_interval: Duration,
}
impl Default for ActiveSetConfig {
fn default() -> Self {
Self {
max_active: 5,
min_score_threshold: 0.40,
rebalance_interval: Duration::from_secs(24 * 60 * 60),
}
}
}
// ---------------------------------------------------------------------------
// Selector
// ---------------------------------------------------------------------------
/// Maintains the active set of assets eligible for ensemble predictions.
///
/// Call [`select`](ActiveSetSelector::select) with fresh scores whenever the
/// selector [`needs_rebalance`](ActiveSetSelector::needs_rebalance).
pub struct ActiveSetSelector {
config: ActiveSetConfig,
active_set: Vec<TradingCandidate>,
last_rebalance: Option<Instant>,
}
impl fmt::Debug for ActiveSetSelector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ActiveSetSelector")
.field("config", &self.config)
.field("active_count", &self.active_set.len())
.field("has_rebalanced", &self.last_rebalance.is_some())
.finish()
}
}
impl ActiveSetSelector {
/// Create a new selector with the given configuration.
#[must_use]
pub const fn new(config: ActiveSetConfig) -> Self {
Self {
config,
active_set: Vec::new(),
last_rebalance: None,
}
}
/// Return a reference to the current active set.
#[must_use]
pub fn active_set(&self) -> &[TradingCandidate] {
&self.active_set
}
/// Return the symbols of all currently active assets.
#[must_use]
pub fn active_symbols(&self) -> Vec<String> {
self.active_set
.iter()
.map(|c| c.score.symbol.clone())
.collect()
}
/// Number of assets currently in the active set.
#[must_use]
pub fn active_count(&self) -> usize {
self.active_set.len()
}
/// Whether the selector has never run or the rebalance interval has
/// elapsed since the last selection.
#[must_use]
pub fn needs_rebalance(&self) -> bool {
match self.last_rebalance {
None => true,
Some(last) => last.elapsed() >= self.config.rebalance_interval,
}
}
/// Run the selection funnel on fresh scores.
///
/// 1. Filter by `min_score_threshold`
/// 2. Sort descending by composite score
/// 3. Take top `max_active`
/// 4. Store as the new active set
///
/// Returns the newly selected candidates (same as [`active_set()`]).
pub fn select(&mut self, scores: &[AssetScore]) -> Vec<TradingCandidate> {
let now = Instant::now();
// 1. Filter by threshold
let mut eligible: Vec<&AssetScore> = scores
.iter()
.filter(|s| s.composite >= self.config.min_score_threshold)
.collect();
// 2. Sort descending by composite
eligible.sort_by(|a, b| {
b.composite
.partial_cmp(&a.composite)
.unwrap_or(Ordering::Equal)
});
// 3. Take top max_active
let selected: Vec<TradingCandidate> = eligible
.iter()
.take(self.config.max_active)
.map(|s| TradingCandidate {
score: (*s).clone(),
selected_at: now,
})
.collect();
// 4. Store and update timestamp
self.active_set = selected.clone();
self.last_rebalance = Some(now);
selected
}
/// Check whether a symbol is currently in the active set.
#[must_use]
pub fn is_active(&self, symbol: &str) -> bool {
self.active_set.iter().any(|c| c.score.symbol == symbol)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::str_to_string, clippy::get_first)]
mod tests {
use super::*;
/// Helper to build an `AssetScore` with a given symbol and composite
/// score. Other fields are set to the composite value for simplicity.
fn make_score(symbol: &str, composite: f64) -> AssetScore {
AssetScore {
symbol: symbol.to_string(),
predictability: composite,
liquidity: composite,
regime_fit: composite,
composite,
}
}
#[test]
fn test_select_top_n() {
let config = ActiveSetConfig {
max_active: 3,
..Default::default()
};
let mut selector = ActiveSetSelector::new(config);
let scores = vec![
make_score("A", 0.90),
make_score("B", 0.80),
make_score("C", 0.70),
make_score("D", 0.60),
make_score("E", 0.50),
];
let selected = selector.select(&scores);
assert_eq!(selected.len(), 3);
assert_eq!(
selected.get(0).map(|c| c.score.symbol.as_str()),
Some("A")
);
assert_eq!(
selected.get(1).map(|c| c.score.symbol.as_str()),
Some("B")
);
assert_eq!(
selected.get(2).map(|c| c.score.symbol.as_str()),
Some("C")
);
}
#[test]
fn test_min_score_filters() {
let config = ActiveSetConfig {
min_score_threshold: 0.5,
..Default::default()
};
let mut selector = ActiveSetSelector::new(config);
let scores = vec![
make_score("HIGH", 0.80),
make_score("MID", 0.50),
make_score("LOW", 0.30),
];
let selected = selector.select(&scores);
// Only HIGH (0.80) and MID (0.50) pass the threshold
assert_eq!(selected.len(), 2);
assert!(selector.is_active("HIGH"));
assert!(selector.is_active("MID"));
assert!(!selector.is_active("LOW"));
}
#[test]
fn test_empty_scores() {
let mut selector = ActiveSetSelector::new(ActiveSetConfig::default());
let selected = selector.select(&[]);
assert!(selected.is_empty());
assert_eq!(selector.active_count(), 0);
}
#[test]
fn test_is_active() {
let config = ActiveSetConfig {
max_active: 2,
min_score_threshold: 0.0,
..Default::default()
};
let mut selector = ActiveSetSelector::new(config);
let scores = vec![
make_score("AAPL", 0.90),
make_score("MSFT", 0.80),
make_score("GOOG", 0.70),
];
selector.select(&scores);
assert!(selector.is_active("AAPL"));
assert!(selector.is_active("MSFT"));
assert!(!selector.is_active("GOOG"));
}
#[test]
fn test_needs_rebalance_initially() {
let selector = ActiveSetSelector::new(ActiveSetConfig::default());
assert!(selector.needs_rebalance());
}
#[test]
fn test_rebalance_resets_timer() {
let mut selector = ActiveSetSelector::new(ActiveSetConfig::default());
assert!(selector.needs_rebalance());
selector.select(&[make_score("X", 0.80)]);
// Right after select the timer was just set, so interval hasn't elapsed
assert!(!selector.needs_rebalance());
}
#[test]
fn test_active_symbols() {
let config = ActiveSetConfig {
max_active: 3,
min_score_threshold: 0.0,
..Default::default()
};
let mut selector = ActiveSetSelector::new(config);
let scores = vec![
make_score("AAPL", 0.90),
make_score("MSFT", 0.80),
make_score("NVDA", 0.70),
];
selector.select(&scores);
let symbols = selector.active_symbols();
assert_eq!(symbols.len(), 3);
assert!(symbols.contains(&"AAPL".to_owned()));
assert!(symbols.contains(&"MSFT".to_owned()));
assert!(symbols.contains(&"NVDA".to_owned()));
}
}