Reduce log noise for non-critical operational paths: connection retries, expected fallbacks, graceful degradation, and optional feature absence. Keeps warn/error for genuine failures requiring attention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
588 lines
19 KiB
Rust
588 lines
19 KiB
Rust
//! Asset Selection Module
|
|
//!
|
|
//! Implements asset selection logic to determine which instruments to trade from the universe.
|
|
//! Integrates ML predictions, technical indicators, and liquidity scoring to rank and select
|
|
//! the best trading opportunities.
|
|
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Utc};
|
|
use common::ml_strategy::{MLPrediction, SharedMLStrategy};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Asset score with multiple dimensions
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssetScore {
|
|
/// Trading symbol
|
|
pub symbol: String,
|
|
/// ML prediction score (0.0-1.0)
|
|
pub ml_score: f64,
|
|
/// Technical momentum score (0.0-1.0)
|
|
pub momentum_score: f64,
|
|
/// Fundamental value score (0.0-1.0)
|
|
pub value_score: f64,
|
|
/// Trading liquidity score (0.0-1.0)
|
|
pub liquidity_score: f64,
|
|
/// Weighted composite score (0.0-1.0)
|
|
pub composite_score: f64,
|
|
/// Timestamp when score was calculated
|
|
pub timestamp: DateTime<Utc>,
|
|
/// Additional metadata
|
|
pub metadata: HashMap<String, f64>,
|
|
}
|
|
|
|
/// Weighting configuration for composite scoring
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScoringWeights {
|
|
/// ML model weight (default: 0.4)
|
|
pub ml_weight: f64,
|
|
/// Momentum weight (default: 0.3)
|
|
pub momentum_weight: f64,
|
|
/// Value weight (default: 0.2)
|
|
pub value_weight: f64,
|
|
/// Liquidity weight (default: 0.1)
|
|
pub liquidity_weight: f64,
|
|
}
|
|
|
|
impl Default for ScoringWeights {
|
|
fn default() -> Self {
|
|
Self {
|
|
ml_weight: 0.4,
|
|
momentum_weight: 0.3,
|
|
value_weight: 0.2,
|
|
liquidity_weight: 0.1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ScoringWeights {
|
|
/// Validate that weights sum to approximately 1.0
|
|
pub fn validate(&self) -> Result<()> {
|
|
let sum = self.ml_weight + self.momentum_weight + self.value_weight + self.liquidity_weight;
|
|
if (sum - 1.0).abs() > 0.01 {
|
|
anyhow::bail!("Scoring weights must sum to 1.0, got {}", sum);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Normalize weights to sum to 1.0
|
|
pub fn normalize(&mut self) {
|
|
let sum = self.ml_weight + self.momentum_weight + self.value_weight + self.liquidity_weight;
|
|
if sum > 0.0 {
|
|
self.ml_weight /= sum;
|
|
self.momentum_weight /= sum;
|
|
self.value_weight /= sum;
|
|
self.liquidity_weight /= sum;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Market data for scoring calculations
|
|
#[derive(Debug, Clone)]
|
|
struct MarketData {
|
|
symbol: String,
|
|
current_price: f64,
|
|
volume_24h: f64,
|
|
prices_20d: Vec<f64>, // 20-day price history for momentum
|
|
}
|
|
|
|
/// Asset selector for choosing instruments from universe
|
|
pub struct AssetSelector {
|
|
/// Database connection pool
|
|
pool: PgPool,
|
|
/// ML strategy for predictions
|
|
ml_strategy: Arc<SharedMLStrategy>,
|
|
/// Scoring weights configuration
|
|
weights: ScoringWeights,
|
|
/// ML prediction cache (symbol -> (timestamp, prediction))
|
|
ml_cache: Arc<tokio::sync::RwLock<HashMap<String, (DateTime<Utc>, MLPrediction)>>>,
|
|
/// Cache TTL in seconds
|
|
cache_ttl_seconds: i64,
|
|
}
|
|
|
|
impl AssetSelector {
|
|
/// Create new asset selector
|
|
pub fn new(
|
|
pool: PgPool,
|
|
ml_strategy: Arc<SharedMLStrategy>,
|
|
weights: Option<ScoringWeights>,
|
|
) -> Result<Self> {
|
|
let mut weights = weights.unwrap_or_default();
|
|
weights.normalize(); // Ensure weights sum to 1.0
|
|
|
|
Ok(Self {
|
|
pool,
|
|
ml_strategy,
|
|
weights,
|
|
ml_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
|
|
cache_ttl_seconds: 300, // 5 minutes default
|
|
})
|
|
}
|
|
|
|
/// Select assets from universe based on composite scoring
|
|
pub async fn select_assets(
|
|
&self,
|
|
universe_id: &str,
|
|
max_assets: usize,
|
|
) -> Result<Vec<AssetScore>> {
|
|
debug!(
|
|
"Selecting up to {} assets from universe {}",
|
|
max_assets, universe_id
|
|
);
|
|
|
|
// Get instruments from universe
|
|
let symbols = self.get_universe_instruments(universe_id).await?;
|
|
|
|
if symbols.is_empty() {
|
|
info!("Universe {} has no instruments", universe_id);
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Get market data for all symbols
|
|
let market_data = self.fetch_market_data(&symbols).await?;
|
|
|
|
// Query ML predictions (with caching)
|
|
let ml_predictions = self.query_ml_predictions(&symbols).await?;
|
|
|
|
// Calculate scores for each asset
|
|
let mut asset_scores = Vec::new();
|
|
for data in market_data {
|
|
let ml_score = ml_predictions
|
|
.get(&data.symbol)
|
|
.map(|p| p.prediction_value)
|
|
.unwrap_or(0.5); // Default to neutral if ML unavailable
|
|
|
|
let momentum_score = self.calculate_momentum_score(&data)?;
|
|
let liquidity_score = self.calculate_liquidity_score(&data)?;
|
|
let value_score = self.calculate_value_score(&data)?;
|
|
|
|
let composite_score = self.calculate_composite_score(
|
|
ml_score,
|
|
momentum_score,
|
|
value_score,
|
|
liquidity_score,
|
|
);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("current_price".to_string(), data.current_price);
|
|
metadata.insert("volume_24h".to_string(), data.volume_24h);
|
|
|
|
asset_scores.push(AssetScore {
|
|
symbol: data.symbol.clone(),
|
|
ml_score,
|
|
momentum_score,
|
|
value_score,
|
|
liquidity_score,
|
|
composite_score,
|
|
timestamp: Utc::now(),
|
|
metadata,
|
|
});
|
|
}
|
|
|
|
// Sort by composite score (descending)
|
|
asset_scores.sort_by(|a, b| {
|
|
b.composite_score
|
|
.partial_cmp(&a.composite_score)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
|
|
// Take top N assets
|
|
let selected = asset_scores
|
|
.into_iter()
|
|
.take(max_assets)
|
|
.collect::<Vec<_>>();
|
|
|
|
// Store selection in database
|
|
self.store_selection(universe_id, &selected).await?;
|
|
|
|
Ok(selected)
|
|
}
|
|
|
|
/// Get selected assets by selection ID
|
|
pub async fn get_selected_assets(&self, selection_id: &str) -> Result<Vec<AssetScore>> {
|
|
let record = sqlx::query!(
|
|
r#"
|
|
SELECT asset_scores, selected_at
|
|
FROM asset_selections
|
|
WHERE id::text = $1
|
|
"#,
|
|
selection_id
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch selected assets")?;
|
|
|
|
if let Some(record) = record {
|
|
let assets: Vec<AssetScore> = serde_json::from_value(record.asset_scores)
|
|
.context("Failed to deserialize asset scores")?;
|
|
Ok(assets)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Query ML predictions for symbols (with caching)
|
|
async fn query_ml_predictions(
|
|
&self,
|
|
symbols: &[String],
|
|
) -> Result<HashMap<String, MLPrediction>> {
|
|
let mut predictions = HashMap::new();
|
|
let now = Utc::now();
|
|
|
|
// Check cache first
|
|
let cache = self.ml_cache.read().await;
|
|
let mut symbols_to_query = Vec::new();
|
|
|
|
for symbol in symbols {
|
|
if let Some((timestamp, prediction)) = cache.get(symbol) {
|
|
if (now - *timestamp).num_seconds() < self.cache_ttl_seconds {
|
|
predictions.insert(symbol.clone(), prediction.clone());
|
|
continue;
|
|
}
|
|
}
|
|
symbols_to_query.push(symbol.clone());
|
|
}
|
|
drop(cache);
|
|
|
|
// Query ML for uncached symbols
|
|
if !symbols_to_query.is_empty() {
|
|
match self.query_ml_batch(&symbols_to_query).await {
|
|
Ok(new_predictions) => {
|
|
let mut cache = self.ml_cache.write().await;
|
|
for (symbol, prediction) in new_predictions {
|
|
cache.insert(symbol.clone(), (now, prediction.clone()));
|
|
predictions.insert(symbol, prediction);
|
|
}
|
|
},
|
|
Err(e) => {
|
|
info!("ML service unavailable, using fallback scores: {}", e);
|
|
// Use technical scores only as fallback
|
|
},
|
|
}
|
|
}
|
|
|
|
Ok(predictions)
|
|
}
|
|
|
|
/// Query ML predictions in batch
|
|
async fn query_ml_batch(&self, symbols: &[String]) -> Result<HashMap<String, MLPrediction>> {
|
|
let mut predictions = HashMap::new();
|
|
|
|
// For each symbol, get ensemble prediction
|
|
for symbol in symbols {
|
|
// Use default market data for ML query
|
|
// In production, this would use real-time data
|
|
let price = 100.0; // Placeholder
|
|
let volume = 10000.0; // Placeholder
|
|
|
|
match self
|
|
.ml_strategy
|
|
.get_ensemble_prediction(price, volume, Utc::now())
|
|
.await
|
|
{
|
|
Ok(preds) if !preds.is_empty() => {
|
|
// Use the first prediction (or could use ensemble vote)
|
|
if let Some(first_pred) = preds.first() {
|
|
predictions.insert(symbol.clone(), first_pred.clone());
|
|
}
|
|
},
|
|
Ok(_) => {
|
|
debug!("No ML predictions for symbol {}", symbol);
|
|
},
|
|
Err(e) => {
|
|
warn!("Failed to get ML prediction for {}: {}", symbol, e);
|
|
},
|
|
}
|
|
}
|
|
|
|
Ok(predictions)
|
|
}
|
|
|
|
/// Calculate momentum score based on 20-day returns
|
|
fn calculate_momentum_score(&self, data: &MarketData) -> Result<f64> {
|
|
// Get oldest price or return neutral score
|
|
let oldest_price = match data.prices_20d.first() {
|
|
Some(price) => *price,
|
|
None => return Ok(0.5), // Neutral score if no history
|
|
};
|
|
let current_price = data.current_price;
|
|
|
|
if oldest_price == 0.0 {
|
|
return Ok(0.5);
|
|
}
|
|
|
|
let return_20d = (current_price - oldest_price) / oldest_price;
|
|
|
|
// Normalize to 0-1 range using sigmoid
|
|
// Strong momentum: >10% return
|
|
let normalized = 1.0 / (1.0 + (-return_20d * 10.0).exp());
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
/// Calculate liquidity score based on volume
|
|
fn calculate_liquidity_score(&self, data: &MarketData) -> Result<f64> {
|
|
// Normalize volume to 0-1 range
|
|
// High liquidity: >$10M daily volume
|
|
let volume_millions = data.volume_24h / 1_000_000.0;
|
|
let score = (volume_millions / 10.0).min(1.0);
|
|
|
|
Ok(score)
|
|
}
|
|
|
|
/// Calculate value score (placeholder for fundamental analysis)
|
|
fn calculate_value_score(&self, _data: &MarketData) -> Result<f64> {
|
|
// Placeholder: in production would use fundamental metrics
|
|
// P/E ratio, book value, earnings, etc.
|
|
Ok(0.5) // Neutral value score
|
|
}
|
|
|
|
/// Calculate composite score from component scores
|
|
fn calculate_composite_score(
|
|
&self,
|
|
ml_score: f64,
|
|
momentum_score: f64,
|
|
value_score: f64,
|
|
liquidity_score: f64,
|
|
) -> f64 {
|
|
ml_score * self.weights.ml_weight
|
|
+ momentum_score * self.weights.momentum_weight
|
|
+ value_score * self.weights.value_weight
|
|
+ liquidity_score * self.weights.liquidity_weight
|
|
}
|
|
|
|
/// Get instruments from universe
|
|
async fn get_universe_instruments(&self, universe_id: &str) -> Result<Vec<String>> {
|
|
// Query database for universe instruments from JSONB column
|
|
let record = sqlx::query!(
|
|
r#"
|
|
SELECT instruments
|
|
FROM trading_universes
|
|
WHERE universe_id = $1
|
|
"#,
|
|
universe_id
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch universe instruments")?;
|
|
|
|
if let Some(record) = record {
|
|
// Parse JSONB array of instruments
|
|
let instruments: Vec<serde_json::Value> = serde_json::from_value(record.instruments)
|
|
.context("Failed to deserialize instruments")?;
|
|
|
|
// Extract symbols from instrument objects
|
|
let symbols = instruments
|
|
.into_iter()
|
|
.filter_map(|inst| {
|
|
inst.as_object()
|
|
.and_then(|obj| obj.get("symbol"))
|
|
.and_then(|s| s.as_str())
|
|
.map(|s| s.to_string())
|
|
})
|
|
.collect();
|
|
|
|
Ok(symbols)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Fetch market data for symbols
|
|
async fn fetch_market_data(&self, symbols: &[String]) -> Result<Vec<MarketData>> {
|
|
let mut market_data = Vec::new();
|
|
|
|
for symbol in symbols {
|
|
// Query latest market data for current price and volume
|
|
let latest = sqlx::query!(
|
|
r#"
|
|
SELECT close_price, volume
|
|
FROM market_data
|
|
WHERE symbol = $1
|
|
ORDER BY timestamp DESC
|
|
LIMIT 1
|
|
"#,
|
|
symbol
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch latest market data")?;
|
|
|
|
// Query 20-day price history for momentum calculation
|
|
let history = sqlx::query!(
|
|
r#"
|
|
SELECT close_price
|
|
FROM market_data
|
|
WHERE symbol = $1
|
|
ORDER BY timestamp DESC
|
|
LIMIT 20
|
|
"#,
|
|
symbol
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.context("Failed to fetch price history")?;
|
|
|
|
if let Some(latest_data) = latest {
|
|
let current_price = {
|
|
use num_traits::ToPrimitive;
|
|
latest_data.close_price.to_f64().unwrap_or(0.0)
|
|
};
|
|
|
|
let volume_24h = {
|
|
use num_traits::ToPrimitive;
|
|
latest_data.volume.to_f64().unwrap_or(0.0)
|
|
};
|
|
|
|
let prices_20d = history
|
|
.iter()
|
|
.filter_map(|r| {
|
|
use num_traits::ToPrimitive;
|
|
r.close_price.to_f64()
|
|
})
|
|
.collect();
|
|
|
|
market_data.push(MarketData {
|
|
symbol: symbol.clone(),
|
|
current_price,
|
|
volume_24h,
|
|
prices_20d,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(market_data)
|
|
}
|
|
|
|
/// Store selection in database
|
|
async fn store_selection(&self, universe_id: &str, assets: &[AssetScore]) -> Result<()> {
|
|
// Serialize assets to JSONB
|
|
let asset_scores_json =
|
|
serde_json::to_value(assets).context("Failed to serialize asset scores")?;
|
|
|
|
// Create criteria JSON (default for now)
|
|
let criteria = serde_json::json!({
|
|
"ml_weight": self.weights.ml_weight,
|
|
"momentum_weight": self.weights.momentum_weight,
|
|
"value_weight": self.weights.value_weight,
|
|
"liquidity_weight": self.weights.liquidity_weight,
|
|
});
|
|
|
|
// Create metrics JSON
|
|
let metrics = serde_json::json!({
|
|
"total_assets": assets.len(),
|
|
"avg_composite_score": if !assets.is_empty() {
|
|
assets.iter().map(|a| a.composite_score).sum::<f64>() / assets.len() as f64
|
|
} else {
|
|
0.0
|
|
},
|
|
"avg_ml_score": if !assets.is_empty() {
|
|
assets.iter().map(|a| a.ml_score).sum::<f64>() / assets.len() as f64
|
|
} else {
|
|
0.0
|
|
},
|
|
});
|
|
|
|
sqlx::query!(
|
|
r#"
|
|
INSERT INTO asset_selections (universe_id, criteria, asset_scores, metrics)
|
|
VALUES ($1, $2, $3, $4)
|
|
"#,
|
|
universe_id,
|
|
criteria,
|
|
asset_scores_json,
|
|
metrics
|
|
)
|
|
.execute(&self.pool)
|
|
.await
|
|
.context("Failed to store asset selection")?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_scoring_weights_default() {
|
|
let weights = ScoringWeights::default();
|
|
assert_eq!(weights.ml_weight, 0.4);
|
|
assert_eq!(weights.momentum_weight, 0.3);
|
|
assert_eq!(weights.value_weight, 0.2);
|
|
assert_eq!(weights.liquidity_weight, 0.1);
|
|
|
|
// Should sum to 1.0
|
|
let sum = weights.ml_weight
|
|
+ weights.momentum_weight
|
|
+ weights.value_weight
|
|
+ weights.liquidity_weight;
|
|
assert!((sum - 1.0).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scoring_weights_normalize() {
|
|
let mut weights = ScoringWeights {
|
|
ml_weight: 2.0,
|
|
momentum_weight: 1.0,
|
|
value_weight: 1.0,
|
|
liquidity_weight: 0.5,
|
|
};
|
|
|
|
weights.normalize();
|
|
|
|
// Should sum to 1.0 after normalization
|
|
let sum = weights.ml_weight
|
|
+ weights.momentum_weight
|
|
+ weights.value_weight
|
|
+ weights.liquidity_weight;
|
|
assert!((sum - 1.0).abs() < 0.001);
|
|
|
|
// Ratios should be preserved
|
|
assert!((weights.ml_weight / weights.momentum_weight - 2.0).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scoring_weights_validate() {
|
|
let weights = ScoringWeights::default();
|
|
assert!(weights.validate().is_ok());
|
|
|
|
let invalid_weights = ScoringWeights {
|
|
ml_weight: 0.5,
|
|
momentum_weight: 0.3,
|
|
value_weight: 0.2,
|
|
liquidity_weight: 0.2, // Sum > 1.0
|
|
};
|
|
assert!(invalid_weights.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_asset_score_serialization() {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("test_key".to_string(), 42.0);
|
|
|
|
let score = AssetScore {
|
|
symbol: "BTC".to_string(),
|
|
ml_score: 0.75,
|
|
momentum_score: 0.6,
|
|
value_score: 0.5,
|
|
liquidity_score: 0.9,
|
|
composite_score: 0.7,
|
|
timestamp: Utc::now(),
|
|
metadata,
|
|
};
|
|
|
|
// Test serialization round-trip
|
|
let json = serde_json::to_string(&score).expect("INVARIANT: Serialization should succeed for valid types");
|
|
let deserialized: AssetScore = serde_json::from_str(&json).expect("INVARIANT: Deserialization should succeed for valid JSON");
|
|
|
|
assert_eq!(deserialized.symbol, score.symbol);
|
|
assert_eq!(deserialized.ml_score, score.ml_score);
|
|
assert_eq!(deserialized.composite_score, score.composite_score);
|
|
}
|
|
}
|