Files
foxhunt/crates/ml-ppo/src/action_space.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

210 lines
6.5 KiB
Rust

//! PPO Action Space Abstraction
//!
//! This module provides a unified interface for both discrete and continuous action spaces.
//! It enables the same PPO framework to handle:
//! - Discrete actions: 63-action factored space (7x3x3 = exposure x order x urgency)
//! - Continuous actions: Gaussian policy for position sizing (0.0 to 1.0)
use ml_core::action_space::FactoredAction;
use crate::continuous_policy::ContinuousAction;
use ml_core::MLError;
use serde::{Deserialize, Serialize};
/// Action space type discriminator
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionType {
/// Discrete action space (63-action factored space)
Discrete,
/// Continuous action space (Gaussian policy)
Continuous,
}
/// Unified action space supporting both discrete and continuous actions
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ActionSpace {
/// Discrete trading action (63-action factored space)
Discrete(FactoredAction),
/// Continuous trading action (position sizing)
Continuous(ContinuousAction),
}
impl ActionSpace {
/// Create discrete action from factored action
pub const fn discrete(action: FactoredAction) -> Self {
ActionSpace::Discrete(action)
}
/// Create continuous action from position size
pub const fn continuous(action: ContinuousAction) -> Self {
ActionSpace::Continuous(action)
}
/// Get action type
pub const fn action_type(&self) -> ActionType {
match self {
ActionSpace::Discrete(_) => ActionType::Discrete,
ActionSpace::Continuous(_) => ActionType::Continuous,
}
}
/// Convert action to index (discrete only)
pub fn to_index(&self) -> Result<usize, MLError> {
match self {
ActionSpace::Discrete(action) => Ok(action.to_index()),
ActionSpace::Continuous(_) => Err(MLError::InvalidInput(
"Cannot convert continuous action to index".to_owned(),
)),
}
}
/// Create action from index (discrete)
pub fn from_index(idx: usize) -> Result<Self, MLError> {
let action = FactoredAction::from_index(idx)?;
Ok(ActionSpace::Discrete(action))
}
/// Get discrete action (if applicable)
pub const fn as_discrete(&self) -> Option<&FactoredAction> {
match self {
ActionSpace::Discrete(action) => Some(action),
ActionSpace::Continuous(_) => None,
}
}
/// Get continuous action (if applicable)
pub const fn as_continuous(&self) -> Option<&ContinuousAction> {
match self {
ActionSpace::Discrete(_) => None,
ActionSpace::Continuous(action) => Some(action),
}
}
/// Validate action is within bounds
pub fn is_valid(&self) -> bool {
match self {
ActionSpace::Discrete(action) => action.to_index() < 63,
ActionSpace::Continuous(action) => action.is_valid(),
}
}
/// Get human-readable description
pub fn description(&self) -> String {
match self {
ActionSpace::Discrete(action) => {
format!("{}", action)
}
ActionSpace::Continuous(action) => {
format!("Position: {:.2}%", action.position_size() * 100.0)
}
}
}
}
impl std::fmt::Display for ActionSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
impl Default for ActionSpace {
fn default() -> Self {
use ml_core::action_space::{ExposureLevel, OrderType, Urgency};
ActionSpace::Discrete(FactoredAction::new(
ExposureLevel::Flat,
OrderType::Market,
Urgency::Normal,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ml_core::action_space::{ExposureLevel, OrderType, Urgency};
#[test]
fn test_action_type() {
let discrete = ActionSpace::discrete(FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
));
assert_eq!(discrete.action_type(), ActionType::Discrete);
let continuous = ActionSpace::continuous(ContinuousAction::new(0.5));
assert_eq!(continuous.action_type(), ActionType::Continuous);
}
#[test]
fn test_as_discrete() {
let action = FactoredAction::new(
ExposureLevel::ShortSmall,
OrderType::IoC,
Urgency::Patient,
);
let action_space = ActionSpace::discrete(action);
assert!(action_space.as_discrete().is_some());
assert!(action_space.as_continuous().is_none());
assert_eq!(*action_space.as_discrete().unwrap(), action);
}
#[test]
fn test_as_continuous() {
let action = ContinuousAction::new(0.3);
let action_space = ActionSpace::continuous(action);
assert!(action_space.as_continuous().is_some());
assert!(action_space.as_discrete().is_none());
assert_eq!(*action_space.as_continuous().unwrap(), action);
}
#[test]
fn test_validation() {
let valid_discrete = ActionSpace::discrete(FactoredAction::from_index(0).unwrap());
assert!(valid_discrete.is_valid());
let valid_continuous = ActionSpace::continuous(ContinuousAction::new(0.5));
assert!(valid_continuous.is_valid());
}
#[test]
fn test_description() {
let discrete = ActionSpace::discrete(FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
));
let desc = discrete.description();
assert!(desc.contains("LongFull"));
assert!(desc.contains("Market"));
assert!(desc.contains("Aggressive"));
let continuous = ActionSpace::continuous(ContinuousAction::new(0.65));
let desc = continuous.description();
assert!(desc.contains("65"));
}
#[test]
fn test_all_discrete_actions() -> Result<(), MLError> {
for idx in 0..63 {
let action = FactoredAction::from_index(idx)?;
let action_space = ActionSpace::discrete(action);
assert!(action_space.is_valid());
let recovered_idx = action_space.to_index()?;
assert_eq!(recovered_idx, idx);
}
Ok(())
}
#[test]
fn test_default() {
let default_action = ActionSpace::default();
assert_eq!(default_action.action_type(), ActionType::Discrete);
let action = default_action.as_discrete().unwrap();
assert!(action.is_hold());
}
}