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        Ok(Self {
66            window,
67            sides: VecDeque::with_capacity(window),
68            buy_count: 0,
69            last: None,
70        })
71    }
72
73    /// Configured window of trades.
74    pub const fn window(&self) -> usize {
75        self.window
76    }
77
78    /// Current value if available.
79    pub const fn value(&self) -> Option<f64> {
80        self.last
81    }
82}
83
84impl Indicator for Pin {
85    type Input = Trade;
86    type Output = f64;
87
88    fn update(&mut self, trade: Trade) -> Option<f64> {
89        let is_buy = trade.side.sign() > 0.0;
90        if self.sides.len() == self.window {
91            let old = self.sides.pop_front().expect("non-empty");
92            if old > 0.0 {
93                self.buy_count -= 1;
94            }
95        }
96        self.sides.push_back(if is_buy { 1.0 } else { 0.0 });
97        if is_buy {
98            self.buy_count += 1;
99        }
100        if self.sides.len() < self.window {
101            return None;
102        }
103        // The window is full and `window >= 1` (zero is rejected at
104        // construction), so the trade count is always positive — `|B - S| / N`
105        // needs no zero guard.
106        let buys = self.buy_count as f64;
107        let sells = self.window as f64 - buys;
108        let total = self.window as f64;
109        let pin = (buys - sells).abs() / total;
110        self.last = Some(pin);
111        Some(pin)
112    }
113
114    fn reset(&mut self) {
115        self.sides.clear();
116        self.buy_count = 0;
117        self.last = None;
118    }
119
120    fn warmup_period(&self) -> usize {
121        self.window
122    }
123
124    fn is_ready(&self) -> bool {
125        self.last.is_some()
126    }
127
128    fn name(&self) -> &'static str {
129        "PIN"
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::microstructure::Side;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    fn buy() -> Trade {
141        Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
142    }
143
144    fn sell() -> Trade {
145        Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
146    }
147
148    #[test]
149    fn rejects_zero_window() {
150        assert!(matches!(Pin::new(0), Err(Error::PeriodZero)));
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let p = Pin::new(20).unwrap();
156        assert_eq!(p.window(), 20);
157        assert_eq!(p.warmup_period(), 20);
158        assert_eq!(p.name(), "PIN");
159        assert!(!p.is_ready());
160        assert_eq!(p.value(), None);
161    }
162
163    #[test]
164    fn first_emission_at_warmup_period() {
165        let mut p = Pin::new(4).unwrap();
166        let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]);
167        for v in out.iter().take(3) {
168            assert!(v.is_none());
169        }
170        assert!(out[3].is_some());
171    }
172
173    #[test]
174    fn one_sided_flow_is_one() {
175        let mut p = Pin::new(10).unwrap();
176        let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
177        let last = p.batch(&trades).into_iter().flatten().last().unwrap();
178        assert_relative_eq!(last, 1.0, epsilon = 1e-12);
179    }
180
181    #[test]
182    fn balanced_flow_is_zero() {
183        let mut p = Pin::new(10).unwrap();
184        let trades: Vec<Trade> = (0..20)
185            .map(|i| if i % 2 == 0 { buy() } else { sell() })
186            .collect();
187        let last = p.batch(&trades).into_iter().flatten().last().unwrap();
188        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
189    }
190
191    #[test]
192    fn output_in_range() {
193        let mut p = Pin::new(16).unwrap();
194        let trades: Vec<Trade> = (0..200)
195            .map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() })
196            .collect();
197        for v in p.batch(&trades).into_iter().flatten() {
198            assert!((0.0..=1.0).contains(&v));
199        }
200    }
201
202    #[test]
203    fn reset_clears_state() {
204        let mut p = Pin::new(4).unwrap();
205        p.batch(&[buy(), buy(), sell(), buy()]);
206        assert!(p.is_ready());
207        p.reset();
208        assert!(!p.is_ready());
209        assert_eq!(p.value(), None);
210        assert_eq!(p.update(buy()), None);
211    }
212
213    #[test]
214    fn batch_equals_streaming() {
215        let trades: Vec<Trade> = (0..120)
216            .map(|i| if i % 3 == 0 { sell() } else { buy() })
217            .collect();
218        let batch = Pin::new(16).unwrap().batch(&trades);
219        let mut b = Pin::new(16).unwrap();
220        let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
221        assert_eq!(batch, streamed);
222    }
223}