feat(ml): add position-aware feature extraction (PositionFeatures)
Add PositionFeatures struct that extracts 3 features for RL agent position awareness (54→57 dim state vector): - unrealized_pnl: normalized by EMA volatility - bars_in_position: log-scaled to [0, 1] - cost_basis: relative to price in bps, clamped [-500, 500] 8 unit tests, 0 clippy warnings. Not yet wired into trainers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ pub mod microstructure_features; // Wave C: Additional microstructure features (
|
||||
pub mod minio_integration;
|
||||
pub mod ofi_calculator; // Order Flow Imbalance features (8 features, indices 226-233)
|
||||
pub mod normalization; // Wave C: Feature normalization pipeline (5 strategies)
|
||||
pub mod position_features; // RL agent position-aware features (3 features: pnl, bars, cost basis)
|
||||
pub mod pipeline; // Wave C: 5-stage feature assembly pipeline (orchestrates all extractors)
|
||||
pub mod price_features; // Wave C: Price-based features (15 features)
|
||||
pub mod production_adapter; // WAVE 10: Adapter for common::ml_strategy 225-feature injection
|
||||
@@ -103,6 +104,9 @@ pub use regime_adaptive::RegimeAdaptiveFeatures;
|
||||
pub use regime_cusum::RegimeCUSUMFeatures;
|
||||
pub use regime_transition::RegimeTransitionFeatures;
|
||||
|
||||
// Position-aware features for RL agents (3 features)
|
||||
pub use position_features::PositionFeatures;
|
||||
|
||||
// OFI features (Order Flow Imbalance)
|
||||
pub use ofi_calculator::{OFICalculator, OFIFeatures};
|
||||
|
||||
|
||||
270
crates/ml/src/features/position_features.rs
Normal file
270
crates/ml/src/features/position_features.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Position-Aware Feature Extraction for RL Agents
|
||||
//!
|
||||
//! Extracts 3 features that give the RL agent awareness of its own position:
|
||||
//! - `unrealized_pnl`: normalized by EMA volatility
|
||||
//! - `bars_in_position`: log-scaled to [0, 1]
|
||||
//! - `cost_basis`: relative to current price in bps, clamped to [-500, 500]
|
||||
//!
|
||||
//! These features are appended to the state vector after the existing 54 features
|
||||
//! (51 market + 3 portfolio), producing a 57-dim state vector.
|
||||
|
||||
/// Maximum number of bars for log scaling (1 trading day at 1-min resolution).
|
||||
const MAX_BARS_LOG: f64 = 390.0;
|
||||
|
||||
/// Cost basis clamp bound in basis points.
|
||||
const COST_BASIS_CLAMP_BPS: f64 = 500.0;
|
||||
|
||||
/// Default EMA span for volatility estimation.
|
||||
const DEFAULT_VOL_SPAN: usize = 100;
|
||||
|
||||
/// Minimum volatility floor to prevent division by zero.
|
||||
const MIN_VOLATILITY: f64 = 1e-10;
|
||||
|
||||
/// Extracts position-aware features for RL agents.
|
||||
///
|
||||
/// Maintains an EMA of absolute returns to normalize unrealized PnL,
|
||||
/// ensuring the agent perceives PnL relative to recent market movement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionFeatures {
|
||||
/// EMA smoothing factor: alpha = 2 / (span + 1)
|
||||
alpha: f64,
|
||||
/// Current EMA of absolute returns (volatility proxy)
|
||||
ema_volatility: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for PositionFeatures {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_VOL_SPAN)
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionFeatures {
|
||||
/// Create a new `PositionFeatures` extractor with the given EMA span.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `span` - Number of periods for the EMA volatility estimate.
|
||||
/// A span of 100 gives alpha = 2/101 ~ 0.0198.
|
||||
#[must_use]
|
||||
pub fn new(span: usize) -> Self {
|
||||
let alpha = 2.0 / (span as f64 + 1.0);
|
||||
Self {
|
||||
alpha,
|
||||
ema_volatility: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the EMA volatility estimate with a new absolute return observation.
|
||||
///
|
||||
/// Call this once per bar with `|close_t / close_{t-1} - 1|`.
|
||||
pub fn update_volatility(&mut self, abs_return: f64) {
|
||||
let val = abs_return.abs(); // defensive: ensure positive
|
||||
self.ema_volatility = Some(match self.ema_volatility {
|
||||
Some(prev) => self.alpha * val + (1.0 - self.alpha) * prev,
|
||||
None => val, // seed with first observation
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract 3 position-aware features.
|
||||
///
|
||||
/// Returns `[unrealized_pnl_norm, bars_in_position_norm, cost_basis_bps_norm]`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `position_size` - Current position size (positive = long, negative = short, 0 = flat).
|
||||
/// * `unrealized_pnl` - Dollar PnL of the open position.
|
||||
/// * `bars_held` - Number of bars the current position has been held.
|
||||
/// * `entry_price` - Average entry price of the current position.
|
||||
/// * `current_price` - Current mid/close price.
|
||||
///
|
||||
/// # Feature definitions
|
||||
///
|
||||
/// 1. **unrealized_pnl_norm**: `unrealized_pnl / ema_volatility` (0 when flat or no vol).
|
||||
/// 2. **bars_in_position_norm**: `ln(1 + bars_held) / ln(1 + MAX_BARS)`, clamped to [0, 1].
|
||||
/// 3. **cost_basis_bps_norm**: `(current_price / entry_price - 1) * 10_000`, clamped to [-500, 500],
|
||||
/// then scaled to [-1, 1] by dividing by 500. Sign is flipped for short positions.
|
||||
#[must_use]
|
||||
pub fn extract(
|
||||
&self,
|
||||
position_size: f64,
|
||||
unrealized_pnl: f64,
|
||||
bars_held: u64,
|
||||
entry_price: f64,
|
||||
current_price: f64,
|
||||
) -> Vec<f64> {
|
||||
let is_flat = position_size.abs() < f64::EPSILON;
|
||||
|
||||
// Feature 1: unrealized PnL normalized by EMA volatility
|
||||
let pnl_norm = if is_flat {
|
||||
0.0
|
||||
} else {
|
||||
let vol = self.ema_volatility.unwrap_or(MIN_VOLATILITY).max(MIN_VOLATILITY);
|
||||
unrealized_pnl / vol
|
||||
};
|
||||
|
||||
// Feature 2: bars in position, log-scaled to [0, 1]
|
||||
let bars_norm = if is_flat {
|
||||
0.0
|
||||
} else {
|
||||
let log_bars = (1.0 + bars_held as f64).ln();
|
||||
let log_max = (1.0 + MAX_BARS_LOG).ln();
|
||||
(log_bars / log_max).clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
// Feature 3: cost basis relative to current price in bps, scaled to [-1, 1]
|
||||
let cost_basis_norm = if is_flat || entry_price.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
let raw_bps = (current_price / entry_price - 1.0) * 10_000.0;
|
||||
// Flip sign for short positions: a price increase is bad for shorts
|
||||
let directional_bps = if position_size < 0.0 { -raw_bps } else { raw_bps };
|
||||
directional_bps.clamp(-COST_BASIS_CLAMP_BPS, COST_BASIS_CLAMP_BPS) / COST_BASIS_CLAMP_BPS
|
||||
};
|
||||
|
||||
vec![pnl_norm, bars_norm, cost_basis_norm]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPSILON: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn test_flat_position_returns_zeros() {
|
||||
let pf = PositionFeatures::default();
|
||||
let features = pf.extract(0.0, 0.0, 0, 0.0, 100.0);
|
||||
assert_eq!(features.len(), 3);
|
||||
assert!((features[0]).abs() < EPSILON, "pnl should be 0 when flat");
|
||||
assert!((features[1]).abs() < EPSILON, "bars should be 0 when flat");
|
||||
assert!((features[2]).abs() < EPSILON, "cost basis should be 0 when flat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_position_positive_pnl() {
|
||||
let mut pf = PositionFeatures::new(100);
|
||||
// Seed volatility with a few updates
|
||||
for _ in 0..10 {
|
||||
pf.update_volatility(0.01); // 1% absolute return
|
||||
}
|
||||
|
||||
let features = pf.extract(
|
||||
1.0, // long 1 unit
|
||||
500.0, // $500 unrealized profit
|
||||
60, // held for 60 bars
|
||||
100.0, // entered at 100
|
||||
105.0, // now at 105
|
||||
);
|
||||
|
||||
assert_eq!(features.len(), 3);
|
||||
// PnL normalized by vol should be positive
|
||||
assert!(features[0] > 0.0, "long + profit => positive pnl_norm");
|
||||
// 60 bars => ln(61)/ln(391) ~ 4.11/5.97 ~ 0.689
|
||||
assert!(features[1] > 0.5 && features[1] < 0.8, "60 bars should be ~0.69, got {}", features[1]);
|
||||
// (105/100 - 1) * 10000 = 500 bps => 500/500 = 1.0
|
||||
assert!((features[2] - 1.0).abs() < EPSILON, "cost basis should be 1.0, got {}", features[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_position_flips_cost_basis() {
|
||||
let mut pf = PositionFeatures::new(100);
|
||||
pf.update_volatility(0.01);
|
||||
|
||||
let features = pf.extract(
|
||||
-1.0, // short 1 unit
|
||||
200.0, // $200 unrealized profit (price went down)
|
||||
30, // held for 30 bars
|
||||
100.0, // entered at 100
|
||||
98.0, // now at 98 (good for short)
|
||||
);
|
||||
|
||||
assert_eq!(features.len(), 3);
|
||||
// PnL normalized by vol should be positive (profitable short)
|
||||
assert!(features[0] > 0.0, "short + profit => positive pnl_norm");
|
||||
// (98/100 - 1) * 10000 = -200 bps, then flipped for short => +200 bps => 200/500 = 0.4
|
||||
assert!(
|
||||
(features[2] - 0.4).abs() < 0.01,
|
||||
"short cost basis should be ~0.4, got {}",
|
||||
features[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_scaling_bars() {
|
||||
let pf = PositionFeatures::default();
|
||||
|
||||
// 1 bar: ln(2)/ln(391) ~ 0.693/5.97 ~ 0.116
|
||||
let f1 = pf.extract(1.0, 0.0, 1, 100.0, 100.0);
|
||||
assert!(f1[1] > 0.1 && f1[1] < 0.15, "1 bar ~ 0.116, got {}", f1[1]);
|
||||
|
||||
// 390 bars (full day): ln(391)/ln(391) = 1.0
|
||||
let f390 = pf.extract(1.0, 0.0, 390, 100.0, 100.0);
|
||||
assert!((f390[1] - 1.0).abs() < EPSILON, "390 bars should be 1.0, got {}", f390[1]);
|
||||
|
||||
// 1000 bars (> max): should clamp to 1.0
|
||||
let f1000 = pf.extract(1.0, 0.0, 1000, 100.0, 100.0);
|
||||
assert!((f1000[1] - 1.0).abs() < EPSILON, "1000 bars should clamp to 1.0, got {}", f1000[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_basis_clamping() {
|
||||
let pf = PositionFeatures::default();
|
||||
|
||||
// Extreme price move: 100 -> 200 => (200/100 - 1) * 10000 = 10000 bps
|
||||
// Should clamp to 500 bps => 500/500 = 1.0
|
||||
let f_up = pf.extract(1.0, 0.0, 10, 100.0, 200.0);
|
||||
assert!(
|
||||
(f_up[2] - 1.0).abs() < EPSILON,
|
||||
"extreme up should clamp to 1.0, got {}",
|
||||
f_up[2]
|
||||
);
|
||||
|
||||
// Extreme price drop: 100 -> 50 => (50/100 - 1) * 10000 = -5000 bps
|
||||
// Should clamp to -500 bps => -500/500 = -1.0
|
||||
let f_down = pf.extract(1.0, 0.0, 10, 100.0, 50.0);
|
||||
assert!(
|
||||
(f_down[2] - (-1.0)).abs() < EPSILON,
|
||||
"extreme down should clamp to -1.0, got {}",
|
||||
f_down[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema_volatility_update() {
|
||||
let mut pf = PositionFeatures::new(100);
|
||||
assert!(pf.ema_volatility.is_none());
|
||||
|
||||
// First update seeds the EMA
|
||||
pf.update_volatility(0.02);
|
||||
assert!((pf.ema_volatility.unwrap() - 0.02).abs() < EPSILON);
|
||||
|
||||
// Second update blends: alpha * 0.01 + (1-alpha) * 0.02
|
||||
// alpha = 2/101 ~ 0.0198
|
||||
pf.update_volatility(0.01);
|
||||
let alpha = 2.0 / 101.0;
|
||||
let expected = alpha * 0.01 + (1.0 - alpha) * 0.02;
|
||||
assert!(
|
||||
(pf.ema_volatility.unwrap() - expected).abs() < EPSILON,
|
||||
"EMA should be {}, got {}",
|
||||
expected,
|
||||
pf.ema_volatility.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_entry_price_returns_zero_cost_basis() {
|
||||
let pf = PositionFeatures::default();
|
||||
let features = pf.extract(1.0, 100.0, 10, 0.0, 100.0);
|
||||
assert!((features[2]).abs() < EPSILON, "zero entry price => 0 cost basis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_volatility_uses_floor() {
|
||||
let pf = PositionFeatures::default(); // no update_volatility calls
|
||||
let features = pf.extract(1.0, 100.0, 10, 100.0, 101.0);
|
||||
// PnL should be 100 / 1e-10 = 1e12 (huge, but finite)
|
||||
assert!(features[0].is_finite(), "pnl_norm should be finite");
|
||||
assert!(features[0] > 0.0, "pnl_norm should be positive");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user