refactor(ml): move FactoredAction to common/action.rs (canonical location)

Consolidates ExposureLevel, Urgency, FactoredAction from dqn/action_space.rs
and ppo/factored_action.rs into common/action.rs. Both dqn:: and ppo::
re-export for backward compatibility. Deletes ppo/factored_action.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-02 14:24:57 +01:00
parent 669c3b93d4
commit f12336f16f
5 changed files with 359 additions and 585 deletions

View File

@@ -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<Self, MLError> {
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<Self, MLError> {
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<Self, MLError> {
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");
}
}

View File

@@ -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<Self, MLError> {
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<Self, MLError> {
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<Self, MLError> {
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

View File

@@ -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]

View File

@@ -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<Self, MLError> {
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<Self, MLError> {
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<Self, MLError> {
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);
}
}

View File

@@ -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,