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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user