use crate::math::MulAdd;
#[derive(Debug, Clone)]
pub struct RollSpreadF64 {
alpha: f64,
one_minus_alpha: f64,
ew_cov: f64,
ew_var: f64,
prev_price: f64,
prev_diff: f64,
count: u64,
min_samples: u64,
}
#[derive(Debug, Clone)]
pub struct RollSpreadF64Builder {
alpha: Option<f64>,
min_samples: u64,
}
impl RollSpreadF64 {
#[inline]
#[must_use]
pub fn builder() -> RollSpreadF64Builder {
RollSpreadF64Builder {
alpha: None,
min_samples: 30,
}
}
#[inline]
pub fn update(&mut self, price: f64) -> Result<(), crate::DataError> {
check_finite!(price);
self.count += 1;
if self.count == 1 {
self.prev_price = price;
return Ok(());
}
let diff = price - self.prev_price;
self.prev_price = price;
if self.count == 2 {
self.prev_diff = diff;
return Ok(());
}
self.ew_cov = self
.alpha
.fma(diff * self.prev_diff, self.one_minus_alpha * self.ew_cov);
self.ew_var = self
.alpha
.fma(diff * diff, self.one_minus_alpha * self.ew_var);
self.prev_diff = diff;
Ok(())
}
#[inline]
#[must_use]
pub fn spread(&self) -> Option<f64> {
if !self.is_primed() {
return None;
}
if self.ew_cov >= 0.0 {
return None;
}
Some(2.0 * crate::math::sqrt(-self.ew_cov))
}
#[inline]
#[must_use]
pub fn hasbrouck_spread(&self) -> Option<f64> {
let s = self.spread()?;
if self.ew_var <= 0.0 {
return None;
}
let rho = self.ew_cov / self.ew_var;
let factor = 1.0 + rho;
if factor <= 0.0 {
return None;
}
Some(s * crate::math::sqrt(factor))
}
#[inline]
#[must_use]
pub fn autocovariance(&self) -> Option<f64> {
if self.is_primed() {
Some(self.ew_cov)
} else {
None
}
}
#[inline]
#[must_use]
pub fn count(&self) -> u64 {
self.count
}
#[inline]
#[must_use]
pub fn is_primed(&self) -> bool {
self.count >= self.min_samples
}
#[inline]
pub fn reset(&mut self) {
self.ew_cov = 0.0;
self.ew_var = 0.0;
self.prev_price = 0.0;
self.prev_diff = 0.0;
self.count = 0;
}
}
impl RollSpreadF64Builder {
#[inline]
#[must_use]
pub fn alpha(mut self, alpha: f64) -> Self {
self.alpha = Some(alpha);
self
}
#[inline]
#[must_use]
pub fn min_samples(mut self, min: u64) -> Self {
self.min_samples = min;
self
}
#[inline]
pub fn build(self) -> Result<RollSpreadF64, crate::ConfigError> {
let alpha = self.alpha.ok_or(crate::ConfigError::Missing("alpha"))?;
if alpha <= 0.0 || alpha >= 1.0 || !alpha.is_finite() {
return Err(crate::ConfigError::Invalid(
"RollSpread alpha must be in (0, 1)",
));
}
Ok(RollSpreadF64 {
alpha,
one_minus_alpha: 1.0 - alpha,
ew_cov: 0.0,
ew_var: 0.0,
prev_price: 0.0,
prev_diff: 0.0,
count: 0,
min_samples: self.min_samples,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mean_reverting_spread() {
let mut rs = RollSpreadF64::builder()
.alpha(0.05)
.min_samples(10)
.build()
.unwrap();
for i in 0..200 {
let price = 100.0 + if i % 2 == 0 { 0.5 } else { -0.5 };
rs.update(price).unwrap();
}
let spread = rs.spread();
assert!(spread.is_some(), "mean-reverting should produce a spread");
assert!(spread.unwrap() > 0.0, "spread should be positive");
}
#[test]
fn trending_no_spread() {
let mut rs = RollSpreadF64::builder()
.alpha(0.05)
.min_samples(10)
.build()
.unwrap();
for i in 0..200 {
rs.update((i as f64).mul_add(0.1, 100.0)).unwrap();
}
assert!(
rs.spread().is_none(),
"trending series should have no spread (positive autocov)"
);
}
#[test]
fn hasbrouck_vs_roll() {
let mut rs = RollSpreadF64::builder()
.alpha(0.05)
.min_samples(10)
.build()
.unwrap();
for i in 0..200 {
let bounce = if i % 2 == 0 { 0.2 } else { -0.2 };
rs.update((i as f64).mul_add(0.1, 100.0) + bounce).unwrap();
}
let roll = rs.spread().unwrap();
let hasbrouck = rs.hasbrouck_spread().unwrap();
assert!(
hasbrouck > 0.0 && hasbrouck <= roll * 1.5,
"Hasbrouck ({hasbrouck}) should be positive and reasonable vs Roll ({roll})"
);
}
#[test]
fn autocovariance_negative() {
let mut rs = RollSpreadF64::builder()
.alpha(0.05)
.min_samples(10)
.build()
.unwrap();
for i in 0..200 {
let price = 100.0 + if i % 2 == 0 { 0.5 } else { -0.5 };
rs.update(price).unwrap();
}
let cov = rs.autocovariance().unwrap();
assert!(
cov < 0.0,
"mean-reverting should have negative autocov, got {cov}"
);
}
#[test]
fn reset_clears() {
let mut rs = RollSpreadF64::builder()
.alpha(0.05)
.min_samples(10)
.build()
.unwrap();
for i in 0..50 {
let price = 100.0 + if i % 2 == 0 { 0.5 } else { -0.5 };
rs.update(price).unwrap();
}
rs.reset();
assert_eq!(rs.count(), 0);
assert!(rs.spread().is_none());
}
#[test]
fn nan_rejected() {
let mut rs = RollSpreadF64::builder().alpha(0.05).build().unwrap();
assert!(matches!(
rs.update(f64::NAN),
Err(crate::DataError::NotANumber)
));
}
#[test]
fn inf_rejected() {
let mut rs = RollSpreadF64::builder().alpha(0.05).build().unwrap();
assert!(matches!(
rs.update(f64::INFINITY),
Err(crate::DataError::Infinite)
));
}
#[test]
fn builder_validation() {
assert!(matches!(
RollSpreadF64::builder().build(),
Err(crate::ConfigError::Missing("alpha"))
));
assert!(matches!(
RollSpreadF64::builder().alpha(0.0).build(),
Err(crate::ConfigError::Invalid(_))
));
assert!(matches!(
RollSpreadF64::builder().alpha(1.0).build(),
Err(crate::ConfigError::Invalid(_))
));
}
}