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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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%)
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user