Skip to main content

fin_primitives/microstructure/
mod.rs

1//! Tick-level microstructure metrics: bid-ask spread, Amihud illiquidity, Kyle's lambda, Roll implied spread.
2//!
3//! ## Responsibility
4//! Tick-level market microstructure metrics: bid-ask spread, Amihud illiquidity,
5//! Kyle's lambda (market impact coefficient), and Roll's implied spread.
6//!
7//! ## Guarantees
8//! - Zero panics; all fallible operations return `Result<_, FinError>`
9//! - All price/quantity inputs use `rust_decimal::Decimal` for precision
10//! - Rolling windows use `VecDeque`; no unbounded allocation
11//! - Returns `None` from `get()` methods until the window is full
12//!
13//! ## NOT Responsible For
14//! - Order routing, execution, or risk checks
15//! - Persistence
16
17use crate::error::FinError;
18use rust_decimal::prelude::ToPrimitive;
19use rust_decimal::Decimal;
20use std::collections::VecDeque;
21
22// ─────────────────────────────────────────
23//  BidAskSpread
24// ─────────────────────────────────────────
25
26/// Rolling average bid-ask spread tracker, expressed in basis points.
27///
28/// Feed bid/ask prices via [`update`](BidAskSpread::update). Once `window` samples
29/// have been seen, [`average_spread_bps`](BidAskSpread::average_spread_bps) returns
30/// the rolling average.
31///
32/// Basis points = `(ask - bid) / mid * 10_000`.
33///
34/// # Example
35/// ```rust
36/// use fin_primitives::microstructure::BidAskSpread;
37/// use rust_decimal_macros::dec;
38///
39/// let mut tracker = BidAskSpread::new(5).unwrap();
40/// for _ in 0..5 {
41///     tracker.update(dec!(99.90), dec!(100.10)).unwrap();
42/// }
43/// let spread_bps = tracker.average_spread_bps().unwrap();
44/// // spread = 0.20, mid = 100.0 → 20 bps
45/// assert!((spread_bps - 20.0).abs() < 0.01);
46/// ```
47#[derive(Debug)]
48pub struct BidAskSpread {
49    window: usize,
50    /// Rolling buffer of (spread_bps) values.
51    buf: VecDeque<f64>,
52}
53
54impl BidAskSpread {
55    /// Constructs a `BidAskSpread` tracker.
56    ///
57    /// # Errors
58    /// Returns [`FinError::InvalidPeriod`] if `window == 0`.
59    pub fn new(window: usize) -> Result<Self, FinError> {
60        if window == 0 {
61            return Err(FinError::InvalidPeriod(window));
62        }
63        Ok(Self { window, buf: VecDeque::with_capacity(window) })
64    }
65
66    /// Records a bid/ask quote.
67    ///
68    /// # Errors
69    /// Returns [`FinError::InvalidInput`] if `bid >= ask` or `bid <= 0`.
70    pub fn update(&mut self, bid: Decimal, ask: Decimal) -> Result<(), FinError> {
71        if bid <= Decimal::ZERO {
72            return Err(FinError::InvalidInput(format!("bid must be positive, got {bid}")));
73        }
74        if ask <= bid {
75            return Err(FinError::InvalidInput(format!(
76                "ask ({ask}) must be greater than bid ({bid})"
77            )));
78        }
79        let mid = (bid + ask) / Decimal::from(2u32);
80        let spread = ask - bid;
81        let mid_f = mid.to_f64().unwrap_or(0.0);
82        let spread_f = spread.to_f64().unwrap_or(0.0);
83        if mid_f <= 0.0 {
84            return Err(FinError::InvalidInput("mid price must be positive".to_owned()));
85        }
86        let bps = spread_f / mid_f * 10_000.0;
87        self.buf.push_back(bps);
88        if self.buf.len() > self.window {
89            self.buf.pop_front();
90        }
91        Ok(())
92    }
93
94    /// Returns the rolling average spread in basis points, or `None` if not yet ready.
95    pub fn average_spread_bps(&self) -> Option<f64> {
96        if self.buf.len() < self.window {
97            return None;
98        }
99        let sum: f64 = self.buf.iter().sum();
100        Some(sum / self.buf.len() as f64)
101    }
102
103    /// Returns `true` when the window is full.
104    pub fn is_ready(&self) -> bool {
105        self.buf.len() >= self.window
106    }
107
108    /// Returns the configured window size.
109    pub fn window(&self) -> usize {
110        self.window
111    }
112
113    /// Returns the number of samples buffered.
114    pub fn sample_count(&self) -> usize {
115        self.buf.len()
116    }
117
118    /// Resets the tracker.
119    pub fn reset(&mut self) {
120        self.buf.clear();
121    }
122}
123
124// ─────────────────────────────────────────
125//  AmihudIlliquidity
126// ─────────────────────────────────────────
127
128/// Rolling Amihud Illiquidity ratio: `|return| / volume`.
129///
130/// A higher value indicates that prices move more per unit of volume (illiquid market).
131///
132/// `Illiquidity = mean(|r_t| / V_t)` over the rolling window.
133///
134/// # Example
135/// ```rust
136/// use fin_primitives::microstructure::AmihudIlliquidity;
137/// use rust_decimal_macros::dec;
138///
139/// let mut ai = AmihudIlliquidity::new(3).unwrap();
140/// ai.update(dec!(100), dec!(102), dec!(1000)).unwrap();
141/// ai.update(dec!(102), dec!(101), dec!(500)).unwrap();
142/// ai.update(dec!(101), dec!(103), dec!(800)).unwrap();
143/// let illiq = ai.get().unwrap();
144/// assert!(illiq > 0.0);
145/// ```
146#[derive(Debug)]
147pub struct AmihudIlliquidity {
148    window: usize,
149    buf: VecDeque<f64>,
150}
151
152impl AmihudIlliquidity {
153    /// Constructs an `AmihudIlliquidity` tracker.
154    ///
155    /// # Errors
156    /// Returns [`FinError::InvalidPeriod`] if `window == 0`.
157    pub fn new(window: usize) -> Result<Self, FinError> {
158        if window == 0 {
159            return Err(FinError::InvalidPeriod(window));
160        }
161        Ok(Self { window, buf: VecDeque::with_capacity(window) })
162    }
163
164    /// Records a price observation.
165    ///
166    /// - `prev_close`: previous period closing price.
167    /// - `close`: current period closing price.
168    /// - `volume`: trading volume during the period (must be > 0).
169    ///
170    /// # Errors
171    /// Returns [`FinError::InvalidInput`] if `prev_close <= 0`, `close <= 0`, or `volume <= 0`.
172    pub fn update(
173        &mut self,
174        prev_close: Decimal,
175        close: Decimal,
176        volume: Decimal,
177    ) -> Result<(), FinError> {
178        if prev_close <= Decimal::ZERO {
179            return Err(FinError::InvalidInput("prev_close must be positive".to_owned()));
180        }
181        if close <= Decimal::ZERO {
182            return Err(FinError::InvalidInput("close must be positive".to_owned()));
183        }
184        if volume <= Decimal::ZERO {
185            return Err(FinError::InvalidInput("volume must be positive".to_owned()));
186        }
187        let pc = prev_close.to_f64().unwrap_or(1.0);
188        let c = close.to_f64().unwrap_or(pc);
189        let v = volume.to_f64().unwrap_or(1.0);
190        let ret = ((c / pc).ln()).abs();
191        let ratio = ret / v;
192        self.buf.push_back(ratio);
193        if self.buf.len() > self.window {
194            self.buf.pop_front();
195        }
196        Ok(())
197    }
198
199    /// Returns the rolling Amihud illiquidity ratio, or `None` until ready.
200    pub fn get(&self) -> Option<f64> {
201        if self.buf.len() < self.window {
202            return None;
203        }
204        let sum: f64 = self.buf.iter().sum();
205        Some(sum / self.buf.len() as f64)
206    }
207
208    /// Returns `true` when the window is full.
209    pub fn is_ready(&self) -> bool {
210        self.buf.len() >= self.window
211    }
212
213    /// Returns the configured window size.
214    pub fn window(&self) -> usize {
215        self.window
216    }
217
218    /// Returns the number of samples buffered.
219    pub fn sample_count(&self) -> usize {
220        self.buf.len()
221    }
222
223    /// Resets the tracker.
224    pub fn reset(&mut self) {
225        self.buf.clear();
226    }
227}
228
229// ─────────────────────────────────────────
230//  KyleLambda
231// ─────────────────────────────────────────
232
233/// Kyle's Lambda — estimated market impact coefficient.
234///
235/// Estimates how much the price moves per unit of signed order flow (volume imbalance).
236/// Computed as OLS slope of price change on signed volume:
237///
238/// `λ = Cov(ΔP, ΔQ) / Var(ΔQ)`
239///
240/// where `ΔQ` is signed volume (positive = buy-initiated, negative = sell-initiated).
241///
242/// Returns `None` until the window is full or if signed volume has zero variance.
243///
244/// # Example
245/// ```rust
246/// use fin_primitives::microstructure::KyleLambda;
247/// use rust_decimal_macros::dec;
248///
249/// let mut kl = KyleLambda::new(4).unwrap();
250/// kl.update(dec!(0.10), dec!(200)).unwrap();
251/// kl.update(dec!(0.05), dec!(100)).unwrap();
252/// kl.update(dec!(-0.08), dec!(-150)).unwrap();
253/// kl.update(dec!(0.12), dec!(250)).unwrap();
254/// let lambda = kl.get(); // Some(estimated lambda)
255/// ```
256#[derive(Debug)]
257pub struct KyleLambda {
258    window: usize,
259    /// Buffer of (price_change, signed_volume) pairs.
260    buf: VecDeque<(f64, f64)>,
261}
262
263impl KyleLambda {
264    /// Constructs a `KyleLambda` estimator.
265    ///
266    /// # Errors
267    /// Returns [`FinError::InvalidPeriod`] if `window < 2`.
268    pub fn new(window: usize) -> Result<Self, FinError> {
269        if window < 2 {
270            return Err(FinError::InvalidPeriod(window));
271        }
272        Ok(Self { window, buf: VecDeque::with_capacity(window) })
273    }
274
275    /// Records a price change and signed volume observation.
276    ///
277    /// - `price_change`: `close_t - close_{t-1}` (can be negative).
278    /// - `signed_volume`: net order flow (positive = buy pressure, negative = sell pressure).
279    ///
280    /// # Errors
281    /// Returns [`FinError::InvalidInput`] if either value is non-finite.
282    pub fn update(&mut self, price_change: Decimal, signed_volume: Decimal) -> Result<(), FinError> {
283        let dp = price_change.to_f64().ok_or_else(|| {
284            FinError::InvalidInput("price_change is not representable as f64".to_owned())
285        })?;
286        let dq = signed_volume.to_f64().ok_or_else(|| {
287            FinError::InvalidInput("signed_volume is not representable as f64".to_owned())
288        })?;
289        if !dp.is_finite() || !dq.is_finite() {
290            return Err(FinError::InvalidInput(
291                "price_change and signed_volume must be finite".to_owned(),
292            ));
293        }
294        self.buf.push_back((dp, dq));
295        if self.buf.len() > self.window {
296            self.buf.pop_front();
297        }
298        Ok(())
299    }
300
301    /// Returns the estimated Kyle's lambda, or `None` until ready.
302    pub fn get(&self) -> Option<f64> {
303        if self.buf.len() < self.window {
304            return None;
305        }
306        let n = self.buf.len() as f64;
307        let mean_dp = self.buf.iter().map(|(dp, _)| dp).sum::<f64>() / n;
308        let mean_dq = self.buf.iter().map(|(_, dq)| dq).sum::<f64>() / n;
309        let cov: f64 = self.buf.iter().map(|(dp, dq)| (dp - mean_dp) * (dq - mean_dq)).sum::<f64>();
310        let var_dq: f64 = self.buf.iter().map(|(_, dq)| (dq - mean_dq).powi(2)).sum::<f64>();
311        if var_dq == 0.0 {
312            return None;
313        }
314        Some(cov / var_dq)
315    }
316
317    /// Returns `true` when the window is full.
318    pub fn is_ready(&self) -> bool {
319        self.buf.len() >= self.window
320    }
321
322    /// Returns the configured window size.
323    pub fn window(&self) -> usize {
324        self.window
325    }
326
327    /// Returns the number of samples buffered.
328    pub fn sample_count(&self) -> usize {
329        self.buf.len()
330    }
331
332    /// Resets the estimator.
333    pub fn reset(&mut self) {
334        self.buf.clear();
335    }
336}
337
338// ─────────────────────────────────────────
339//  RollImpliedSpread
340// ─────────────────────────────────────────
341
342/// Roll's Implied Spread estimator.
343///
344/// Estimates the effective bid-ask spread from serial autocorrelation of price changes:
345///
346/// `S = 2 * sqrt(-Cov(ΔP_t, ΔP_{t-1}))` when `Cov < 0`.
347///
348/// When `Cov >= 0` (no autocorrelation signal), returns `0.0` (no spread implied).
349///
350/// Returns `None` until `window + 1` price changes have been observed.
351///
352/// # Example
353/// ```rust
354/// use fin_primitives::microstructure::RollImpliedSpread;
355/// use rust_decimal_macros::dec;
356///
357/// let mut roll = RollImpliedSpread::new(10).unwrap();
358/// // Alternating returns simulate bid-ask bounce
359/// for i in 0..11 {
360///     let ret = if i % 2 == 0 { dec!(0.05) } else { dec!(-0.05) };
361///     roll.update(ret).unwrap();
362/// }
363/// let spread = roll.get();
364/// assert!(spread.is_some());
365/// ```
366#[derive(Debug)]
367pub struct RollImpliedSpread {
368    window: usize,
369    /// Rolling buffer of price changes.
370    changes: VecDeque<f64>,
371}
372
373impl RollImpliedSpread {
374    /// Constructs a `RollImpliedSpread` estimator.
375    ///
376    /// # Errors
377    /// Returns [`FinError::InvalidPeriod`] if `window < 2`.
378    pub fn new(window: usize) -> Result<Self, FinError> {
379        if window < 2 {
380            return Err(FinError::InvalidPeriod(window));
381        }
382        Ok(Self {
383            window,
384            changes: VecDeque::with_capacity(window + 1),
385        })
386    }
387
388    /// Records a price change observation.
389    ///
390    /// # Errors
391    /// Returns [`FinError::InvalidInput`] if the value is non-finite.
392    pub fn update(&mut self, price_change: Decimal) -> Result<(), FinError> {
393        let dp = price_change.to_f64().ok_or_else(|| {
394            FinError::InvalidInput("price_change is not representable as f64".to_owned())
395        })?;
396        if !dp.is_finite() {
397            return Err(FinError::InvalidInput("price_change must be finite".to_owned()));
398        }
399        self.changes.push_back(dp);
400        if self.changes.len() > self.window + 1 {
401            self.changes.pop_front();
402        }
403        Ok(())
404    }
405
406    /// Returns the Roll implied spread estimate, or `None` until ready.
407    ///
408    /// Returns `0.0` when the first-order autocovariance is non-negative (no bounce signal).
409    pub fn get(&self) -> Option<f64> {
410        if self.changes.len() < self.window + 1 {
411            return None;
412        }
413        let n = self.changes.len();
414        // Compute first-order autocovariance: Cov(dp_t, dp_{t-1})
415        let mean = self.changes.iter().sum::<f64>() / n as f64;
416        let cov: f64 = self
417            .changes
418            .iter()
419            .zip(self.changes.iter().skip(1))
420            .map(|(a, b)| (a - mean) * (b - mean))
421            .sum::<f64>()
422            / (n - 1) as f64;
423
424        if cov >= 0.0 {
425            Some(0.0)
426        } else {
427            let spread = 2.0 * (-cov).sqrt();
428            Some(spread)
429        }
430    }
431
432    /// Returns `true` when the window is full.
433    pub fn is_ready(&self) -> bool {
434        self.changes.len() > self.window
435    }
436
437    /// Returns the configured window size.
438    pub fn window(&self) -> usize {
439        self.window
440    }
441
442    /// Returns the number of price changes buffered.
443    pub fn sample_count(&self) -> usize {
444        self.changes.len()
445    }
446
447    /// Resets the estimator.
448    pub fn reset(&mut self) {
449        self.changes.clear();
450    }
451}
452
453// ─────────────────────────────────────────
454//  OrderImbalance
455// ─────────────────────────────────────────
456
457/// Rolling buy/sell volume order imbalance measure.
458///
459/// `OIR = (V_buy - V_sell) / (V_buy + V_sell)` for each bar.
460/// Rolling mean over the configured window.
461///
462/// Range: `[-1.0, 1.0]`. Positive values indicate buy-side pressure.
463///
464/// # Example
465/// ```rust
466/// use fin_primitives::microstructure::OrderImbalance;
467/// use rust_decimal_macros::dec;
468///
469/// let mut oi = OrderImbalance::new(3).unwrap();
470/// oi.update(dec!(600), dec!(400)).unwrap(); // OIR = 0.2
471/// oi.update(dec!(700), dec!(300)).unwrap(); // OIR = 0.4
472/// oi.update(dec!(800), dec!(200)).unwrap(); // OIR = 0.6
473/// let imbalance = oi.get().unwrap();
474/// assert!(imbalance > 0.0, "positive buy pressure: {imbalance}");
475/// ```
476#[derive(Debug)]
477pub struct OrderImbalance {
478    window: usize,
479    /// Rolling buffer of per-bar order imbalance ratios.
480    buf: VecDeque<f64>,
481}
482
483impl OrderImbalance {
484    /// Constructs an `OrderImbalance` tracker.
485    ///
486    /// # Errors
487    /// Returns [`FinError`] if `window == 0`.
488    pub fn new(window: usize) -> Result<Self, FinError> {
489        if window == 0 {
490            return Err(FinError::InvalidPeriod(window));
491        }
492        Ok(Self { window, buf: VecDeque::with_capacity(window) })
493    }
494
495    /// Records a volume observation.
496    ///
497    /// - `buy_volume`: aggressive buy volume for the bar (must be >= 0).
498    /// - `sell_volume`: aggressive sell volume for the bar (must be >= 0).
499    ///
500    /// # Errors
501    /// Returns [`FinError::InvalidInput`] if both volumes are zero or either is negative.
502    pub fn update(&mut self, buy_volume: Decimal, sell_volume: Decimal) -> Result<(), FinError> {
503        if buy_volume < Decimal::ZERO {
504            return Err(FinError::InvalidInput(
505                "buy_volume must be non-negative".to_owned(),
506            ));
507        }
508        if sell_volume < Decimal::ZERO {
509            return Err(FinError::InvalidInput(
510                "sell_volume must be non-negative".to_owned(),
511            ));
512        }
513        let total = buy_volume + sell_volume;
514        if total == Decimal::ZERO {
515            return Err(FinError::InvalidInput(
516                "buy_volume + sell_volume must be positive".to_owned(),
517            ));
518        }
519        let bv = buy_volume.to_f64().unwrap_or(0.0);
520        let sv = sell_volume.to_f64().unwrap_or(0.0);
521        let tot = bv + sv;
522        let oir = (bv - sv) / tot;
523        self.buf.push_back(oir);
524        if self.buf.len() > self.window {
525            self.buf.pop_front();
526        }
527        Ok(())
528    }
529
530    /// Returns the rolling mean order imbalance ratio, or `None` until ready.
531    pub fn get(&self) -> Option<f64> {
532        if self.buf.len() < self.window {
533            return None;
534        }
535        let sum: f64 = self.buf.iter().sum();
536        Some(sum / self.buf.len() as f64)
537    }
538
539    /// Returns `true` when the window is full.
540    pub fn is_ready(&self) -> bool {
541        self.buf.len() >= self.window
542    }
543
544    /// Returns the configured window size.
545    pub fn window(&self) -> usize {
546        self.window
547    }
548
549    /// Returns the number of samples buffered.
550    pub fn sample_count(&self) -> usize {
551        self.buf.len()
552    }
553
554    /// Resets the tracker.
555    pub fn reset(&mut self) {
556        self.buf.clear();
557    }
558}
559
560// ─────────────────────────────────────────
561//  MicrostructureMetrics
562// ─────────────────────────────────────────
563
564/// Aggregated snapshot of all available microstructure metrics for a symbol.
565///
566/// Each field is `Some` if the underlying tracker has enough data (window full),
567/// or `None` if still warming up.
568#[derive(Debug, Clone, Default)]
569pub struct MicrostructureSnapshot {
570    /// Rolling average bid-ask spread in basis points.
571    pub avg_spread_bps: Option<f64>,
572    /// Rolling mean order imbalance ratio `[-1, 1]`.
573    pub order_imbalance: Option<f64>,
574    /// Kyle's lambda (price impact per unit of signed order flow).
575    pub kyle_lambda: Option<f64>,
576    /// Amihud illiquidity ratio (`|return| / volume`).
577    pub amihud_illiquidity: Option<f64>,
578    /// Roll implied spread (autocovariance-based).
579    pub roll_spread: Option<f64>,
580}
581
582/// Feeds real-time market data into all microstructure estimators and produces
583/// aggregate [`MicrostructureSnapshot`]s on demand.
584///
585/// # Example
586/// ```rust
587/// use fin_primitives::microstructure::MicrostructureMetrics;
588/// use rust_decimal_macros::dec;
589///
590/// let mut m = MicrostructureMetrics::new(5).unwrap();
591/// for _ in 0..5 {
592///     m.update_spread(dec!(99.90), dec!(100.10)).unwrap();
593///     m.update_volume_imbalance(dec!(600), dec!(400)).unwrap();
594///     m.update_price_impact(dec!(0.05), dec!(100)).unwrap();
595///     m.update_amihud(dec!(100), dec!(102), dec!(1000)).unwrap();
596///     m.update_roll(dec!(0.05)).unwrap();
597/// }
598/// let snap = m.snapshot();
599/// assert!(snap.avg_spread_bps.is_some());
600/// ```
601pub struct MicrostructureMetrics {
602    spread: BidAskSpread,
603    imbalance: OrderImbalance,
604    kyle: KyleLambda,
605    amihud: AmihudIlliquidity,
606    roll: RollImpliedSpread,
607}
608
609impl MicrostructureMetrics {
610    /// Create a new aggregator with the given rolling window for all sub-trackers.
611    ///
612    /// `KyleLambda` and `RollImpliedSpread` require `window >= 2`.
613    ///
614    /// # Errors
615    /// Returns [`FinError::InvalidPeriod`] if `window < 2`.
616    pub fn new(window: usize) -> Result<Self, FinError> {
617        if window < 2 {
618            return Err(FinError::InvalidPeriod(window));
619        }
620        Ok(Self {
621            spread: BidAskSpread::new(window)?,
622            imbalance: OrderImbalance::new(window)?,
623            kyle: KyleLambda::new(window)?,
624            amihud: AmihudIlliquidity::new(window)?,
625            roll: RollImpliedSpread::new(window)?,
626        })
627    }
628
629    /// Feed a bid/ask quote into the spread tracker.
630    ///
631    /// # Errors
632    /// Propagates errors from [`BidAskSpread::update`].
633    pub fn update_spread(&mut self, bid: Decimal, ask: Decimal) -> Result<(), FinError> {
634        self.spread.update(bid, ask)
635    }
636
637    /// Feed a buy/sell volume observation into the order imbalance tracker.
638    ///
639    /// # Errors
640    /// Propagates errors from [`OrderImbalance::update`].
641    pub fn update_volume_imbalance(
642        &mut self,
643        buy_volume: Decimal,
644        sell_volume: Decimal,
645    ) -> Result<(), FinError> {
646        self.imbalance.update(buy_volume, sell_volume)
647    }
648
649    /// Feed a price-change / signed-volume pair into the Kyle's lambda estimator.
650    ///
651    /// # Errors
652    /// Propagates errors from [`KyleLambda::update`].
653    pub fn update_price_impact(
654        &mut self,
655        price_change: Decimal,
656        signed_volume: Decimal,
657    ) -> Result<(), FinError> {
658        self.kyle.update(price_change, signed_volume)
659    }
660
661    /// Feed a prev/current close and volume into the Amihud illiquidity estimator.
662    ///
663    /// # Errors
664    /// Propagates errors from [`AmihudIlliquidity::update`].
665    pub fn update_amihud(
666        &mut self,
667        prev_close: Decimal,
668        close: Decimal,
669        volume: Decimal,
670    ) -> Result<(), FinError> {
671        self.amihud.update(prev_close, close, volume)
672    }
673
674    /// Feed a price change into the Roll spread estimator.
675    ///
676    /// # Errors
677    /// Propagates errors from [`RollImpliedSpread::update`].
678    pub fn update_roll(&mut self, price_change: Decimal) -> Result<(), FinError> {
679        self.roll.update(price_change)
680    }
681
682    /// Produce a snapshot of all currently available metrics.
683    ///
684    /// Fields are `None` until the underlying rolling window is full.
685    pub fn snapshot(&self) -> MicrostructureSnapshot {
686        MicrostructureSnapshot {
687            avg_spread_bps: self.spread.average_spread_bps(),
688            order_imbalance: self.imbalance.get(),
689            kyle_lambda: self.kyle.get(),
690            amihud_illiquidity: self.amihud.get(),
691            roll_spread: self.roll.get(),
692        }
693    }
694
695    /// Reset all sub-trackers.
696    pub fn reset(&mut self) {
697        self.spread.reset();
698        self.imbalance.reset();
699        self.kyle.reset();
700        self.amihud.reset();
701        self.roll.reset();
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use rust_decimal_macros::dec;
709
710    // ── BidAskSpread ──────────────────────────────────────────────────────
711
712    #[test]
713    fn test_bid_ask_spread_zero_window_fails() {
714        assert!(BidAskSpread::new(0).is_err());
715    }
716
717    #[test]
718    fn test_bid_ask_spread_not_ready_before_window() {
719        let mut t = BidAskSpread::new(3).unwrap();
720        t.update(dec!(99.9), dec!(100.1)).unwrap();
721        t.update(dec!(99.9), dec!(100.1)).unwrap();
722        assert!(!t.is_ready());
723        assert!(t.average_spread_bps().is_none());
724    }
725
726    #[test]
727    fn test_bid_ask_spread_correct_bps() {
728        let mut t = BidAskSpread::new(3).unwrap();
729        // spread=0.20, mid=100.0 → 20 bps
730        for _ in 0..3 {
731            t.update(dec!(99.90), dec!(100.10)).unwrap();
732        }
733        let bps = t.average_spread_bps().unwrap();
734        assert!((bps - 20.0).abs() < 0.01, "bps={bps}");
735    }
736
737    #[test]
738    fn test_bid_ask_spread_inverted_fails() {
739        let mut t = BidAskSpread::new(3).unwrap();
740        assert!(t.update(dec!(101), dec!(100)).is_err());
741    }
742
743    #[test]
744    fn test_bid_ask_spread_negative_bid_fails() {
745        let mut t = BidAskSpread::new(3).unwrap();
746        assert!(t.update(dec!(-1), dec!(100)).is_err());
747    }
748
749    #[test]
750    fn test_bid_ask_spread_reset() {
751        let mut t = BidAskSpread::new(2).unwrap();
752        t.update(dec!(99), dec!(101)).unwrap();
753        t.update(dec!(99), dec!(101)).unwrap();
754        assert!(t.is_ready());
755        t.reset();
756        assert!(!t.is_ready());
757    }
758
759    // ── AmihudIlliquidity ─────────────────────────────────────────────────
760
761    #[test]
762    fn test_amihud_zero_window_fails() {
763        assert!(AmihudIlliquidity::new(0).is_err());
764    }
765
766    #[test]
767    fn test_amihud_not_ready_before_window() {
768        let mut ai = AmihudIlliquidity::new(3).unwrap();
769        ai.update(dec!(100), dec!(102), dec!(1000)).unwrap();
770        assert!(!ai.is_ready());
771        assert!(ai.get().is_none());
772    }
773
774    #[test]
775    fn test_amihud_positive_for_price_moves() {
776        let mut ai = AmihudIlliquidity::new(3).unwrap();
777        ai.update(dec!(100), dec!(105), dec!(1000)).unwrap();
778        ai.update(dec!(105), dec!(103), dec!(800)).unwrap();
779        ai.update(dec!(103), dec!(107), dec!(1200)).unwrap();
780        let illiq = ai.get().unwrap();
781        assert!(illiq > 0.0, "illiquidity should be positive: {illiq}");
782    }
783
784    #[test]
785    fn test_amihud_zero_volume_fails() {
786        let mut ai = AmihudIlliquidity::new(3).unwrap();
787        assert!(ai.update(dec!(100), dec!(105), dec!(0)).is_err());
788    }
789
790    #[test]
791    fn test_amihud_reset() {
792        let mut ai = AmihudIlliquidity::new(2).unwrap();
793        ai.update(dec!(100), dec!(102), dec!(500)).unwrap();
794        ai.update(dec!(102), dec!(101), dec!(600)).unwrap();
795        assert!(ai.is_ready());
796        ai.reset();
797        assert!(!ai.is_ready());
798    }
799
800    // ── KyleLambda ────────────────────────────────────────────────────────
801
802    #[test]
803    fn test_kyle_period_1_fails() {
804        assert!(KyleLambda::new(1).is_err());
805    }
806
807    #[test]
808    fn test_kyle_not_ready_before_window() {
809        let mut kl = KyleLambda::new(4).unwrap();
810        kl.update(dec!(0.1), dec!(200)).unwrap();
811        assert!(!kl.is_ready());
812        assert!(kl.get().is_none());
813    }
814
815    #[test]
816    fn test_kyle_positive_lambda_for_aligned_signals() {
817        let mut kl = KyleLambda::new(4).unwrap();
818        // Positive price changes with positive volume → positive lambda
819        kl.update(dec!(0.10), dec!(100)).unwrap();
820        kl.update(dec!(0.20), dec!(200)).unwrap();
821        kl.update(dec!(0.15), dec!(150)).unwrap();
822        kl.update(dec!(0.25), dec!(250)).unwrap();
823        let lambda = kl.get().unwrap();
824        assert!(lambda > 0.0, "lambda should be positive: {lambda}");
825    }
826
827    #[test]
828    fn test_kyle_zero_volume_variance_returns_none() {
829        let mut kl = KyleLambda::new(3).unwrap();
830        // Constant signed volume → zero variance → None
831        kl.update(dec!(0.1), dec!(100)).unwrap();
832        kl.update(dec!(0.2), dec!(100)).unwrap();
833        kl.update(dec!(0.3), dec!(100)).unwrap();
834        assert!(kl.get().is_none());
835    }
836
837    #[test]
838    fn test_kyle_reset() {
839        let mut kl = KyleLambda::new(2).unwrap();
840        kl.update(dec!(0.1), dec!(100)).unwrap();
841        kl.update(dec!(0.2), dec!(200)).unwrap();
842        assert!(kl.is_ready());
843        kl.reset();
844        assert!(!kl.is_ready());
845    }
846
847    // ── RollImpliedSpread ─────────────────────────────────────────────────
848
849    #[test]
850    fn test_roll_period_1_fails() {
851        assert!(RollImpliedSpread::new(1).is_err());
852    }
853
854    #[test]
855    fn test_roll_not_ready_before_window() {
856        let mut r = RollImpliedSpread::new(5).unwrap();
857        r.update(dec!(0.05)).unwrap();
858        assert!(!r.is_ready());
859        assert!(r.get().is_none());
860    }
861
862    #[test]
863    fn test_roll_positive_spread_for_alternating_returns() {
864        let mut r = RollImpliedSpread::new(10).unwrap();
865        for i in 0..11 {
866            let ret = if i % 2 == 0 { dec!(0.05) } else { dec!(-0.05) };
867            r.update(ret).unwrap();
868        }
869        let spread = r.get().unwrap();
870        assert!(spread > 0.0, "alternating returns should give positive Roll spread: {spread}");
871    }
872
873    #[test]
874    fn test_roll_zero_spread_for_trending_returns() {
875        // All positive returns → no bid-ask bounce → cov >= 0 → spread = 0
876        let mut r = RollImpliedSpread::new(5).unwrap();
877        for _ in 0..6 {
878            r.update(dec!(0.10)).unwrap();
879        }
880        let spread = r.get().unwrap();
881        // Constant returns → zero variance → autocovariance = 0 → spread = 0
882        assert_eq!(spread, 0.0);
883    }
884
885    #[test]
886    fn test_roll_reset() {
887        let mut r = RollImpliedSpread::new(3).unwrap();
888        for _ in 0..4 {
889            r.update(dec!(0.01)).unwrap();
890        }
891        assert!(r.is_ready());
892        r.reset();
893        assert!(!r.is_ready());
894    }
895
896    // ── OrderImbalance ────────────────────────────────────────────────────
897
898    #[test]
899    fn test_order_imbalance_zero_window_fails() {
900        assert!(OrderImbalance::new(0).is_err());
901    }
902
903    #[test]
904    fn test_order_imbalance_not_ready_before_window() {
905        let mut oi = OrderImbalance::new(3).unwrap();
906        oi.update(dec!(600), dec!(400)).unwrap();
907        assert!(!oi.is_ready());
908        assert!(oi.get().is_none());
909    }
910
911    #[test]
912    fn test_order_imbalance_positive_for_buy_heavy() {
913        let mut oi = OrderImbalance::new(3).unwrap();
914        oi.update(dec!(800), dec!(200)).unwrap();
915        oi.update(dec!(700), dec!(300)).unwrap();
916        oi.update(dec!(900), dec!(100)).unwrap();
917        let imbalance = oi.get().unwrap();
918        assert!(imbalance > 0.0, "expected positive imbalance: {imbalance}");
919    }
920
921    #[test]
922    fn test_order_imbalance_negative_for_sell_heavy() {
923        let mut oi = OrderImbalance::new(3).unwrap();
924        oi.update(dec!(200), dec!(800)).unwrap();
925        oi.update(dec!(300), dec!(700)).unwrap();
926        oi.update(dec!(100), dec!(900)).unwrap();
927        let imbalance = oi.get().unwrap();
928        assert!(imbalance < 0.0, "expected negative imbalance: {imbalance}");
929    }
930
931    #[test]
932    fn test_order_imbalance_zero_total_fails() {
933        let mut oi = OrderImbalance::new(3).unwrap();
934        assert!(oi.update(dec!(0), dec!(0)).is_err());
935    }
936
937    #[test]
938    fn test_order_imbalance_negative_volume_fails() {
939        let mut oi = OrderImbalance::new(3).unwrap();
940        assert!(oi.update(dec!(-100), dec!(100)).is_err());
941    }
942
943    #[test]
944    fn test_order_imbalance_reset() {
945        let mut oi = OrderImbalance::new(2).unwrap();
946        oi.update(dec!(500), dec!(500)).unwrap();
947        oi.update(dec!(500), dec!(500)).unwrap();
948        assert!(oi.is_ready());
949        oi.reset();
950        assert!(!oi.is_ready());
951    }
952
953    // ── MicrostructureMetrics ──────────────────────────────────────────────
954
955    #[test]
956    fn test_microstructure_metrics_window_too_small_fails() {
957        assert!(MicrostructureMetrics::new(1).is_err());
958        assert!(MicrostructureMetrics::new(0).is_err());
959    }
960
961    #[test]
962    fn test_microstructure_metrics_snapshot_none_before_warm() {
963        let mut m = MicrostructureMetrics::new(5).unwrap();
964        m.update_spread(dec!(99.9), dec!(100.1)).unwrap();
965        let snap = m.snapshot();
966        assert!(snap.avg_spread_bps.is_none());
967        assert!(snap.order_imbalance.is_none());
968        assert!(snap.kyle_lambda.is_none());
969        assert!(snap.amihud_illiquidity.is_none());
970        assert!(snap.roll_spread.is_none());
971    }
972
973    #[test]
974    fn test_microstructure_metrics_snapshot_some_after_warm() {
975        let mut m = MicrostructureMetrics::new(3).unwrap();
976        for i in 0..3 {
977            m.update_spread(dec!(99.90), dec!(100.10)).unwrap();
978            m.update_volume_imbalance(dec!(600), dec!(400)).unwrap();
979            m.update_price_impact(
980                rust_decimal::prelude::FromPrimitive::from_f64(0.05 + i as f64 * 0.01).unwrap_or(dec!(0.05)),
981                rust_decimal::prelude::FromPrimitive::from_f64(100.0 + i as f64 * 50.0).unwrap_or(dec!(100)),
982            ).unwrap();
983            m.update_amihud(dec!(100), dec!(102), dec!(1000)).unwrap();
984            m.update_roll(if i % 2 == 0 { dec!(0.05) } else { dec!(-0.05) }).unwrap();
985        }
986        let snap = m.snapshot();
987        assert!(snap.avg_spread_bps.is_some());
988        assert!(snap.order_imbalance.is_some());
989        assert!(snap.amihud_illiquidity.is_some());
990        // Roll needs window+1 samples; may still be None with 3 samples and window=3
991        let _ = snap.roll_spread; // just verify no panic
992    }
993
994    #[test]
995    fn test_microstructure_metrics_reset() {
996        let mut m = MicrostructureMetrics::new(2).unwrap();
997        for _ in 0..2 {
998            m.update_spread(dec!(99.9), dec!(100.1)).unwrap();
999            m.update_volume_imbalance(dec!(500), dec!(500)).unwrap();
1000            m.update_price_impact(dec!(0.05), dec!(100)).unwrap();
1001            m.update_amihud(dec!(100), dec!(102), dec!(1000)).unwrap();
1002            m.update_roll(dec!(0.05)).unwrap();
1003        }
1004        m.reset();
1005        let snap = m.snapshot();
1006        assert!(snap.avg_spread_bps.is_none());
1007        assert!(snap.order_imbalance.is_none());
1008    }
1009
1010    // ── TradeSign / Lee-Ready ─────────────────────────────────────────────
1011
1012    #[test]
1013    fn test_trade_sign_classify_buy() {
1014        assert_eq!(TradeSign::classify(100.5, 100.0), TradeSign::Buy);
1015    }
1016
1017    #[test]
1018    fn test_trade_sign_classify_sell() {
1019        assert_eq!(TradeSign::classify(99.5, 100.0), TradeSign::Sell);
1020    }
1021
1022    #[test]
1023    fn test_trade_sign_classify_unknown_equal() {
1024        assert_eq!(TradeSign::classify(100.0, 100.0), TradeSign::Unknown);
1025    }
1026
1027    // ── KyleLambdaEstimate ────────────────────────────────────────────────
1028
1029    #[test]
1030    fn test_kyle_lambda_estimate_positive() {
1031        let changes = vec![0.1, 0.2, 0.15, 0.25, -0.05];
1032        let volumes = vec![100.0, 200.0, 150.0, 250.0, -50.0];
1033        let est = KyleLambdaEstimate::estimate(&changes, &volumes);
1034        assert!(est.lambda > 0.0, "lambda={}", est.lambda);
1035        assert!((0.0..=1.0).contains(&est.r_squared), "r2={}", est.r_squared);
1036    }
1037
1038    #[test]
1039    fn test_kyle_lambda_estimate_empty() {
1040        let est = KyleLambdaEstimate::estimate(&[], &[]);
1041        assert_eq!(est.lambda, 0.0);
1042        assert_eq!(est.r_squared, 0.0);
1043    }
1044
1045    #[test]
1046    fn test_kyle_lambda_estimate_zero_variance() {
1047        let changes = vec![0.1, 0.1, 0.1];
1048        let volumes = vec![100.0, 100.0, 100.0];
1049        let est = KyleLambdaEstimate::estimate(&changes, &volumes);
1050        assert_eq!(est.lambda, 0.0);
1051        assert_eq!(est.r_squared, 0.0);
1052    }
1053
1054    // ── HasbrouckShare ────────────────────────────────────────────────────
1055
1056    #[test]
1057    fn test_hasbrouck_share_equal_variance() {
1058        let a = vec![0.1, -0.1, 0.2, -0.2, 0.1];
1059        let b = vec![0.1, -0.1, 0.2, -0.2, 0.1];
1060        let hs = HasbrouckShare::estimate(&a, &b);
1061        // Equal variance → shares close to 0.5 each
1062        assert!((hs.security_a_share - 0.5).abs() < 0.01, "a={}", hs.security_a_share);
1063        assert!((hs.security_b_share - 0.5).abs() < 0.01, "b={}", hs.security_b_share);
1064    }
1065
1066    #[test]
1067    fn test_hasbrouck_share_sums_to_one() {
1068        let a = vec![0.1, 0.2, -0.1, 0.3];
1069        let b = vec![0.05, 0.1, -0.2, 0.15];
1070        let hs = HasbrouckShare::estimate(&a, &b);
1071        let total = hs.security_a_share + hs.security_b_share;
1072        assert!((total - 1.0).abs() < 1e-10, "total={total}");
1073    }
1074
1075    #[test]
1076    fn test_hasbrouck_share_empty() {
1077        let hs = HasbrouckShare::estimate(&[], &[]);
1078        assert_eq!(hs.security_a_share, 0.5);
1079        assert_eq!(hs.security_b_share, 0.5);
1080    }
1081
1082    // ── PinEstimate ───────────────────────────────────────────────────────
1083
1084    #[test]
1085    fn test_pin_probability_basic() {
1086        let buys = vec![80u64, 90, 85, 70, 95];
1087        let sells = vec![20u64, 10, 15, 30, 5];
1088        let pin = PinEstimate::from_trade_counts(&buys, &sells);
1089        let p = pin.pin_probability();
1090        assert!((0.0..=1.0).contains(&p), "pin={p}");
1091    }
1092
1093    #[test]
1094    fn test_pin_probability_zero_denominator() {
1095        let pin = PinEstimate { alpha: 0.0, mu: 0.0, epsilon_b: 0.0, epsilon_s: 0.0 };
1096        assert_eq!(pin.pin_probability(), 0.0);
1097    }
1098
1099    #[test]
1100    fn test_pin_from_empty_counts() {
1101        let pin = PinEstimate::from_trade_counts(&[], &[]);
1102        assert_eq!(pin.pin_probability(), 0.0);
1103    }
1104}
1105
1106// ─────────────────────────────────────────
1107//  TradeSign — Lee-Ready tick classification
1108// ─────────────────────────────────────────
1109
1110/// Trade direction classification using the Lee-Ready tick test.
1111///
1112/// The tick test compares the current trade price to the previous trade price:
1113/// - Price increased → the trade is a `Buy` (up-tick).
1114/// - Price decreased → the trade is a `Sell` (down-tick).
1115/// - Price unchanged → `Unknown` (zero-tick; caller may apply the previous sign).
1116///
1117/// # Example
1118/// ```rust
1119/// use fin_primitives::microstructure::TradeSign;
1120///
1121/// assert_eq!(TradeSign::classify(100.5, 100.0), TradeSign::Buy);
1122/// assert_eq!(TradeSign::classify(99.5, 100.0), TradeSign::Sell);
1123/// assert_eq!(TradeSign::classify(100.0, 100.0), TradeSign::Unknown);
1124/// ```
1125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1126pub enum TradeSign {
1127    /// Trade occurred on an up-tick (buy-initiated).
1128    Buy,
1129    /// Trade occurred on a down-tick (sell-initiated).
1130    Sell,
1131    /// Trade occurred on a zero-tick (direction indeterminate).
1132    Unknown,
1133}
1134
1135impl TradeSign {
1136    /// Classify a trade using the Lee-Ready tick test.
1137    ///
1138    /// - `price`: current trade price.
1139    /// - `prev_price`: immediately preceding trade price.
1140    pub fn classify(price: f64, prev_price: f64) -> Self {
1141        if price > prev_price {
1142            Self::Buy
1143        } else if price < prev_price {
1144            Self::Sell
1145        } else {
1146            Self::Unknown
1147        }
1148    }
1149}
1150
1151// ─────────────────────────────────────────
1152//  KyleLambdaEstimate — OLS price-impact coefficient
1153// ─────────────────────────────────────────
1154
1155/// Kyle's Lambda estimated via OLS regression of price changes on signed volume.
1156///
1157/// `lambda` = OLS slope of `price_changes ~ signed_volumes`.
1158/// `r_squared` = coefficient of determination of that regression (0 to 1).
1159///
1160/// # Example
1161/// ```rust
1162/// use fin_primitives::microstructure::KyleLambdaEstimate;
1163///
1164/// let changes = vec![0.1, 0.2, -0.05, 0.15];
1165/// let volumes = vec![100.0, 200.0, -50.0, 150.0];
1166/// let est = KyleLambdaEstimate::estimate(&changes, &volumes);
1167/// assert!(est.lambda >= 0.0 || est.lambda < 0.0); // finite value
1168/// assert!((0.0..=1.0).contains(&est.r_squared));
1169/// ```
1170#[derive(Debug, Clone, Copy)]
1171pub struct KyleLambdaEstimate {
1172    /// OLS price-impact coefficient (price change per unit of signed order flow).
1173    pub lambda: f64,
1174    /// R-squared of the OLS regression.
1175    pub r_squared: f64,
1176}
1177
1178impl KyleLambdaEstimate {
1179    /// Estimate Kyle's Lambda from slices of price changes and signed volumes.
1180    ///
1181    /// Uses OLS: `lambda = Cov(ΔP, Q) / Var(Q)`.
1182    /// R-squared is computed as `(Cor(ΔP, Q))^2`.
1183    ///
1184    /// Returns `lambda = 0.0` and `r_squared = 0.0` when there are fewer than 2
1185    /// observations or when signed volume has zero variance.
1186    pub fn estimate(price_changes: &[f64], signed_volumes: &[f64]) -> Self {
1187        let n = price_changes.len().min(signed_volumes.len());
1188        if n < 2 {
1189            return Self { lambda: 0.0, r_squared: 0.0 };
1190        }
1191        let nf = n as f64;
1192        let mean_dp = price_changes[..n].iter().sum::<f64>() / nf;
1193        let mean_dq = signed_volumes[..n].iter().sum::<f64>() / nf;
1194
1195        let mut cov_pq = 0.0_f64;
1196        let mut var_q = 0.0_f64;
1197        let mut var_p = 0.0_f64;
1198        for i in 0..n {
1199            let dp = price_changes[i] - mean_dp;
1200            let dq = signed_volumes[i] - mean_dq;
1201            cov_pq += dp * dq;
1202            var_q += dq * dq;
1203            var_p += dp * dp;
1204        }
1205
1206        if var_q == 0.0 {
1207            return Self { lambda: 0.0, r_squared: 0.0 };
1208        }
1209
1210        let lambda = cov_pq / var_q;
1211        let r_squared = if var_p == 0.0 {
1212            0.0
1213        } else {
1214            let cor = cov_pq / (var_q.sqrt() * var_p.sqrt());
1215            (cor * cor).min(1.0).max(0.0)
1216        };
1217
1218        Self { lambda, r_squared }
1219    }
1220}
1221
1222// ─────────────────────────────────────────
1223//  HasbrouckShare — variance decomposition information share
1224// ─────────────────────────────────────────
1225
1226/// Simplified Hasbrouck (1995) information share via variance decomposition.
1227///
1228/// Decomposes the contribution of each security to the common efficient price
1229/// using the fraction of total return variance attributable to each series.
1230///
1231/// `security_a_share = Var(a) / (Var(a) + Var(b))`, and similarly for B.
1232/// Both shares sum to 1.0. When total variance is zero, each share is 0.5.
1233///
1234/// # Example
1235/// ```rust
1236/// use fin_primitives::microstructure::HasbrouckShare;
1237///
1238/// let a = vec![0.1, -0.1, 0.2];
1239/// let b = vec![0.05, -0.05, 0.1];
1240/// let hs = HasbrouckShare::estimate(&a, &b);
1241/// assert!((hs.security_a_share + hs.security_b_share - 1.0).abs() < 1e-10);
1242/// ```
1243#[derive(Debug, Clone, Copy)]
1244pub struct HasbrouckShare {
1245    /// Information share attributed to security A (0 to 1).
1246    pub security_a_share: f64,
1247    /// Information share attributed to security B (0 to 1).
1248    pub security_b_share: f64,
1249}
1250
1251impl HasbrouckShare {
1252    /// Estimate Hasbrouck information shares from return series.
1253    ///
1254    /// `returns_a` and `returns_b` are period returns for the two securities.
1255    /// Uses the length of the shorter slice.
1256    pub fn estimate(returns_a: &[f64], returns_b: &[f64]) -> Self {
1257        let n = returns_a.len().min(returns_b.len());
1258        if n == 0 {
1259            return Self { security_a_share: 0.5, security_b_share: 0.5 };
1260        }
1261        let nf = n as f64;
1262        let mean_a = returns_a[..n].iter().sum::<f64>() / nf;
1263        let mean_b = returns_b[..n].iter().sum::<f64>() / nf;
1264        let var_a: f64 = returns_a[..n].iter().map(|x| (x - mean_a).powi(2)).sum::<f64>() / nf;
1265        let var_b: f64 = returns_b[..n].iter().map(|x| (x - mean_b).powi(2)).sum::<f64>() / nf;
1266        let total = var_a + var_b;
1267        if total == 0.0 {
1268            return Self { security_a_share: 0.5, security_b_share: 0.5 };
1269        }
1270        Self {
1271            security_a_share: var_a / total,
1272            security_b_share: var_b / total,
1273        }
1274    }
1275}
1276
1277// ─────────────────────────────────────────
1278//  PinEstimate — Probability of Informed Trading
1279// ─────────────────────────────────────────
1280
1281/// Easley et al. (1996) PIN model parameters estimated via method-of-moments.
1282///
1283/// Parameters:
1284/// - `alpha`: probability of an information event (0 to 1).
1285/// - `mu`: arrival rate of informed traders given an event.
1286/// - `epsilon_b`: uninformed buy-order arrival rate.
1287/// - `epsilon_s`: uninformed sell-order arrival rate.
1288///
1289/// # Example
1290/// ```rust
1291/// use fin_primitives::microstructure::PinEstimate;
1292///
1293/// let buys = vec![80u64, 90, 85];
1294/// let sells = vec![20u64, 10, 15];
1295/// let pin = PinEstimate::from_trade_counts(&buys, &sells);
1296/// let p = pin.pin_probability();
1297/// assert!((0.0..=1.0).contains(&p));
1298/// ```
1299#[derive(Debug, Clone, Copy)]
1300pub struct PinEstimate {
1301    /// Probability of an information event occurring.
1302    pub alpha: f64,
1303    /// Informed trader arrival rate conditional on an event.
1304    pub mu: f64,
1305    /// Uninformed buy-order arrival rate.
1306    pub epsilon_b: f64,
1307    /// Uninformed sell-order arrival rate.
1308    pub epsilon_s: f64,
1309}
1310
1311impl PinEstimate {
1312    /// Estimate PIN parameters from daily buy/sell trade counts via method-of-moments.
1313    ///
1314    /// Method-of-moments approximation:
1315    /// - `epsilon_b ≈ mean(min(buys, sells))` (uninformed baseline)
1316    /// - `epsilon_s ≈ mean(min(buys, sells))`
1317    /// - `mu ≈ mean(|buys - sells|)` (excess flow attributable to informed)
1318    /// - `alpha ≈ fraction of days with buys > sells` (event probability)
1319    ///
1320    /// Returns all-zero parameters when both slices are empty.
1321    pub fn from_trade_counts(buy_days: &[u64], sell_days: &[u64]) -> Self {
1322        let n = buy_days.len().min(sell_days.len());
1323        if n == 0 {
1324            return Self { alpha: 0.0, mu: 0.0, epsilon_b: 0.0, epsilon_s: 0.0 };
1325        }
1326        let nf = n as f64;
1327        let mut sum_min = 0.0_f64;
1328        let mut sum_excess = 0.0_f64;
1329        let mut event_days = 0u64;
1330
1331        for i in 0..n {
1332            let b = buy_days[i] as f64;
1333            let s = sell_days[i] as f64;
1334            let mn = b.min(s);
1335            let excess = (b - s).abs();
1336            sum_min += mn;
1337            sum_excess += excess;
1338            if (b - s).abs() > 1e-9 {
1339                event_days += 1;
1340            }
1341        }
1342
1343        let epsilon_b = sum_min / nf;
1344        let epsilon_s = epsilon_b;
1345        let mu = sum_excess / nf;
1346        let alpha = event_days as f64 / nf;
1347
1348        Self { alpha, mu, epsilon_b, epsilon_s }
1349    }
1350
1351    /// Compute the PIN probability.
1352    ///
1353    /// `PIN = alpha * mu / (alpha * mu + epsilon_b + epsilon_s)`
1354    ///
1355    /// Returns `0.0` when the denominator is zero.
1356    pub fn pin_probability(&self) -> f64 {
1357        let numerator = self.alpha * self.mu;
1358        let denominator = numerator + self.epsilon_b + self.epsilon_s;
1359        if denominator == 0.0 {
1360            0.0
1361        } else {
1362            numerator / denominator
1363        }
1364    }
1365}