use crate::error::FinError;
use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BidAskSpread {
pub bid: f64,
pub ask: f64,
}
impl BidAskSpread {
pub fn new(bid: f64, ask: f64) -> Result<Self, FinError> {
if !bid.is_finite() || !ask.is_finite() {
return Err(FinError::InvalidInput("bid and ask must be finite".into()));
}
if bid >= ask {
return Err(FinError::InvalidInput(
"bid must be strictly less than ask".into(),
));
}
Ok(Self { bid, ask })
}
#[must_use]
pub fn spread(&self) -> f64 {
self.ask - self.bid
}
#[must_use]
pub fn mid_price(&self) -> f64 {
(self.bid + self.ask) / 2.0
}
#[must_use]
pub fn relative_spread(&self) -> f64 {
let mid = self.mid_price();
if mid == 0.0 {
0.0
} else {
self.spread() / mid
}
}
}
#[derive(Debug, Clone)]
pub struct MarketDepth {
pub levels: Vec<(f64, f64)>,
}
impl MarketDepth {
#[must_use]
pub fn depth_at_price(&self, price: f64) -> f64 {
self.levels
.iter()
.filter(|(p, _)| *p <= price)
.map(|(_, q)| q)
.sum()
}
#[must_use]
pub fn weighted_mid_price(&self) -> Option<f64> {
let total_qty: f64 = self.levels.iter().map(|(_, q)| q).sum();
if total_qty == 0.0 || self.levels.is_empty() {
return None;
}
let vwap: f64 = self
.levels
.iter()
.map(|(p, q)| p * q)
.sum::<f64>()
/ total_qty;
Some(vwap)
}
#[must_use]
pub fn total_quantity(&self) -> f64 {
self.levels.iter().map(|(_, q)| q).sum()
}
}
#[derive(Debug, Clone, Copy)]
pub struct LiquidityScore {
pub spread_score: f64,
pub depth_score: f64,
pub turnover_score: f64,
}
impl LiquidityScore {
#[must_use]
pub fn new(spread_score: f64, depth_score: f64, turnover_score: f64) -> Self {
Self {
spread_score: spread_score.clamp(0.0, 1.0),
depth_score: depth_score.clamp(0.0, 1.0),
turnover_score: turnover_score.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn combined_score(&self) -> f64 {
(self.spread_score + self.depth_score + self.turnover_score) / 3.0
}
pub fn from_observables(
spread_bps: f64,
depth: f64,
turnover: f64,
reference_depth: f64,
reference_turnover: f64,
) -> Result<Self, FinError> {
if reference_depth <= 0.0 || reference_turnover <= 0.0 {
return Err(FinError::InvalidInput(
"reference_depth and reference_turnover must be positive".into(),
));
}
let spread_score = (-spread_bps / 10.0).exp();
let depth_score = 1.0 - (-depth / reference_depth).exp();
let turnover_score = 1.0 - (-turnover / reference_turnover).exp();
Ok(Self::new(spread_score, depth_score, turnover_score))
}
}
#[derive(Debug)]
pub struct AmihudIlliquidity {
window: usize,
buf: VecDeque<f64>,
}
impl AmihudIlliquidity {
pub fn new(window: usize) -> Result<Self, FinError> {
if window == 0 {
return Err(FinError::InvalidPeriod(0));
}
Ok(Self {
window,
buf: VecDeque::with_capacity(window),
})
}
pub fn update(&mut self, abs_return: f64, dollar_volume: f64) {
if dollar_volume <= 0.0 {
return;
}
let ratio = abs_return / dollar_volume;
if self.buf.len() == self.window {
self.buf.pop_front();
}
self.buf.push_back(ratio);
}
#[must_use]
pub fn average(&self) -> Option<f64> {
if self.buf.len() < self.window {
return None;
}
let sum: f64 = self.buf.iter().sum();
Some(sum / self.buf.len() as f64)
}
#[must_use]
pub fn len(&self) -> usize {
self.buf.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bid_ask_spread_basic() {
let s = BidAskSpread::new(99.90, 100.10).unwrap();
assert!((s.spread() - 0.20).abs() < 1e-10);
assert!((s.mid_price() - 100.0).abs() < 1e-10);
assert!((s.relative_spread() - 0.002).abs() < 1e-10);
}
#[test]
fn bid_ask_spread_rejects_inverted() {
assert!(BidAskSpread::new(100.0, 99.0).is_err());
assert!(BidAskSpread::new(100.0, 100.0).is_err());
}
#[test]
fn bid_ask_spread_rejects_non_finite() {
assert!(BidAskSpread::new(f64::NAN, 100.0).is_err());
assert!(BidAskSpread::new(100.0, f64::INFINITY).is_err());
}
#[test]
fn market_depth_at_price() {
let d = MarketDepth {
levels: vec![(100.0, 10.0), (101.0, 5.0), (102.0, 3.0)],
};
assert!((d.depth_at_price(100.0) - 10.0).abs() < 1e-10);
assert!((d.depth_at_price(101.0) - 15.0).abs() < 1e-10);
assert!((d.depth_at_price(102.0) - 18.0).abs() < 1e-10);
assert!((d.depth_at_price(99.0) - 0.0).abs() < 1e-10);
}
#[test]
fn market_depth_weighted_mid_price() {
let d = MarketDepth {
levels: vec![(100.0, 2.0), (102.0, 2.0)],
};
let vwap = d.weighted_mid_price().unwrap();
assert!((vwap - 101.0).abs() < 1e-10);
}
#[test]
fn market_depth_weighted_mid_price_empty() {
let d = MarketDepth { levels: vec![] };
assert!(d.weighted_mid_price().is_none());
}
#[test]
fn liquidity_score_combined() {
let s = LiquidityScore::new(0.8, 0.6, 0.7);
assert!((s.combined_score() - 0.7).abs() < 1e-10);
}
#[test]
fn liquidity_score_clamped() {
let s = LiquidityScore::new(1.5, -0.1, 0.5);
assert!(s.spread_score <= 1.0);
assert!(s.depth_score >= 0.0);
}
#[test]
fn liquidity_score_from_observables() {
let s = LiquidityScore::from_observables(5.0, 1_000.0, 1_000_000.0, 1_000.0, 1_000_000.0)
.unwrap();
assert!(s.combined_score() > 0.0);
assert!(s.combined_score() <= 1.0);
}
#[test]
fn liquidity_score_from_observables_invalid_reference() {
assert!(
LiquidityScore::from_observables(5.0, 1000.0, 1_000_000.0, 0.0, 1_000_000.0).is_err()
);
}
#[test]
fn amihud_window_zero_rejected() {
assert!(AmihudIlliquidity::new(0).is_err());
}
#[test]
fn amihud_not_ready_until_full() {
let mut a = AmihudIlliquidity::new(3).unwrap();
a.update(0.01, 1_000_000.0);
assert!(a.average().is_none());
a.update(0.005, 500_000.0);
assert!(a.average().is_none());
a.update(0.02, 2_000_000.0);
assert!(a.average().is_some());
}
#[test]
fn amihud_rolling_window() {
let mut a = AmihudIlliquidity::new(2).unwrap();
a.update(0.01, 1_000_000.0);
a.update(0.01, 1_000_000.0);
let avg1 = a.average().unwrap();
a.update(0.02, 1_000_000.0);
let avg2 = a.average().unwrap();
assert!(avg2 > avg1);
}
#[test]
fn amihud_skips_zero_volume() {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(0.01, 0.0); assert!(a.average().is_none());
a.update(0.01, 1_000.0);
assert!(a.average().is_some());
}
}