feat(ml): wire PPO to 45 factored actions via FactoredAction
sample_action(), act(), act_with_log_prob(), greedy_action() now return FactoredAction instead of TradingAction. Fixes the architectural disconnect where num_actions=45 output neurons were sampled through a 3-action bottleneck. TrajectoryStep.action and TrajectoryBatch.actions now use FactoredAction. Added FactoredAction::from_legacy() for backward compatibility in tests. Updated all PPO consumers: trainers/ppo.rs, hyperopt/adapters/ppo.rs, validation/ppo_adapter.rs, benchmark/ppo_benchmark.rs. 2487 tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::ppo::ppo::{PPOConfig, PPO};
|
||||
use crate::ppo::trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep};
|
||||
use crate::MLError;
|
||||
@@ -246,8 +246,13 @@ impl PpoBenchmarkRunner {
|
||||
let state = self.create_synthetic_state(traj_idx, step_idx);
|
||||
|
||||
// Create synthetic action and reward
|
||||
let hold_action = FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
);
|
||||
let action =
|
||||
TradingAction::from_int((step_idx % 3) as u8).unwrap_or(TradingAction::Hold);
|
||||
FactoredAction::from_index(step_idx % 45).unwrap_or(hold_action);
|
||||
let reward = ((step_idx as f32 * 0.01).sin() * 0.1).clamp(-1.0, 1.0);
|
||||
let log_prob = -1.0; // Simplified
|
||||
let value = reward * 0.5; // Simplified value estimate
|
||||
|
||||
@@ -222,6 +222,23 @@ impl FactoredAction {
|
||||
self.urgency.urgency_weight()
|
||||
}
|
||||
|
||||
/// Create a FactoredAction from a legacy TradingAction.
|
||||
/// Maps Buy -> Long100/Market/Normal, Sell -> Short100/Market/Normal, Hold -> Flat/Market/Normal.
|
||||
pub fn from_legacy(action: crate::dqn::agent::TradingAction) -> Self {
|
||||
use crate::dqn::agent::TradingAction;
|
||||
match action {
|
||||
TradingAction::Buy => {
|
||||
Self::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
|
||||
}
|
||||
TradingAction::Sell => {
|
||||
Self::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal)
|
||||
}
|
||||
TradingAction::Hold => {
|
||||
Self::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert FactoredAction to legacy TradingAction for reward calculation
|
||||
///
|
||||
/// Maps exposure levels to simple Buy/Sell/Hold actions:
|
||||
@@ -337,6 +354,37 @@ mod tests {
|
||||
assert_eq!(hold.to_legacy_action(), TradingAction::Hold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_factored_action_from_legacy_round_trip() {
|
||||
use crate::dqn::agent::TradingAction;
|
||||
// from_legacy -> to_legacy_action should round-trip
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Buy).to_legacy_action(),
|
||||
TradingAction::Buy
|
||||
);
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Sell).to_legacy_action(),
|
||||
TradingAction::Sell
|
||||
);
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Hold).to_legacy_action(),
|
||||
TradingAction::Hold
|
||||
);
|
||||
// Verify specific indices
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Buy).to_index(),
|
||||
37 // Long100=4 * 9 + Market=0 * 3 + Normal=1 = 37
|
||||
);
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Sell).to_index(),
|
||||
1 // Short100=0 * 9 + Market=0 * 3 + Normal=1 = 1
|
||||
);
|
||||
assert_eq!(
|
||||
FactoredAction::from_legacy(TradingAction::Hold).to_index(),
|
||||
19 // Flat=2 * 9 + Market=0 * 3 + Normal=1 = 19
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exposure_level_values() {
|
||||
assert_eq!(ExposureLevel::Short100 as usize, 0);
|
||||
|
||||
@@ -38,7 +38,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::hyperopt::paths::TrainingPaths;
|
||||
use crate::hyperopt::traits::{HardwareBudget, HyperparameterOptimizable, ParameterSpace};
|
||||
use crate::ppo::gae::GAEConfig;
|
||||
@@ -645,14 +645,18 @@ fn gpu_ppo_batch_to_trajectory_batch(
|
||||
.map(|c| c.get(..model_state_dim).unwrap_or(c).to_vec())
|
||||
.collect();
|
||||
|
||||
let actions: Vec<TradingAction> = batch
|
||||
let hold_action = FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
);
|
||||
let actions: Vec<FactoredAction> = batch
|
||||
.actions
|
||||
.iter()
|
||||
.take(total)
|
||||
.map(|&a| match a.clamp(0, 2) {
|
||||
0 => TradingAction::Buy,
|
||||
1 => TradingAction::Sell,
|
||||
_ => TradingAction::Hold,
|
||||
.map(|&a| {
|
||||
let idx = (a.clamp(0, 44)) as usize;
|
||||
FactoredAction::from_index(idx).unwrap_or(hold_action)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1449,13 +1453,16 @@ impl PPOTrainer {
|
||||
let (action, log_prob, value) = match agent.act_with_log_prob(&state) {
|
||||
Ok((a, lp, v)) => (a, lp, v),
|
||||
Err(_) => {
|
||||
// Fallback: random action with uniform log_prob
|
||||
let a = match rng.gen_range(0..3) {
|
||||
0 => TradingAction::Buy,
|
||||
1 => TradingAction::Sell,
|
||||
_ => TradingAction::Hold,
|
||||
};
|
||||
(a, -(3.0_f32).ln(), 0.0_f32)
|
||||
// Fallback: random factored action with uniform log_prob
|
||||
let random_idx = rng.gen_range(0..45_usize);
|
||||
let a = FactoredAction::from_index(random_idx).unwrap_or_else(|_| {
|
||||
FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
)
|
||||
});
|
||||
(a, -(45.0_f32).ln(), 0.0_f32)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1473,10 +1480,12 @@ impl PPOTrainer {
|
||||
};
|
||||
let total_cost = (self.tx_cost_bps * 0.0001 + spread_cost) as f32;
|
||||
|
||||
match action {
|
||||
TradingAction::Buy => price_change as f32 - total_cost,
|
||||
TradingAction::Sell => -price_change as f32 - total_cost,
|
||||
TradingAction::Hold => 0.0,
|
||||
// Use target_exposure for PnL: +1.0 (long), -1.0 (short), 0.0 (flat)
|
||||
let exposure = action.target_exposure() as f32;
|
||||
if exposure.abs() > f32::EPSILON {
|
||||
exposure * price_change as f32 - total_cost
|
||||
} else {
|
||||
0.0 // Flat position — no PnL
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
|
||||
@@ -291,9 +291,14 @@ pub fn compute_advantages(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::ppo::trajectories::{Trajectory, TrajectoryStep};
|
||||
|
||||
/// Helper: build a FactoredAction from a legacy TradingAction for test brevity.
|
||||
fn fa(ta: TradingAction) -> FactoredAction {
|
||||
FactoredAction::from_legacy(ta)
|
||||
}
|
||||
|
||||
fn create_test_trajectory() -> Trajectory {
|
||||
let mut trajectory = Trajectory::new();
|
||||
@@ -301,7 +306,7 @@ mod tests {
|
||||
// Add some test steps
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![1.0, 2.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
-0.5,
|
||||
10.0, // value
|
||||
1.0, // reward
|
||||
@@ -310,7 +315,7 @@ mod tests {
|
||||
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![2.0, 3.0],
|
||||
TradingAction::Sell,
|
||||
fa(TradingAction::Sell),
|
||||
-0.3,
|
||||
8.0, // value
|
||||
2.0, // reward
|
||||
@@ -319,7 +324,7 @@ mod tests {
|
||||
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![3.0, 4.0],
|
||||
TradingAction::Hold,
|
||||
fa(TradingAction::Hold),
|
||||
-0.7,
|
||||
5.0, // value
|
||||
0.0, // reward
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::dqn::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
|
||||
use crate::dqn::portfolio_tracker::PortfolioTracker;
|
||||
use crate::dqn::xavier_init::linear_xavier;
|
||||
use crate::dqn::reward::RewardNormalizer;
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::MLError;
|
||||
|
||||
/// Actor network variants supporting both MLP and LSTM architectures
|
||||
@@ -97,7 +97,7 @@ impl ActorNetwork {
|
||||
}
|
||||
|
||||
/// Sample action from policy (MLP-only)
|
||||
pub fn sample_action(&self, input: &Tensor) -> Result<(TradingAction, f32), MLError> {
|
||||
pub fn sample_action(&self, input: &Tensor) -> Result<(FactoredAction, f32), MLError> {
|
||||
match self {
|
||||
ActorNetwork::MLP(network) => network.sample_action(input),
|
||||
ActorNetwork::LSTM(_) => Err(MLError::ModelError(
|
||||
@@ -467,7 +467,7 @@ impl PolicyNetwork {
|
||||
}
|
||||
|
||||
/// Sample action from policy
|
||||
pub fn sample_action(&self, input: &Tensor) -> Result<(TradingAction, f32), MLError> {
|
||||
pub fn sample_action(&self, input: &Tensor) -> Result<(FactoredAction, f32), MLError> {
|
||||
let probs = self.action_probabilities(input)?;
|
||||
let probs_vec = probs
|
||||
.flatten_all()?
|
||||
@@ -482,8 +482,7 @@ impl PolicyNetwork {
|
||||
for (i, &prob) in probs_vec.iter().enumerate() {
|
||||
cumulative += prob;
|
||||
if sample <= cumulative {
|
||||
let action = TradingAction::from_int(i as u8)
|
||||
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", i)))?;
|
||||
let action = FactoredAction::from_index(i)?;
|
||||
const EPSILON: f32 = 1e-8;
|
||||
let log_prob = (prob + EPSILON).ln();
|
||||
return Ok((action, log_prob));
|
||||
@@ -491,11 +490,10 @@ impl PolicyNetwork {
|
||||
}
|
||||
|
||||
// Fallback to last action if rounding errors occur
|
||||
let last_idx = probs_vec.len() - 1;
|
||||
let action = TradingAction::from_int(last_idx as u8)
|
||||
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", last_idx)))?;
|
||||
let last_idx = probs_vec.len().saturating_sub(1);
|
||||
let action = FactoredAction::from_index(last_idx)?;
|
||||
const EPSILON: f32 = 1e-8;
|
||||
let log_prob = (probs_vec[last_idx] + EPSILON).ln();
|
||||
let log_prob = (probs_vec.get(last_idx).copied().unwrap_or(EPSILON) + EPSILON).ln();
|
||||
Ok((action, log_prob))
|
||||
}
|
||||
|
||||
@@ -904,7 +902,7 @@ impl PPO {
|
||||
}
|
||||
|
||||
/// Select action and get value estimate
|
||||
pub fn act(&self, state: &[f32]) -> Result<(TradingAction, f32), MLError> {
|
||||
pub fn act(&self, state: &[f32]) -> Result<(FactoredAction, f32), MLError> {
|
||||
let (action, _log_prob, value) = self.act_with_log_prob(state)?;
|
||||
Ok((action, value))
|
||||
}
|
||||
@@ -914,7 +912,7 @@ impl PPO {
|
||||
/// Returns the **real** policy log-probability needed for PPO importance
|
||||
/// sampling. Use this instead of [`act`] when collecting trajectories
|
||||
/// for training.
|
||||
pub fn act_with_log_prob(&self, state: &[f32]) -> Result<(TradingAction, f32, f32), MLError> {
|
||||
pub fn act_with_log_prob(&self, state: &[f32]) -> Result<(FactoredAction, f32, f32), MLError> {
|
||||
let state_tensor = Tensor::from_vec(
|
||||
state.to_vec(),
|
||||
(1, self.config.state_dim),
|
||||
@@ -1293,7 +1291,11 @@ impl PPO {
|
||||
let log_probs_dist = candle_nn::ops::log_softmax(&logits, candle_core::D::Minus1)
|
||||
.map_err(|e| MLError::ModelError(format!("Log softmax failed: {}", e)))?;
|
||||
|
||||
let action_idx = sequence.actions[t].to_int() as i64;
|
||||
let action_idx = sequence.actions.get(t)
|
||||
.map(|a| a.to_index() as i64)
|
||||
.ok_or_else(|| MLError::ModelError(
|
||||
format!("Sequence action index {} out of bounds", t)
|
||||
))?;
|
||||
let action_tensor = Tensor::from_vec(
|
||||
vec![action_idx],
|
||||
(1, 1),
|
||||
@@ -1664,7 +1666,7 @@ impl PPO {
|
||||
/// "training_steps": 1234,
|
||||
/// "config": {
|
||||
/// "state_dim": 64,
|
||||
/// "num_actions": 3,
|
||||
/// "num_actions": 45,
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
@@ -2014,16 +2016,15 @@ impl PPO {
|
||||
///
|
||||
/// Use this for validation/evaluation where stochastic sampling would
|
||||
/// introduce noise into the performance metric.
|
||||
pub fn greedy_action(&self, state: &[f32]) -> Result<TradingAction, MLError> {
|
||||
pub fn greedy_action(&self, state: &[f32]) -> Result<FactoredAction, MLError> {
|
||||
let probs = self.predict(state)?;
|
||||
let best_idx = probs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(2); // default HOLD
|
||||
TradingAction::from_int(best_idx as u8)
|
||||
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", best_idx)))
|
||||
.unwrap_or(19); // default Flat/Market/Normal (index 19)
|
||||
FactoredAction::from_index(best_idx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use tracing::info;
|
||||
use super::gae::compute_gae_single_trajectory;
|
||||
use super::ppo::{PPOConfig, PPO};
|
||||
use super::trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep};
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::training::unified_trainer::{CheckpointMetadata, TrainingMetrics, UnifiedTrainable};
|
||||
use crate::MLError;
|
||||
|
||||
@@ -101,9 +101,7 @@ impl UnifiedPPO {
|
||||
.to_scalar::<u32>()
|
||||
.map_err(|e| MLError::TrainingError(format!("Failed to extract action: {}", e)))?;
|
||||
|
||||
let action = TradingAction::from_int(action_idx as u8).ok_or_else(|| {
|
||||
MLError::InvalidInput(format!("Invalid action index: {}", action_idx))
|
||||
})?;
|
||||
let action = FactoredAction::from_index(action_idx as usize)?;
|
||||
|
||||
// Get log prob and value from current policy
|
||||
let state_unsqueezed = state_tensor.unsqueeze(0)?;
|
||||
@@ -408,7 +406,7 @@ mod tests {
|
||||
fn test_unified_ppo_creation() -> Result<(), MLError> {
|
||||
let config = PPOConfig {
|
||||
state_dim: 16,
|
||||
num_actions: 3,
|
||||
num_actions: 45,
|
||||
policy_hidden_dims: vec![32],
|
||||
value_hidden_dims: vec![32],
|
||||
policy_learning_rate: 3e-4,
|
||||
@@ -430,7 +428,7 @@ mod tests {
|
||||
fn test_unified_ppo_forward() -> Result<(), MLError> {
|
||||
let config = PPOConfig {
|
||||
state_dim: 16,
|
||||
num_actions: 3,
|
||||
num_actions: 45,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -443,8 +441,8 @@ mod tests {
|
||||
// Forward pass
|
||||
let output = ppo.forward(&input)?;
|
||||
|
||||
// Check output shape
|
||||
assert_eq!(output.dims(), &[1, 3]);
|
||||
// Check output shape (45 factored actions)
|
||||
assert_eq!(output.dims(), &[1, 45]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use candle_core::Tensor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::MLError;
|
||||
|
||||
/// Single step trajectory data
|
||||
@@ -15,7 +15,7 @@ pub struct TrajectoryStep {
|
||||
/// State at this step
|
||||
pub state: Vec<f32>,
|
||||
/// Action taken
|
||||
pub action: TradingAction,
|
||||
pub action: FactoredAction,
|
||||
/// Action probability (log probability)
|
||||
pub log_prob: f32,
|
||||
/// Value estimate at this state
|
||||
@@ -30,7 +30,7 @@ impl TrajectoryStep {
|
||||
/// Create new trajectory step
|
||||
pub fn new(
|
||||
state: Vec<f32>,
|
||||
action: TradingAction,
|
||||
action: FactoredAction,
|
||||
log_prob: f32,
|
||||
value: f32,
|
||||
reward: f32,
|
||||
@@ -81,7 +81,7 @@ impl Trajectory {
|
||||
}
|
||||
|
||||
/// Get trajectory actions
|
||||
pub fn get_actions(&self) -> Vec<TradingAction> {
|
||||
pub fn get_actions(&self) -> Vec<FactoredAction> {
|
||||
self.steps.iter().map(|step| step.action).collect()
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ impl Trajectory {
|
||||
}
|
||||
|
||||
/// Extend a buffer with all actions (avoids intermediate `Vec` allocation).
|
||||
pub fn extend_actions(&self, buf: &mut Vec<TradingAction>) {
|
||||
pub fn extend_actions(&self, buf: &mut Vec<FactoredAction>) {
|
||||
buf.extend(self.steps.iter().map(|s| s.action));
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ pub struct TrajectoryBatch {
|
||||
/// GPU batch constructors should set this to `vec![]` (the fallback path handles it).
|
||||
pub states_flat: Vec<f32>,
|
||||
/// Flattened actions from all trajectories
|
||||
pub actions: Vec<TradingAction>,
|
||||
pub actions: Vec<FactoredAction>,
|
||||
/// Flattened log probabilities
|
||||
pub log_probs: Vec<f32>,
|
||||
/// Flattened values
|
||||
@@ -307,7 +307,7 @@ impl TrajectoryBatch {
|
||||
let action_indices: Vec<u32> = self
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action| action.to_int() as u32)
|
||||
.map(|action| action.to_index() as u32)
|
||||
.collect();
|
||||
let actions_tensor = Tensor::from_vec(action_indices, batch_size, device).map_err(|e| {
|
||||
MLError::TrainingError(format!("Failed to create actions tensor: {}", e))
|
||||
@@ -478,7 +478,7 @@ pub struct TrajectoryTensors {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiniBatch {
|
||||
pub states: Vec<Vec<f32>>,
|
||||
pub actions: Vec<TradingAction>,
|
||||
pub actions: Vec<FactoredAction>,
|
||||
pub log_probs: Vec<f32>,
|
||||
pub values: Vec<f32>,
|
||||
pub advantages: Vec<f32>,
|
||||
@@ -492,7 +492,7 @@ pub struct MiniBatch {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrajectorySequence {
|
||||
pub states: Vec<Vec<f32>>,
|
||||
pub actions: Vec<TradingAction>,
|
||||
pub actions: Vec<FactoredAction>,
|
||||
pub log_probs: Vec<f32>,
|
||||
pub values: Vec<f32>,
|
||||
pub rewards: Vec<f32>,
|
||||
@@ -539,7 +539,7 @@ impl MiniBatch {
|
||||
let action_indices: Vec<u32> = self
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action| action.to_int() as u32)
|
||||
.map(|action| action.to_index() as u32)
|
||||
.collect();
|
||||
let actions_tensor = Tensor::from_vec(action_indices, batch_size, device).map_err(|e| {
|
||||
MLError::TrainingError(format!("Failed to create actions tensor: {}", e))
|
||||
@@ -610,6 +610,13 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::action::{ExposureLevel, OrderType, Urgency};
|
||||
use crate::dqn::TradingAction;
|
||||
|
||||
/// Helper: build a FactoredAction from a legacy TradingAction for test brevity.
|
||||
fn fa(ta: TradingAction) -> FactoredAction {
|
||||
FactoredAction::from_legacy(ta)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trajectory_creation() {
|
||||
@@ -619,7 +626,7 @@ mod tests {
|
||||
|
||||
let step = TrajectoryStep::new(
|
||||
vec![1.0, 2.0, 3.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
-0.5,
|
||||
10.0,
|
||||
1.0,
|
||||
@@ -638,7 +645,7 @@ mod tests {
|
||||
// Add some steps
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![0.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
@@ -646,7 +653,7 @@ mod tests {
|
||||
));
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![1.0],
|
||||
TradingAction::Sell,
|
||||
fa(TradingAction::Sell),
|
||||
0.0,
|
||||
0.0,
|
||||
2.0,
|
||||
@@ -654,7 +661,7 @@ mod tests {
|
||||
));
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![2.0],
|
||||
TradingAction::Hold,
|
||||
fa(TradingAction::Hold),
|
||||
0.0,
|
||||
0.0,
|
||||
3.0,
|
||||
@@ -678,7 +685,7 @@ mod tests {
|
||||
let mut traj1 = Trajectory::new();
|
||||
traj1.add_step(TrajectoryStep::new(
|
||||
vec![1.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
@@ -686,7 +693,7 @@ mod tests {
|
||||
));
|
||||
traj1.add_step(TrajectoryStep::new(
|
||||
vec![2.0],
|
||||
TradingAction::Sell,
|
||||
fa(TradingAction::Sell),
|
||||
0.0,
|
||||
0.0,
|
||||
2.0,
|
||||
@@ -696,7 +703,7 @@ mod tests {
|
||||
let mut traj2 = Trajectory::new();
|
||||
traj2.add_step(TrajectoryStep::new(
|
||||
vec![3.0],
|
||||
TradingAction::Hold,
|
||||
fa(TradingAction::Hold),
|
||||
0.0,
|
||||
0.0,
|
||||
3.0,
|
||||
@@ -743,7 +750,8 @@ mod tests {
|
||||
let advantages = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let returns = vec![0.0; 5];
|
||||
let states = vec![vec![1.0]; 5];
|
||||
let actions = vec![TradingAction::Buy; 5];
|
||||
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||||
let actions = vec![buy_action; 5];
|
||||
let log_probs = vec![0.0; 5];
|
||||
let values = vec![0.0; 5];
|
||||
let dones = vec![false; 5];
|
||||
@@ -768,7 +776,7 @@ mod tests {
|
||||
let mut trajectory = Trajectory::new();
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![1.0, 2.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -776,7 +784,7 @@ mod tests {
|
||||
));
|
||||
trajectory.add_step(TrajectoryStep::new(
|
||||
vec![3.0, 4.0],
|
||||
TradingAction::Sell,
|
||||
fa(TradingAction::Sell),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -818,7 +826,7 @@ mod tests {
|
||||
let mut traj = Trajectory::new();
|
||||
traj.add_step(TrajectoryStep::new(
|
||||
vec![1.0, 2.0, 3.0],
|
||||
TradingAction::Buy,
|
||||
fa(TradingAction::Buy),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
@@ -826,7 +834,7 @@ mod tests {
|
||||
));
|
||||
traj.add_step(TrajectoryStep::new(
|
||||
vec![4.0, 5.0, 6.0],
|
||||
TradingAction::Sell,
|
||||
fa(TradingAction::Sell),
|
||||
0.0,
|
||||
0.0,
|
||||
2.0,
|
||||
|
||||
@@ -16,7 +16,7 @@ use candle_core::Device;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::gpu::DeviceConfig;
|
||||
use crate::gpu::capabilities::cached_capabilities;
|
||||
use crate::gpu::memory_profile::{self, resolve_batch_size};
|
||||
@@ -824,7 +824,13 @@ impl PpoTrainer {
|
||||
|
||||
// sample_action returns (action_idx, probs_vec) — reuse probs_vec, no duplicate sync
|
||||
let (action_idx, probs_vec) = self.sample_action(&action_probs)?;
|
||||
let action = TradingAction::from_int(action_idx as u8).unwrap_or(TradingAction::Hold);
|
||||
// Default: Flat/Market/Normal (Hold equivalent)
|
||||
let hold_action = FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
);
|
||||
let action = FactoredAction::from_index(action_idx).unwrap_or(hold_action);
|
||||
|
||||
// Get log probability from the already-synced probs_vec (safe indexing)
|
||||
const EPSILON: f32 = 1e-8;
|
||||
@@ -942,8 +948,13 @@ impl PpoTrainer {
|
||||
|
||||
// sample_action returns (action_idx, probs_vec) — reuse probs_vec, no duplicate sync
|
||||
let (action_idx, probs_vec) = self.sample_action(&action_probs)?;
|
||||
let action =
|
||||
TradingAction::from_int(action_idx as u8).unwrap_or(TradingAction::Hold);
|
||||
// Default: Flat/Market/Normal (Hold equivalent)
|
||||
let hold_action = FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
);
|
||||
let action = FactoredAction::from_index(action_idx).unwrap_or(hold_action);
|
||||
|
||||
// Get log probability from the already-synced probs_vec (safe indexing)
|
||||
const EPSILON: f32 = 1e-8;
|
||||
@@ -1526,12 +1537,18 @@ fn gpu_batch_to_trajectory_batch(
|
||||
.map(|c| c.to_vec())
|
||||
.collect();
|
||||
|
||||
let actions: Vec<TradingAction> = batch
|
||||
let hold_action = FactoredAction::new(
|
||||
crate::common::action::ExposureLevel::Flat,
|
||||
crate::common::action::OrderType::Market,
|
||||
crate::common::action::Urgency::Normal,
|
||||
);
|
||||
let actions: Vec<FactoredAction> = batch
|
||||
.actions
|
||||
.iter()
|
||||
.take(total)
|
||||
.map(|&a| {
|
||||
TradingAction::from_int(a.clamp(0, 44) as u8).unwrap_or(TradingAction::Hold)
|
||||
let idx = (a.clamp(0, 44)) as usize;
|
||||
FactoredAction::from_index(idx).unwrap_or(hold_action)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::cell::RefCell;
|
||||
use candle_core::{Device, Tensor};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::dqn::TradingAction;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::ppo::gae::compute_gae;
|
||||
#[cfg(test)]
|
||||
use crate::ppo::gae::GAEConfig;
|
||||
@@ -128,10 +128,8 @@ impl ValidatableStrategy for PpoStrategy {
|
||||
/// Evaluate the MLP PPO on out-of-sample data.
|
||||
///
|
||||
/// For each bar, computes action probabilities via the actor, takes the
|
||||
/// argmax action, and maps it to PnL:
|
||||
/// - Buy (0): `+return`
|
||||
/// - Sell (1): `-return`
|
||||
/// - Hold (2): `0`
|
||||
/// argmax action, and maps it to PnL using the factored action's target
|
||||
/// exposure (e.g., Long100 -> +1.0, Flat -> 0.0, Short100 -> -1.0).
|
||||
fn evaluate(&self, data: &TimeSeriesData) -> Result<Vec<f64>, MLError> {
|
||||
let num_returns = data.returns.len();
|
||||
let mut pnl = Vec::with_capacity(num_returns);
|
||||
@@ -160,10 +158,10 @@ impl ValidatableStrategy for PpoStrategy {
|
||||
let action_idx = argmax(&probs_vec);
|
||||
let bar_return = data.returns.get(i).copied().unwrap_or(0.0);
|
||||
|
||||
let bar_pnl = match action_idx {
|
||||
0 => bar_return, // Buy
|
||||
1 => -bar_return, // Sell
|
||||
_ => 0.0, // Hold
|
||||
let bar_pnl = if let Ok(action) = FactoredAction::from_index(action_idx) {
|
||||
action.target_exposure() * bar_return
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
pnl.push(bar_pnl);
|
||||
@@ -385,10 +383,10 @@ impl ValidatableStrategy for PpoLstmStrategy {
|
||||
let action_idx = argmax(&probs_vec);
|
||||
let bar_return = data.returns.get(i).copied().unwrap_or(0.0);
|
||||
|
||||
let bar_pnl = match action_idx {
|
||||
0 => bar_return, // Buy
|
||||
1 => -bar_return, // Sell
|
||||
_ => 0.0, // Hold
|
||||
let bar_pnl = if let Ok(action) = FactoredAction::from_index(action_idx) {
|
||||
action.target_exposure() * bar_return
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
pnl.push(bar_pnl);
|
||||
@@ -433,8 +431,8 @@ fn argmax(values: &[f32]) -> usize {
|
||||
|
||||
/// Sample an action from logits via softmax + categorical sampling.
|
||||
///
|
||||
/// Returns `(TradingAction, log_prob)`.
|
||||
fn sample_action_from_logits(logits: &Tensor, _device: &Device) -> Result<(TradingAction, f32), MLError> {
|
||||
/// Returns `(FactoredAction, log_prob)`.
|
||||
fn sample_action_from_logits(logits: &Tensor, _device: &Device) -> Result<(FactoredAction, f32), MLError> {
|
||||
let probs = candle_nn::ops::softmax(logits, candle_core::D::Minus1)
|
||||
.map_err(|e| MLError::ModelError(format!("Softmax failed: {}", e)))?;
|
||||
let probs_vec = probs
|
||||
@@ -451,8 +449,7 @@ fn sample_action_from_logits(logits: &Tensor, _device: &Device) -> Result<(Tradi
|
||||
for (i, &prob) in probs_vec.iter().enumerate() {
|
||||
cumulative += prob;
|
||||
if sample <= cumulative {
|
||||
let action = TradingAction::from_int(i as u8)
|
||||
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", i)))?;
|
||||
let action = FactoredAction::from_index(i)?;
|
||||
const EPSILON: f32 = 1e-8;
|
||||
let log_prob = (prob + EPSILON).ln();
|
||||
return Ok((action, log_prob));
|
||||
@@ -461,8 +458,7 @@ fn sample_action_from_logits(logits: &Tensor, _device: &Device) -> Result<(Tradi
|
||||
|
||||
// Fallback to last valid action
|
||||
let last_idx = probs_vec.len().saturating_sub(1);
|
||||
let action = TradingAction::from_int(last_idx as u8)
|
||||
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", last_idx)))?;
|
||||
let action = FactoredAction::from_index(last_idx)?;
|
||||
const EPSILON: f32 = 1e-8;
|
||||
let log_prob = (probs_vec.get(last_idx).copied().unwrap_or(EPSILON) + EPSILON).ln();
|
||||
Ok((action, log_prob))
|
||||
@@ -513,7 +509,7 @@ mod tests {
|
||||
fn make_mlp_config() -> PPOConfig {
|
||||
PPOConfig {
|
||||
state_dim: 10,
|
||||
num_actions: 3,
|
||||
num_actions: 45,
|
||||
policy_hidden_dims: vec![16, 8],
|
||||
value_hidden_dims: vec![16, 8],
|
||||
batch_size: 4,
|
||||
|
||||
Reference in New Issue
Block a user