Files
foxhunt/crates/ml-features/src/position_features.rs
jgrusewski 7ef92983f9 fix(clippy): apply cargo clippy --fix across workspace
Mechanical auto-fixes: redundant borrows, clone on Copy, or_insert_with,
single-char push_str, get(0) → first(), needless borrow, let_and_return.
150 files, no behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:51 +01:00

271 lines
9.8 KiB
Rust

//! 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 40 market features
//! (producing a 43-dim state with portfolio, or 51-dim with OFI).
/// 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");
}
}