Files
foxhunt/crates/ml/src/microstructure/vpin_implementation.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

558 lines
16 KiB
Rust

//! # Complete VPIN Calculator Implementation
//!
//! Volume-Synchronized Probability of Informed Trading implementation
//! for detecting order flow toxicity and informed trading activity.
//!
//! ## Performance Targets
//! - Target latency: <25μs per calculation
//! - Memory: Ring buffer with zero allocations in hot path
//! - Precision: Integer arithmetic with 10,000x scaling
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::MLError;
/// Precision factor for integer arithmetic (10,000x scaling)
const VPIN_PRECISION_FACTOR: i64 = 10_000;
/// Maximum latency target in microseconds
const MAX_CALCULATION_LATENCY_US: u64 = 25;
/// Trade direction classification
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeDirection {
/// Buyer-initiated trade (aggressive buy)
Buy,
/// Seller-initiated trade (aggressive sell)
Sell,
/// Unknown direction (at mid-price or insufficient data)
Unknown,
}
impl TradeDirection {
/// Classify trade using Lee-Ready algorithm
pub fn classify_lee_ready(trade_price: i64, bid: i64, ask: i64, prev_price: i64) -> Self {
let mid_price = (bid + ask) / 2;
if trade_price > mid_price {
TradeDirection::Buy
} else if trade_price < mid_price {
TradeDirection::Sell
} else {
// At midpoint - use tick rule
Self::classify_tick_rule(trade_price, prev_price)
}
}
/// Classify trade using tick rule
pub fn classify_tick_rule(trade_price: i64, prev_price: i64) -> Self {
if trade_price > prev_price {
TradeDirection::Buy
} else if trade_price < prev_price {
TradeDirection::Sell
} else {
TradeDirection::Unknown
}
}
}
/// Market data update for VPIN calculation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketDataUpdate {
/// Timestamp in microseconds
pub timestamp: u64,
/// Symbol identifier
pub symbol: String,
/// Trade price (scaled by PRECISION_FACTOR)
pub price: i64,
/// Trade volume
pub volume: u64,
/// Best bid price (scaled)
pub bid: i64,
/// Best ask price (scaled)
pub ask: i64,
/// Bid size
pub bid_size: u64,
/// Ask size
pub ask_size: u64,
/// Trade direction (if known)
pub direction: Option<TradeDirection>,
}
/// VPIN calculation configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VPINConfig {
/// Volume per bucket
pub bucket_volume: u64,
/// Number of buckets for rolling calculation
pub bucket_count: usize,
/// Toxicity threshold (scaled by PRECISION_FACTOR)
pub toxicity_threshold: i64,
/// Maximum age for data points (microseconds)
pub max_age_us: u64,
}
impl Default for VPINConfig {
fn default() -> Self {
Self {
bucket_volume: 10_000,
bucket_count: 50,
toxicity_threshold: 3_000, // 0.3 scaled
max_age_us: 300_000_000, // 5 minutes
}
}
}
/// VPIN calculation metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VPINMetrics {
/// Current VPIN value (0.0 to 1.0)
pub vpin: f64,
/// Order flow imbalance (-1.0 to 1.0)
pub order_flow_imbalance: f64,
/// Toxicity score (0.0 to 1.0)
pub toxicity_score: f64,
/// Whether market is currently toxic
pub is_toxic: bool,
/// Number of completed buckets
pub bucket_count: usize,
/// Current bucket fill percentage (0.0 to 1.0)
pub current_bucket_fill: f64,
}
impl Default for VPINMetrics {
fn default() -> Self {
Self {
vpin: 0.0,
order_flow_imbalance: 0.0,
toxicity_score: 0.0,
is_toxic: false,
bucket_count: 0,
current_bucket_fill: 0.0,
}
}
}
/// Performance metrics for VPIN calculator
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VPINPerformanceMetrics {
/// Total number of calculations performed
pub total_calculations: u64,
/// Average latency in microseconds
pub avg_latency_us: f64,
/// Maximum latency in microseconds
pub max_latency_us: u64,
/// Number of calculations exceeding latency target
pub over_latency_count: u64,
}
impl Default for VPINPerformanceMetrics {
fn default() -> Self {
Self {
total_calculations: 0,
avg_latency_us: 0.0,
max_latency_us: 0,
over_latency_count: 0,
}
}
}
/// Volume bucket for VPIN calculation
#[derive(Debug, Clone)]
struct VolumeBucket {
/// Bucket index
index: usize,
/// Buy volume (scaled)
buy_volume: u64,
/// Sell volume (scaled)
sell_volume: u64,
/// Total volume
total_volume: u64,
/// First trade timestamp
start_time: u64,
/// Last trade timestamp
end_time: u64,
}
impl VolumeBucket {
fn new(index: usize, timestamp: u64) -> Self {
Self {
index,
buy_volume: 0,
sell_volume: 0,
total_volume: 0,
start_time: timestamp,
end_time: timestamp,
}
}
fn add_trade(&mut self, volume: u64, direction: TradeDirection, timestamp: u64) {
match direction {
TradeDirection::Buy => self.buy_volume += volume,
TradeDirection::Sell => self.sell_volume += volume,
TradeDirection::Unknown => {
// Split unknown trades equally
self.buy_volume += volume / 2;
self.sell_volume += volume / 2;
},
}
self.total_volume += volume;
self.end_time = timestamp;
}
fn is_full(&self, target_volume: u64) -> bool {
self.total_volume >= target_volume
}
fn calculate_imbalance(&self) -> i64 {
if self.total_volume == 0 {
return 0;
}
let imbalance = (self.buy_volume as i64 - self.sell_volume as i64) * VPIN_PRECISION_FACTOR;
imbalance / self.total_volume as i64
}
}
/// Ring buffer for efficient bucket storage
#[derive(Debug, Clone)]
pub struct RingBuffer<T> {
data: Vec<T>,
head: usize,
len: usize,
capacity: usize,
}
impl<T: Clone> RingBuffer<T> {
pub fn new(capacity: usize) -> Self {
Self {
data: Vec::with_capacity(capacity),
head: 0,
len: 0,
capacity,
}
}
pub fn push(&mut self, item: T) {
if self.len < self.capacity {
self.data.push(item);
self.len += 1;
} else {
self.data[self.head] = item;
self.head = (self.head + 1) % self.capacity;
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn get(&self, index: usize) -> Option<&T> {
if index >= self.len {
return None;
}
if self.len < self.capacity {
self.data.get(index)
} else {
let real_index = (self.head + index) % self.capacity;
self.data.get(real_index)
}
}
fn iter(&self) -> RingBufferIterator<'_, T> {
RingBufferIterator {
buffer: self,
index: 0,
}
}
}
struct RingBufferIterator<'a, T> {
buffer: &'a RingBuffer<T>,
index: usize,
}
impl<'a, T: Clone> Iterator for RingBufferIterator<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
if self.index >= self.buffer.len() {
None
} else {
let item = self.buffer.get(self.index);
self.index += 1;
item
}
}
}
/// Main VPIN calculator with high-performance implementation
#[derive(Debug)]
pub struct VPINCalculator {
/// Configuration
config: VPINConfig,
/// Completed volume buckets (ring buffer)
buckets: RingBuffer<VolumeBucket>,
/// Current active bucket
current_bucket: Option<VolumeBucket>,
/// Last trade price for tick rule
last_price: i64,
/// Performance metrics
performance: VPINPerformanceMetrics,
/// Calculation counter
calculation_count: AtomicU64,
/// Latency accumulator
latency_accumulator: AtomicU64,
}
impl VPINCalculator {
/// Create new VPIN calculator with configuration
pub fn new(config: VPINConfig) -> Self {
Self {
buckets: RingBuffer::new(config.bucket_count),
current_bucket: None,
last_price: 0,
performance: VPINPerformanceMetrics::default(),
calculation_count: AtomicU64::new(0),
latency_accumulator: AtomicU64::new(0),
config,
}
}
/// Update VPIN with new market data
pub fn update(&mut self, update: &MarketDataUpdate) -> Result<(), MLError> {
let start_time = Instant::now();
// Classify trade direction if not provided
let direction = update
.direction
.unwrap_or_else(|| self.classify_trade(update));
// Create new bucket if needed
if self.current_bucket.is_none() {
self.current_bucket = Some(VolumeBucket::new(self.buckets.len(), update.timestamp));
}
// Add trade to current bucket
if let Some(ref mut bucket) = self.current_bucket {
bucket.add_trade(update.volume, direction, update.timestamp);
// Check if bucket is full
if bucket.is_full(self.config.bucket_volume) {
// Calculate remaining volume before moving bucket
let remaining_volume = bucket.total_volume - self.config.bucket_volume;
// Move to completed buckets
let completed_bucket = self.current_bucket.take()
.ok_or_else(|| MLError::ModelError("current_bucket unexpectedly None".to_owned()))?;
self.buckets.push(completed_bucket);
// Start new bucket with remaining volume
if remaining_volume > 0 {
let mut new_bucket = VolumeBucket::new(self.buckets.len(), update.timestamp);
new_bucket.add_trade(remaining_volume, direction, update.timestamp);
self.current_bucket = Some(new_bucket);
}
}
}
// Update last price for tick rule
self.last_price = update.price;
// Record performance metrics
let latency_us = start_time.elapsed().as_micros() as u64;
self.update_performance_metrics(latency_us);
Ok(())
}
/// Classify trade direction using available information
pub fn classify_trade(&self, update: &MarketDataUpdate) -> TradeDirection {
// Use Lee-Ready algorithm if we have bid/ask
if update.bid > 0 && update.ask > 0 {
TradeDirection::classify_lee_ready(
update.price,
update.bid,
update.ask,
self.last_price,
)
} else if self.last_price > 0 {
// Fall back to tick rule
TradeDirection::classify_tick_rule(update.price, self.last_price)
} else {
TradeDirection::Unknown
}
}
/// Get current VPIN value
pub fn get_vpin(&self) -> f64 {
if self.buckets.len() == 0 {
return 0.0;
}
let total_imbalance: i64 = self
.buckets
.iter()
.map(|bucket| bucket.calculate_imbalance().abs())
.sum();
let avg_imbalance = total_imbalance / (self.buckets.len() as i64);
(avg_imbalance as f64) / (VPIN_PRECISION_FACTOR as f64)
}
/// Get number of completed buckets
pub fn get_bucket_count(&self) -> usize {
self.buckets.len()
}
/// Check if market is currently toxic
pub fn is_toxic(&self) -> bool {
let vpin_scaled = (self.get_vpin() * VPIN_PRECISION_FACTOR as f64) as i64;
vpin_scaled > self.config.toxicity_threshold
}
/// Get comprehensive VPIN result
pub fn get_result(&self) -> VPINMetrics {
let vpin = self.get_vpin();
let order_flow_imbalance = self.calculate_order_flow_imbalance();
let toxicity_score = vpin; // Simple mapping for now
let current_bucket_fill = self.get_current_bucket_fill();
VPINMetrics {
vpin,
order_flow_imbalance,
toxicity_score,
is_toxic: self.is_toxic(),
bucket_count: self.buckets.len(),
current_bucket_fill,
}
}
/// Get performance metrics
pub fn get_metrics(&self) -> VPINPerformanceMetrics {
let total_calcs = self.calculation_count.load(Ordering::Relaxed);
let total_latency = self.latency_accumulator.load(Ordering::Relaxed);
VPINPerformanceMetrics {
total_calculations: total_calcs,
avg_latency_us: if total_calcs > 0 {
total_latency as f64 / total_calcs as f64
} else {
0.0
},
max_latency_us: self.performance.max_latency_us,
over_latency_count: self.performance.over_latency_count,
}
}
/// Get current bucket fill percentage
pub fn get_current_bucket_fill(&self) -> f64 {
if let Some(ref bucket) = self.current_bucket {
bucket.total_volume as f64 / self.config.bucket_volume as f64
} else {
0.0
}
}
/// Calculate order flow imbalance
fn calculate_order_flow_imbalance(&self) -> f64 {
if self.buckets.len() == 0 {
return 0.0;
}
let total_buy: u64 = self.buckets.iter().map(|b| b.buy_volume).sum();
let total_sell: u64 = self.buckets.iter().map(|b| b.sell_volume).sum();
let total_volume = total_buy + total_sell;
if total_volume == 0 {
0.0
} else {
(total_buy as f64 - total_sell as f64) / total_volume as f64
}
}
/// Update performance metrics
fn update_performance_metrics(&mut self, latency_us: u64) {
self.calculation_count.fetch_add(1, Ordering::Relaxed);
self.latency_accumulator
.fetch_add(latency_us, Ordering::Relaxed);
if latency_us > self.performance.max_latency_us {
self.performance.max_latency_us = latency_us;
}
if latency_us > MAX_CALCULATION_LATENCY_US {
self.performance.over_latency_count += 1;
}
}
}
impl Default for VPINCalculator {
fn default() -> Self {
Self::new(VPINConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trade_direction_classification() {
// Test Lee-Ready algorithm
let direction = TradeDirection::classify_lee_ready(
105000, // trade price (10.50)
104000, // bid (10.40)
106000, // ask (10.60)
104500, // prev price (10.45)
);
assert_eq!(direction, TradeDirection::Buy);
// Test tick rule
let direction = TradeDirection::classify_tick_rule(105000, 104000);
assert_eq!(direction, TradeDirection::Buy);
}
#[test]
fn test_volume_bucket() {
let mut bucket = VolumeBucket::new(0, 1000000);
bucket.add_trade(500, TradeDirection::Buy, 1000001);
bucket.add_trade(300, TradeDirection::Sell, 1000002);
assert_eq!(bucket.total_volume, 800);
assert!(!bucket.is_full(1000));
let imbalance = bucket.calculate_imbalance();
// (500 - 300) * 10000 / 800 = 2500
assert_eq!(imbalance, 2500);
}
#[test]
fn test_ring_buffer() {
let mut buffer = RingBuffer::new(3);
buffer.push(1);
buffer.push(2);
buffer.push(3);
assert_eq!(buffer.len(), 3);
assert_eq!(buffer.get(0), Some(&1));
assert_eq!(buffer.get(1), Some(&2));
assert_eq!(buffer.get(2), Some(&3));
buffer.push(4);
assert_eq!(buffer.len(), 3);
assert_eq!(buffer.get(0), Some(&2));
assert_eq!(buffer.get(1), Some(&3));
assert_eq!(buffer.get(2), Some(&4));
}
}