diff --git a/crates/ml/src/common/action.rs b/crates/ml/src/common/action.rs index 129c55e29..702bf9178 100644 --- a/crates/ml/src/common/action.rs +++ b/crates/ml/src/common/action.rs @@ -1,5 +1,6 @@ use crate::MLError; use serde::{Deserialize, Serialize}; +use std::fmt; /// Order type for execution strategy. /// @@ -52,8 +53,8 @@ impl OrderType { } } -impl std::fmt::Display for OrderType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for OrderType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { OrderType::Market => write!(f, "Market"), OrderType::LimitMaker => write!(f, "LimitMaker"), @@ -61,3 +62,353 @@ impl std::fmt::Display for OrderType { } } } + +/// Exposure level for position sizing (-100% to +100%) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ExposureLevel { + Short100 = 0, // -100% of max position + Short50 = 1, // -50% + Flat = 2, // 0% (neutral) + Long50 = 3, // +50% + Long100 = 4, // +100% +} + +impl ExposureLevel { + /// Get target portfolio value percentage + pub fn target_exposure(&self) -> f64 { + match self { + ExposureLevel::Short100 => -1.0, + ExposureLevel::Short50 => -0.5, + ExposureLevel::Flat => 0.0, + ExposureLevel::Long50 => 0.5, + ExposureLevel::Long100 => 1.0, + } + } + + /// Convert from index (0-4) + pub fn from_index(idx: usize) -> Result { + match idx { + 0 => Ok(ExposureLevel::Short100), + 1 => Ok(ExposureLevel::Short50), + 2 => Ok(ExposureLevel::Flat), + 3 => Ok(ExposureLevel::Long50), + 4 => Ok(ExposureLevel::Long100), + _ => Err(MLError::InvalidInput(format!( + "Invalid exposure level index: {}", + idx + ))), + } + } +} + +impl fmt::Display for ExposureLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ExposureLevel::Short100 => write!(f, "Short100"), + ExposureLevel::Short50 => write!(f, "Short50"), + ExposureLevel::Flat => write!(f, "Flat"), + ExposureLevel::Long50 => write!(f, "Long50"), + ExposureLevel::Long100 => write!(f, "Long100"), + } + } +} + +/// Urgency level for execution timing +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Urgency { + Patient = 0, // Wait for better price + Normal = 1, // Standard execution + Aggressive = 2, // Immediate execution +} + +impl Urgency { + /// Get urgency weight (0.5-1.5) + pub fn urgency_weight(&self) -> f64 { + match self { + Urgency::Patient => 0.5, + Urgency::Normal => 1.0, + Urgency::Aggressive => 1.5, + } + } + + /// Convert from index (0-2) + pub fn from_index(idx: usize) -> Result { + match idx { + 0 => Ok(Urgency::Patient), + 1 => Ok(Urgency::Normal), + 2 => Ok(Urgency::Aggressive), + _ => Err(MLError::InvalidInput(format!( + "Invalid urgency index: {}", + idx + ))), + } + } +} + +impl fmt::Display for Urgency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Urgency::Patient => write!(f, "Patient"), + Urgency::Normal => write!(f, "Normal"), + Urgency::Aggressive => write!(f, "Aggressive"), + } + } +} + +/// Factored trading action combining exposure, order type, and urgency. +/// +/// 5 exposure levels x 3 order types x 3 urgency levels = 45 actions. +/// Index mapping: `index = exposure * 9 + order * 3 + urgency` (0-44). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FactoredAction { + pub exposure: ExposureLevel, + pub order: OrderType, + pub urgency: Urgency, +} + +impl FactoredAction { + /// Create a new trading action + pub fn new(exposure: ExposureLevel, order: OrderType, urgency: Urgency) -> Self { + Self { + exposure, + order, + urgency, + } + } + + /// Map action index (0-44) to (exposure, order, urgency) + /// Index = exposure * 9 + order * 3 + urgency + pub fn from_index(idx: usize) -> Result { + if idx >= 45 { + return Err(MLError::InvalidInput(format!( + "Action index {} out of bounds (0-44)", + idx + ))); + } + + let exposure_idx = idx / 9; + let order_idx = (idx % 9) / 3; + let urgency_idx = idx % 3; + + Ok(Self { + exposure: ExposureLevel::from_index(exposure_idx)?, + order: OrderType::from_index(order_idx)?, + urgency: Urgency::from_index(urgency_idx)?, + }) + } + + /// Map (exposure, order, urgency) to action index (0-44) + /// Index = exposure * 9 + order * 3 + urgency + pub fn to_index(&self) -> usize { + let exposure_idx = self.exposure as usize; + let order_idx = self.order as usize; + let urgency_idx = self.urgency as usize; + + exposure_idx * 9 + order_idx * 3 + urgency_idx + } + + /// Get target portfolio value percentage + pub fn target_exposure(&self) -> f64 { + self.exposure.target_exposure() + } + + /// Get transaction cost multiplier + pub fn transaction_cost(&self) -> f64 { + self.order.transaction_cost() + } + + /// Get urgency weight (0.5-1.5) + pub fn urgency_weight(&self) -> f64 { + self.urgency.urgency_weight() + } + + /// Convert FactoredAction to legacy TradingAction for reward calculation + /// + /// Maps exposure levels to simple Buy/Sell/Hold actions: + /// - Long100, Long50 -> Buy + /// - Flat -> Hold + /// - Short50, Short100 -> Sell + pub fn to_legacy_action(&self) -> crate::dqn::agent::TradingAction { + use crate::dqn::agent::TradingAction; + match self.exposure { + ExposureLevel::Long100 | ExposureLevel::Long50 => TradingAction::Buy, + ExposureLevel::Flat => TradingAction::Hold, + ExposureLevel::Short50 | ExposureLevel::Short100 => TradingAction::Sell, + } + } + + /// Alias for to_legacy_action() - converts to TradingAction for backward compatibility + pub fn to_trading_action(&self) -> crate::dqn::agent::TradingAction { + self.to_legacy_action() + } + + /// Calculate total transaction cost for this action given trade value + /// + /// # Arguments + /// + /// * `trade_value` - Absolute value of the trade (price x position_size x |exposure|) + /// + /// # Returns + /// + /// Total cost in dollars based on order type: + /// - **Market**: 0.15% (0.0015 x trade_value) + /// - **LimitMaker**: 0.05% (0.0005 x trade_value) + /// - **IoC**: 0.10% (0.0010 x trade_value) + pub fn calculate_transaction_cost(&self, trade_value: f64) -> f64 { + trade_value * self.transaction_cost() + } + + /// Check if this action is a buy (long exposure) + pub fn is_buy(&self) -> bool { + matches!( + self.exposure, + ExposureLevel::Long100 | ExposureLevel::Long50 + ) + } + + /// Check if this action is a sell (short exposure) + pub fn is_sell(&self) -> bool { + matches!( + self.exposure, + ExposureLevel::Short100 | ExposureLevel::Short50 + ) + } + + /// Check if this action is neutral (flat exposure) + pub fn is_hold(&self) -> bool { + matches!(self.exposure, ExposureLevel::Flat) + } + + /// Convert action to position delta + /// + /// Calculates the change in position needed to reach the target exposure. + /// + /// # Arguments + /// * `current_position` - Current absolute position (e.g., 1.0, -0.5) + /// * `max_position` - Maximum allowed position magnitude (typically 2.0) + /// + /// # Returns + /// Position delta to achieve target exposure + pub fn to_position_delta(&self, current_position: f64, max_position: f64) -> f64 { + let target_exposure = self.target_exposure(); + let target_position = target_exposure * max_position; + target_position - current_position + } +} + +impl fmt::Display for FactoredAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}+{}+{}", self.exposure, self.order, self.urgency) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_factored_action_round_trip_all_45() { + for idx in 0..45 { + let action = FactoredAction::from_index(idx).unwrap(); + assert_eq!( + action.to_index(), + idx, + "round-trip failed for index {}", + idx + ); + } + } + + #[test] + fn test_factored_action_out_of_bounds() { + assert!(FactoredAction::from_index(45).is_err()); + assert!(FactoredAction::from_index(100).is_err()); + } + + #[test] + fn test_factored_action_to_legacy() { + use crate::dqn::agent::TradingAction; + let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); + assert_eq!(buy.to_legacy_action(), TradingAction::Buy); + let sell = + FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal); + assert_eq!(sell.to_legacy_action(), TradingAction::Sell); + let hold = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); + assert_eq!(hold.to_legacy_action(), TradingAction::Hold); + } + + #[test] + fn test_exposure_level_values() { + assert_eq!(ExposureLevel::Short100 as usize, 0); + assert_eq!(ExposureLevel::Short50 as usize, 1); + assert_eq!(ExposureLevel::Flat as usize, 2); + assert_eq!(ExposureLevel::Long50 as usize, 3); + assert_eq!(ExposureLevel::Long100 as usize, 4); + } + + #[test] + fn test_urgency_values() { + assert_eq!(Urgency::Patient as usize, 0); + assert_eq!(Urgency::Normal as usize, 1); + assert_eq!(Urgency::Aggressive as usize, 2); + } + + #[test] + fn test_target_exposure() { + assert_eq!(ExposureLevel::Short100.target_exposure(), -1.0); + assert_eq!(ExposureLevel::Short50.target_exposure(), -0.5); + assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0); + assert_eq!(ExposureLevel::Long50.target_exposure(), 0.5); + assert_eq!(ExposureLevel::Long100.target_exposure(), 1.0); + } + + #[test] + fn test_urgency_weights() { + assert_eq!(Urgency::Patient.urgency_weight(), 0.5); + assert_eq!(Urgency::Normal.urgency_weight(), 1.0); + assert_eq!(Urgency::Aggressive.urgency_weight(), 1.5); + } + + #[test] + fn test_is_buy_sell_hold() { + let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); + assert!(buy.is_buy()); + assert!(!buy.is_sell()); + assert!(!buy.is_hold()); + + let sell = + FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal); + assert!(sell.is_sell()); + assert!(!sell.is_buy()); + assert!(!sell.is_hold()); + + let hold = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); + assert!(hold.is_hold()); + assert!(!hold.is_buy()); + assert!(!hold.is_sell()); + } + + #[test] + fn test_position_delta() { + let action = + FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); + let delta = action.to_position_delta(0.0, 2.0); + assert!((delta - 2.0).abs() < 1e-10); + } + + #[test] + fn test_calculate_transaction_cost() { + let action = + FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); + let cost = action.calculate_transaction_cost(10_000.0); + assert!((cost - 15.0).abs() < 1e-10); + } + + #[test] + fn test_display() { + let action = + FactoredAction::new(ExposureLevel::Long50, OrderType::LimitMaker, Urgency::Patient); + let s = format!("{}", action); + assert_eq!(s, "Long50+LimitMaker+Patient"); + } +} diff --git a/crates/ml/src/dqn/action_space.rs b/crates/ml/src/dqn/action_space.rs index fa87ab289..60ca5602c 100644 --- a/crates/ml/src/dqn/action_space.rs +++ b/crates/ml/src/dqn/action_space.rs @@ -1,295 +1,8 @@ -pub use crate::common::action::OrderType; -use crate::MLError; -use serde::{Deserialize, Serialize}; - -/// Exposure level for position sizing (-100% to +100%) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ExposureLevel { - Short100 = 0, // -100% of max position - Short50 = 1, // -50% - Flat = 2, // 0% (neutral) - Long50 = 3, // +50% - Long100 = 4, // +100% -} - -impl ExposureLevel { - /// Get target portfolio value percentage - pub fn target_exposure(&self) -> f64 { - match self { - ExposureLevel::Short100 => -1.0, - ExposureLevel::Short50 => -0.5, - ExposureLevel::Flat => 0.0, - ExposureLevel::Long50 => 0.5, - ExposureLevel::Long100 => 1.0, - } - } - - /// Convert from index (0-4) - pub fn from_index(idx: usize) -> Result { - match idx { - 0 => Ok(ExposureLevel::Short100), - 1 => Ok(ExposureLevel::Short50), - 2 => Ok(ExposureLevel::Flat), - 3 => Ok(ExposureLevel::Long50), - 4 => Ok(ExposureLevel::Long100), - _ => Err(MLError::InvalidInput(format!( - "Invalid exposure level index: {}", - idx - ))), - } - } -} - -/// Urgency level for execution timing -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Urgency { - Patient = 0, // Wait for better price - Normal = 1, // Standard execution - Aggressive = 2, // Immediate execution -} - -impl Urgency { - /// Get urgency weight (0.5-1.5) - pub fn urgency_weight(&self) -> f64 { - match self { - Urgency::Patient => 0.5, - Urgency::Normal => 1.0, - Urgency::Aggressive => 1.5, - } - } - - /// Convert from index (0-2) - pub fn from_index(idx: usize) -> Result { - match idx { - 0 => Ok(Urgency::Patient), - 1 => Ok(Urgency::Normal), - 2 => Ok(Urgency::Aggressive), - _ => Err(MLError::InvalidInput(format!( - "Invalid urgency index: {}", - idx - ))), - } - } -} - -/// Factored trading action combining exposure, order type, and urgency -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct FactoredAction { - pub exposure: ExposureLevel, - pub order: OrderType, - pub urgency: Urgency, -} - -impl FactoredAction { - /// Create a new trading action - pub fn new(exposure: ExposureLevel, order: OrderType, urgency: Urgency) -> Self { - Self { - exposure, - order, - urgency, - } - } - - /// Map action index (0-44) to (exposure, order, urgency) - /// Index = exposure * 9 + order * 3 + urgency - pub fn from_index(idx: usize) -> Result { - if idx >= 45 { - return Err(MLError::InvalidInput(format!( - "Action index {} out of bounds (0-44)", - idx - ))); - } - - let exposure_idx = idx / 9; - let order_idx = (idx % 9) / 3; - let urgency_idx = idx % 3; - - Ok(Self { - exposure: ExposureLevel::from_index(exposure_idx)?, - order: OrderType::from_index(order_idx)?, - urgency: Urgency::from_index(urgency_idx)?, - }) - } - - /// Map (exposure, order, urgency) to action index (0-44) - /// Index = exposure * 9 + order * 3 + urgency - pub fn to_index(&self) -> usize { - let exposure_idx = self.exposure as usize; - let order_idx = self.order as usize; - let urgency_idx = self.urgency as usize; - - exposure_idx * 9 + order_idx * 3 + urgency_idx - } - - /// Get target portfolio value percentage - pub fn target_exposure(&self) -> f64 { - self.exposure.target_exposure() - } - - /// Get transaction cost multiplier - pub fn transaction_cost(&self) -> f64 { - self.order.transaction_cost() - } - - /// Get urgency weight (0.5-1.5) - pub fn urgency_weight(&self) -> f64 { - self.urgency.urgency_weight() - } - - /// Convert FactoredAction to legacy TradingAction for reward calculation - /// - /// Maps exposure levels to simple Buy/Sell/Hold actions: - /// - Long100, Long50 → Buy - /// - Flat → Hold - /// - Short50, Short100 → Sell - pub fn to_legacy_action(&self) -> crate::dqn::agent::TradingAction { - use crate::dqn::agent::TradingAction; - match self.exposure { - ExposureLevel::Long100 | ExposureLevel::Long50 => TradingAction::Buy, - ExposureLevel::Flat => TradingAction::Hold, - ExposureLevel::Short50 | ExposureLevel::Short100 => TradingAction::Sell, - } - } - - /// Calculate total transaction cost for this action given trade value - /// - /// # Arguments - /// - /// * `trade_value` - Absolute value of the trade (price × position_size × |exposure|) - /// - /// # Returns - /// - /// Total cost in dollars based on order type: - /// - **Market**: 0.15% (0.0015 × trade_value) - /// - **LimitMaker**: 0.05% (0.0005 × trade_value) - /// - **IoC**: 0.10% (0.0010 × trade_value) - /// - /// # Example - /// - /// ```rust - /// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// - /// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); - /// let trade_value = 10_000.0; // $10,000 trade - /// let cost = action.calculate_transaction_cost(trade_value); - /// assert_eq!(cost, 15.0); // 0.15% × $10,000 = $15 - /// ``` - pub fn calculate_transaction_cost(&self, trade_value: f64) -> f64 { - trade_value * self.transaction_cost() - } - - /// Alias for to_legacy_action() - converts to TradingAction for backward compatibility - /// - /// This is a convenience method that calls `to_legacy_action()` internally. - /// - /// # Example - /// - /// ```rust - /// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// use ml::dqn::agent::TradingAction; - /// - /// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); - /// assert_eq!(action.to_trading_action(), TradingAction::Buy); - /// ``` - pub fn to_trading_action(&self) -> crate::dqn::agent::TradingAction { - self.to_legacy_action() - } - - /// Check if this action is a buy (long exposure) - /// - /// Returns true for Long100 or Long50 exposure levels. - /// - /// # Example - /// - /// ```rust - /// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// - /// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); - /// assert!(action.is_buy()); - /// - /// let action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); - /// assert!(!action.is_buy()); - /// ``` - pub fn is_buy(&self) -> bool { - matches!( - self.exposure, - ExposureLevel::Long100 | ExposureLevel::Long50 - ) - } - - /// Check if this action is a sell (short exposure) - /// - /// Returns true for Short100 or Short50 exposure levels. - /// - /// # Example - /// - /// ```rust - /// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// - /// let action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal); - /// assert!(action.is_sell()); - /// - /// let action = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal); - /// assert!(!action.is_sell()); - /// ``` - pub fn is_sell(&self) -> bool { - matches!( - self.exposure, - ExposureLevel::Short100 | ExposureLevel::Short50 - ) - } - - /// Check if this action is neutral (flat exposure) - /// - /// Returns true for Flat exposure level. - /// - /// # Example - /// - /// ```rust - /// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// - /// let action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); - /// assert!(action.is_hold()); - /// - /// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); - /// assert!(!action.is_hold()); - /// ``` - pub fn is_hold(&self) -> bool { - matches!(self.exposure, ExposureLevel::Flat) - } -} - -impl std::fmt::Display for ExposureLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ExposureLevel::Short100 => write!(f, "Short100"), - ExposureLevel::Short50 => write!(f, "Short50"), - ExposureLevel::Flat => write!(f, "Flat"), - ExposureLevel::Long50 => write!(f, "Long50"), - ExposureLevel::Long100 => write!(f, "Long100"), - } - } -} - -impl std::fmt::Display for Urgency { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Urgency::Patient => write!(f, "Patient"), - Urgency::Normal => write!(f, "Normal"), - Urgency::Aggressive => write!(f, "Aggressive"), - } - } -} - -impl std::fmt::Display for FactoredAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}+{}+{}", self.exposure, self.order, self.urgency) - } -} +pub use crate::common::action::{ExposureLevel, FactoredAction, OrderType, Urgency}; /// Returns action mask where true=valid, false=invalid based on current position /// -/// Prevents invalid actions that would violate position limits (±2.0 max position). +/// Prevents invalid actions that would violate position limits (+/-2.0 max position). /// Used in epsilon-greedy action selection to mask out actions that would exceed limits. /// /// # Arguments diff --git a/crates/ml/src/ppo/action_space.rs b/crates/ml/src/ppo/action_space.rs index b3386252b..70041f5af 100644 --- a/crates/ml/src/ppo/action_space.rs +++ b/crates/ml/src/ppo/action_space.rs @@ -11,7 +11,7 @@ //! - Type-safe action handling //! - Seamless integration with existing PPO infrastructure -use crate::ppo::factored_action::FactoredAction; +use crate::common::action::FactoredAction; use crate::ppo::continuous_policy::ContinuousAction; use candle_core::{Tensor, Device}; use crate::MLError; @@ -169,7 +169,7 @@ impl std::fmt::Display for ActionSpace { impl Default for ActionSpace { fn default() -> Self { // Default to flat exposure with market order and normal urgency - use crate::ppo::factored_action::{ExposureLevel, OrderType, Urgency}; + use crate::common::action::{ExposureLevel, OrderType, Urgency}; ActionSpace::Discrete(FactoredAction::new( ExposureLevel::Flat, OrderType::Market, @@ -181,7 +181,7 @@ impl Default for ActionSpace { #[cfg(test)] mod tests { use super::*; - use crate::ppo::factored_action::{ExposureLevel, OrderType, Urgency}; + use crate::common::action::{ExposureLevel, OrderType, Urgency}; use candle_core::Device; #[test] diff --git a/crates/ml/src/ppo/factored_action.rs b/crates/ml/src/ppo/factored_action.rs deleted file mode 100644 index 8547079ce..000000000 --- a/crates/ml/src/ppo/factored_action.rs +++ /dev/null @@ -1,289 +0,0 @@ -/// PPO Factored Action Space Module -/// -/// Port of DQN's 45-action factored space (5×3×3 = 45 actions) -/// for fine-grained trading control in PPO. -/// -/// Structure: -/// - 5 ExposureLevels: Short100, Short50, Flat, Long50, Long100 -/// - 3 OrderTypes: Market (0.15%), LimitMaker (0.05%), IoC (0.10%) -/// - 3 Urgency levels: Patient (0.5x), Normal (1.0x), Aggressive (1.5x) -/// -/// Index mapping: index = exposure*9 + order*3 + urgency (0-44) - -pub use crate::common::action::OrderType; -use crate::MLError; -use serde::{Deserialize, Serialize}; - -/// Exposure level for position sizing (-100% to +100%) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ExposureLevel { - Short100 = 0, // -100% of max position - Short50 = 1, // -50% - Flat = 2, // 0% (neutral) - Long50 = 3, // +50% - Long100 = 4, // +100% -} - -impl ExposureLevel { - /// Get target portfolio value percentage - pub fn target_exposure(&self) -> f64 { - match self { - ExposureLevel::Short100 => -1.0, - ExposureLevel::Short50 => -0.5, - ExposureLevel::Flat => 0.0, - ExposureLevel::Long50 => 0.5, - ExposureLevel::Long100 => 1.0, - } - } - - /// Convert from index (0-4) - pub fn from_index(idx: usize) -> Result { - match idx { - 0 => Ok(ExposureLevel::Short100), - 1 => Ok(ExposureLevel::Short50), - 2 => Ok(ExposureLevel::Flat), - 3 => Ok(ExposureLevel::Long50), - 4 => Ok(ExposureLevel::Long100), - _ => Err(MLError::InvalidInput(format!( - "Invalid exposure level index: {}", - idx - ))), - } - } -} - -/// Urgency level for execution timing -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Urgency { - Patient = 0, // Wait for better price (0.5x weight) - Normal = 1, // Standard execution (1.0x weight) - Aggressive = 2, // Immediate execution (1.5x weight) -} - -impl Urgency { - /// Get urgency weight (0.5-1.5) - pub fn urgency_weight(&self) -> f64 { - match self { - Urgency::Patient => 0.5, - Urgency::Normal => 1.0, - Urgency::Aggressive => 1.5, - } - } - - /// Convert from index (0-2) - pub fn from_index(idx: usize) -> Result { - match idx { - 0 => Ok(Urgency::Patient), - 1 => Ok(Urgency::Normal), - 2 => Ok(Urgency::Aggressive), - _ => Err(MLError::InvalidInput(format!( - "Invalid urgency index: {}", - idx - ))), - } - } -} - -/// Factored trading action combining exposure, order type, and urgency -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct FactoredAction { - pub exposure: ExposureLevel, - pub order: OrderType, - pub urgency: Urgency, -} - -impl FactoredAction { - /// Create a new trading action - pub fn new(exposure: ExposureLevel, order: OrderType, urgency: Urgency) -> Self { - Self { - exposure, - order, - urgency, - } - } - - /// Map action index (0-44) to (exposure, order, urgency) - /// Index = exposure * 9 + order * 3 + urgency - pub fn from_index(idx: usize) -> Result { - if idx >= 45 { - return Err(MLError::InvalidInput(format!( - "Action index {} out of bounds (0-44)", - idx - ))); - } - - let exposure_idx = idx / 9; - let order_idx = (idx % 9) / 3; - let urgency_idx = idx % 3; - - Ok(Self { - exposure: ExposureLevel::from_index(exposure_idx)?, - order: OrderType::from_index(order_idx)?, - urgency: Urgency::from_index(urgency_idx)?, - }) - } - - /// Map (exposure, order, urgency) to action index (0-44) - /// Index = exposure * 9 + order * 3 + urgency - pub fn to_index(&self) -> usize { - let exposure_idx = self.exposure as usize; - let order_idx = self.order as usize; - let urgency_idx = self.urgency as usize; - - exposure_idx * 9 + order_idx * 3 + urgency_idx - } - - /// Get target portfolio value percentage - pub fn target_exposure(&self) -> f64 { - self.exposure.target_exposure() - } - - /// Get transaction cost multiplier - pub fn transaction_cost(&self) -> f64 { - self.order.transaction_cost() - } - - /// Get urgency weight (0.5-1.5) - pub fn urgency_weight(&self) -> f64 { - self.urgency.urgency_weight() - } - - /// Convert action to position delta - /// - /// Calculates the change in position needed to reach the target exposure. - /// - /// # Arguments - /// * `current_position` - Current absolute position (e.g., 1.0, -0.5) - /// * `max_position` - Maximum allowed position magnitude (typically 2.0) - /// - /// # Returns - /// Position delta to achieve target exposure - /// - /// # Example - /// ``` - /// use ml::ppo::{FactoredAction, ExposureLevel, OrderType, Urgency}; - /// - /// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal); - /// let delta = action.to_position_delta(0.0, 2.0); - /// assert_eq!(delta, 2.0); // Move from 0.0 to +1.0 exposure (= 2.0 units with max_position=2.0) - /// ``` - pub fn to_position_delta(&self, current_position: f64, max_position: f64) -> f64 { - let target_exposure = self.target_exposure(); - let target_position = target_exposure * max_position; - target_position - current_position - } - - /// Check if this action is a buy (long exposure) - pub fn is_buy(&self) -> bool { - matches!( - self.exposure, - ExposureLevel::Long100 | ExposureLevel::Long50 - ) - } - - /// Check if this action is a sell (short exposure) - pub fn is_sell(&self) -> bool { - matches!( - self.exposure, - ExposureLevel::Short100 | ExposureLevel::Short50 - ) - } - - /// Check if this action is neutral (flat exposure) - pub fn is_hold(&self) -> bool { - matches!(self.exposure, ExposureLevel::Flat) - } -} - -impl std::fmt::Display for ExposureLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ExposureLevel::Short100 => write!(f, "Short100"), - ExposureLevel::Short50 => write!(f, "Short50"), - ExposureLevel::Flat => write!(f, "Flat"), - ExposureLevel::Long50 => write!(f, "Long50"), - ExposureLevel::Long100 => write!(f, "Long100"), - } - } -} - -impl std::fmt::Display for Urgency { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Urgency::Patient => write!(f, "Patient"), - Urgency::Normal => write!(f, "Normal"), - Urgency::Aggressive => write!(f, "Aggressive"), - } - } -} - -impl std::fmt::Display for FactoredAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}+{}+{}", self.exposure, self.order, self.urgency) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_exposure_enum_values() { - assert_eq!(ExposureLevel::Short100 as usize, 0); - assert_eq!(ExposureLevel::Short50 as usize, 1); - assert_eq!(ExposureLevel::Flat as usize, 2); - assert_eq!(ExposureLevel::Long50 as usize, 3); - assert_eq!(ExposureLevel::Long100 as usize, 4); - } - - #[test] - fn test_order_type_enum_values() { - assert_eq!(OrderType::Market as usize, 0); - assert_eq!(OrderType::LimitMaker as usize, 1); - assert_eq!(OrderType::IoC as usize, 2); - } - - #[test] - fn test_urgency_enum_values() { - assert_eq!(Urgency::Patient as usize, 0); - assert_eq!(Urgency::Normal as usize, 1); - assert_eq!(Urgency::Aggressive as usize, 2); - } - - #[test] - fn test_action_from_index_bidirectional() { - // Test all 45 actions round-trip - for idx in 0..45 { - let action = FactoredAction::from_index(idx).unwrap(); - assert_eq!( - action.to_index(), - idx, - "Round-trip failed for index {}", - idx - ); - } - } - - #[test] - fn test_target_exposure_values() { - assert_eq!(ExposureLevel::Short100.target_exposure(), -1.0); - assert_eq!(ExposureLevel::Short50.target_exposure(), -0.5); - assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0); - assert_eq!(ExposureLevel::Long50.target_exposure(), 0.5); - assert_eq!(ExposureLevel::Long100.target_exposure(), 1.0); - } - - #[test] - fn test_transaction_costs() { - assert_eq!(OrderType::Market.transaction_cost(), 0.0015); - assert_eq!(OrderType::LimitMaker.transaction_cost(), 0.0005); - assert_eq!(OrderType::IoC.transaction_cost(), 0.0010); - } - - #[test] - fn test_urgency_weights() { - assert_eq!(Urgency::Patient.urgency_weight(), 0.5); - assert_eq!(Urgency::Normal.urgency_weight(), 1.0); - assert_eq!(Urgency::Aggressive.urgency_weight(), 1.5); - } -} diff --git a/crates/ml/src/ppo/mod.rs b/crates/ml/src/ppo/mod.rs index 100c0cd02..917e91172 100644 --- a/crates/ml/src/ppo/mod.rs +++ b/crates/ml/src/ppo/mod.rs @@ -27,7 +27,6 @@ pub mod portfolio_tracker; pub mod entropy_regularization; pub mod action_masking; pub mod stress_testing; -pub mod factored_action; pub mod hidden_state_manager; pub mod lstm_networks; pub mod action_space; @@ -46,7 +45,7 @@ pub use ppo::{PPOConfig, ValueNetwork, PPO, WorkingPPO}; pub use trainable_adapter::{train_batch, UnifiedPPO as UnifiedTrainablePPO}; pub use trajectories::{Trajectory, TrajectoryBatch, TrajectorySequence, TrajectoryStep}; pub use portfolio_tracker::PortfolioTracker; -pub use factored_action::{ExposureLevel, FactoredAction, OrderType, Urgency}; +pub use crate::common::action::{ExposureLevel, FactoredAction, OrderType, Urgency}; pub use action_space::{ActionSpace, ActionType}; pub use continuous_action_masking::{ mask_continuous_actions, ContinuousActionConstraints,