Skip to main content

wickra_core/indicators/
pin.rs

1//! PIN — Probability of Informed Trading (single-window EKOP estimate).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8
9/// PIN — the **Probability of Informed Trading**, estimated from the buy/sell order
10/// imbalance over a rolling window of trades.
11///
12/// ```text
13/// over the last `window` trades: B = buys, S = sells   (B + S = window)
14/// PIN ≈ |B − S| / (B + S)        ∈ [0, 1]
15/// ```
16///
17/// The Easley-Kiefer-O'Hara-Paperman (EKOP) model splits order flow into an
18/// uninformed component (balanced buys and sells, rate `ε` per side) and an
19/// informed component that trades one-directionally when private information
20/// arrives (rate `μ`, probability `α`). The probability that any given trade is
21/// information-motivated is `PIN = αμ / (αμ + 2ε)`. Estimated over a single window,
22/// the informed flow shows up as the **net imbalance** `|B − S|` and the uninformed
23/// flow as the balanced remainder, giving the moment estimator above. A high PIN
24/// flags a one-sided, likely-informed market; a low PIN flags balanced, uninformed
25/// flow.
26///
27/// This is distinct from [`Vpin`](crate::Vpin), the volume-synchronised variant
28/// that buckets by volume and uses bulk-volume classification; here trades are
29/// counted in event time and classified by their tagged aggressor side. The full
30/// PIN is fit by maximum likelihood over many periods — this single-window
31/// estimator is the streaming moment approximation. The output is in `[0, 1]`; the
32/// first value lands after `window` trades.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, Pin, Side, Trade};
38///
39/// let mut indicator = Pin::new(20).unwrap();
40/// let mut last = None;
41/// for i in 0..40 {
42///     // All buys -> maximally one-sided -> PIN 1.
43///     last = indicator.update(Trade::new(100.0, 1.0, Side::Buy, i).unwrap());
44/// }
45/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
46/// ```
47#[derive(Debug, Clone)]
48pub struct Pin {
49    window: usize,
50    sides: VecDeque<f64>,
51    buy_count: usize,
52    last: Option<f64>,
53}
54
55impl Pin {
56    /// Construct a PIN estimator over `window` trades.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::PeriodZero`] if `window == 0`.
61    pub fn new(window: usize) -> Result<Self> {
62        if window == 0 {
63            return Err(Error::PeriodZero);
64        }
65        if window > crate::error::MAX_PERIOD {
66            return Err(Error::InvalidPeriod {
67                message: crate::error::PERIOD_ABOVE_MAX,
68            });
69        }
70        Ok(Self {
71            window,
72            sides: VecDeque::with_capacity(window),
73            buy_count: 0,
74            last: None,
75        })
76    }
77
78    /// Configured window of trades.
79    pub const fn window(&self) -> usize {
80        self.window
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.last
86    }
87}
88
89impl Indicator for Pin {
90    type Input = Trade;
91    type Output = f64;
92
93    #[inline]
94    fn update(&mut self, trade: Trade) -> Option<f64> {
95        let is_buy = trade.side.sign() > 0.0;
96        if self.sides.len() == self.window {
97            let old = self.sides.pop_front().expect("non-empty");
98            if old > 0.0 {
99                self.buy_count -= 1;
100            }
101        }
102        self.sides.push_back(if is_buy { 1.0 } else { 0.0 });
103        if is_buy {
104            self.buy_count += 1;
105        }
106        if self.sides.len() < self.window {
107            return None;
108        }
109        // The window is full and `window >= 1` (zero is rejected at
110        // construction), so the trade count is always positive — `|B - S| / N`
111        // needs no zero guard.
112        let buys = self.buy_count as f64;
113        let sells = self.window as f64 - buys;
114        let total = self.window as f64;
115        let pin = (buys - sells).abs() / total;
116        self.last = Some(pin);
117        Some(pin)
118    }
119
120    fn reset(&mut self) {
121        self.sides.clear();
122        self.buy_count = 0;
123        self.last = None;
124    }
125
126    #[inline]
127    fn warmup_period(&self) -> usize {
128        self.window
129    }
130
131    #[inline]
132    fn is_ready(&self) -> bool {
133        self.last.is_some()
134    }
135
136    #[inline]
137    fn name(&self) -> &'static str {
138        "PIN"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::microstructure::Side;
146    use crate::traits::BatchExt;
147    use approx::assert_relative_eq;
148
149    fn buy() -> Trade {
150        Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
151    }
152
153    fn sell() -> Trade {
154        Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
155    }
156
157    #[test]
158    fn rejects_zero_window() {
159        assert!(matches!(Pin::new(0), Err(Error::PeriodZero)));
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let p = Pin::new(20).unwrap();
165        assert_eq!(p.window(), 20);
166        assert_eq!(p.warmup_period(), 20);
167        assert_eq!(p.name(), "PIN");
168        assert!(!p.is_ready());
169        assert_eq!(p.value(), None);
170    }
171
172    #[test]
173    fn first_emission_at_warmup_period() {
174        let mut p = Pin::new(4).unwrap();
175        let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]);
176        for v in out.iter().take(3) {
177            assert!(v.is_none());
178        }
179        assert!(out[3].is_some());
180    }
181
182    #[test]
183    fn one_sided_flow_is_one() {
184        let mut p = Pin::new(10).unwrap();
185        let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
186        let last = p.batch(&trades).into_iter().flatten().last().unwrap();
187        assert_relative_eq!(last, 1.0, epsilon = 1e-12);
188    }
189
190    #[test]
191    fn balanced_flow_is_zero() {
192        let mut p = Pin::new(10).unwrap();
193        let trades: Vec<Trade> = (0..20)
194            .map(|i| if i % 2 == 0 { buy() } else { sell() })
195            .collect();
196        let last = p.batch(&trades).into_iter().flatten().last().unwrap();
197        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
198    }
199
200    #[test]
201    fn output_in_range() {
202        let mut p = Pin::new(16).unwrap();
203        let trades: Vec<Trade> = (0..200)
204            .map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() })
205            .collect();
206        for v in p.batch(&trades).into_iter().flatten() {
207            assert!((0.0..=1.0).contains(&v));
208        }
209    }
210
211    #[test]
212    fn reset_clears_state() {
213        let mut p = Pin::new(4).unwrap();
214        p.batch(&[buy(), buy(), sell(), buy()]);
215        assert!(p.is_ready());
216        p.reset();
217        assert!(!p.is_ready());
218        assert_eq!(p.value(), None);
219        assert_eq!(p.update(buy()), None);
220    }
221
222    #[test]
223    fn batch_equals_streaming() {
224        let trades: Vec<Trade> = (0..120)
225            .map(|i| if i % 3 == 0 { sell() } else { buy() })
226            .collect();
227        let batch = Pin::new(16).unwrap().batch(&trades);
228        let mut b = Pin::new(16).unwrap();
229        let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
230        assert_eq!(batch, streamed);
231    }
232}