use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone)]
pub struct MarketDepth {
pub bid_levels: Vec<(f64, f64)>,
pub ask_levels: Vec<(f64, f64)>,
}
impl MarketDepth {
pub fn mid_price(&self) -> f64 {
let best_bid = self.bid_levels.first().map(|l| l.0).unwrap_or(0.0);
let best_ask = self.ask_levels.first().map(|l| l.0).unwrap_or(0.0);
(best_bid + best_ask) / 2.0
}
pub fn bid_ask_spread(&self) -> f64 {
let best_bid = self.bid_levels.first().map(|l| l.0).unwrap_or(0.0);
let best_ask = self.ask_levels.first().map(|l| l.0).unwrap_or(0.0);
(best_ask - best_bid).max(0.0)
}
pub fn depth_at_bps(&self, bps: f64) -> (f64, f64) {
let mid = self.mid_price();
if mid <= 0.0 {
return (0.0, 0.0);
}
let factor = bps / 10_000.0;
let lower = mid * (1.0 - factor);
let upper = mid * (1.0 + factor);
let bid_vol: f64 = self.bid_levels.iter()
.filter(|(p, _)| *p >= lower)
.map(|(_, s)| s)
.sum();
let ask_vol: f64 = self.ask_levels.iter()
.filter(|(p, _)| *p <= upper)
.map(|(_, s)| s)
.sum();
(bid_vol, ask_vol)
}
pub fn market_impact_cost(&self, quantity: f64, side: Side) -> f64 {
let mid = self.mid_price();
if mid <= 0.0 || quantity <= 0.0 {
return 0.0;
}
let levels: &Vec<(f64, f64)> = match side {
Side::Buy => &self.ask_levels,
Side::Sell => &self.bid_levels,
};
let mut remaining = quantity;
let mut total_cost = 0.0;
for (price, size) in levels {
if remaining <= 0.0 { break; }
let fill = remaining.min(*size);
total_cost += fill * price;
remaining -= fill;
}
if remaining > 0.0 {
if let Some((last_price, _)) = levels.last() {
total_cost += remaining * last_price;
}
}
let mid_cost = quantity * mid;
match side {
Side::Buy => total_cost - mid_cost,
Side::Sell => mid_cost - total_cost,
}
}
pub fn resilience_score(&self) -> f64 {
let spread = self.bid_ask_spread();
let mid = self.mid_price();
if mid <= 0.0 || spread <= 0.0 {
return 0.0;
}
let spread_bps = (spread / mid) * 10_000.0;
let total_vol: f64 = self.bid_levels.iter().map(|(_, s)| s).sum::<f64>()
+ self.ask_levels.iter().map(|(_, s)| s).sum::<f64>();
total_vol / spread_bps
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LiquidityRating {
HighlyLiquid,
Liquid,
ModeratelyLiquid,
Illiquid,
HighlyIlliquid,
}
impl fmt::Display for LiquidityRating {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LiquidityRating::HighlyLiquid => write!(f, "Highly Liquid"),
LiquidityRating::Liquid => write!(f, "Liquid"),
LiquidityRating::ModeratelyLiquid => write!(f, "Moderately Liquid"),
LiquidityRating::Illiquid => write!(f, "Illiquid"),
LiquidityRating::HighlyIlliquid => write!(f, "Highly Illiquid"),
}
}
}
impl LiquidityRating {
fn from_score(score: f64) -> Self {
if score >= 80.0 {
LiquidityRating::HighlyLiquid
} else if score >= 60.0 {
LiquidityRating::Liquid
} else if score >= 40.0 {
LiquidityRating::ModeratelyLiquid
} else if score >= 20.0 {
LiquidityRating::Illiquid
} else {
LiquidityRating::HighlyIlliquid
}
}
}
#[derive(Debug, Clone)]
pub struct LiquidityScore {
pub asset: String,
pub bid_ask_spread_bps: f64,
pub market_depth_score: f64,
pub turnover_ratio: f64,
pub amihud_illiquidity: f64,
pub composite_score: f64,
pub rating: LiquidityRating,
}
pub struct AmihudIlliquidity;
impl AmihudIlliquidity {
pub fn compute(returns: &[f64], volumes: &[f64]) -> f64 {
let n = returns.len().min(volumes.len());
if n == 0 {
return 0.0;
}
let mut sum = 0.0;
let mut count = 0u64;
for i in 0..n {
if volumes[i] > 0.0 {
sum += returns[i].abs() / volumes[i];
count += 1;
}
}
if count == 0 { 0.0 } else { sum / count as f64 }
}
pub fn rolling(returns: &[f64], volumes: &[f64], window: usize) -> Vec<f64> {
let n = returns.len().min(volumes.len());
if n == 0 || window == 0 {
return Vec::new();
}
let mut result = Vec::with_capacity(n.saturating_sub(window) + 1);
for start in 0..=n.saturating_sub(window) {
let end = (start + window).min(n);
let val = Self::compute(&returns[start..end], &volumes[start..end]);
result.push(val);
}
result
}
}
pub struct LiquidationCost;
impl LiquidationCost {
pub fn linear_cost(
quantity: f64,
bid_ask_bps: f64,
market_impact_bps_per_unit: f64,
) -> f64 {
quantity * (bid_ask_bps / 2.0 + market_impact_bps_per_unit * quantity) / 10_000.0
}
pub fn optimal_liquidation_schedule(
quantity: f64,
adv: f64,
horizon_days: u32,
sigma: f64,
lambda: f64,
) -> Vec<f64> {
if horizon_days == 0 || adv <= 0.0 {
return Vec::new();
}
let t = horizon_days as f64;
let kappa = (lambda.max(0.0) * sigma * sigma).sqrt().max(1e-9);
let mut schedule = Vec::with_capacity(horizon_days as usize);
let mut remaining = quantity;
for day in 0..horizon_days {
let days_left = t - day as f64;
let sell_rate = if kappa * t > 700.0 {
if day == 0 { quantity } else { 0.0 }
} else {
let sinh_remaining = (kappa * days_left).sinh();
let sinh_total = (kappa * t).sinh();
if sinh_total < 1e-15 {
quantity / t
} else {
quantity * (1.0 - sinh_remaining / sinh_total)
- if day == 0 {
0.0
} else {
let days_left_prev = t - (day as f64 - 1.0);
quantity * (1.0 - (kappa * days_left_prev).sinh() / sinh_total)
}
}
};
let to_sell = sell_rate.max(0.0).min(remaining).min(adv);
schedule.push(to_sell);
remaining -= to_sell;
}
if remaining > 0.0 {
if let Some(last) = schedule.last_mut() {
*last += remaining;
}
}
schedule
}
pub fn vwap_liquidation_cost(
quantity: f64,
daily_volume: f64,
volatility: f64,
days: u32,
) -> f64 {
if daily_volume <= 0.0 || days == 0 {
return 0.0;
}
let participation = quantity / (daily_volume * days as f64);
volatility * participation.sqrt() * quantity
}
}
pub struct LiquidityRiskEngine;
impl LiquidityRiskEngine {
pub fn score_asset(
&self,
asset: &str,
returns: &[f64],
volumes: &[f64],
depth: &MarketDepth,
) -> LiquidityScore {
let mid = depth.mid_price();
let spread = depth.bid_ask_spread();
let spread_bps = if mid > 0.0 { (spread / mid) * 10_000.0 } else { 9999.0 };
let spread_score = (100.0 - spread_bps).clamp(0.0, 100.0);
let resilience = depth.resilience_score();
let depth_score = (resilience * 10.0).clamp(0.0, 100.0);
let amihud = AmihudIlliquidity::compute(returns, volumes);
let amihud_score = (100.0 / (1.0 + amihud * 1e6)).clamp(0.0, 100.0);
let avg_volume: f64 = if volumes.is_empty() {
0.0
} else {
volumes.iter().sum::<f64>() / volumes.len() as f64
};
let turnover_ratio = avg_volume / (mid * 1e6).max(1.0);
let composite = 0.4 * spread_score + 0.3 * depth_score + 0.3 * amihud_score;
LiquidityScore {
asset: asset.to_string(),
bid_ask_spread_bps: spread_bps,
market_depth_score: depth_score,
turnover_ratio,
amihud_illiquidity: amihud,
composite_score: composite,
rating: LiquidityRating::from_score(composite),
}
}
pub fn portfolio_liquidity_var(
positions: &[(f64, LiquidityScore)],
confidence: f64,
) -> f64 {
let z = if confidence >= 0.99 { 2.326 } else { 1.645 };
positions.iter().map(|(value, score)| {
let haircut = 1.0 - score.composite_score / 100.0;
value.abs() * haircut * z
}).sum()
}
pub fn days_to_liquidate(
position_value: f64,
adv: f64,
max_participation: f64,
) -> f64 {
if adv <= 0.0 || max_participation <= 0.0 {
return f64::INFINITY;
}
let daily_capacity = adv * max_participation;
(position_value / daily_capacity).ceil()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_depth() -> MarketDepth {
MarketDepth {
bid_levels: vec![(99.9, 100.0), (99.8, 200.0), (99.7, 300.0)],
ask_levels: vec![(100.1, 100.0), (100.2, 200.0), (100.3, 300.0)],
}
}
#[test]
fn mid_and_spread() {
let d = sample_depth();
assert!((d.mid_price() - 100.0).abs() < 1e-9);
assert!((d.bid_ask_spread() - 0.2).abs() < 1e-9);
}
#[test]
fn depth_at_bps() {
let d = sample_depth();
let (bv, av) = d.depth_at_bps(10.0); assert!(bv > 0.0 && av > 0.0);
}
#[test]
fn market_impact_positive() {
let d = sample_depth();
let impact = d.market_impact_cost(50.0, Side::Buy);
assert!(impact >= 0.0);
}
#[test]
fn amihud_compute() {
let returns = vec![0.01, -0.02, 0.015];
let volumes = vec![1_000_000.0, 2_000_000.0, 500_000.0];
let val = AmihudIlliquidity::compute(&returns, &volumes);
assert!(val > 0.0);
}
#[test]
fn rolling_amihud_length() {
let returns: Vec<f64> = (0..20).map(|i| (i as f64) * 0.001).collect();
let volumes: Vec<f64> = (0..20).map(|_| 1_000_000.0).collect();
let result = AmihudIlliquidity::rolling(&returns, &volumes, 5);
assert_eq!(result.len(), 16);
}
#[test]
fn optimal_schedule_sums_to_quantity() {
let schedule = LiquidationCost::optimal_liquidation_schedule(
1000.0, 200.0, 5, 0.02, 1e-4,
);
let total: f64 = schedule.iter().sum();
assert!((total - 1000.0).abs() < 1e-6);
}
#[test]
fn days_to_liquidate_basic() {
let days = LiquidityRiskEngine::days_to_liquidate(1_000_000.0, 500_000.0, 0.2);
assert!((days - 10.0).abs() < 1e-9);
}
#[test]
fn score_asset_rating() {
let engine = LiquidityRiskEngine;
let d = sample_depth();
let returns = vec![0.001, -0.002, 0.001];
let volumes = vec![1_000_000.0; 3];
let score = engine.score_asset("TEST", &returns, &volumes, &d);
assert!(score.composite_score >= 0.0 && score.composite_score <= 100.0);
}
#[test]
fn liquidity_rating_display() {
assert_eq!(LiquidityRating::HighlyLiquid.to_string(), "Highly Liquid");
assert_eq!(LiquidityRating::Illiquid.to_string(), "Illiquid");
}
}