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>
This commit is contained in:
jgrusewski
2026-04-11 11:54:09 +02:00
parent 55be1776b5
commit 448b61d095
61 changed files with 737 additions and 710 deletions

View File

@@ -161,7 +161,7 @@ pub async fn load_models_for_backtest(specs: &[ModelSpec]) -> Result<(), MLError
"DQN" => {
let config = DQNConfig {
state_dim: 51,
num_actions: 45,
num_actions: 63,
hidden_dims: vec![128, 128],
..Default::default()
};
@@ -177,7 +177,7 @@ pub async fn load_models_for_backtest(specs: &[ModelSpec]) -> Result<(), MLError
"PPO" => {
let config = PPOConfig {
state_dim: 51,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
..Default::default()
@@ -217,7 +217,7 @@ mod tests {
fn test_dqn_config() -> DQNConfig {
DQNConfig {
state_dim: 51,
num_actions: 45,
num_actions: 63,
hidden_dims: vec![64, 64],
..Default::default()
}

View File

@@ -171,7 +171,7 @@ async fn test_replay_engine_streams_events() {
async fn test_model_loads_and_predicts() {
let config = DQNConfig {
state_dim: 51,
num_actions: 45,
num_actions: 63,
hidden_dims: vec![64, 64],
..Default::default()
};

View File

@@ -3,18 +3,18 @@ pub use crate::common::action::{ExposureLevel, FactoredAction, OrderType, Urgenc
/// Returns action mask where true=valid, false=invalid based on position limits.
///
/// Prevents invalid exposure actions that would violate position limits.
/// DQN outputs 5 exposure actions (Short100, Short50, Flat, Long50, Long100).
/// DQN outputs 7 exposure actions (ShortSmall..LongFull).
///
/// # Arguments
/// * `_current_position` - Current portfolio position (unused - actions use absolute targets)
/// * `max_position` - Maximum allowed position magnitude (typically 2.0)
///
/// # Returns
/// Boolean mask of length 5 where:
/// Boolean mask of length 7 where:
/// - `true` = exposure action is valid (does not violate position limits)
/// - `false` = exposure action is invalid (would exceed max_position)
pub fn get_valid_action_mask(_current_position: f64, max_position: f64) -> Vec<bool> {
let exposures = [-1.0_f64, -0.5, 0.0, 0.5, 1.0]; // Short100..Long100
let exposures = [-0.25_f64, -0.50, -1.0, 0.0, 0.25, 0.50, 1.0];
exposures
.iter()
.map(|&exp| exp.abs() <= max_position)
@@ -28,15 +28,13 @@ mod tests {
#[test]
fn test_exposure_enum_values() {
assert_eq!(ExposureLevel::Short100 as usize, 0);
assert_eq!(ExposureLevel::Short75 as usize, 1);
assert_eq!(ExposureLevel::Short50 as usize, 2);
assert_eq!(ExposureLevel::Short25 as usize, 3);
assert_eq!(ExposureLevel::Flat as usize, 4);
assert_eq!(ExposureLevel::Long25 as usize, 5);
assert_eq!(ExposureLevel::Long50 as usize, 6);
assert_eq!(ExposureLevel::Long75 as usize, 7);
assert_eq!(ExposureLevel::Long100 as usize, 8);
assert_eq!(ExposureLevel::ShortSmall as usize, 0);
assert_eq!(ExposureLevel::ShortHalf as usize, 1);
assert_eq!(ExposureLevel::ShortFull as usize, 2);
assert_eq!(ExposureLevel::Flat as usize, 3);
assert_eq!(ExposureLevel::LongSmall as usize, 4);
assert_eq!(ExposureLevel::LongHalf as usize, 5);
assert_eq!(ExposureLevel::LongFull as usize, 6);
}
#[test]
@@ -55,8 +53,8 @@ mod tests {
#[test]
fn test_action_from_index_bidirectional() {
// Test all 45 actions round-trip
for idx in 0..45 {
// Test all 63 actions round-trip
for idx in 0..63 {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.to_index(),
@@ -70,25 +68,20 @@ mod tests {
#[test]
fn test_action_from_index_bounds() {
// Test out of bounds indices
assert!(FactoredAction::from_index(81).is_err());
assert!(FactoredAction::from_index(63).is_err());
assert!(FactoredAction::from_index(100).is_err());
assert!(FactoredAction::from_index(usize::MAX).is_err());
}
#[test]
fn test_target_exposure_values() {
// 4-branch: dir × mag. Index = dir*3 + mag.
// dir: 0=Short(-1), 1=Flat(0), 2=Long(+1)
// mag: 0=Small(0.25), 1=Half(0.50), 2=Full(1.00)
assert_eq!(ExposureLevel::Short100.target_exposure(), -0.25); // Short×Small
assert_eq!(ExposureLevel::Short75.target_exposure(), -0.50); // Short×Half
assert_eq!(ExposureLevel::Short50.target_exposure(), -1.0); // Short×Full
assert_eq!(ExposureLevel::Short25.target_exposure(), 0.0); // Flat×Small
assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0); // Flat×Half
assert_eq!(ExposureLevel::Long25.target_exposure(), 0.0); // Flat×Full
assert_eq!(ExposureLevel::Long50.target_exposure(), 0.25); // Long×Small
assert_eq!(ExposureLevel::Long75.target_exposure(), 0.50); // Long×Half
assert_eq!(ExposureLevel::Long100.target_exposure(), 1.0); // Long×Full
assert_eq!(ExposureLevel::ShortSmall.target_exposure(), -0.25);
assert_eq!(ExposureLevel::ShortHalf.target_exposure(), -0.50);
assert_eq!(ExposureLevel::ShortFull.target_exposure(), -1.0);
assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0);
assert_eq!(ExposureLevel::LongSmall.target_exposure(), 0.25);
assert_eq!(ExposureLevel::LongHalf.target_exposure(), 0.50);
assert_eq!(ExposureLevel::LongFull.target_exposure(), 1.0);
}
#[test]
@@ -109,12 +102,12 @@ mod tests {
#[test]
fn test_action_equality() {
let action1 = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
let action2 = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
@@ -127,35 +120,35 @@ mod tests {
#[test]
fn test_action_debug() {
let action = FactoredAction::new(
ExposureLevel::Long50,
ExposureLevel::LongHalf,
OrderType::LimitMaker,
Urgency::Patient,
);
let debug_str = format!("{:?}", action);
assert!(debug_str.contains("Long50"));
assert!(debug_str.contains("LongHalf"));
assert!(debug_str.contains("LimitMaker"));
assert!(debug_str.contains("Patient"));
}
#[test]
fn test_action_clone() {
let action1 = FactoredAction::new(ExposureLevel::Short50, OrderType::IoC, Urgency::Normal);
let action1 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::IoC, Urgency::Normal);
let action2 = action1.clone();
assert_eq!(action1, action2);
}
#[test]
fn test_index_bijection() {
// Verify all 45 indices map to unique actions
// Verify all 63 indices map to unique actions
use std::collections::HashSet;
let mut actions = HashSet::new();
for idx in 0..45 {
for idx in 0..63 {
let action = FactoredAction::from_index(idx).unwrap();
assert!(actions.insert(action), "Duplicate action for index {}", idx);
}
assert_eq!(actions.len(), 45);
assert_eq!(actions.len(), 63);
}
#[test]
@@ -163,23 +156,22 @@ mod tests {
// Test common neutral action
let action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
// Index = 4 * 9 + 0 * 3 + 1 = 36 + 0 + 1 = 37
assert_eq!(action.to_index(), 37);
// Index = 3 * 9 + 0 * 3 + 1 = 27 + 0 + 1 = 28
assert_eq!(action.to_index(), 28);
assert_eq!(action.target_exposure(), 0.0);
assert_eq!(action.transaction_cost(), 0.0015); // Wave 2.5 calibration
assert_eq!(action.urgency_weight(), 1.0);
// Round-trip
let reconstructed = FactoredAction::from_index(37).unwrap();
let reconstructed = FactoredAction::from_index(28).unwrap();
assert_eq!(action, reconstructed);
}
#[test]
fn test_extreme_actions() {
// 4-branch: Short100(=0) is Short×Small = -0.25 (NOT -1.0)
// Full short is Short50(=2) = Short×Full = -1.0
// ShortFull = -1.0
let short_extreme = FactoredAction::new(
ExposureLevel::Short50, // Short×Full = -1.0
ExposureLevel::ShortFull,
OrderType::Market,
Urgency::Aggressive,
);
@@ -188,13 +180,13 @@ mod tests {
assert_eq!(short_extreme.transaction_cost(), 0.0015);
assert_eq!(short_extreme.urgency_weight(), 1.5);
// Long100(=8) is Long×Full = +1.0 (still correct)
// LongFull = +1.0
let long_extreme = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
assert_eq!(long_extreme.to_index(), 8 * 9 + 0 * 3 + 2); // 74
assert_eq!(long_extreme.to_index(), 6 * 9 + 0 * 3 + 2); // 56
assert_eq!(long_extreme.target_exposure(), 1.0);
assert_eq!(long_extreme.transaction_cost(), 0.0015);
assert_eq!(long_extreme.urgency_weight(), 1.5);
@@ -203,7 +195,7 @@ mod tests {
#[test]
fn test_serialization() {
let action = FactoredAction::new(
ExposureLevel::Long50,
ExposureLevel::LongHalf,
OrderType::LimitMaker,
Urgency::Patient,
);
@@ -219,12 +211,12 @@ mod tests {
#[test]
fn test_action_masking_all_valid_at_standard_limit() {
// With max_position=2.0, all 5 exposure actions should be valid
// With max_position=2.0, all 7 exposure actions should be valid
let mask = get_valid_action_mask(0.0, 2.0);
assert_eq!(mask.len(), 5);
assert_eq!(mask.len(), 7);
assert!(
mask.iter().all(|&v| v),
"All 5 exposure actions should be valid at max_position=2.0"
"All 7 exposure actions should be valid at max_position=2.0"
);
}
@@ -232,27 +224,29 @@ mod tests {
fn test_action_masking_at_limit_1() {
// With max_position=1.0, all actions valid (max exposure = 1.0)
let mask = get_valid_action_mask(0.0, 1.0);
assert_eq!(mask.len(), 5);
assert_eq!(mask.len(), 7);
assert!(mask.iter().all(|&v| v), "All valid at max_position=1.0");
}
#[test]
fn test_action_masking_restrictive_limit() {
// With max_position=0.6, Short100/Long100 masked (exposure ±1.0 > 0.6)
// With max_position=0.6, ShortFull/LongFull masked (exposure |1.0| > 0.6)
let mask = get_valid_action_mask(0.0, 0.6);
assert_eq!(mask.len(), 5);
assert!(!mask[0], "Short100 should be INVALID at max_position=0.6");
assert!(mask[1], "Short50 should be valid at max_position=0.6");
assert!(mask[2], "Flat should be valid at max_position=0.6");
assert!(mask[3], "Long50 should be valid at max_position=0.6");
assert!(!mask[4], "Long100 should be INVALID at max_position=0.6");
assert_eq!(mask.len(), 7);
assert!(mask[0], "ShortSmall (0.25) should be valid at max_position=0.6");
assert!(mask[1], "ShortHalf (0.50) should be valid at max_position=0.6");
assert!(!mask[2], "ShortFull (1.0) should be INVALID at max_position=0.6");
assert!(mask[3], "Flat should be valid at max_position=0.6");
assert!(mask[4], "LongSmall (0.25) should be valid at max_position=0.6");
assert!(mask[5], "LongHalf (0.50) should be valid at max_position=0.6");
assert!(!mask[6], "LongFull (1.0) should be INVALID at max_position=0.6");
}
#[test]
fn test_action_masking_flat_always_valid() {
for pos in [-1.5, -1.0, 0.0, 1.0, 1.5] {
let mask = get_valid_action_mask(pos, 0.1);
assert!(mask[2], "Flat (exposure=0) should always be valid");
assert!(mask[3], "Flat (exposure=0) should always be valid");
}
}
}

View File

@@ -63,83 +63,122 @@ impl fmt::Display for OrderType {
}
}
/// Exposure level for position sizing (-100% to +100%)
/// Exposure level for position sizing (-100% to +100%).
///
/// 7-level enum representing the composite direction × magnitude outcome.
/// The 4-branch DQN produces `direction(3) × magnitude(3)` but three
/// Flat × {Small,Half,Full} combos all map to 0.0 target exposure, so
/// they collapse into a single `Flat` variant.
///
/// Total factored actions: 7 × 3 (order) × 3 (urgency) = 63.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExposureLevel {
/// 4-branch factored DQN: discriminant = dir * 3 + mag
/// dir: 0=Short(-1), 1=Flat(0), 2=Long(+1)
/// mag: 0=Small(0.25), 1=Half(0.50), 2=Full(1.00)
///
/// LEGACY NAMES: variant names reflect the old flat 9-level model.
/// Actual position = direction × magnitude (see target_exposure()).
Short100 = 0, // dir=0(Short), mag=0(Small) → -0.25
Short75 = 1, // dir=0(Short), mag=1(Half) → -0.50
Short50 = 2, // dir=0(Short), mag=2(Full) → -1.00
Short25 = 3, // dir=1(Flat), mag=0(Small) → 0
Flat = 4, // dir=1(Flat), mag=1(Half) → 0
Long25 = 5, // dir=1(Flat), mag=2(Full) → 0
Long50 = 6, // dir=2(Long), mag=0(Small) → +0.25
Long75 = 7, // dir=2(Long), mag=1(Half) → +0.50
Long100 = 8, // dir=2(Long), mag=2(Full) → +1.00
ShortSmall = 0, // -0.25
ShortHalf = 1, // -0.50
ShortFull = 2, // -1.00
Flat = 3, // 0.00
LongSmall = 4, // +0.25
LongHalf = 5, // +0.50
LongFull = 6, // +1.00
}
impl ExposureLevel {
/// Target exposure for the 4-branch factored DQN.
///
/// Decodes from composite index `dir * 3 + mag`:
/// direction: [-1.0, 0.0, +1.0]
/// magnitude: [0.25, 0.50, 1.00]
/// result: direction × magnitude
/// Target exposure as a fraction of max position.
pub fn target_exposure(&self) -> f64 {
let idx = *self as usize;
let dir = idx / 3;
let mag = idx % 3;
let direction: f64 = match dir {
0 => -1.0,
2 => 1.0,
_ => 0.0,
};
let magnitude: f64 = match mag {
0 => 0.25,
1 => 0.50,
_ => 1.00,
};
direction * magnitude
match self {
ExposureLevel::ShortSmall => -0.25,
ExposureLevel::ShortHalf => -0.50,
ExposureLevel::ShortFull => -1.00,
ExposureLevel::Flat => 0.00,
ExposureLevel::LongSmall => 0.25,
ExposureLevel::LongHalf => 0.50,
ExposureLevel::LongFull => 1.00,
}
}
/// Convert from composite index (0-8): dir * 3 + mag
/// Convert from index (0-6).
pub fn from_index(idx: usize) -> Result<Self, MLError> {
match idx {
0 => Ok(ExposureLevel::Short100),
1 => Ok(ExposureLevel::Short75),
2 => Ok(ExposureLevel::Short50),
3 => Ok(ExposureLevel::Short25),
4 => Ok(ExposureLevel::Flat),
5 => Ok(ExposureLevel::Long25),
6 => Ok(ExposureLevel::Long50),
7 => Ok(ExposureLevel::Long75),
8 => Ok(ExposureLevel::Long100),
0 => Ok(ExposureLevel::ShortSmall),
1 => Ok(ExposureLevel::ShortHalf),
2 => Ok(ExposureLevel::ShortFull),
3 => Ok(ExposureLevel::Flat),
4 => Ok(ExposureLevel::LongSmall),
5 => Ok(ExposureLevel::LongHalf),
6 => Ok(ExposureLevel::LongFull),
_ => Err(MLError::InvalidInput(format!(
"Invalid exposure level index: {} (expected 0-8)",
"Invalid exposure level index: {} (expected 0-6)",
idx
))),
}
}
/// Direction component: 0=Short, 1=Flat, 2=Long.
pub fn direction(&self) -> u8 {
match self {
ExposureLevel::ShortSmall | ExposureLevel::ShortHalf | ExposureLevel::ShortFull => 0,
ExposureLevel::Flat => 1,
ExposureLevel::LongSmall | ExposureLevel::LongHalf | ExposureLevel::LongFull => 2,
}
}
/// Magnitude component: 0=Small, 1=Half, 2=Full, 3=N/A (Flat).
pub fn magnitude(&self) -> u8 {
match self {
ExposureLevel::ShortSmall | ExposureLevel::LongSmall => 0,
ExposureLevel::ShortHalf | ExposureLevel::LongHalf => 1,
ExposureLevel::ShortFull | ExposureLevel::LongFull => 2,
ExposureLevel::Flat => 3,
}
}
/// Construct from direction (0-2) and magnitude (0-2) branch outputs.
/// When dir=1 (Flat), magnitude is ignored and Flat is returned.
pub fn from_dir_mag(dir: usize, mag: usize) -> Result<Self, MLError> {
match dir {
0 => match mag {
0 => Ok(ExposureLevel::ShortSmall),
1 => Ok(ExposureLevel::ShortHalf),
2 => Ok(ExposureLevel::ShortFull),
_ => Err(MLError::InvalidInput(format!("Invalid magnitude: {}", mag))),
},
1 => Ok(ExposureLevel::Flat),
2 => match mag {
0 => Ok(ExposureLevel::LongSmall),
1 => Ok(ExposureLevel::LongHalf),
2 => Ok(ExposureLevel::LongFull),
_ => Err(MLError::InvalidInput(format!("Invalid magnitude: {}", mag))),
},
_ => Err(MLError::InvalidInput(format!("Invalid direction: {}", dir))),
}
}
/// Check if this is a buy (long direction).
pub fn is_buy(&self) -> bool {
matches!(self, ExposureLevel::LongSmall | ExposureLevel::LongHalf | ExposureLevel::LongFull)
}
/// Check if this is a sell (short direction).
pub fn is_sell(&self) -> bool {
matches!(self, ExposureLevel::ShortSmall | ExposureLevel::ShortHalf | ExposureLevel::ShortFull)
}
/// Check if this is hold (flat).
pub fn is_hold(&self) -> bool {
matches!(self, ExposureLevel::Flat)
}
}
impl fmt::Display for ExposureLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Display as 4-branch dir×mag combo for clarity
match self {
ExposureLevel::Short100 => write!(f, "S25"), // Short×Small
ExposureLevel::Short75 => write!(f, "S50"), // Short×Half
ExposureLevel::Short50 => write!(f, "S100"), // Short×Full
ExposureLevel::Short25 => write!(f, "F25"), // Flat×Small
ExposureLevel::Flat => write!(f, "F50"), // Flat×Half
ExposureLevel::Long25 => write!(f, "F100"), // Flat×Full
ExposureLevel::Long50 => write!(f, "L25"), // Long×Small
ExposureLevel::Long75 => write!(f, "L50"), // Long×Half
ExposureLevel::Long100 => write!(f, "L100"), // Long×Full
ExposureLevel::ShortSmall => write!(f, "ShortSmall"),
ExposureLevel::ShortHalf => write!(f, "ShortHalf"),
ExposureLevel::ShortFull => write!(f, "ShortFull"),
ExposureLevel::Flat => write!(f, "Flat"),
ExposureLevel::LongSmall => write!(f, "LongSmall"),
ExposureLevel::LongHalf => write!(f, "LongHalf"),
ExposureLevel::LongFull => write!(f, "LongFull"),
}
}
}
@@ -188,14 +227,13 @@ impl fmt::Display for Urgency {
/// Factored trading action combining exposure, order type, and urgency.
///
/// 9 exposure levels x 3 order types x 3 urgency levels = 81 actions.
/// Index mapping: `index = exposure * 9 + order * 3 + urgency` (0-80).
/// 7 exposure levels x 3 order types x 3 urgency levels = 63 actions.
/// Index mapping: `index = exposure * 9 + order * 3 + urgency` (0-62).
///
/// NOTE: The CUDA training pipeline uses a 4-branch architecture
/// (direction × magnitude × order × urgency) where the first two branches
/// correspond to the `ExposureLevel` enum (9 = 3×3 combinations).
// TODO: update for 4-branch — split `exposure` into `direction` + `magnitude`
// fields once all ~64 callers are migrated in a dedicated refactor.
/// The CUDA training pipeline uses a 4-branch architecture
/// (direction × magnitude × order × urgency). Direction and magnitude
/// are collapsed into the 7-level `ExposureLevel` (Flat absorbs all
/// three Flat×{Small,Half,Full} combos into one variant).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FactoredAction {
pub exposure: ExposureLevel,
@@ -213,12 +251,12 @@ impl FactoredAction {
}
}
/// Map action index (0-80) to (exposure, order, urgency)
/// Map action index (0-62) to (exposure, order, urgency).
/// Index = exposure * 9 + order * 3 + urgency
pub fn from_index(idx: usize) -> Result<Self, MLError> {
if idx >= 81 {
if idx >= 63 {
return Err(MLError::InvalidInput(format!(
"Action index {} out of bounds (0-80)",
"Action index {} out of bounds (0-62)",
idx
)));
}
@@ -234,7 +272,7 @@ impl FactoredAction {
})
}
/// Map (exposure, order, urgency) to action index (0-80)
/// Map (exposure, order, urgency) to action index (0-62).
/// Index = exposure * 9 + order * 3 + urgency
pub fn to_index(&self) -> usize {
let exposure_idx = self.exposure as usize;
@@ -260,37 +298,31 @@ impl FactoredAction {
}
/// Create a FactoredAction from a TradingAction.
/// Buy → Long+Full(+1.0), Sell → Short+Full(-1.0), Hold → Flat+Half(0).
///
/// In the 4-branch encoding: Long+Full = Long100(=8), Short+Full = Short50(=2),
/// Flat+Half = Flat(=4). Legacy variant names don't match 4-branch semantics.
/// Buy → LongFull(+1.0), Sell → ShortFull(-1.0), Hold → Flat(0).
pub fn from_trading_action(action: crate::trading_action::TradingAction) -> Self {
use crate::trading_action::TradingAction;
match action {
TradingAction::Buy => {
Self::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal) // Long+Full = +1.0
Self::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
}
TradingAction::Sell => {
Self::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal) // Short+Full = -1.0
Self::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal)
}
TradingAction::Hold => {
Self::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal) // Flat+Half = 0
Self::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal)
}
}
}
/// Convert FactoredAction to TradingAction for reward calculation.
///
/// Maps exposure levels to simple Buy/Sell/Hold actions:
/// - Long100, Long50 -> Buy
/// - Flat -> Hold
/// - Short50, Short100 -> Sell
pub fn to_trading_action(&self) -> crate::trading_action::TradingAction {
use crate::trading_action::TradingAction;
match self.exposure {
ExposureLevel::Long100 | ExposureLevel::Long75 | ExposureLevel::Long50 | ExposureLevel::Long25 => TradingAction::Buy,
ExposureLevel::Flat => TradingAction::Hold,
ExposureLevel::Short25 | ExposureLevel::Short50 | ExposureLevel::Short75 | ExposureLevel::Short100 => TradingAction::Sell,
if self.exposure.is_buy() {
TradingAction::Buy
} else if self.exposure.is_sell() {
TradingAction::Sell
} else {
TradingAction::Hold
}
}
@@ -310,28 +342,19 @@ impl FactoredAction {
trade_value * self.transaction_cost()
}
/// Check if this action is a buy (long direction, dir=2, indices 6-8)
/// Check if this action is a buy (long direction).
pub fn is_buy(&self) -> bool {
matches!(
self.exposure,
ExposureLevel::Long50 | ExposureLevel::Long75 | ExposureLevel::Long100
)
self.exposure.is_buy()
}
/// Check if this action is a sell (short direction, dir=0, indices 0-2)
/// Check if this action is a sell (short direction).
pub fn is_sell(&self) -> bool {
matches!(
self.exposure,
ExposureLevel::Short100 | ExposureLevel::Short75 | ExposureLevel::Short50
)
self.exposure.is_sell()
}
/// Check if this action is neutral (flat direction, dir=1, indices 3-5)
/// Check if this action is neutral (flat).
pub fn is_hold(&self) -> bool {
matches!(
self.exposure,
ExposureLevel::Short25 | ExposureLevel::Flat | ExposureLevel::Long25
)
self.exposure.is_hold()
}
/// Convert action to position delta
@@ -363,8 +386,8 @@ mod tests {
use super::*;
#[test]
fn test_factored_action_round_trip_all_81() {
for idx in 0..81 {
fn test_factored_action_round_trip_all_63() {
for idx in 0..63 {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.to_index(),
@@ -377,17 +400,17 @@ mod tests {
#[test]
fn test_factored_action_out_of_bounds() {
assert!(FactoredAction::from_index(81).is_err());
assert!(FactoredAction::from_index(63).is_err());
assert!(FactoredAction::from_index(100).is_err());
}
#[test]
fn test_factored_action_to_trading_action() {
use crate::trading_action::TradingAction;
let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
assert_eq!(buy.to_trading_action(), TradingAction::Buy);
let sell =
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
assert_eq!(sell.to_trading_action(), TradingAction::Sell);
let hold = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
assert_eq!(hold.to_trading_action(), TradingAction::Hold);
@@ -412,15 +435,13 @@ mod tests {
#[test]
fn test_exposure_level_values() {
assert_eq!(ExposureLevel::Short100 as usize, 0);
assert_eq!(ExposureLevel::Short75 as usize, 1);
assert_eq!(ExposureLevel::Short50 as usize, 2);
assert_eq!(ExposureLevel::Short25 as usize, 3);
assert_eq!(ExposureLevel::Flat as usize, 4);
assert_eq!(ExposureLevel::Long25 as usize, 5);
assert_eq!(ExposureLevel::Long50 as usize, 6);
assert_eq!(ExposureLevel::Long75 as usize, 7);
assert_eq!(ExposureLevel::Long100 as usize, 8);
assert_eq!(ExposureLevel::ShortSmall as usize, 0);
assert_eq!(ExposureLevel::ShortHalf as usize, 1);
assert_eq!(ExposureLevel::ShortFull as usize, 2);
assert_eq!(ExposureLevel::Flat as usize, 3);
assert_eq!(ExposureLevel::LongSmall as usize, 4);
assert_eq!(ExposureLevel::LongHalf as usize, 5);
assert_eq!(ExposureLevel::LongFull as usize, 6);
}
#[test]
@@ -432,12 +453,13 @@ mod tests {
#[test]
fn test_target_exposure() {
// 4-branch: dir × mag formula
assert_eq!(ExposureLevel::Short100.target_exposure(), -0.25); // Short×Small
assert_eq!(ExposureLevel::Short50.target_exposure(), -1.0); // Short×Full
assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0); // Flat×Half
assert_eq!(ExposureLevel::Long50.target_exposure(), 0.25); // Long×Small
assert_eq!(ExposureLevel::Long100.target_exposure(), 1.0); // Long×Full
assert_eq!(ExposureLevel::ShortSmall.target_exposure(), -0.25);
assert_eq!(ExposureLevel::ShortHalf.target_exposure(), -0.50);
assert_eq!(ExposureLevel::ShortFull.target_exposure(), -1.0);
assert_eq!(ExposureLevel::Flat.target_exposure(), 0.0);
assert_eq!(ExposureLevel::LongSmall.target_exposure(), 0.25);
assert_eq!(ExposureLevel::LongHalf.target_exposure(), 0.50);
assert_eq!(ExposureLevel::LongFull.target_exposure(), 1.0);
}
#[test]
@@ -449,13 +471,13 @@ mod tests {
#[test]
fn test_is_buy_sell_hold() {
let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy = FactoredAction::new(ExposureLevel::LongFull, 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);
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
assert!(sell.is_sell());
assert!(!sell.is_buy());
assert!(!sell.is_hold());
@@ -469,7 +491,7 @@ mod tests {
#[test]
fn test_position_delta() {
let action =
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let delta = action.to_position_delta(0.0, 2.0);
assert!((delta - 2.0).abs() < 1e-10);
}
@@ -477,7 +499,7 @@ mod tests {
#[test]
fn test_calculate_transaction_cost() {
let action =
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive);
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Aggressive);
let cost = action.calculate_transaction_cost(10_000.0);
assert!((cost - 15.0).abs() < 1e-10);
}
@@ -485,8 +507,33 @@ mod tests {
#[test]
fn test_display() {
let action =
FactoredAction::new(ExposureLevel::Long50, OrderType::LimitMaker, Urgency::Patient);
FactoredAction::new(ExposureLevel::LongSmall, OrderType::LimitMaker, Urgency::Patient);
let s = format!("{}", action);
assert_eq!(s, "L25+LimitMaker+Patient"); // Long50 displays as L25 (Long×Small)
assert_eq!(s, "LongSmall+LimitMaker+Patient");
}
#[test]
fn test_from_dir_mag() {
assert_eq!(ExposureLevel::from_dir_mag(0, 0).unwrap(), ExposureLevel::ShortSmall);
assert_eq!(ExposureLevel::from_dir_mag(0, 1).unwrap(), ExposureLevel::ShortHalf);
assert_eq!(ExposureLevel::from_dir_mag(0, 2).unwrap(), ExposureLevel::ShortFull);
assert_eq!(ExposureLevel::from_dir_mag(1, 0).unwrap(), ExposureLevel::Flat);
assert_eq!(ExposureLevel::from_dir_mag(1, 1).unwrap(), ExposureLevel::Flat);
assert_eq!(ExposureLevel::from_dir_mag(1, 2).unwrap(), ExposureLevel::Flat);
assert_eq!(ExposureLevel::from_dir_mag(2, 0).unwrap(), ExposureLevel::LongSmall);
assert_eq!(ExposureLevel::from_dir_mag(2, 1).unwrap(), ExposureLevel::LongHalf);
assert_eq!(ExposureLevel::from_dir_mag(2, 2).unwrap(), ExposureLevel::LongFull);
}
#[test]
fn test_direction_magnitude() {
assert_eq!(ExposureLevel::ShortSmall.direction(), 0);
assert_eq!(ExposureLevel::ShortSmall.magnitude(), 0);
assert_eq!(ExposureLevel::ShortFull.direction(), 0);
assert_eq!(ExposureLevel::ShortFull.magnitude(), 2);
assert_eq!(ExposureLevel::Flat.direction(), 1);
assert_eq!(ExposureLevel::Flat.magnitude(), 3); // N/A
assert_eq!(ExposureLevel::LongFull.direction(), 2);
assert_eq!(ExposureLevel::LongFull.magnitude(), 2);
}
}

View File

@@ -63,7 +63,7 @@ mod tests {
#[test]
fn test_tight_spread_uses_limit_maker() {
let action = OrderRouter::route(
ExposureLevel::Long100,
ExposureLevel::LongFull,
0.5, // spread
1.0, // median_spread (spread < median)
0.01, // vol
@@ -75,7 +75,7 @@ mod tests {
#[test]
fn test_wide_spread_uses_market() {
let action = OrderRouter::route(
ExposureLevel::Short100,
ExposureLevel::ShortFull,
2.5, // spread
1.0, // median_spread (spread > 2x median)
0.01, // vol
@@ -99,7 +99,7 @@ mod tests {
#[test]
fn test_high_vol_aggressive() {
let action = OrderRouter::route(
ExposureLevel::Long50,
ExposureLevel::LongSmall,
1.0, // spread
1.0, // median_spread
0.05, // vol
@@ -111,7 +111,7 @@ mod tests {
#[test]
fn test_low_vol_patient() {
let action = OrderRouter::route(
ExposureLevel::Short50,
ExposureLevel::ShortFull,
1.0, // spread
1.0, // median_spread
0.005, // vol
@@ -134,8 +134,8 @@ mod tests {
#[test]
fn test_route_default() {
let action = OrderRouter::route_default(ExposureLevel::Long100);
assert_eq!(action.exposure, ExposureLevel::Long100);
let action = OrderRouter::route_default(ExposureLevel::LongFull);
assert_eq!(action.exposure, ExposureLevel::LongFull);
assert_eq!(action.order, OrderType::Market);
assert_eq!(action.urgency, Urgency::Normal);
}
@@ -143,7 +143,7 @@ mod tests {
#[test]
fn test_zero_median_spread_defaults_to_market() {
let action = OrderRouter::route(
ExposureLevel::Long100,
ExposureLevel::LongFull,
0.5,
0.0, // zero median
0.01,
@@ -155,7 +155,7 @@ mod tests {
#[test]
fn test_zero_median_vol_defaults_to_normal() {
let action = OrderRouter::route(
ExposureLevel::Long100,
ExposureLevel::LongFull,
1.0,
1.0,
0.01,
@@ -166,7 +166,7 @@ mod tests {
#[test]
fn test_all_exposure_levels() {
for idx in 0..5 {
for idx in 0..7 {
let exposure = ExposureLevel::from_index(idx).unwrap();
let action = OrderRouter::route_default(exposure);
assert_eq!(action.exposure, exposure);

View File

@@ -202,7 +202,7 @@ impl PortfolioTracker {
/// use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
///
/// let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
/// let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
/// let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
/// tracker.execute_action(action, 100.0, 100.0);
/// assert_eq!(tracker.position_size, 100.0); // Full long position
/// ```
@@ -878,7 +878,7 @@ mod tests {
tracker.position_size = 0.0;
// peak_value should be 100K from initialization
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 100.0, 4.0);
// Should refuse to open position when drawdown > 20%
@@ -896,7 +896,7 @@ mod tests {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0, 0.0);
// Open a long position first (at no drawdown)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 100.0, 4.0);
assert!((tracker.current_position() - 4.0).abs() < f32::EPSILON);
@@ -907,7 +907,7 @@ mod tests {
// Total value = 50_000 + 4*100 = 50_400. Drawdown = 1 - 50400/100000 = 49.6%
// Any action should force-close the position
let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action2 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action2, 100.0, 4.0);
// Should have force-closed to flat
@@ -928,7 +928,7 @@ mod tests {
tracker.cash = 85_000.0;
tracker.position_size = 0.0;
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 100.0, 4.0);
// Should allow the trade (drawdown < 20%)

View File

@@ -1236,8 +1236,8 @@ mod tests {
let actions = BranchingDuelingQNetwork::greedy_branch_actions(&output, &stream)?;
assert_eq!(actions.len(), 3);
assert!(
*actions.get(0).unwrap_or(&99) < 5,
"Exposure action should be 0-4"
*actions.get(0).unwrap_or(&99) < 7,
"Exposure action should be 0-6"
);
assert!(
*actions.get(1).unwrap_or(&99) < 3,
@@ -1252,7 +1252,7 @@ mod tests {
#[test]
fn test_decompose_compose_roundtrip() {
for idx in 0..81_u32 {
for idx in 0..63_u32 {
let (e, o, u) = BranchingDuelingQNetwork::decompose_factored_action(idx as usize, 3, 3);
let composed =
BranchingDuelingQNetwork::compose_factored_action(e as u32, o as u32, u as u32, 3, 3);
@@ -1263,8 +1263,8 @@ mod tests {
#[test]
fn test_decompose_actions_batch() -> anyhow::Result<()> {
let stream = test_stream();
// Action 0 = (0,0,0), Action 13 = (1,1,1), Action 44 = (4,2,2)
let actions = vec![0_u32, 13, 44];
// Action 0 = (0,0,0), Action 13 = (1,1,1), Action 62 = (6,2,2)
let actions = vec![0_u32, 13, 62];
let branches =
BranchingDuelingQNetwork::decompose_actions_batch(&actions, &stream, 3, 3)?;
@@ -1281,7 +1281,7 @@ mod tests {
.ok_or_else(|| anyhow::anyhow!("missing branch 2"))?
.to_host(&stream)?;
assert_eq!(e, vec![0.0_f32, 1.0, 4.0]);
assert_eq!(e, vec![0.0_f32, 1.0, 6.0]);
assert_eq!(o, vec![0.0_f32, 1.0, 2.0]);
assert_eq!(u, vec![0.0_f32, 1.0, 2.0]);
Ok(())
@@ -1290,13 +1290,13 @@ mod tests {
#[test]
fn test_decompose_actions_batch_gpu_matches_cpu() -> anyhow::Result<()> {
let stream = test_stream();
// Test all 45 factored actions -- GPU-native version must match CPU version
let all_actions: Vec<u32> = (0..45).collect();
// Test all 63 factored actions -- GPU-native version must match CPU version
let all_actions: Vec<u32> = (0..63).collect();
let cpu_branches =
BranchingDuelingQNetwork::decompose_actions_batch(&all_actions, &stream, 3, 3)?;
let all_f32: Vec<f32> = all_actions.iter().map(|&x| x as f32).collect();
let actions_tensor = GpuTensor::from_host(&all_f32, vec![45], &stream)?;
let actions_tensor = GpuTensor::from_host(&all_f32, vec![63], &stream)?;
let gpu_branches =
BranchingDuelingQNetwork::decompose_actions_batch_gpu(&actions_tensor, 3, 3, &stream)?;

View File

@@ -2,31 +2,31 @@
mod tests {
#[test]
fn test_factored_action_composition() {
// Verify: exposure * 9 + order * 3 + urgency covers 0-44
// Verify: exposure * 9 + order * 3 + urgency covers 0-62
let mut seen = std::collections::HashSet::new();
for exposure in 0..5_u32 {
for exposure in 0..7_u32 {
for order in 0..3_u32 {
for urgency in 0..3_u32 {
let factored = exposure * 9 + order * 3 + urgency;
assert!(factored < 45, "factored {} out of range", factored);
assert!(factored < 63, "factored {} out of range", factored);
seen.insert(factored);
}
}
}
assert_eq!(seen.len(), 45, "Must cover all 45 factored actions");
assert_eq!(seen.len(), 63, "Must cover all 63 factored actions");
}
#[test]
fn test_factored_action_decomposition() {
// Verify round-trip: compose then decompose
for original in 0..45_u32 {
for original in 0..63_u32 {
let exposure = original / 9;
let order = (original % 9) / 3;
let urgency = original % 3;
let recomposed = exposure * 9 + order * 3 + urgency;
assert_eq!(original, recomposed, "Round-trip failed for {}", original);
assert!(exposure < 5);
assert!(exposure < 7);
assert!(order < 3);
assert!(urgency < 3);
}

View File

@@ -115,9 +115,9 @@ impl ForwardDynamicsModel {
// One-hot encode action
let action_idx = match action.exposure {
ExposureLevel::Short100 | ExposureLevel::Short75 | ExposureLevel::Short50 | ExposureLevel::Short25 => 0_usize,
ExposureLevel::ShortSmall | ExposureLevel::ShortHalf | ExposureLevel::ShortFull => 0_usize,
ExposureLevel::Flat => 1_usize,
ExposureLevel::Long25 | ExposureLevel::Long50 | ExposureLevel::Long75 | ExposureLevel::Long100 => 2_usize,
ExposureLevel::LongSmall | ExposureLevel::LongHalf | ExposureLevel::LongFull => 2_usize,
};
// Build input: [market_features | action_onehot] per sample
@@ -304,7 +304,7 @@ mod tests {
// Helper to create a test BUY action
fn test_buy_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive)
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Aggressive)
}
fn make_stream() -> Arc<CudaStream> {
@@ -414,8 +414,8 @@ mod tests {
let state = vec![0.0_f32; MARKET_DIM];
// Predict with different actions
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive);
let sell_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Aggressive);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Aggressive);
let sell_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Aggressive);
let hold_action = FactoredAction::new(ExposureLevel::Flat, OrderType::LimitMaker, Urgency::Patient);
let pred_buy = model.predict(&state, 1, MARKET_DIM, buy_action)?;

View File

@@ -245,7 +245,7 @@ impl Default for DQNConfig {
fn default() -> Self {
Self {
state_dim: 48, // 40 market + 3 portfolio = 43, padded to 48 for tensor core alignment
num_actions: 9, // 9 exposure levels (25% steps)
num_actions: 7, // 7 exposure levels (ShortSmall..LongFull + Flat)
hidden_dims: vec![256, 256],
learning_rate: 3e-5, // Conservative default for stable training (matches conservative())
gamma: 0.95,
@@ -1514,10 +1514,10 @@ impl DQN {
if exp_random && ord_random && urg_random {
// All branches random — skip forward pass.
// 4-branch: exposure = dir*3 + mag (9 combos), order(3), urgency(3).
// 4-branch: dir(3) × mag(3) → 7-level ExposureLevel, order(3), urgency(3).
let dir = rng.gen_range(0..3_usize);
let mag = rng.gen_range(0..3_usize);
let exposure = ExposureLevel::from_index(dir * 3 + mag)?;
let exposure = ExposureLevel::from_dir_mag(dir, mag)?;
let order = OrderType::from_index(rng.gen_range(0..3_usize))?;
let urgency = Urgency::from_index(rng.gen_range(0..3_usize))?;
FactoredAction { exposure, order, urgency }
@@ -1532,15 +1532,15 @@ impl DQN {
let output = branching_net.forward_branches_eval(&state_tensor)?;
let greedy = super::branching::BranchingDuelingQNetwork::greedy_branch_actions(&output, &self.stream)?;
// 4-branch: greedy[0]=dir(0-2), greedy[1]=mag(0-2), composite = dir*3+mag
// 4-branch: greedy[0]=dir(0-2), greedy[1]=mag(0-2) → 7-level ExposureLevel
let exposure = if exp_random {
let dir = rng.gen_range(0..3_usize);
let mag = rng.gen_range(0..3_usize);
ExposureLevel::from_index(dir * 3 + mag)?
ExposureLevel::from_dir_mag(dir, mag)?
} else {
let dir = greedy.first().copied().unwrap_or(1) as usize;
let mag = greedy.get(1).copied().unwrap_or(1) as usize;
ExposureLevel::from_index(dir * 3 + mag)?
ExposureLevel::from_dir_mag(dir, mag)?
};
// 4-branch greedy: [dir, mag, order, urgency] → order at [2], urgency at [3]
let order = if ord_random {
@@ -1720,7 +1720,7 @@ impl DQN {
// 4-branch: exposure = dir*3 + mag (9 combos), order(3), urgency(3)
let dir = rng.gen_range(0..3_usize);
let mag = rng.gen_range(0..3_usize);
let exposure = ExposureLevel::from_index(dir * 3 + mag)?;
let exposure = ExposureLevel::from_dir_mag(dir, mag)?;
let order = OrderType::from_index(rng.gen_range(0..3_usize))?;
let urgency = Urgency::from_index(rng.gen_range(0..3_usize))?;
let uniform_conf = (1.0_f32 / 81.0).clamp(0.5, 0.95);
@@ -1756,11 +1756,11 @@ impl DQN {
let exposure = if exp_random {
let dir = rng.gen_range(0..3_usize);
let mag = rng.gen_range(0..3_usize);
ExposureLevel::from_index(dir * 3 + mag)?
ExposureLevel::from_dir_mag(dir, mag)?
} else {
let dir = greedy.first().copied().unwrap_or(1) as usize;
let mag = greedy.get(1).copied().unwrap_or(1) as usize;
ExposureLevel::from_index(dir * 3 + mag)?
ExposureLevel::from_dir_mag(dir, mag)?
};
// 4-branch greedy: [dir, mag, order, urgency] -> order at [2], urgency at [3]
let order = if ord_random {
@@ -1925,7 +1925,7 @@ impl DQN {
// 4-branch: [dir, mag, order, urgency] → exposure = dir*3 + mag
let dir = branch_actions.first().copied().unwrap_or(1) as usize;
let mag = branch_actions.get(1).copied().unwrap_or(1) as usize;
let exposure = ExposureLevel::from_index(dir * 3 + mag)?;
let exposure = ExposureLevel::from_dir_mag(dir, mag)?;
let order = OrderType::from_index(branch_actions.get(2).copied().unwrap_or(0) as usize)?;
let urgency = Urgency::from_index(branch_actions.get(3).copied().unwrap_or(1) as usize)?;

View File

@@ -425,8 +425,8 @@ mod tests {
fn factored_same_exposure_no_trade() {
let mut engine = EvaluationEngine::new(10000.0);
let b = bar(100.0);
engine.process_bar_factored(0, &b, &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &b, &market_action(ExposureLevel::Long100));
engine.process_bar_factored(0, &b, &market_action(ExposureLevel::LongFull));
engine.process_bar_factored(1, &b, &market_action(ExposureLevel::LongFull));
assert_eq!(
engine.trades.len(),
0,
@@ -437,8 +437,8 @@ mod tests {
#[test]
fn factored_partial_close_generates_trade() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &bar(110.0), &market_action(ExposureLevel::Long50));
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::LongFull));
engine.process_bar_factored(1, &bar(110.0), &market_action(ExposureLevel::LongSmall));
assert_eq!(
engine.trades.len(),
1,
@@ -454,8 +454,8 @@ mod tests {
#[test]
fn factored_reversal_generates_trade() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &bar(105.0), &market_action(ExposureLevel::Short50));
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::LongFull));
engine.process_bar_factored(1, &bar(105.0), &market_action(ExposureLevel::ShortFull));
assert!(
!engine.trades.is_empty(),
"Reversal should generate at least 1 trade"
@@ -467,7 +467,7 @@ mod tests {
#[test]
fn factored_flat_from_long_closes() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::LongFull));
engine.process_bar_factored(1, &bar(95.0), &market_action(ExposureLevel::Flat));
assert_eq!(engine.trades.len(), 1);
assert!(
@@ -480,7 +480,7 @@ mod tests {
#[test]
fn factored_close_at_end() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Short50));
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::ShortFull));
engine.close_factored_position(1, &bar(90.0));
assert_eq!(engine.trades.len(), 1);
assert!(
@@ -497,7 +497,7 @@ mod tests {
engine.process_bar_factored(
i,
&bar(100.0 + i as f32),
&market_action(ExposureLevel::Long100),
&market_action(ExposureLevel::LongFull),
);
}
assert_eq!(engine.trades.len(), 0);
@@ -510,9 +510,9 @@ mod tests {
let mut engine = EvaluationEngine::new(10000.0);
for i in 0..10 {
let action = if i % 2 == 0 {
market_action(ExposureLevel::Long100)
market_action(ExposureLevel::LongFull)
} else {
market_action(ExposureLevel::Short100)
market_action(ExposureLevel::ShortSmall)
};
engine.process_bar_factored(i, &bar(100.0), &action);
}

View File

@@ -533,7 +533,7 @@ mod tests {
let mut tracker = MultiAssetPortfolioTracker::new(symbols.clone(), Decimal::from(10_000));
// ES: Long 10 at 4500
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(&Symbol::new("ES_FUT"), action, 4500.0, 10.0);
let prices = HashMap::from([

View File

@@ -1021,7 +1021,7 @@ mod tests {
// Test Market order (0.15% fee)
let market_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
@@ -1037,7 +1037,7 @@ mod tests {
// Test LimitMaker order (0.05% fee) with Patient urgency (0.5x)
let limit_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Patient,
);
@@ -1053,7 +1053,7 @@ mod tests {
// Test IoC order (0.10% fee) with Aggressive urgency (1.5x)
let ioc_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::IoC,
Urgency::Aggressive,
);
@@ -1108,7 +1108,7 @@ mod tests {
// Market order action
let market_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);

View File

@@ -41,25 +41,27 @@ pub fn create_action_mask(current_position: f64, max_position: f64, num_actions:
if current_position - 1.0 < -max_position {
mask[1] = false;
}
} else if num_actions == 45 {
} else if num_actions == 63 {
for action_idx in 0..num_actions {
let exposure_idx = action_idx / 9;
match exposure_idx {
3 | 4 => {
// 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;
}
}
}
0 | 1 => {
// 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 {
@@ -143,56 +145,50 @@ mod tests {
}
#[test]
fn test_create_action_mask_45_flat_position() {
let mask = create_action_mask(0.0, 2.0, 45);
assert_eq!(mask.len(), 45);
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_45_at_max_position() {
let mask = create_action_mask(2.0, 2.0, 45);
assert_eq!(mask.len(), 45);
for i in 0..9 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
for i in 9..18 {
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
for i in 18..27 {
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(true), false);
assert_eq!(mask.get(i).copied().unwrap_or(false), true);
}
for i in 36..45 {
// 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_45_at_min_position() {
let mask = create_action_mask(-2.0, 2.0, 45);
for i in 0..9 {
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);
}
for i in 9..18 {
assert_eq!(mask.get(i).copied().unwrap_or(true), false);
}
for i in 18..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);
}
for i in 36..45 {
// 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_45_partial_position() {
let mask = create_action_mask(1.0, 2.0, 45);
fn test_create_action_mask_63_partial_position() {
let mask = create_action_mask(1.0, 2.0, 63);
assert!(mask.iter().all(|&v| v));
}
@@ -209,18 +205,18 @@ mod tests {
}
#[test]
fn test_45_action_masking_canonical_layout() {
// 45-action layout: 5 exposure levels × 9 sub-actions each
fn test_63_action_masking_canonical_layout() {
// 63-action layout: 7 exposure levels × 9 sub-actions each
// exposure_idx = action_idx / 9:
// 0 = Short100, 1 = Short50, 2 = Flat, 3 = Long50, 4 = Long100
// 0=ShortSmall, 1=ShortHalf, 2=ShortFull, 3=Flat, 4=LongSmall, 5=LongHalf, 6=LongFull
let max_position = 5.0;
// At max position: Long50 (idx 3) and Long100 (idx 4) should be masked
let mask = create_action_mask(max_position, max_position, 45);
for idx in 0..45 {
// 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 {
3 | 4 => {
4 | 5 | 6 => {
assert!(
!mask[idx],
"Long action {} (exposure_idx={}) should be masked at max position",
@@ -237,12 +233,12 @@ mod tests {
}
}
// At min position: Short100 (idx 0) and Short50 (idx 1) should be masked
let mask = create_action_mask(-max_position, max_position, 45);
for idx in 0..45 {
// 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 => {
0 | 1 | 2 => {
assert!(
!mask[idx],
"Short action {} (exposure_idx={}) should be masked at min position",

View File

@@ -2,7 +2,7 @@
//!
//! This module provides a unified interface for both discrete and continuous action spaces.
//! It enables the same PPO framework to handle:
//! - Discrete actions: 45-action factored space (5x3x3 = exposure x order x urgency)
//! - 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;
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
/// Action space type discriminator
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionType {
/// Discrete action space (45-action factored space)
/// Discrete action space (63-action factored space)
Discrete,
/// Continuous action space (Gaussian policy)
Continuous,
@@ -22,7 +22,7 @@ pub enum ActionType {
/// Unified action space supporting both discrete and continuous actions
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ActionSpace {
/// Discrete trading action (45-action factored space)
/// Discrete trading action (63-action factored space)
Discrete(FactoredAction),
/// Continuous trading action (position sizing)
Continuous(ContinuousAction),
@@ -82,7 +82,7 @@ impl ActionSpace {
/// Validate action is within bounds
pub fn is_valid(&self) -> bool {
match self {
ActionSpace::Discrete(action) => action.to_index() < 45,
ActionSpace::Discrete(action) => action.to_index() < 63,
ActionSpace::Continuous(action) => action.is_valid(),
}
}
@@ -125,7 +125,7 @@ mod tests {
#[test]
fn test_action_type() {
let discrete = ActionSpace::discrete(FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
));
@@ -138,7 +138,7 @@ mod tests {
#[test]
fn test_as_discrete() {
let action = FactoredAction::new(
ExposureLevel::Short100,
ExposureLevel::ShortSmall,
OrderType::IoC,
Urgency::Patient,
);
@@ -171,12 +171,12 @@ mod tests {
#[test]
fn test_description() {
let discrete = ActionSpace::discrete(FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
));
let desc = discrete.description();
assert!(desc.contains("Long100"));
assert!(desc.contains("LongFull"));
assert!(desc.contains("Market"));
assert!(desc.contains("Aggressive"));
@@ -187,7 +187,7 @@ mod tests {
#[test]
fn test_all_discrete_actions() -> Result<(), MLError> {
for idx in 0..45 {
for idx in 0..63 {
let action = FactoredAction::from_index(idx)?;
let action_space = ActionSpace::discrete(action);
assert!(action_space.is_valid());

View File

@@ -27,7 +27,7 @@ impl Default for AdaptiveEntropyConfig {
initial_alpha: 0.05,
target_ratio: 0.5,
alpha_lr: 3e-4,
num_actions: 45,
num_actions: 63,
}
}
}
@@ -155,7 +155,7 @@ mod tests {
initial_alpha: 0.05,
target_ratio: 0.5,
alpha_lr: 0.01,
num_actions: 45,
num_actions: 63,
};
let mut ae = AdaptiveEntropyCoeff::new(&config)?;
let initial_alpha = ae.alpha()?;
@@ -180,7 +180,7 @@ mod tests {
initial_alpha: 0.05,
target_ratio: 0.5,
alpha_lr: 0.01,
num_actions: 45,
num_actions: 63,
};
let mut ae = AdaptiveEntropyCoeff::new(&config)?;
let initial_alpha = ae.alpha()?;

View File

@@ -138,7 +138,7 @@ impl Default for PPOConfig {
fn default() -> Self {
Self {
state_dim: 64,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![256, 128, 64],
policy_learning_rate: 3e-5,

View File

@@ -539,7 +539,7 @@ 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 buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let actions = vec![buy_action; 5];
let log_probs = vec![0.0; 5];
let values = vec![0.0; 5];

View File

@@ -1365,7 +1365,7 @@ fn evaluate_ppo_fold_gpu(
#[allow(clippy::integer_division)]
let config = PPOConfig {
state_dim: args.feature_dim,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: {
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(256);
let align = |x: usize| x.div_ceil(8) * 8;

View File

@@ -108,7 +108,7 @@ impl PpoBenchmarkRunner {
// Step 4: Create PPO model with batch configuration
let config = PPOConfig {
state_dim: 64,
num_actions: 45, // 5 exposure × 3 order × 3 urgency (FactoredAction)
num_actions: 63, // 5 exposure × 3 order × 3 urgency (FactoredAction)
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
policy_learning_rate: 3e-4,

View File

@@ -41,19 +41,19 @@ fn load_kernels(context: &Arc<CudaContext>) -> Result<KernelSet, MLError> {
// ── Public API ──────────────────────────────────────────────────────────
/// Aggregate PPO 45-action softmax probabilities into 5 exposure scores.
/// Aggregate PPO 63-action softmax probabilities into 7 exposure scores.
///
/// PPO uses a 45-action factored space (5 exposure x 3 order x 3 urgency).
/// This collapses the order/urgency dimensions to produce a `[batch * 5]`
/// PPO uses a 63-action factored space (7 exposure x 3 order x 3 urgency).
/// This collapses the order/urgency dimensions to produce a `[batch * 7]`
/// `CudaSlice` whose per-row argmax gives the dominant exposure bucket.
///
/// # Arguments
/// * `probs` - GPU-resident `[batch * 45]` softmax probabilities
/// * `probs` - GPU-resident `[batch * 63]` softmax probabilities
/// * `batch` - number of rows
/// * `stream` - CUDA stream for kernel launch and allocation
///
/// # Returns
/// GPU-resident `CudaSlice<f32>` of shape `[batch * 5]`.
/// GPU-resident `CudaSlice<f32>` of shape `[batch * 7]`.
///
/// # Errors
/// Returns `MLError::ModelError` on kernel compilation or launch failure.
@@ -66,7 +66,7 @@ pub fn ppo_to_exposure_scores(
let kernels = load_kernels(&context)?;
let mut out = stream
.alloc_zeros::<f32>(batch * 5)
.alloc_zeros::<f32>(batch * 7)
.map_err(|e| MLError::ModelError(format!("alloc exposure scores: {e}")))?;
let batch_i32 = batch as i32;
@@ -91,15 +91,15 @@ pub fn ppo_to_exposure_scores(
Ok(out)
}
/// Map scalar return predictions (bps) to 5-action one-hot-ish scores.
/// Map scalar return predictions (bps) to 7-action one-hot-ish scores.
///
/// Thresholds partition the real line into five bins matching the DQN
/// exposure actions:
/// - `pred < -high` => Short100 (action 0)
/// - `-high <= pred < -low` => Short50 (action 1)
/// - `-low <= pred <= +low` => Flat (action 2)
/// - `+low < pred <= +high` => Long50 (action 3)
/// - `pred > +high` => Long100 (action 4)
/// Thresholds partition the real line into bins matching the 7-level
/// ExposureLevel enum:
/// - `pred < -high` => ShortFull (action 2)
/// - `-high <= pred < -low` => ShortHalf (action 1)
/// - `-low <= pred <= +low` => Flat (action 3)
/// - `+low < pred <= +high` => LongHalf (action 5)
/// - `pred > +high` => LongFull (action 6)
///
/// # Arguments
/// * `predictions` - GPU-resident `[batch]` scalar signals
@@ -109,7 +109,7 @@ pub fn ppo_to_exposure_scores(
/// * `stream` - CUDA stream for kernel launch and allocation
///
/// # Returns
/// GPU-resident `CudaSlice<f32>` of shape `[batch * 5]` (one-hot rows).
/// GPU-resident `CudaSlice<f32>` of shape `[batch * 7]` (one-hot rows).
///
/// # Errors
/// Returns `MLError::ModelError` on kernel compilation or launch failure.
@@ -124,7 +124,7 @@ pub fn signal_to_action_scores(
let kernels = load_kernels(&context)?;
let mut out = stream
.alloc_zeros::<f32>(batch * 5)
.alloc_zeros::<f32>(batch * 7)
.map_err(|e| MLError::ModelError(format!("alloc action scores: {e}")))?;
let batch_i32 = batch as i32;
@@ -259,26 +259,26 @@ mod tests {
fn test_ppo_to_exposure_scores_shape() {
let stream = cuda_stream();
let batch = 4;
let uniform = vec![1.0_f32 / 45.0; batch * 45];
let uniform = vec![1.0_f32 / 63.0; batch * 63];
let bf16_uniform: Vec<f32> = uniform.to_vec();
let mut probs_buf = stream.alloc_zeros::<f32>(batch * 45).unwrap();
let mut probs_buf = stream.alloc_zeros::<f32>(batch * 63).unwrap();
stream.memcpy_htod(&bf16_uniform, &mut probs_buf).unwrap();
let scores = ppo_to_exposure_scores(&probs_buf, batch, &stream).unwrap();
// Download and check shape + values
let mut host_bf16 = vec![0.0_f32; batch * 5];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap(); // test readback
let mut host_bf16 = vec![0.0_f32; batch * 7];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap();
stream.synchronize().unwrap();
let host: Vec<f32> = host_bf16.to_vec();
assert_eq!(host.len(), batch * 5);
// Each of 5 bins sums 9 cells of 1/45 = 0.2
// BF16 has limited precision -- relax tolerance
assert_eq!(host.len(), batch * 7);
// Each of 7 bins sums 9 cells of 1/63 ≈ 0.1429
let expected = 9.0 / 63.0;
for val in &host {
assert!(
(*val - 0.2).abs() < 0.01,
"expected ~0.2 everywhere, got {val}"
(*val - expected).abs() < 0.01,
"expected ~{expected} everywhere, got {val}"
);
}
}
@@ -286,45 +286,45 @@ mod tests {
#[test]
fn test_ppo_to_exposure_scores_argmax() {
let stream = cuda_stream();
// Put all mass on actions 36..44 => exposure bin 4 (Long100)
// Put all mass on actions 54..62 => exposure bin 6 (LongFull)
let batch = 2;
let mut raw = vec![0.0_f32; batch * 45];
let mut raw = vec![0.0_f32; batch * 63];
for b in 0..batch {
for a in 36..45 {
if let Some(slot) = raw.get_mut(b * 45 + a) {
for a in 54..63 {
if let Some(slot) = raw.get_mut(b * 63 + a) {
*slot = 1.0 / 9.0;
}
}
}
let bf16_raw: Vec<f32> = raw.to_vec();
let mut probs_buf = stream.alloc_zeros::<f32>(batch * 45).unwrap();
let mut probs_buf = stream.alloc_zeros::<f32>(batch * 63).unwrap();
stream.memcpy_htod(&bf16_raw, &mut probs_buf).unwrap();
let scores = ppo_to_exposure_scores(&probs_buf, batch, &stream).unwrap();
let mut host_bf16 = vec![0.0_f32; batch * 5];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap(); // test readback
let mut host_bf16 = vec![0.0_f32; batch * 7];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap();
stream.synchronize().unwrap();
let host: Vec<f32> = host_bf16.to_vec();
// Each row should have argmax at index 4 (Long100)
// Each row should have argmax at index 6 (LongFull)
for b in 0..batch {
let row_start = b * 5;
let row_start = b * 7;
let mut best_idx = 0;
let mut best_val = f32::NEG_INFINITY;
for i in 0..5 {
for i in 0..7 {
let v = host[row_start + i];
if v > best_val {
best_val = v;
best_idx = i;
}
}
assert_eq!(best_idx, 4, "batch {b}: expected argmax=4, got {best_idx}");
assert_eq!(best_idx, 6, "batch {b}: expected argmax=6 (LongFull), got {best_idx}");
}
}
// ── signal_to_action_scores ─────────────────────────────────────────
/// Host-side argmax over a [1, 5] row downloaded from GPU.
/// Host-side argmax over a [1, 7] row downloaded from GPU.
fn host_argmax(host: &[f32]) -> usize {
let mut best_idx = 0;
let mut best_val = f32::NEG_INFINITY;
@@ -343,8 +343,8 @@ mod tests {
let mut pred_buf = stream.alloc_zeros::<f32>(1).unwrap();
stream.memcpy_htod(&bf16_pred, &mut pred_buf).unwrap();
let scores = signal_to_action_scores(&pred_buf, 1, high, low, &stream).unwrap();
let mut host_bf16 = vec![0.0_f32; 5];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap(); // test readback
let mut host_bf16 = vec![0.0_f32; 7];
stream.memcpy_dtoh(&scores, &mut host_bf16).unwrap();
stream.synchronize().unwrap();
host_bf16.to_vec()
}
@@ -352,31 +352,31 @@ mod tests {
#[test]
fn test_signal_to_action_scores_strong_long() {
let host = run_signal_test(20.0, 10.0, 5.0);
assert_eq!(host_argmax(&host), 4); // Long100
assert_eq!(host_argmax(&host), 6); // LongFull
}
#[test]
fn test_signal_to_action_scores_strong_short() {
let host = run_signal_test(-20.0, 10.0, 5.0);
assert_eq!(host_argmax(&host), 0); // Short100
assert_eq!(host_argmax(&host), 2); // ShortFull
}
#[test]
fn test_signal_to_action_scores_flat() {
let host = run_signal_test(0.0, 10.0, 5.0);
assert_eq!(host_argmax(&host), 2); // Flat
assert_eq!(host_argmax(&host), 3); // Flat
}
#[test]
fn test_signal_to_action_scores_mild_long() {
let host = run_signal_test(7.0, 10.0, 5.0);
assert_eq!(host_argmax(&host), 3); // Long50
assert_eq!(host_argmax(&host), 5); // LongHalf
}
#[test]
fn test_signal_to_action_scores_mild_short() {
let host = run_signal_test(-7.0, 10.0, 5.0);
assert_eq!(host_argmax(&host), 1); // Short50
assert_eq!(host_argmax(&host), 1); // ShortHalf
}
// ── tft_quantile_to_signal ──────────────────────────────────────────

View File

@@ -4,19 +4,19 @@
* Three fused kernels replacing Candle tensor operations:
*
* 1. ppo_to_exposure_scores_kernel:
* Aggregates PPO 45-action softmax probs into 5 exposure scores.
* Input: [batch * 45] BF16 probs
* Output: [batch * 5] BF16 scores (sum of 9 order/urgency combos per bucket)
* Aggregates PPO 63-action softmax probs into 7 exposure scores.
* Input: [batch * 63] probs
* Output: [batch * 7] scores (sum of 9 order/urgency combos per bucket)
*
* 2. signal_to_action_scores_kernel:
* Maps scalar predictions to [batch, 5] one-hot-ish action scores via thresholds.
* Input: [batch] BF16 predictions
* Output: [batch * 5] BF16 scores (one-hot based on threshold buckets)
* Maps scalar predictions to [batch, 7] one-hot-ish action scores via thresholds.
* Input: [batch] predictions
* Output: [batch * 7] scores (one-hot based on threshold buckets)
*
* 3. tft_quantile_extract_kernel:
* Extracts median signal from TFT quantile predictions.
* Input: [batch * horizon * num_quantiles] BF16
* Output: [batch] BF16 median values (quantile index 1, horizon index 0)
* Input: [batch * horizon * num_quantiles]
* Output: [batch] median values (quantile index 1, horizon index 0)
*
* None of these kernels require common_device_functions.cuh (standalone).
* Launch config: grid=(ceil(batch/256), 1, 1), block=(256, 1, 1).
@@ -24,30 +24,30 @@
*/
extern "C" __global__ void ppo_to_exposure_scores_kernel(
const float* __restrict__ probs, /* [batch * 45] softmax probs */
float* __restrict__ out_scores, /* [batch * 5] exposure scores */
const float* __restrict__ probs, /* [batch * 63] softmax probs */
float* __restrict__ out_scores, /* [batch * 7] exposure scores */
int batch
) {
int b = blockIdx.x * blockDim.x + threadIdx.x;
if (b >= batch) return;
/* For each of 5 exposure buckets, sum over 9 order/urgency combos.
* probs layout: [batch, 45] where 45 = 5 exposure * 9 (3 order * 3 urgency)
/* For each of 7 exposure buckets, sum over 9 order/urgency combos.
* probs layout: [batch, 63] where 63 = 7 exposure * 9 (3 order * 3 urgency)
* bucket e covers indices [e*9 .. e*9+8] within each batch row. */
int base = b * 45;
for (int e = 0; e < 5; ++e) {
float sum = bf16_zero();
int base = b * 63;
for (int e = 0; e < 7; ++e) {
float sum = 0.0f;
int offset = base + e * 9;
for (int j = 0; j < 9; ++j) {
sum = sum + probs[offset + j];
}
out_scores[b * 5 + e] = sum;
out_scores[b * 7 + e] = sum;
}
}
extern "C" __global__ void signal_to_action_scores_kernel(
const float* __restrict__ predictions, /* [batch] scalar signals */
float* __restrict__ out_scores, /* [batch * 5] one-hot scores */
float* __restrict__ out_scores, /* [batch * 7] one-hot scores */
float high_threshold_bps,
float low_threshold_bps,
int batch
@@ -56,29 +56,26 @@ extern "C" __global__ void signal_to_action_scores_kernel(
if (b >= batch) return;
float pred = predictions[b];
float high_bf = bf16(high_threshold_bps);
float low_bf = bf16(low_threshold_bps);
float neg_high = bf16(-high_threshold_bps);
float neg_low = bf16(-low_threshold_bps);
int out_base = b * 5;
int out_base = b * 7;
/* Zero all 5 scores, then set the matching bucket to 1.0 */
out_scores[out_base + 0] = bf16_zero();
out_scores[out_base + 1] = bf16_zero();
out_scores[out_base + 2] = bf16_zero();
out_scores[out_base + 3] = bf16_zero();
out_scores[out_base + 4] = bf16_zero();
/* Zero all 7 scores, then set the matching bucket to 1.0.
* Exposure levels: 0=ShortSmall, 1=ShortHalf, 2=ShortFull,
* 3=Flat,
* 4=LongSmall, 5=LongHalf, 6=LongFull */
for (int i = 0; i < 7; ++i) {
out_scores[out_base + i] = 0.0f;
}
if (pred < neg_high) {
out_scores[out_base + 0] = bf16_one(); /* Short100 */
} else if (pred < neg_low) {
out_scores[out_base + 1] = bf16_one(); /* Short50 */
} else if (pred > high_bf) {
out_scores[out_base + 4] = bf16_one(); /* Long100 */
} else if (pred > low_bf) {
out_scores[out_base + 3] = bf16_one(); /* Long50 */
if (pred < -high_threshold_bps) {
out_scores[out_base + 2] = 1.0f; /* ShortFull */
} else if (pred < -low_threshold_bps) {
out_scores[out_base + 1] = 1.0f; /* ShortHalf */
} else if (pred > high_threshold_bps) {
out_scores[out_base + 6] = 1.0f; /* LongFull */
} else if (pred > low_threshold_bps) {
out_scores[out_base + 5] = 1.0f; /* LongHalf */
} else {
out_scores[out_base + 2] = bf16_one(); /* Flat */
out_scores[out_base + 3] = 1.0f; /* Flat */
}
}

View File

@@ -202,7 +202,7 @@ mod tests {
fn test_config() -> PPOConfig {
PPOConfig {
state_dim: 64,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![64, 64],
value_hidden_dims: vec![64, 64],
..Default::default()

View File

@@ -841,7 +841,7 @@ impl HyperparameterOptimizable for PPOTrainer {
// Create PPO config with trial hyperparameters
let ppo_config = PPOConfig {
state_dim: 48, // 42 market + 3 portfolio = 45, aligned to 48 for tensor cores
num_actions: 45, // 5 exposure × 3 order × 3 urgency (FactoredAction)
num_actions: 63, // 5 exposure × 3 order × 3 urgency (FactoredAction)
policy_hidden_dims: {
let base = params.hidden_dim_base;
vec![base, base / 2]

View File

@@ -236,7 +236,7 @@ mod tests {
fn test_unified_ppo_creation() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
policy_learning_rate: 3e-4,

View File

@@ -144,7 +144,7 @@ impl From<PpoHyperparameters> for PPOConfig {
PPOConfig {
state_dim: 48, // 42 market + 3 portfolio = 45, aligned to 48 for tensor cores
num_actions: 45, // 5×3×3 factored action space (size × order type × duration)
num_actions: 63, // 5×3×3 factored action space (size × order type × duration)
// Policy: [base, base/2]
policy_hidden_dims: vec![align(base), align(base / 2)],
// Value: [4*base, 3*base, 2*base, base, base/2]
@@ -880,10 +880,10 @@ impl PpoTrainer {
let entropy = if actions.is_empty() {
0.0_f32
} else {
let mut counts = [0_u32; 45];
let mut counts = [0_u32; 63];
for &a in &actions {
let idx = a.clamp(0, 44) as usize;
if idx < 45 {
let idx = a.clamp(0, 62) as usize;
if idx < 63 {
if let Some(c) = counts.get_mut(idx) {
*c += 1;
}
@@ -1189,8 +1189,8 @@ mod tests {
let trainer = PpoTrainer::new(params, 64, "/tmp/ppo_checkpoints", true, None).unwrap();
let hold = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let sell = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let buy = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let sell = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
// Test 1: Long position with positive return should be profitable
let reward_long_up = trainer.compute_reward_pnl(&hold, 0.01, 1); // Hold with long position, market up

View File

@@ -456,7 +456,7 @@ mod tests {
fn make_mlp_config() -> PPOConfig {
PPOConfig {
state_dim: 10,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![16, 8],
value_hidden_dims: vec![16, 8],
batch_size: 4,

View File

@@ -75,82 +75,72 @@
//! Smoke tests for action masking functionality (Wave 9 Agent 2)
//!
//! Validates position limit enforcement via action masking.
//! With branching DQN, the mask has 5 elements (one per exposure level):
//! [Short100, Short50, Flat, Long50, Long100]
//! With 7-level ExposureLevel, the mask has 7 elements:
//! [ShortSmall, ShortHalf, ShortFull, Flat, LongSmall, LongHalf, LongFull]
use ml::dqn::action_space::{get_valid_action_mask, ExposureLevel, FactoredAction};
#[test]
fn test_action_masking_at_neutral_position() {
// At position 0.0 with max_position=2.0, all 5 exposure levels valid
// At position 0.0 with max_position=2.0, all 7 exposure levels valid
let mask = get_valid_action_mask(0.0, 2.0);
assert_eq!(mask.len(), 5, "Mask should have 5 elements (one per exposure level)");
assert_eq!(mask.len(), 7, "Mask should have 7 elements (one per exposure level)");
assert_eq!(
mask.iter().filter(|&&v| v).count(),
5,
7,
"All exposure levels should be valid at position 0.0 with max_position=2.0"
);
}
#[test]
fn test_action_masking_very_restrictive_limit() {
// With max_position=0.6, only Flat and ±50% exposures should be valid
// With max_position=0.6, ShortFull and LongFull should be masked (|1.0| > 0.6)
let mask = get_valid_action_mask(0.0, 0.6);
let valid_count = mask.iter().filter(|&&v| v).count();
assert_eq!(
valid_count, 3,
"Only Flat, Short50, and Long50 should be valid (3/5 exposures)"
valid_count, 5,
"ShortSmall, ShortHalf, Flat, LongSmall, LongHalf valid (5/7 exposures)"
);
// Short100 (index 0) invalid: |exposure|=1.0 > 0.6
assert!(!mask[0], "Short100 should be INVALID (exposure=-1.0 > 0.6)");
// Short50 (index 1) valid: |exposure|=0.5 <= 0.6
assert!(mask[1], "Short50 should be VALID (exposure=-0.5 <= 0.6)");
// Flat (index 2) valid: |exposure|=0.0 <= 0.6
assert!(mask[2], "Flat should be VALID (exposure=0.0 <= 0.6)");
// Long50 (index 3) valid: |exposure|=0.5 <= 0.6
assert!(mask[3], "Long50 should be VALID (exposure=+0.5 <= 0.6)");
// Long100 (index 4) invalid: |exposure|=1.0 > 0.6
assert!(!mask[4], "Long100 should be INVALID (exposure=+1.0 > 0.6)");
assert!(mask[0], "ShortSmall (0.25) should be VALID");
assert!(mask[1], "ShortHalf (0.50) should be VALID");
assert!(!mask[2], "ShortFull (1.0) should be INVALID");
assert!(mask[3], "Flat (0.0) should be VALID");
assert!(mask[4], "LongSmall (0.25) should be VALID");
assert!(mask[5], "LongHalf (0.50) should be VALID");
assert!(!mask[6], "LongFull (1.0) should be INVALID");
}
#[test]
fn test_action_masking_preserves_all_action_variants() {
// With masking at max_position=1.0, all 5 exposure levels valid
// With masking at max_position=1.0, all 7 exposure levels valid
let mask = get_valid_action_mask(0.0, 1.0);
assert_eq!(mask.len(), 5);
assert!(mask[0], "Short100 should be valid");
assert!(mask[1], "Short50 should be valid");
assert!(mask[2], "Flat should be valid");
assert!(mask[3], "Long50 should be valid");
assert!(mask[4], "Long100 should be valid");
assert_eq!(mask.len(), 7);
assert!(mask.iter().all(|&v| v), "All 7 exposures should be valid at max_position=1.0");
}
#[test]
fn test_action_masking_index_mapping_correctness() {
let mask = get_valid_action_mask(0.0, 0.6);
assert_eq!(mask.len(), 5);
assert_eq!(mask.len(), 7);
// Exposure order: Short100, Short50, Flat, Long50, Long100
assert!(!mask[0], "Short100 (idx 0) should be masked");
assert!(mask[1], "Short50 (idx 1) should be valid");
assert!(mask[2], "Flat (idx 2) should be valid");
assert!(mask[3], "Long50 (idx 3) should be valid");
assert!(!mask[4], "Long100 (idx 4) should be masked");
// ShortFull (idx 2) and LongFull (idx 6) should be masked
assert!(mask[0], "ShortSmall should be valid");
assert!(mask[1], "ShortHalf should be valid");
assert!(!mask[2], "ShortFull should be masked");
assert!(mask[3], "Flat should be valid");
assert!(mask[4], "LongSmall should be valid");
assert!(mask[5], "LongHalf should be valid");
assert!(!mask[6], "LongFull should be masked");
// Verify FactoredAction mapping still works for the 45-action space
// Verify FactoredAction mapping still works for the 63-action space
let action_0 = FactoredAction::from_index(0).unwrap();
assert_eq!(action_0.exposure, ExposureLevel::Short100);
assert_eq!(action_0.exposure, ExposureLevel::ShortSmall);
let action_18 = FactoredAction::from_index(18).unwrap();
assert_eq!(action_18.exposure, ExposureLevel::Flat);
let action_27 = FactoredAction::from_index(27).unwrap();
assert_eq!(action_27.exposure, ExposureLevel::Flat);
}
#[test]
@@ -158,12 +148,12 @@ fn test_action_masking_boundary_conditions() {
// At max_position=1.0, all exposure levels valid (max |exp| = 1.0 <= 1.0)
let mask = get_valid_action_mask(0.0, 1.0);
let valid_count = mask.iter().filter(|&&v| v).count();
assert_eq!(valid_count, 5, "All exposures valid when max |exposure| equals max_position");
assert_eq!(valid_count, 7, "All exposures valid when max |exposure| equals max_position");
// At max_position=0.99, Short100/Long100 masked (|1.0| > 0.99)
// At max_position=0.99, ShortFull/LongFull masked (|1.0| > 0.99)
let mask_below = get_valid_action_mask(0.0, 0.99);
let valid_below = mask_below.iter().filter(|&&v| v).count();
assert_eq!(valid_below, 3, "Only 3 exposures valid when max_position < 1.0");
assert_eq!(valid_below, 5, "Only 5 exposures valid when max_position < 1.0");
}
#[test]
@@ -172,7 +162,7 @@ fn test_action_masking_flat_always_valid() {
for max_pos in &[0.1, 0.5, 1.0, 2.0, 10.0] {
let mask = get_valid_action_mask(0.0, *max_pos);
assert!(
mask[2], // Flat is index 2 in the 5-element mask
mask[3], // Flat is index 3 in the 7-element mask
"Flat should always be valid (max_position={})",
max_pos
);
@@ -187,9 +177,11 @@ fn test_action_masking_edge_case_zero_max_position() {
let valid_count = mask.iter().filter(|&&v| v).count();
assert_eq!(valid_count, 1, "Only Flat should be valid when max_position=0.0");
assert!(!mask[0], "Short100 invalid at max_position=0.0");
assert!(!mask[1], "Short50 invalid at max_position=0.0");
assert!(mask[2], "Flat valid at max_position=0.0");
assert!(!mask[3], "Long50 invalid at max_position=0.0");
assert!(!mask[4], "Long100 invalid at max_position=0.0");
assert!(!mask[0], "ShortSmall invalid at max_position=0.0");
assert!(!mask[1], "ShortHalf invalid at max_position=0.0");
assert!(!mask[2], "ShortFull invalid at max_position=0.0");
assert!(mask[3], "Flat valid at max_position=0.0");
assert!(!mask[4], "LongSmall invalid at max_position=0.0");
assert!(!mask[5], "LongHalf invalid at max_position=0.0");
assert!(!mask[6], "LongFull invalid at max_position=0.0");
}

View File

@@ -95,12 +95,12 @@ use tracing::info;
/// Helper: Create a BUY action (Long100)
fn create_buy_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
}
/// Helper: Create a SELL action (Short100)
fn create_sell_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal)
}
/// Helper: Create a HOLD action (Flat)

View File

@@ -109,7 +109,7 @@ fn test_cash_reserve_zero_allows_full_trading() {
assert_eq!(tracker.current_position(), 0.0, "Initial position should be 0.0");
// Execute: Long 2.0 @ $5,000/contract (Long100 × max_position=2.0)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5000.0; // $5,000 per contract
let max_position = 2.0; // Target: 2.0 contracts
@@ -169,7 +169,7 @@ fn test_cash_reserve_10_percent_reasonable() {
assert_eq!(tracker.current_position(), 0.0);
// Execute: Long 2.0 @ $5,000/contract
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5000.0;
let max_position = 2.0;
@@ -221,7 +221,7 @@ fn test_high_cash_reserve_prevents_trading() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, cash_reserve);
// Execute: Long 2.0 @ $5,000/contract
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5000.0;
let max_position = 2.0;
@@ -275,7 +275,7 @@ fn test_position_reversals_maintain_solvency() {
let max_position = 2.0;
// Reversal 1: Long 2.0 @ $5,000
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, 5000.0, max_position);
let cash_after_long1 = tracker.cash_balance();
@@ -296,7 +296,7 @@ fn test_position_reversals_maintain_solvency() {
);
// Reversal 2: Close and reverse to Short -2.0 @ $5,100
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, new_price, max_position);
let cash_after_short = tracker.cash_balance();
@@ -371,7 +371,7 @@ fn test_short_positions_generate_cash() {
assert_eq!(initial_cash, 100_000.0);
// Execute: Short -2.0 @ $5,000/contract
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
let price = 5000.0;
let max_position = 2.0;
@@ -426,7 +426,7 @@ fn test_cash_reserve_boundary_zero_point_one() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, cash_reserve);
// Execute: Long 2.0 @ $5,000
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 2.0);
let position = tracker.current_position();
@@ -458,7 +458,7 @@ fn test_cash_reserve_boundary_fifty_percent() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, cash_reserve);
// Execute: Long 2.0 @ $5,000
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 2.0);
// NOTE: Current implementation does NOT enforce reserve constraint

View File

@@ -184,7 +184,7 @@ fn test_unrealized_pnl_with_current_price() {
// Open a long position at $5000
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0); // 10 contracts at $5000
// Verify position opened
@@ -221,7 +221,7 @@ fn test_portfolio_tracker_comprehensive_integration() {
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
// Test 1: Open long position
let long_action = FactoredAction::new(ExposureLevel::Long50, OrderType::LimitMaker, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::LimitMaker, Urgency::Normal);
tracker.execute_action(long_action, 5000.0, 10.0); // 5 contracts (50% of 10 max)
assert_eq!(tracker.current_position(), 5.0);

View File

@@ -83,7 +83,7 @@ mod cash_accounting_tests {
let initial_cash = tracker.cash_balance();
// Buy 1 contract at $5,600 (go from 0 to +1 position)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5600.0, 1.0);
let final_cash = tracker.cash_balance();
@@ -107,7 +107,7 @@ mod cash_accounting_tests {
let initial_cash = tracker.cash_balance();
// Sell 1 contract at $5,600 (go from 0 to -1 position)
let action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5600.0, 1.0);
let final_cash = tracker.cash_balance();
@@ -130,7 +130,7 @@ mod cash_accounting_tests {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// First, buy 1 contract
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5600.0, 1.0);
let cash_after_buy = tracker.cash_balance();
@@ -155,7 +155,7 @@ mod cash_accounting_tests {
// Execute 10 round-trip trades at same price
for _ in 0..10 {
// Buy
let buy = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy, 5600.0, 1.0);
// Sell

View File

@@ -112,7 +112,7 @@ fn test_no_reserve_baseline() {
// Action: BUY Long100 at $5000 with max_position=10 contracts (use smaller max for simpler version)
// Target: 10 contracts (Long100 = 1.0 × 10)
// Cost: 10 contracts × $5000 = $50,000
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Expected: Trade executes (0% reserve = no constraint)
@@ -135,7 +135,7 @@ fn test_conservative_reserve_buy_accepted() {
// Action: BUY Long50 at $5000 (0.5 contracts clamped to 0.5)
// Cost: 5.0 × $5000 = $25,000
// Cash after: $100K - $25K = $75K (> $3.75K reserve) ✅
let action = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Expected: Trade executes
@@ -157,7 +157,7 @@ fn test_standard_reserve_buy_accepted() {
// Action: BUY Long50 at $5000 (0.5 × 10 = 5.0 position)
// Cost: 5.0 × $5000 = $25,000
// Cash after: $100K - $25K = $75K (> $7.5K reserve) ✅
let action = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Expected: Trade executes
@@ -181,12 +181,12 @@ fn test_standard_reserve_buy_rejected() {
// Long100 (1.0) × max_position=10 = 10.0 position
// Cost: 10.0 × $5000 = $50,000
// Cash after: $100K - $50K = $50K (portfolio value ~$50K, reserve ~$5K)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Step 2: Attempt second buy which should violate reserve
// This would cost another $50K, leaving cash at $0 (< $5K reserve) ❌
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Expected: Trade REJECTED or REDUCED (cash should not go below reserve)
@@ -217,12 +217,12 @@ fn test_aggressive_reserve_buy_rejected() {
// Step 1: Execute first buy
// Cost: 10.0 × $5000 = $50,000
// Cash after: $50K (portfolio value ~$50K, reserve ~$7.5K)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
// Step 2: Attempt second buy
// This would cost another $50K, leaving cash at $0 (< $7.5K reserve) ❌
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
let cash_after = tracker.cash_balance();
@@ -246,14 +246,14 @@ fn test_reserve_sell_always_allowed() {
let mut tracker = create_tracker_with_reserve(10.0);
// Step 1: Build a long position
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5000.0, 10.0);
let cash_before_sell = tracker.cash_balance();
let position_before_sell = tracker.current_position();
// Step 2: Execute SELL (should always work, regardless of cash level)
let sell_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let sell_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(sell_action, 5000.0, 10.0);
// Expected: SELL executes (position changes, cash increases)
@@ -283,7 +283,7 @@ fn test_reserve_dynamic_portfolio_growth() {
// Initial: $100K cash, reserve = $10K
// Step 1: BUY Long50 at $5000
let action = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
let pv_after_buy = tracker.total_value(5000.0);
@@ -313,7 +313,7 @@ fn test_reserve_dynamic_portfolio_shrinkage() {
let mut tracker = create_tracker_with_reserve(10.0);
// Step 1: BUY Long100 at $5000
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5000.0, 10.0);
let pv_at_5k = tracker.total_value(5000.0);
@@ -344,7 +344,7 @@ fn test_reserve_with_transaction_costs() {
// Action: BUY with Market order (higher fee 0.15% vs LimitMaker 0.05%)
// Cost: 10.0 × $5000 = $50,000
let market_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let market_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(market_action, 5000.0, 10.0);
let cash_after_market = tracker.cash_balance();
@@ -378,14 +378,14 @@ fn test_reserve_edge_case_exact_boundary() {
// Drain cash to just above reserve
// First buy: $50K cost, leaves $50K cash
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
let portfolio_value = tracker.total_value(5000.0);
let reserve_required = portfolio_value * 0.10;
// Attempt second trade (would drain $50K → $0, violating reserve)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5000.0, 10.0);
let cash_final = tracker.cash_balance();

View File

@@ -98,7 +98,7 @@ fn test_es_multiplier_50_per_point() {
let max_position = 10.0;
// Buy 1 contract (Long100 with MAX_POSITION_CONTRACTS=1.0 limit)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
@@ -126,7 +126,7 @@ fn test_nq_multiplier_20_per_point() {
let price = 18000.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
@@ -154,7 +154,7 @@ fn test_zn_multiplier_1000_per_point() {
let price = 110.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
@@ -183,7 +183,7 @@ fn test_es_round_trip_with_multiplier() {
let max_position = 10.0;
// Buy 1 contract @ 5600
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, buy_price, max_position);
// Sell 1 contract @ 5650 (50 point profit)
@@ -221,7 +221,7 @@ fn test_cash_accounting_with_multiplier() {
let initial_cash = tracker.cash_balance();
// Buy 1 contract @ 5600
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let final_cash = tracker.cash_balance();
@@ -250,7 +250,7 @@ fn test_no_multiplier_default() {
let price = 5600.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
@@ -281,7 +281,7 @@ fn test_multiple_contracts_with_multiplier() {
let price = 5600.0;
let max_position = 10.0; // Request 10 contracts
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
@@ -305,7 +305,7 @@ fn test_short_position_with_multiplier() {
let max_position = 10.0;
// Short 1 contract @ 5600
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, short_price, max_position);
// Cover @ 5550 (50 point profit)
@@ -341,7 +341,7 @@ fn test_unrealized_pnl_with_multiplier() {
let max_position = 10.0;
// Buy 1 contract @ 5600
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, entry_price, max_position);
// Calculate unrealized P&L at 5650

View File

@@ -92,7 +92,7 @@ fn test_position_delta_sign_when_buying() {
// Execute: Go from FLAT (0) to LONG (1.0 contract)
let action = FactoredAction::new(
ExposureLevel::Long100, // target_exposure = +1.0
ExposureLevel::LongFull, // target_exposure = +1.0
OrderType::Market,
Urgency::Normal
);
@@ -164,7 +164,7 @@ fn test_position_delta_sign_when_selling() {
info!("TEST 2: GOING SHORT — Initial: FLAT (0 contracts) to Short100 (-1.0 contracts)");
let action = FactoredAction::new(
ExposureLevel::Short100, // target_exposure = -1.0
ExposureLevel::ShortFull, // target_exposure = -1.0
OrderType::Market,
Urgency::Normal
);

View File

@@ -331,7 +331,7 @@ fn test_action_mapping_correctness() {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.exposure,
ExposureLevel::Short100,
ExposureLevel::ShortSmall,
"Action {} should have Short100 exposure",
idx
);
@@ -342,7 +342,7 @@ fn test_action_mapping_correctness() {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.exposure,
ExposureLevel::Short50,
ExposureLevel::ShortFull,
"Action {} should have Short50 exposure",
idx
);
@@ -364,7 +364,7 @@ fn test_action_mapping_correctness() {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.exposure,
ExposureLevel::Long50,
ExposureLevel::LongSmall,
"Action {} should have Long50 exposure",
idx
);
@@ -375,7 +375,7 @@ fn test_action_mapping_correctness() {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(
action.exposure,
ExposureLevel::Long100,
ExposureLevel::LongFull,
"Action {} should have Long100 exposure",
idx
);

View File

@@ -99,11 +99,13 @@ fn test_all_45_actions_sign_convention() {
// Expected exposure values for each ExposureLevel
let expected_exposures = [
(ExposureLevel::Long100, 1.0, "Long100"),
(ExposureLevel::Long50, 0.5, "Long50"),
(ExposureLevel::LongFull, 1.0, "LongFull"),
(ExposureLevel::LongHalf, 0.5, "LongHalf"),
(ExposureLevel::LongSmall, 0.25, "LongSmall"),
(ExposureLevel::Flat, 0.0, "Flat"),
(ExposureLevel::Short50, -0.5, "Short50"),
(ExposureLevel::Short100, -1.0, "Short100"),
(ExposureLevel::ShortSmall, -0.25, "ShortSmall"),
(ExposureLevel::ShortHalf, -0.5, "ShortHalf"),
(ExposureLevel::ShortFull, -1.0, "ShortFull"),
];
let order_types = [
@@ -326,18 +328,18 @@ fn test_position_transitions() {
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
// Transition 1: Flat → Long100
let action1 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action1 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action1, price as f32, max_position as f32);
let pos1 = portfolio.current_position() as f64;
info!(pos1, "Flat to Long100 position");
assert_eq!(pos1, 10.0, "Should be at +10.0 after Long100");
// Transition 2: Long100 → Short100
let action2 = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
// Transition 2: LongFull → ShortFull
let action2 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action2, price as f32, max_position as f32);
let pos2 = portfolio.current_position() as f64;
info!(pos2, "Long100 to Short100 position");
assert_eq!(pos2, -10.0, "Should be at -10.0 after Short100");
info!(pos2, "LongFull to ShortFull position");
assert_eq!(pos2, -10.0, "Should be at -10.0 after ShortFull");
// Transition 3: Short100 → Flat
let action3 = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
@@ -346,19 +348,19 @@ fn test_position_transitions() {
info!(pos3, "Short100 to Flat position");
assert_eq!(pos3, 0.0, "Should be at 0.0 after Flat");
// Transition 4: Flat → Short50
let action4 = FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal);
// Transition 4: Flat → ShortFull
let action4 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action4, price as f32, max_position as f32);
let pos4 = portfolio.current_position() as f64;
info!(pos4, "Flat to Short50 position");
assert_eq!(pos4, -5.0, "Should be at -5.0 after Short50");
info!(pos4, "Flat to ShortFull position");
assert_eq!(pos4, -10.0, "Should be at -10.0 after ShortFull");
// Transition 5: Short50 → Long50
let action5 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
// Transition 5: ShortFull → LongHalf
let action5 = FactoredAction::new(ExposureLevel::LongHalf, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action5, price as f32, max_position as f32);
let pos5 = portfolio.current_position() as f64;
info!(pos5, "Short50 to Long50 position");
assert_eq!(pos5, 5.0, "Should be at +5.0 after Long50");
info!(pos5, "ShortFull to LongHalf position");
assert_eq!(pos5, 5.0, "Should be at +5.0 after LongHalf");
info!("All position transitions validated successfully");
}

View File

@@ -151,7 +151,7 @@ fn test_network_output_dtype() -> Result<(), MLError> {
let config = DistributionalDuelingConfig {
state_dim: 54,
num_actions: 45,
num_actions: 63,
num_atoms: 51,
shared_hidden_dims: vec![128, 64],
value_hidden_dim: 64,

View File

@@ -101,7 +101,7 @@ fn test_network_outputs_f32_naturally() -> Result<(), MLError> {
// Create network config matching production
let config = DistributionalDuelingConfig {
state_dim: 54,
num_actions: 45,
num_actions: 63,
num_atoms: 51,
shared_hidden_dims: vec![256, 128],
value_hidden_dim: 128,
@@ -174,7 +174,7 @@ fn test_if_dtype_conversion_line_1087_is_necessary() -> Result<(), MLError> {
// Recreate exact code path from dqn.rs lines 1079-1087
let config = DistributionalDuelingConfig {
state_dim: 54,
num_actions: 45,
num_actions: 63,
num_atoms: 51,
shared_hidden_dims: vec![256, 128],
value_hidden_dim: 128,

View File

@@ -260,7 +260,7 @@ fn test_kelly_scales_position_size() {
// Test 1: Kelly = 0.5 (half Kelly)
let mut tracker_half = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
let long_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
@@ -374,7 +374,7 @@ fn test_kelly_applied_in_backtest() {
for &price in &price_sequence {
let long_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
@@ -389,7 +389,7 @@ fn test_kelly_applied_in_backtest() {
for &price in &price_sequence {
let long_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);

View File

@@ -109,7 +109,7 @@ fn test_pnl_calculation_realistic_sizing() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Long100 (full long position)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be ~1.724 units (NOT 1.0 or 0.0345)
@@ -147,7 +147,7 @@ fn test_pnl_range_realistic() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Go long at $5,800
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price_entry, max_position);
// AND: Price increases 10% to $6,380
@@ -186,7 +186,7 @@ fn test_old_bug_wrong_pnl() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Go long at $5,800 with 1.0 unit
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price_entry, max_position_wrong);
// AND: Price increases 10% to $6,380
@@ -244,7 +244,7 @@ fn test_short_position_sizing() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Short100 (full short position)
let action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be -1.724 units (negative for short)
@@ -275,7 +275,7 @@ fn test_trainer_initial_capital() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Long100 at $5,800
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be ~17.24 units
@@ -312,7 +312,7 @@ fn test_last_price_updated() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// WHEN: Execute action at $5,800
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5_800.0;
let max_position = 100_000.0 / price;
tracker.execute_action(action, price, max_position);
@@ -350,15 +350,15 @@ fn test_epoch_pnl_range() {
// Simulate 10 trades across price range
let actions = vec![
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
];

View File

@@ -207,21 +207,21 @@ fn test_hyperopt_search_space_reduced() {
fn test_factored_action_transaction_costs() {
// Market order (most expensive)
let market_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
// Limit maker order (cheapest)
let limit_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Patient,
);
// IoC order (middle)
let ioc_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::IoC,
Urgency::Normal,
);

View File

@@ -105,7 +105,7 @@ async fn test_order_type_transaction_costs() {
// Market order: 0.15% (highest cost)
let market_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
@@ -117,7 +117,7 @@ async fn test_order_type_transaction_costs() {
// LimitMaker order: 0.05% (lowest cost)
let limit_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Patient,
);
@@ -128,7 +128,7 @@ async fn test_order_type_transaction_costs() {
);
// IoC order: 0.10% (medium cost)
let ioc_action = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Normal);
let ioc_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Normal);
let ioc_cost = ioc_action.calculate_transaction_cost(trade_value);
assert_eq!(ioc_cost, 10.0, "IoC order should cost $10 (0.10% of $10k)");
}
@@ -140,11 +140,11 @@ async fn test_market_costs_twice_limitmaker() {
let trade_value = 5_000.0;
let market_action =
FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
let market_cost = market_action.calculate_transaction_cost(trade_value);
let limit_action = FactoredAction::new(
ExposureLevel::Long50,
ExposureLevel::LongSmall,
OrderType::LimitMaker,
Urgency::Normal,
);
@@ -182,12 +182,12 @@ fn test_exposure_scaling_transaction_costs() {
let position_size = 1.0;
// Short100 (-100% exposure)
let short100 = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short100 = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
let short100_value = entry_price * position_size * short100.target_exposure().abs();
let short100_cost = short100.calculate_transaction_cost(short100_value);
// Short50 (-50% exposure)
let short50 = FactoredAction::new(ExposureLevel::Short50, OrderType::Market, Urgency::Normal);
let short50 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
let short50_value = entry_price * position_size * short50.target_exposure().abs();
let short50_cost = short50.calculate_transaction_cost(short50_value);
@@ -197,12 +197,12 @@ fn test_exposure_scaling_transaction_costs() {
let flat_cost = flat.calculate_transaction_cost(flat_value);
// Long50 (+50% exposure)
let long50 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let long50 = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
let long50_value = entry_price * position_size * long50.target_exposure().abs();
let long50_cost = long50.calculate_transaction_cost(long50_value);
// Long100 (+100% exposure)
let long100 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long100 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let long100_value = entry_price * position_size * long100.target_exposure().abs();
let long100_cost = long100.calculate_transaction_cost(long100_value);
@@ -238,10 +238,10 @@ fn test_urgency_does_not_affect_transaction_costs() {
let trade_value = 8_000.0;
let patient = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Patient);
let normal = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let patient = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Patient);
let normal = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let aggressive = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive,
);
@@ -267,7 +267,7 @@ fn test_all_45_actions_have_valid_transaction_costs() {
let trade_value = 10_000.0;
for action_idx in 0..45 {
for action_idx in 0..63 {
let action = FactoredAction::from_index(action_idx)
.expect(&format!("Action index {} should be valid", action_idx));
@@ -346,7 +346,7 @@ fn test_cost_calculation_matches_documentation() {
let trade_value = 100_000.0; // $100k trade
let market_action =
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
assert_eq!(
market_action.calculate_transaction_cost(trade_value),
150.0,
@@ -354,7 +354,7 @@ fn test_cost_calculation_matches_documentation() {
);
let limit_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::LimitMaker,
Urgency::Normal,
);
@@ -364,7 +364,7 @@ fn test_cost_calculation_matches_documentation() {
"LimitMaker: 0.05% of $100k = $50"
);
let ioc_action = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Normal);
let ioc_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Normal);
assert_eq!(
ioc_action.calculate_transaction_cost(trade_value),
100.0,
@@ -379,7 +379,7 @@ fn test_transaction_cost_precision() {
let small_trade = 100.0; // $100 trade
let market_action =
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let cost = market_action.calculate_transaction_cost(small_trade);
// 0.15% of $100 = $0.15

View File

@@ -94,7 +94,7 @@ use ml::tft::TFTConfig;
fn small_dqn_config() -> DQNConfig {
DQNConfig {
state_dim: 51,
num_actions: 45,
num_actions: 63,
hidden_dims: vec![64, 64],
..Default::default()
}
@@ -104,7 +104,7 @@ fn small_dqn_config() -> DQNConfig {
fn small_ppo_config() -> PPOConfig {
PPOConfig {
state_dim: 64,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![64, 64],
value_hidden_dims: vec![64, 64],
..Default::default()

View File

@@ -120,14 +120,14 @@ fn test_full_reversal_sufficient_cash() {
let price = 5_600.0; // ES futures typical price
// Step 1: Open short position (position = -1.0 contract)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let position_after_short = tracker.current_position();
assert_eq!(position_after_short, -1.0, "Should have -1.0 short position");
// Step 2: Execute Long100 reversal with sufficient cash
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
@@ -156,13 +156,13 @@ fn test_short_to_flat_only() {
let mut tracker = PortfolioTracker::new(starting_cash, 0.0001, 0.0);
// Open short position first
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
assert_eq!(tracker.current_position(), -1.0, "Should have -1.0 short position");
// Now attempt Long100 reversal - should partially fill to Flat only
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// This test validates that with limited cash, the reversal stops at Flat (0.0)
// The current implementation may reject entirely - we'll see what happens
@@ -191,7 +191,7 @@ fn test_short_to_partial_long() {
let price = 5_600.0;
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
assert_eq!(tracker.current_position(), -1.0, "Should have -1.0 short position");
@@ -206,7 +206,7 @@ fn test_short_to_partial_long() {
// With partial reversal support, if we have cash for Phase 1 (close short) + partial Phase 2,
// we should end up with a position between 0.0 and +1.0 (e.g., +0.3)
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
@@ -227,13 +227,13 @@ fn test_long_to_flat_only() {
let price = 5_600.0;
// Open long position
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
assert_eq!(tracker.current_position(), 1.0, "Should have +1.0 long position");
// Attempt Short100 reversal with limited cash
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let final_position = tracker.current_position();
@@ -255,13 +255,13 @@ fn test_long_to_partial_short() {
let price = 5_600.0;
// Open long position
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
assert_eq!(tracker.current_position(), 1.0, "Should have +1.0 long position");
// Attempt Short100 reversal
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let final_position = tracker.current_position();
@@ -283,7 +283,7 @@ fn test_zero_cash_reversal() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_after_short = tracker.cash_balance();
@@ -295,7 +295,7 @@ fn test_zero_cash_reversal() {
// If we had zero cash, attempting Long100 should reject entirely
// For now, we'll test with very low cash scenario
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
@@ -319,13 +319,13 @@ fn test_exact_phase1_boundary() {
let price = 5_600.0;
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
info!(cash = tracker.cash_balance(), position = tracker.current_position(), "Test 7: Cash after short");
// Attempt reversal
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
info!(cash = tracker.cash_balance(), position = tracker.current_position(), "Test 7: Final cash and position");
@@ -345,12 +345,12 @@ fn test_transaction_cost_verification() {
// Test with Market order
let mut tracker_market = PortfolioTracker::new(25_000.0, 0.0001, 0.0);
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker_market.execute_action(short_action, price, 1.0);
let cash_after_short = tracker_market.cash_balance();
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker_market.execute_action(long_action, price, 1.0);
let cash_after_reversal = tracker_market.cash_balance();
@@ -375,7 +375,7 @@ fn test_negative_cash_guard() {
let price = 5_600.0;
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
info!(cash = tracker.cash_balance(), "Test 9: Cash after short");
@@ -383,7 +383,7 @@ fn test_negative_cash_guard() {
// If cash were negative, any trade should be rejected
// Current implementation has this guard at line ~235 in portfolio_tracker.rs
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
info!(cash = tracker.cash_balance(), position = tracker.current_position(), "Test 9: Final cash and position");
@@ -407,7 +407,7 @@ fn test_maximum_partial_fill() {
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0);
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_after_short = tracker.cash_balance();
@@ -421,7 +421,7 @@ fn test_maximum_partial_fill() {
info!(remaining_cash_for_phase2, affordable_phase2_contracts, "Test 10: Phase 2 capacity");
// Attempt Long100 reversal
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
@@ -442,12 +442,12 @@ fn test_multiple_partial_reversals() {
let price = 5_600.0;
// Reversal 1: Flat → Short
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "Reversal 1: Short");
// Reversal 2: Short → Long
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "Reversal 2: Long");

View File

@@ -88,13 +88,13 @@ fn test_full_reversal_sufficient_cash() {
let price = 5_600.0;
// Step 1: Open short position (-1.0)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
assert_eq!(tracker.current_position(), -1.0, "Should have -1.0 short position");
// Step 2: Reverse to long (+1.0) with sufficient cash
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
assert_eq!(tracker.current_position(), 1.0, "Should complete full reversal to +1.0 long");
@@ -108,7 +108,7 @@ fn test_partial_reversal_limited_cash() {
let price = 5_600.0;
// Step 1: Open short position (-1.0)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_before_reversal = tracker.cash_balance();
@@ -116,7 +116,7 @@ fn test_partial_reversal_limited_cash() {
// Step 2: Attempt full reversal to long (+1.0)
// With limited cash, should achieve partial reversal
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
@@ -143,7 +143,7 @@ fn test_reversal_to_flat_only() {
let price = 5_600.0;
// Step 1: Open long position (+1.0)
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let cash_after_long = tracker.cash_balance();
@@ -151,7 +151,7 @@ fn test_reversal_to_flat_only() {
// Step 2: Attempt reversal to short (-1.0)
// With very limited cash, should only close long (Phase 1), not open short (Phase 2)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let final_position = tracker.current_position();
@@ -172,13 +172,13 @@ fn test_transaction_costs_tracked() {
let price = 5_600.0;
// Execute short → long reversal
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let tx_cost_after_short = tracker.transaction_costs();
assert!(tx_cost_after_short > 0.0, "Should have transaction costs from short");
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let tx_cost_after_reversal = tracker.transaction_costs();
@@ -204,7 +204,7 @@ fn test_negative_cash_guard() {
let price = 5_600.0;
// Try to open short with minimal capital
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
// Cash should never go negative
@@ -218,12 +218,12 @@ fn test_multiple_reversals() {
let price = 5_600.0;
// Reversal 1: Flat → Short
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "After Reversal 1 (Short)");
// Reversal 2: Short → Long
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "After Reversal 2 (Long)");
@@ -249,14 +249,14 @@ fn test_cash_reserve_enforcement() {
let price = 5_600.0;
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_before = tracker.cash_balance();
info!(cash_before, "Cash before reversal");
// Attempt reversal with reserve requirement
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let long_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();

View File

@@ -120,9 +120,9 @@ fn test_pnl_in_realistic_range() -> Result<()> {
for (i, &price) in price_sequence.iter().enumerate() {
let exposure = match i % 3 {
0 => ExposureLevel::Long100,
0 => ExposureLevel::LongFull,
1 => ExposureLevel::Flat,
2 => ExposureLevel::Short50,
2 => ExposureLevel::ShortFull,
_ => ExposureLevel::Flat,
};
@@ -171,7 +171,7 @@ fn test_pnl_return_percentage_reasonable() -> Result<()> {
for (i, &price) in price_sequence.iter().enumerate() {
let exposure = if i % 2 == 0 {
ExposureLevel::Long50
ExposureLevel::LongSmall
} else {
ExposureLevel::Flat
};
@@ -215,7 +215,7 @@ fn test_pnl_matches_position_changes() -> Result<()> {
// Open long position
tracker.execute_action(
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
entry_price,
position_size,
);
@@ -265,7 +265,7 @@ fn test_transaction_costs_reduce_pnl() -> Result<()> {
// Open position
tracker.execute_action(
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
entry_price,
position_size,
);
@@ -316,9 +316,9 @@ fn test_pnl_accumulation_correct() -> Result<()> {
for step in 0..10 {
let price = base_price + (step as f32 * 2.0);
let exposure = match step % 3 {
0 => ExposureLevel::Long50,
0 => ExposureLevel::LongSmall,
1 => ExposureLevel::Flat,
2 => ExposureLevel::Short50,
2 => ExposureLevel::ShortFull,
_ => ExposureLevel::Flat,
};
@@ -367,7 +367,7 @@ fn test_long_position_pnl_calculation() -> Result<()> {
// Open long position at $4500
tracker.execute_action(
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
4500.0,
100.0,
);
@@ -407,7 +407,7 @@ fn test_short_position_pnl_calculation() -> Result<()> {
// Open short position at $4500
tracker.execute_action(
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
4500.0,
100.0,
);
@@ -452,7 +452,7 @@ fn test_pnl_explosion_detector() -> Result<()> {
let preprocessed_z_score = -2.5; // This should NEVER be a price
tracker.execute_action(
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
preprocessed_z_score,
100.0,
);

View File

@@ -92,11 +92,11 @@ use tracing::info;
// Helper functions for consistent 3-action semantics in tests
fn buy_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
}
fn sell_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal)
}
fn hold_action() -> FactoredAction {

View File

@@ -169,7 +169,7 @@ fn test_reset_with_factored_actions() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Execute factored action (Long100 = full long exposure)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 100.0, 100.0); // max_position = 100 contracts
// Verify position opened

View File

@@ -128,12 +128,12 @@ use tracing::info;
/// Create a BUY action (Long100 + Market + Normal)
fn create_buy_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
}
/// Create a SELL action (Short100 + Market + Normal)
fn create_sell_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal)
}
/// Create a FLAT action (closes position)

View File

@@ -95,7 +95,7 @@ fn test_policy_network_45_output() -> Result<()> {
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 45, // 5x3x3 factored action space
num_actions: 63, // 5x3x3 factored action space
state_dim: 54, // Wave 3 features (54->54)
..Default::default()
};
@@ -125,7 +125,7 @@ fn test_value_network_single_output() -> Result<()> {
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 45, // 5x3x3 factored action space
num_actions: 63, // 5x3x3 factored action space
state_dim: 54, // Wave 3 features (54->54)
..Default::default()
};
@@ -155,7 +155,7 @@ fn test_policy_network_softmax() -> Result<()> {
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 45,
num_actions: 63,
state_dim: 54,
..Default::default()
};
@@ -208,7 +208,7 @@ fn test_network_forward_pass() -> Result<()> {
// Create PPO with 45 actions
let config = PPOConfig {
num_actions: 45,
num_actions: 63,
state_dim: 54,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![256, 128, 64],
@@ -354,7 +354,7 @@ fn test_hyperopt_adapter_default_45_actions() -> Result<()> {
let config = PPOConfig {
state_dim: 54,
num_actions: 45, // This is what hyperopt adapter should use
num_actions: 63, // This is what hyperopt adapter should use
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![512, 384, 256, 128, 64],
policy_learning_rate: params.policy_learning_rate,

View File

@@ -86,53 +86,53 @@ fn test_factored_action_index_mapping() {
// Test boundary cases
let action_0 = FactoredAction::from_index(0).unwrap();
assert_eq!(action_0.exposure, ExposureLevel::Short100);
assert_eq!(action_0.exposure, ExposureLevel::ShortSmall);
assert_eq!(action_0.order, OrderType::Market);
assert_eq!(action_0.urgency, Urgency::Patient);
assert_eq!(action_0.to_index(), 0);
let action_44 = FactoredAction::from_index(44).unwrap();
assert_eq!(action_44.exposure, ExposureLevel::Long100);
assert_eq!(action_44.order, OrderType::IoC);
assert_eq!(action_44.urgency, Urgency::Aggressive);
assert_eq!(action_44.to_index(), 44);
let action_62 = FactoredAction::from_index(62).unwrap();
assert_eq!(action_62.exposure, ExposureLevel::LongFull);
assert_eq!(action_62.order, OrderType::IoC);
assert_eq!(action_62.urgency, Urgency::Aggressive);
assert_eq!(action_62.to_index(), 62);
// Test middle case (Flat + Market + Normal)
// index = 2*9 + 0*3 + 1 = 19
let action_19 = FactoredAction::from_index(19).unwrap();
assert_eq!(action_19.exposure, ExposureLevel::Flat);
assert_eq!(action_19.order, OrderType::Market);
// index = 3*9 + 0*3 + 1 = 28
let action_28 = FactoredAction::from_index(28).unwrap();
assert_eq!(action_28.exposure, ExposureLevel::Flat);
assert_eq!(action_28.order, OrderType::Market);
assert_eq!(action_19.urgency, Urgency::Normal);
assert_eq!(action_19.to_index(), 19);
// Test round-trip for all 45 actions
for idx in 0..45 {
for idx in 0..63 {
let action = FactoredAction::from_index(idx).unwrap();
assert_eq!(action.to_index(), idx, "Round-trip failed for index {}", idx);
}
// Test out of bounds
assert!(FactoredAction::from_index(45).is_err());
assert!(FactoredAction::from_index(63).is_err());
assert!(FactoredAction::from_index(100).is_err());
}
#[test]
fn test_factored_action_exposure_levels() {
// Test all 5 exposure levels: -100%, -50%, 0%, +50%, +100%
// Test all 7 exposure levels
let short100 = FactoredAction::new(
ExposureLevel::Short100,
let short_small = FactoredAction::new(
ExposureLevel::ShortSmall,
OrderType::Market,
Urgency::Normal,
);
assert_eq!(short100.target_exposure(), -1.0);
assert_eq!(short_small.target_exposure(), -0.25);
let short50 = FactoredAction::new(
ExposureLevel::Short50,
let short_full = FactoredAction::new(
ExposureLevel::ShortFull,
OrderType::Market,
Urgency::Normal,
);
assert_eq!(short50.target_exposure(), -0.5);
assert_eq!(short_full.target_exposure(), -1.0);
let flat = FactoredAction::new(
ExposureLevel::Flat,
@@ -141,19 +141,19 @@ fn test_factored_action_exposure_levels() {
);
assert_eq!(flat.target_exposure(), 0.0);
let long50 = FactoredAction::new(
ExposureLevel::Long50,
let long_small = FactoredAction::new(
ExposureLevel::LongSmall,
OrderType::Market,
Urgency::Normal,
);
assert_eq!(long50.target_exposure(), 0.5);
assert_eq!(long_small.target_exposure(), 0.25);
let long100 = FactoredAction::new(
ExposureLevel::Long100,
let long_full = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
assert_eq!(long100.target_exposure(), 1.0);
assert_eq!(long_full.target_exposure(), 1.0);
}
#[test]
@@ -215,7 +215,7 @@ fn test_factored_action_to_position_delta() {
// Test Long100 from Flat position
let long100 = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Normal,
);
@@ -236,56 +236,55 @@ fn test_factored_action_to_position_delta() {
// delta = 0.0 - 1.0 = -1.0
assert_eq!(delta, -1.0);
// Test Short100 from Flat position
let short100 = FactoredAction::new(
ExposureLevel::Short100,
// Test ShortFull from Flat position
let short_full = FactoredAction::new(
ExposureLevel::ShortFull,
OrderType::Market,
Urgency::Normal,
);
let delta = short100.to_position_delta(0.0, 2.0);
let delta = short_full.to_position_delta(0.0, 2.0);
// target_exposure = -1.0, current = 0.0
// delta = (-1.0 - 0.0) * 2.0 = -2.0
// delta = (-1.0 * 2.0) - 0.0 = -2.0
assert_eq!(delta, -2.0);
// Test no change (already at target)
let long50 = FactoredAction::new(
ExposureLevel::Long50,
let long_small = FactoredAction::new(
ExposureLevel::LongSmall,
OrderType::Market,
Urgency::Normal,
);
let delta = long50.to_position_delta(1.0, 2.0);
// target_exposure = 0.5, current = 1.0
// current_exposure = 1.0 / 2.0 = 0.5
// delta = (0.5 - 0.5) * 2.0 = 0.0
let delta = long_small.to_position_delta(0.5, 2.0);
// target_exposure = 0.25, current = 0.5
// target_position = 0.25 * 2.0 = 0.5
// delta = 0.5 - 0.5 = 0.0
assert_eq!(delta, 0.0);
}
#[test]
fn test_factored_action_boundary_cases() {
// Test index 0: Short100 + Market + Patient
// Test index 0: ShortSmall + Market + Patient
let action_0 = FactoredAction::from_index(0).unwrap();
assert_eq!(action_0.exposure, ExposureLevel::Short100);
assert_eq!(action_0.exposure, ExposureLevel::ShortSmall);
assert_eq!(action_0.order, OrderType::Market);
assert_eq!(action_0.urgency, Urgency::Patient);
assert_eq!(action_0.target_exposure(), -1.0);
assert_eq!(action_0.target_exposure(), -0.25);
assert_eq!(action_0.transaction_cost(), 0.0015);
assert_eq!(action_0.urgency_weight(), 0.5);
// Test index 44: Long100 + IoC + Aggressive
let action_44 = FactoredAction::from_index(44).unwrap();
assert_eq!(action_44.exposure, ExposureLevel::Long100);
assert_eq!(action_44.order, OrderType::IoC);
assert_eq!(action_44.urgency, Urgency::Aggressive);
assert_eq!(action_44.target_exposure(), 1.0);
assert_eq!(action_44.transaction_cost(), 0.0010);
assert_eq!(action_44.urgency_weight(), 1.5);
// Test index 62: LongFull + IoC + Aggressive
let action_62 = FactoredAction::from_index(62).unwrap();
assert_eq!(action_62.exposure, ExposureLevel::LongFull);
assert_eq!(action_62.order, OrderType::IoC);
assert_eq!(action_62.urgency, Urgency::Aggressive);
assert_eq!(action_62.target_exposure(), 1.0);
assert_eq!(action_62.transaction_cost(), 0.0010);
assert_eq!(action_62.urgency_weight(), 1.5);
// Test specific known index: 22 = Flat + LimitMaker + Patient
// index = 2*9 + 1*3 + 0 = 18 + 3 + 0 = 21 (wait, let me recalculate)
// index = 2*9 + 1*3 + 0 = 18 + 3 = 21
let action_21 = FactoredAction::from_index(21).unwrap();
assert_eq!(action_21.exposure, ExposureLevel::Flat);
assert_eq!(action_21.order, OrderType::LimitMaker);
assert_eq!(action_21.urgency, Urgency::Patient);
// Test specific known index: Flat + LimitMaker + Patient
// index = 3*9 + 1*3 + 0 = 27 + 3 = 30
let action_30 = FactoredAction::from_index(30).unwrap();
assert_eq!(action_30.exposure, ExposureLevel::Flat);
assert_eq!(action_30.order, OrderType::LimitMaker);
assert_eq!(action_30.urgency, Urgency::Patient);
assert_eq!(action_21.to_index(), 21);
}

View File

@@ -115,7 +115,7 @@ fn test_ppo_training_with_lstm_disabled() {
// Test backward compatibility: standard MLP networks should work
let config = PPOConfig {
state_dim: 32,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![64, 32],
value_hidden_dims: vec![64, 32],
policy_learning_rate: 3e-4,
@@ -170,7 +170,7 @@ fn test_ppo_training_with_lstm_enabled() {
// NOTE: LSTM integration now complete via enum-based architecture
let config = PPOConfig {
state_dim: 32,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![64, 32],
value_hidden_dims: vec![64, 32],
policy_learning_rate: 3e-4,
@@ -227,7 +227,7 @@ fn test_lstm_network_initialization() {
// Test that LSTM networks are correctly initialized based on config
let lstm_config = PPOConfig {
state_dim: 32,
num_actions: 45,
num_actions: 63,
use_lstm: true,
lstm_hidden_dim: 128,
lstm_num_layers: 2,
@@ -236,7 +236,7 @@ fn test_lstm_network_initialization() {
let mlp_config = PPOConfig {
state_dim: 32,
num_actions: 45,
num_actions: 63,
use_lstm: false,
..PPOConfig::default()
};

View File

@@ -104,7 +104,7 @@ fn test_recurrent_ppo_single_episode() {
// Test that LSTM-enhanced PPO can train on a single episode
let config = PPOConfig {
state_dim: 32,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![64],
value_hidden_dims: vec![64],
use_lstm: true,
@@ -150,7 +150,7 @@ fn test_recurrent_ppo_single_episode() {
let mut trajectory = Trajectory::new();
for t in 0..10 {
let state = vec![t as f32; 32]; // Simple incrementing state
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let log_prob = -1.0;
let value = 5.0 + t as f32;
let reward = 1.0;
@@ -187,7 +187,7 @@ fn test_recurrent_ppo_hidden_state_continuity() {
// Test that hidden states persist and evolve across timesteps within an episode
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -232,7 +232,7 @@ fn test_recurrent_ppo_episode_boundaries() {
// Test that hidden states reset between episodes
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -253,7 +253,7 @@ fn test_recurrent_ppo_episode_boundaries() {
for t in 0..5 {
episode1.add_step(TrajectoryStep::new(
vec![1.0; 16],
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -265,7 +265,7 @@ fn test_recurrent_ppo_episode_boundaries() {
for t in 0..5 {
episode2.add_step(TrajectoryStep::new(
vec![2.0; 16],
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -317,7 +317,7 @@ fn test_recurrent_vs_feedforward_ppo() {
// Compare LSTM vs non-LSTM PPO training behavior
let base_config = PPOConfig {
state_dim: 16,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
batch_size: 16,
@@ -352,7 +352,7 @@ fn test_recurrent_vs_feedforward_ppo() {
for t in 0..10 {
trajectory.add_step(TrajectoryStep::new(
vec![t as f32; 16],
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -396,7 +396,7 @@ fn test_recurrent_ppo_checkpointing() {
// to enable loading LSTM checkpoints from safetensors files
let config = PPOConfig {
state_dim: 16,
num_actions: 45,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -417,7 +417,7 @@ fn test_recurrent_ppo_checkpointing() {
for t in 0..10 {
trajectory.add_step(TrajectoryStep::new(
vec![t as f32; 16],
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,

View File

@@ -179,7 +179,7 @@ fn test_episode_boundary_state_values() {
for i in 0..5 {
episode1.add_step(TrajectoryStep {
state: vec![100.0 + i as f32; 32], // States: 100, 101, 102, 103, 104
action: FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
action: FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
log_prob: -1.0,
value: 0.5,
reward: 1.0,
@@ -191,7 +191,7 @@ fn test_episode_boundary_state_values() {
for i in 0..7 {
episode2.add_step(TrajectoryStep {
state: vec![200.0 + i as f32; 32], // States: 200, 201, 202, 203, 204, 205, 206
action: FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal),
action: FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
log_prob: -1.0,
value: 0.5,
reward: 1.0,
@@ -289,7 +289,7 @@ fn test_action_continuity_within_sequences() {
for i in 0..5 {
episode1.add_step(TrajectoryStep {
state: vec![0.0; 32],
action: FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal), // All Buy
action: FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal), // All Buy
log_prob: -1.0,
value: 0.5,
reward: 1.0,
@@ -301,7 +301,7 @@ fn test_action_continuity_within_sequences() {
for i in 0..7 {
episode2.add_step(TrajectoryStep {
state: vec![0.0; 32],
action: FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal), // All Sell
action: FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal), // All Sell
log_prob: -1.0,
value: 0.5,
reward: 1.0,

View File

@@ -113,7 +113,7 @@ fn test_all_prices_positive() -> Result<()> {
// Execute action with positive price
let action = FactoredAction::new(
ExposureLevel::Long50,
ExposureLevel::LongSmall,
OrderType::Market,
Urgency::Normal,
);
@@ -213,7 +213,7 @@ fn test_no_preprocessed_prices_in_pnl() -> Result<()> {
// Execute action
let action = if i % 2 == 0 {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
} else {
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal)
};
@@ -249,7 +249,7 @@ fn test_training_vs_validation_price_consistency() -> Result<()> {
for &price in &training_prices {
training_tracker.execute_action(
FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
price,
100.0,
);
@@ -262,7 +262,7 @@ fn test_training_vs_validation_price_consistency() -> Result<()> {
for &price in &validation_prices {
validation_tracker.execute_action(
FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
price,
100.0,
);
@@ -299,11 +299,11 @@ fn test_portfolio_tracker_price_updates() -> Result<()> {
// Execute action
let exposure = match i % 5 {
0 => ExposureLevel::Long100,
1 => ExposureLevel::Long50,
0 => ExposureLevel::LongFull,
1 => ExposureLevel::LongSmall,
2 => ExposureLevel::Flat,
3 => ExposureLevel::Short50,
4 => ExposureLevel::Short100,
3 => ExposureLevel::ShortFull,
4 => ExposureLevel::ShortSmall,
_ => ExposureLevel::Flat,
};
@@ -346,7 +346,7 @@ fn test_zero_price_protection() -> Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
// Attempt to execute action at price = 0 (should be protected)
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Note: PortfolioTracker allows price=0 but normalized_position calculation has fallback
tracker.execute_action(action, 0.0, 100.0);
@@ -379,7 +379,7 @@ fn test_negative_price_rejection() -> Result<()> {
// Execute action (system accepts negative prices, but P&L will be wrong)
tracker.execute_action(
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
preprocessed_z_score,
100.0,
);

View File

@@ -258,7 +258,7 @@ impl MockPositionLimiter {
// Action space: (exposure * 9) + (order_type * 3) + urgency
// Exposure: 0=Short100, 1=Short50, 2=Flat, 3=Long50, 4=Long100
for action_idx in 0..45 {
for action_idx in 0..63 {
let exposure_idx = action_idx / 9;
let exposure_level = match exposure_idx {
0 => -1.0, // Short100

View File

@@ -444,7 +444,7 @@ async fn test_ppo_training_with_single_step_trajectory() -> Result<(), Box<dyn s
let mut trajectory = Trajectory::new();
trajectory.add_step(TrajectoryStep::new(
vec![1.0, 2.0],
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
-0.5,
10.0,
1.0,

View File

@@ -92,7 +92,7 @@ fn test_transaction_costs_reduce_portfolio_value_market_order() {
// When: Execute Market order (0.15% fee)
// Buy 10 contracts at $5000 = $50,000 trade value
// Fee: $50,000 * 0.0015 = $75
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5000.0;
let max_position = 10.0;
tracker.execute_action(action, price, max_position);
@@ -115,7 +115,7 @@ fn test_transaction_costs_reduce_portfolio_value_ioc_order() {
// When: Execute IoC order (0.10% fee)
// Buy 10 contracts at $5000 = $50,000 trade value
// Fee: $50,000 * 0.0010 = $50
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Aggressive);
let price = 5000.0;
let max_position = 10.0;
tracker.execute_action(action, price, max_position);
@@ -138,7 +138,7 @@ fn test_limitmaker_rebates_increase_portfolio_value() {
// When: Execute LimitMaker order (0.05% rebate)
// Buy 10 contracts at $5000 = $50,000 trade value
// Rebate: $50,000 * 0.0005 = $25
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::LimitMaker, Urgency::Patient);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::LimitMaker, Urgency::Patient);
let price = 5000.0;
let max_position = 10.0;
tracker.execute_action(action, price, max_position);
@@ -161,7 +161,7 @@ fn test_pnl_includes_transaction_costs() {
// When: Buy at $5000 (Market, fee: $75), sell at $5100 (Market, fee: $76.50)
// Buy: 10 contracts at $5000 = $50,000 trade, fee = $75
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5000.0, 10.0);
// Sell: 10 contracts at $5100 = $51,000 trade, fee = $76.50
@@ -186,11 +186,11 @@ fn test_transaction_costs_accumulate_across_trades() {
// When: Execute multiple trades with different order types
// Trade 1: Market order (0.15% fee)
let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action1 = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action1, 5000.0, 10.0); // $25,000 trade, $37.50 fee
// Trade 2: IoC order (0.10% fee)
let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive);
let action2 = FactoredAction::new(ExposureLevel::LongFull, OrderType::IoC, Urgency::Aggressive);
tracker.execute_action(action2, 5000.0, 10.0); // $25,000 trade (delta from Long50 to Long100), $25 fee
// Trade 3: LimitMaker order (0.05% fee)
@@ -215,7 +215,7 @@ fn test_transaction_costs_with_price_movement() {
// When: Buy at $5000, price moves to $5100, then sell
// Buy: Market order (0.15% fee)
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5000.0, 10.0); // $50,000 trade, $75 fee
// Price moves to $5100 (unrealized gain: $1000)
@@ -268,7 +268,7 @@ fn test_transaction_costs_for_partial_position_change() {
// When: Go from Flat → Long50 → Long100
// Trade 1: Long50 (0.15% fee on $25,000 trade = $37.50)
let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action1 = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action1, 5000.0, 10.0); // Trade value: $25,000
let value_after_trade1 = tracker.get_raw_portfolio_features(5000.0)[0];
@@ -276,7 +276,7 @@ fn test_transaction_costs_for_partial_position_change() {
assert_eq!(cost1, 37.50, "Long50 should cost $37.50 in fees");
// Trade 2: Long100 (0.15% fee on $25,000 incremental trade = $37.50)
let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action2 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action2, 5000.0, 10.0); // Additional $25,000 trade
let value_after_trade2 = tracker.get_raw_portfolio_features(5000.0)[0];
@@ -291,7 +291,7 @@ fn test_transaction_costs_with_short_positions() {
// When: Short 10 contracts at $5000 (Market order, 0.15% fee)
// Short: $50,000 trade value, $75 fee
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, 5000.0, 10.0);
// Then: Portfolio value should reflect transaction cost

View File

@@ -83,7 +83,7 @@ fn test_position_size_respects_max_limit() {
// Try to build massive 10,000 contract position (Wave 16R bug)
for i in 0..100 {
let long_action = FactoredAction::new(
ExposureLevel::Long100, // Maximum long exposure
ExposureLevel::LongFull, // Maximum long exposure
OrderType::Market,
Urgency::Aggressive
);
@@ -122,7 +122,7 @@ fn test_position_prevents_portfolio_explosion() {
// Build position to limit
for _ in 0..50 {
let action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive
);
@@ -156,7 +156,7 @@ fn test_negative_position_also_clamped() {
// Try to build massive short position
for _ in 0..100 {
let short_action = FactoredAction::new(
ExposureLevel::Short100, // Maximum short exposure
ExposureLevel::ShortSmall, // Maximum short exposure
OrderType::Market,
Urgency::Aggressive
);
@@ -191,7 +191,7 @@ fn test_low_price_creates_catastrophic_positions() {
// Execute Long100 action with uncapped max_position
let long_action = FactoredAction::new(
ExposureLevel::Long100,
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive
);

View File

@@ -83,7 +83,7 @@ use ml::dqn::portfolio_tracker::PortfolioTracker;
#[test]
fn test_price_validation_rejects_nan() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with NaN price
tracker.execute_action(action, f32::NAN, 100.0);
@@ -96,7 +96,7 @@ fn test_price_validation_rejects_nan() {
#[test]
fn test_price_validation_rejects_infinity() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with Inf price
tracker.execute_action(action, f32::INFINITY, 100.0);
@@ -109,7 +109,7 @@ fn test_price_validation_rejects_infinity() {
#[test]
fn test_price_validation_rejects_zero() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with zero price
tracker.execute_action(action, 0.0, 100.0);
@@ -122,7 +122,7 @@ fn test_price_validation_rejects_zero() {
#[test]
fn test_price_validation_rejects_negative() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with negative price
tracker.execute_action(action, -100.0, 100.0);
@@ -136,7 +136,7 @@ fn test_price_validation_rejects_negative() {
fn test_price_validation_rejects_corrupted_price_1_11() {
// This is the exact bug that caused -$1.93B portfolio value
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with corrupted price $1.11 (instead of ~$5000 for ES)
tracker.execute_action(action, 1.11, 100.0);
@@ -149,7 +149,7 @@ fn test_price_validation_rejects_corrupted_price_1_11() {
#[test]
fn test_price_validation_rejects_below_es_min() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with price below ES minimum ($1000)
tracker.execute_action(action, 999.99, 100.0);
@@ -162,7 +162,7 @@ fn test_price_validation_rejects_below_es_min() {
#[test]
fn test_price_validation_rejects_above_es_max() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt to execute with price above ES maximum ($10,000)
tracker.execute_action(action, 10_000.01, 100.0);
@@ -175,7 +175,7 @@ fn test_price_validation_rejects_above_es_max() {
#[test]
fn test_price_validation_accepts_valid_es_price() {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
// Execute with valid ES price (~$5000)
tracker.execute_action(action, 5000.0, 100.0);
@@ -230,7 +230,7 @@ fn test_price_validation_continuity_warning() {
fn test_price_validation_multiple_rejections() {
// Verify that multiple rejected actions don't corrupt portfolio state
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
// Attempt multiple invalid prices
tracker.execute_action(action, f32::NAN, 100.0);