use crate::data::HistoricalData; use crate::types::{ExitConfig, ExitReason, ExitSignal, Fill, MarketCandidate, Side, Signal, TradingContext}; use rust_decimal::Decimal; use rust_decimal::prelude::ToPrimitive; use std::sync::Arc; #[derive(Debug, Clone)] pub struct PositionSizingConfig { pub kelly_fraction: f64, pub max_position_pct: f64, pub min_position_size: u64, pub max_position_size: u64, } impl Default for PositionSizingConfig { fn default() -> Self { // iteration 4: increased kelly from 0.25 to 0.40 // research shows half-kelly to full-kelly range works well // with 100% win rate on closed trades, we can be more aggressive Self { kelly_fraction: 0.40, max_position_pct: 0.30, min_position_size: 10, max_position_size: 1000, } } } impl PositionSizingConfig { pub fn conservative() -> Self { Self { kelly_fraction: 0.1, max_position_pct: 0.1, min_position_size: 10, max_position_size: 500, } } pub fn aggressive() -> Self { Self { kelly_fraction: 0.5, max_position_pct: 0.4, min_position_size: 10, max_position_size: 2000, } } } /// maps scoring edge [-inf, +inf] to win probability [0, 1] /// tanh squashes extreme values smoothly; +1)/2 shifts from [-1,1] to [0,1] fn edge_to_win_probability(edge: f64) -> f64 { (1.0 + edge.tanh()) / 2.0 } fn kelly_size( edge: f64, price: f64, bankroll: f64, config: &PositionSizingConfig, ) -> u64 { if edge.abs() < 0.01 || price <= 0.0 || price >= 1.0 { return 0; } let win_prob = edge_to_win_probability(edge); let odds = (1.0 - price) / price; if odds <= 0.0 { return 0; } let kelly = (odds * win_prob - (1.0 - win_prob)) / odds; let safe_kelly = (kelly * config.kelly_fraction).max(0.0); let position_value = bankroll * safe_kelly.min(config.max_position_pct); let shares = (position_value / price).floor() as u64; shares.max(config.min_position_size).min(config.max_position_size) } pub struct Executor { data: Arc, slippage_bps: u32, max_position_size: u64, sizing_config: PositionSizingConfig, exit_config: ExitConfig, } impl Executor { pub fn new(data: Arc, slippage_bps: u32, max_position_size: u64) -> Self { Self { data, slippage_bps, max_position_size, sizing_config: PositionSizingConfig::default(), exit_config: ExitConfig::default(), } } pub fn with_sizing_config(mut self, config: PositionSizingConfig) -> Self { self.sizing_config = config; self } pub fn with_exit_config(mut self, config: ExitConfig) -> Self { self.exit_config = config; self } pub fn generate_exit_signals( &self, context: &TradingContext, candidate_scores: &std::collections::HashMap, ) -> Vec { let mut exits = Vec::new(); for (ticker, position) in &context.portfolio.positions { let current_price = match self.data.get_current_price(ticker, context.timestamp) { Some(p) => p, None => continue, }; let effective_price = match position.side { Side::Yes => current_price, Side::No => Decimal::ONE - current_price, }; let entry_price_f64 = position.avg_entry_price.to_f64().unwrap_or(0.5); let current_price_f64 = effective_price.to_f64().unwrap_or(0.5); if entry_price_f64 <= 0.0 { continue; } let pnl_pct = (current_price_f64 - entry_price_f64) / entry_price_f64; if pnl_pct >= self.exit_config.take_profit_pct { exits.push(ExitSignal { ticker: ticker.clone(), reason: ExitReason::TakeProfit { pnl_pct }, current_price, }); continue; } if pnl_pct <= -self.exit_config.stop_loss_pct { exits.push(ExitSignal { ticker: ticker.clone(), reason: ExitReason::StopLoss { pnl_pct }, current_price, }); continue; } let hours_held = (context.timestamp - position.entry_time).num_hours(); if hours_held >= self.exit_config.max_hold_hours { exits.push(ExitSignal { ticker: ticker.clone(), reason: ExitReason::TimeStop { hours_held }, current_price, }); continue; } if let Some(&new_score) = candidate_scores.get(ticker) { if new_score < self.exit_config.score_reversal_threshold { exits.push(ExitSignal { ticker: ticker.clone(), reason: ExitReason::ScoreReversal { new_score }, current_price, }); } } } exits } pub fn generate_signals( &self, candidates: &[MarketCandidate], context: &TradingContext, ) -> Vec { candidates .iter() .filter_map(|c| self.candidate_to_signal(c, context)) .collect() } fn candidate_to_signal( &self, candidate: &MarketCandidate, context: &TradingContext, ) -> Option { let current_position = context.portfolio.get_position(&candidate.ticker); let current_qty = current_position.map(|p| p.quantity).unwrap_or(0); if current_qty >= self.max_position_size { return None; } let yes_price = candidate.current_yes_price.to_f64().unwrap_or(0.5); // positive score = bullish signal, so buy the cheaper side (better risk/reward) // negative score = bearish signal, so buy against the expensive side let side = if candidate.final_score > 0.0 { if yes_price < 0.5 { Side::Yes } else { Side::No } } else if candidate.final_score < 0.0 { if yes_price > 0.5 { Side::No } else { Side::Yes } } else { return None; }; let price = match side { Side::Yes => candidate.current_yes_price, Side::No => candidate.current_no_price, }; let available_cash = context.portfolio.cash.to_f64().unwrap_or(0.0); let price_f64 = price.to_f64().unwrap_or(0.5); if price_f64 <= 0.0 { return None; } let kelly_qty = kelly_size( candidate.final_score, price_f64, available_cash, &self.sizing_config, ); let max_affordable = (available_cash / price_f64) as u64; let quantity = kelly_qty .min(max_affordable) .min(self.max_position_size - current_qty); if quantity < self.sizing_config.min_position_size { return None; } Some(Signal { ticker: candidate.ticker.clone(), side, quantity, limit_price: Some(price), reason: format!( "score={:.3}, side={:?}, price={:.2}", candidate.final_score, side, price_f64 ), }) } pub fn execute_signal( &self, signal: &Signal, context: &TradingContext, ) -> Option { let market_price = self.data.get_current_price(&signal.ticker, context.timestamp)?; let effective_price = match signal.side { Side::Yes => market_price, Side::No => Decimal::ONE - market_price, }; let slippage = Decimal::from(self.slippage_bps) / Decimal::from(10000); let fill_price = effective_price * (Decimal::ONE + slippage); if let Some(limit) = signal.limit_price { if fill_price > limit * (Decimal::ONE + slippage * Decimal::from(2)) { return None; } } let cost = fill_price * Decimal::from(signal.quantity); if cost > context.portfolio.cash { let affordable = (context.portfolio.cash / fill_price) .to_u64() .unwrap_or(0); if affordable == 0 { return None; } return Some(Fill { ticker: signal.ticker.clone(), side: signal.side, quantity: affordable, price: fill_price, timestamp: context.timestamp, }); } Some(Fill { ticker: signal.ticker.clone(), side: signal.side, quantity: signal.quantity, price: fill_price, timestamp: context.timestamp, }) } } pub fn simple_signal_generator( candidates: &[MarketCandidate], context: &TradingContext, position_size: u64, ) -> Vec { candidates .iter() .filter(|c| c.final_score > 0.0) .filter(|c| !context.portfolio.has_position(&c.ticker)) .map(|c| { let yes_price = c.current_yes_price.to_f64().unwrap_or(0.5); let (side, price) = if yes_price < 0.5 { (Side::Yes, c.current_yes_price) } else { (Side::No, c.current_no_price) }; Signal { ticker: c.ticker.clone(), side, quantity: position_size, limit_price: Some(price), reason: format!("simple: score={:.3}", c.final_score), } }) .collect() }