Files
foxhunt/crates/ml-ppo/src/action_masking.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

259 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Action Masking for PPO
//!
//! Prevents the agent from selecting actions that would violate position limits.
//! This is adapted from DQN's action masking but works with PPO's policy network.
//!
//! # Key Differences from DQN:
//! - **DQN**: Masks Q-values after forward pass, selects argmax from unmasked actions
//! - **PPO**: Masks logits BEFORE softmax, samples from masked probability distribution
//!
//! # 3-Action Space:
//! - Action 0: BUY (increase position by +1.0)
//! - Action 1: SELL (decrease position by -1.0)
//! - Action 2: HOLD (keep position unchanged)
//!
//! # 45-Action Factored Space (5 exposure x 3 order x 3 urgency):
//! Index = exposure * 9 + order * 3 + urgency
//! - `exposure_idx` = idx / 9
//! - 0 = Short100 (indices 0..9)
//! - 1 = Short50 (indices 9..18)
//! - 2 = Flat (indices 18..27)
//! - 3 = Long50 (indices 27..36)
//! - 4 = Long100 (indices 36..45)
//! - Masking: Long50/Long100 masked at max position, Short100/Short50 masked at min position
/// Creates an action mask based on current position and position limits.
///
/// # Arguments
/// * `current_position` - Current portfolio position
/// * `max_position` - Maximum allowed position magnitude (typically 2.0)
/// * `num_actions` - Number of actions in the action space (3 or 45)
///
/// # Returns
/// Boolean mask where `true` = action is valid, `false` = action is invalid
pub fn create_action_mask(current_position: f64, max_position: f64, num_actions: usize) -> Vec<bool> {
let mut mask = vec![true; num_actions];
if num_actions == 3 {
if current_position + 1.0 > max_position {
mask[0] = false;
}
if current_position - 1.0 < -max_position {
mask[1] = false;
}
} else if num_actions == 63 {
for action_idx in 0..num_actions {
let exposure_idx = action_idx / 9;
match exposure_idx {
// Long actions (LongSmall=4, LongHalf=5, LongFull=6)
4 | 5 | 6 => {
if current_position >= max_position {
if let Some(m) = mask.get_mut(action_idx) {
*m = false;
}
}
}
// Short actions (ShortSmall=0, ShortHalf=1, ShortFull=2)
0 | 1 | 2 => {
if current_position <= -max_position {
if let Some(m) = mask.get_mut(action_idx) {
*m = false;
}
}
}
_ => {} // Flat=3
}
}
} else {
// Unsupported action space size, all actions remain valid
}
mask
}
/// Applies action mask to logits by setting masked actions to -1e9.
///
/// Invalid actions (mask[i] = false) have their logits set to -1e9 (effectively -inf).
/// This ensures that after softmax, masked actions have probability ~0.
///
/// # Arguments
/// * `logits` - Raw logits from policy network (shape: [`num_actions`])
/// * `mask` - Boolean mask where false = invalid action
///
/// # Returns
/// Masked logits where invalid actions have logit = -1e9
pub fn apply_mask_to_logits_vec(logits: &[f32], mask: &[bool]) -> Vec<f32> {
logits
.iter()
.zip(mask.iter())
.map(|(&logit, &valid)| if valid { logit } else { -1e9 })
.collect()
}
#[cfg(test)]
#[allow(
clippy::bool_assert_comparison,
clippy::wildcard_enum_match_arm
)]
mod tests {
use super::*;
#[test]
fn test_create_action_mask_flat_position() {
let mask = create_action_mask(0.0, 2.0, 3);
assert_eq!(mask, vec![true, true, true]);
}
#[test]
fn test_create_action_mask_at_max_position() {
let mask = create_action_mask(2.0, 2.0, 3);
assert_eq!(mask[0], false);
assert_eq!(mask[1], true);
assert_eq!(mask[2], true);
}
#[test]
fn test_create_action_mask_at_min_position() {
let mask = create_action_mask(-2.0, 2.0, 3);
assert_eq!(mask[0], true);
assert_eq!(mask[1], false);
assert_eq!(mask[2], true);
}
#[test]
fn test_apply_mask_to_logits() {
let logits = vec![0.5_f32, 0.3, 0.2];
let mask = vec![false, true, true];
let masked = apply_mask_to_logits_vec(&logits, &mask);
assert!(masked[0] < -1e8);
assert_eq!(masked[1], 0.3);
assert_eq!(masked[2], 0.2);
}
#[test]
fn test_apply_mask_all_valid() {
let logits = vec![0.5_f32, 0.3, 0.2];
let mask = vec![true, true, true];
let masked = apply_mask_to_logits_vec(&logits, &mask);
assert_eq!(masked[0], 0.5);
assert_eq!(masked[1], 0.3);
assert_eq!(masked[2], 0.2);
}
#[test]
fn test_create_action_mask_63_flat_position() {
let mask = create_action_mask(0.0, 2.0, 63);
assert_eq!(mask.len(), 63);
assert!(mask.iter().all(|&v| v));
}
#[test]
fn test_create_action_mask_63_at_max_position() {
let mask = create_action_mask(2.0, 2.0, 63);
assert_eq!(mask.len(), 63);
// Short actions (exposure 0,1,2) should be valid
for i in 0..27 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
// Flat (exposure 3) should be valid
for i in 27..36 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
// Long actions (exposure 4,5,6) should be masked
for i in 36..63 {
assert_eq!(mask.get(i).copied().unwrap_or(true), false);
}
}
#[test]
fn test_create_action_mask_63_at_min_position() {
let mask = create_action_mask(-2.0, 2.0, 63);
// Short actions (exposure 0,1,2) should be masked
for i in 0..27 {
assert_eq!(mask.get(i).copied().unwrap_or(true), false);
}
// Flat (exposure 3) should be valid
for i in 27..36 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
// Long actions (exposure 4,5,6) should be valid
for i in 36..63 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
}
#[test]
fn test_create_action_mask_63_partial_position() {
let mask = create_action_mask(1.0, 2.0, 63);
assert!(mask.iter().all(|&v| v));
}
#[test]
fn test_apply_mask_all_invalid() {
let logits = vec![0.5_f32, 0.3, 0.2];
let mask = vec![false, false, false];
let masked = apply_mask_to_logits_vec(&logits, &mask);
assert!(masked[0] < -1e8);
assert!(masked[1] < -1e8);
assert!(masked[2] < -1e8);
}
#[test]
fn test_63_action_masking_canonical_layout() {
// 63-action layout: 7 exposure levels × 9 sub-actions each
// exposure_idx = action_idx / 9:
// 0=ShortSmall, 1=ShortHalf, 2=ShortFull, 3=Flat, 4=LongSmall, 5=LongHalf, 6=LongFull
let max_position = 5.0;
// At max position: Long actions (idx 4,5,6) should be masked
let mask = create_action_mask(max_position, max_position, 63);
for idx in 0..63 {
let exposure_idx = idx / 9;
match exposure_idx {
4 | 5 | 6 => {
assert!(
!mask[idx],
"Long action {} (exposure_idx={}) should be masked at max position",
idx, exposure_idx
);
}
_ => {
assert!(
mask[idx],
"Non-long action {} (exposure_idx={}) should NOT be masked at max position",
idx, exposure_idx
);
}
}
}
// At min position: Short actions (idx 0,1,2) should be masked
let mask = create_action_mask(-max_position, max_position, 63);
for idx in 0..63 {
let exposure_idx = idx / 9;
match exposure_idx {
0 | 1 | 2 => {
assert!(
!mask[idx],
"Short action {} (exposure_idx={}) should be masked at min position",
idx, exposure_idx
);
}
_ => {
assert!(
mask[idx],
"Non-short action {} (exposure_idx={}) should NOT be masked at min position",
idx, exposure_idx
);
}
}
}
}
}