Files
foxhunt/services/trading_service/src/assets.rs
jgrusewski e4870b17b9 fix: tune log levels across workspace — demote noisy warn to debug/trace
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>
2026-03-14 11:35:15 +01:00

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);
}
}