feat(microstructure): trade-sign autocorrelation, PIN, Hasbrouck information share (B15) (#212)
## B15 Microstructure — three new indicators (485 → 488) | Indicator | Input | Output | Notes | |-----------|-------|--------|-------| | `TradeSignAutocorrelation` | `Trade` | `f64` ∈ [-1,1] | lag-1 autocorrelation of the signed aggressor (order-flow persistence) | | `Pin` | `Trade` | `f64` ∈ [0,1] | probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator); `name()` = `"PIN"` | | `HasbrouckInformationShare` | `(f64, f64)` | `f64` ∈ [0,1] | variance-ratio proxy for each venue's share of price discovery | ### Wiring - Core structs + full unit tests (every branch). - Hand-written Python/Node/WASM bindings for the two `Trade`-input indicators (precedent `TradeImbalance`); `node_pair_indicator!` / `wasm_pair_indicator!` macro bindings + hand Python pyclass for the pairwise Hasbrouck (precedent `RollingCorrelation`). - Fuzz drives added to `indicator_update_trade.rs` and `indicator_update_pair.rs`. - Dedicated Python + Node streaming-vs-batch and reference tests; Hasbrouck in the `PAIR` registry. - README counter (3 spots) + `docs/README.md` + `FAMILIES` assert bumped to 488. ### Verify (all green, local) - `cargo test -p wickra-core --lib`: 3991 passed - `cargo test -p wickra-core --doc`: 438 passed - `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean - node: 561 passed · pytest: 926 passed
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
//! Hasbrouck Information Share — each venue's contribution to price discovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hasbrouck Information Share — the share of price-discovery attributable to the
|
||||
/// **first** of two synchronised price series (e.g. the same asset on two venues).
|
||||
///
|
||||
/// ```text
|
||||
/// rx_t = x_t − x_{t−1}, ry_t = y_t − y_{t−1} (one-step price changes)
|
||||
/// IS_x = var(rx) / ( var(rx) + var(ry) ) over the window, ∈ [0, 1]
|
||||
/// ```
|
||||
///
|
||||
/// When the same instrument trades on several venues, Joel Hasbrouck's information
|
||||
/// share measures how much each venue contributes to the common efficient price.
|
||||
/// The venue whose innovations carry more of the variance leads price discovery.
|
||||
/// This streaming form uses the **variance-ratio proxy**: the fraction of total
|
||||
/// return variance contributed by series `x`. A reading above `0.5` means venue
|
||||
/// `x` is the price leader; below `0.5`, the follower. (The full Hasbrouck measure
|
||||
/// estimates a vector error-correction model and reports an upper/lower bound from
|
||||
/// the Cholesky ordering; this proxy captures the leading idea without the VECM.)
|
||||
///
|
||||
/// The output is in `[0, 1]`; if both series are flat it reports the neutral `0.5`.
|
||||
/// The first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HasbrouckInformationShare};
|
||||
///
|
||||
/// let mut indicator = HasbrouckInformationShare::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // Venue x moves a lot, venue y barely moves -> x leads.
|
||||
/// let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
/// let y = (f64::from(i) * 0.5).sin() * 1.0;
|
||||
/// last = indicator.update((x, y));
|
||||
/// }
|
||||
/// assert!(last.unwrap() > 0.8);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HasbrouckInformationShare {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xx: f64,
|
||||
sum_yy: f64,
|
||||
}
|
||||
|
||||
impl HasbrouckInformationShare {
|
||||
/// Construct a Hasbrouck information share over `period` return pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs two
|
||||
/// returns).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "information share needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xx: 0.0,
|
||||
sum_yy: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of return pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HasbrouckInformationShare {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (x, y) = input;
|
||||
let Some((px, py)) = self.prev else {
|
||||
self.prev = Some((x, y));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((x, y));
|
||||
let (rx, ry) = (x - px, y - py);
|
||||
if self.window.len() == self.period {
|
||||
let (ox, oy) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xx -= ox * ox;
|
||||
self.sum_yy -= oy * oy;
|
||||
}
|
||||
self.window.push_back((rx, ry));
|
||||
self.sum_x += rx;
|
||||
self.sum_y += ry;
|
||||
self.sum_xx += rx * rx;
|
||||
self.sum_yy += ry * ry;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let var_x = (self.sum_xx / n - (self.sum_x / n).powi(2)).max(0.0);
|
||||
let var_y = (self.sum_yy / n - (self.sum_y / n).powi(2)).max(0.0);
|
||||
let total = var_x + var_y;
|
||||
Some(if total > 0.0 { var_x / total } else { 0.5 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xx = 0.0;
|
||||
self.sum_yy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HasbrouckInformationShare"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
HasbrouckInformationShare::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(HasbrouckInformationShare::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HasbrouckInformationShare::new(20).unwrap();
|
||||
assert_eq!(h.period(), 20);
|
||||
assert_eq!(h.warmup_period(), 21);
|
||||
assert_eq!(h.name(), "HasbrouckInformationShare");
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_needs_period_plus_one() {
|
||||
let mut h = HasbrouckInformationShare::new(3).unwrap();
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
assert_eq!(h.update((2.0, 2.0)), None);
|
||||
assert_eq!(h.update((3.0, 2.5)), None);
|
||||
assert!(h.update((4.0, 3.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_venue_leads() {
|
||||
// x is far more volatile than y -> x holds nearly all the share.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 10.0,
|
||||
(f64::from(i) * 0.5).sin() * 1.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let last = HasbrouckInformationShare::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.8, "the loud venue should lead, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_venues_split_evenly() {
|
||||
// Independent but equal-variance moves -> share near 0.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..200)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 5.0,
|
||||
(f64::from(i) * 0.5).cos() * 5.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for v in HasbrouckInformationShare::new(40)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_series_is_half() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (7.0, 9.0)).collect();
|
||||
let last = HasbrouckInformationShare::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HasbrouckInformationShare::new(4).unwrap();
|
||||
h.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 5.0, (t * 0.5).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
let batch = HasbrouckInformationShare::new(20).unwrap().batch(&pairs);
|
||||
let mut h = HasbrouckInformationShare::new(20).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod harami_cross;
|
||||
mod hasbrouck_information_share;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod heikin_ashi_oscillator;
|
||||
@@ -296,6 +297,7 @@ mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod pgo;
|
||||
mod piercing_dark_cloud;
|
||||
mod pin;
|
||||
mod pivot_reversal;
|
||||
mod plus_di;
|
||||
mod plus_dm;
|
||||
@@ -423,6 +425,7 @@ mod time_of_day_return_profile;
|
||||
mod tower_top_bottom;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trade_sign_autocorrelation;
|
||||
mod trade_volume_index;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
@@ -659,6 +662,7 @@ pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use harami_cross::HaramiCross;
|
||||
pub use hasbrouck_information_share::HasbrouckInformationShare;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
|
||||
@@ -781,6 +785,7 @@ pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
pub use piercing_dark_cloud::PiercingDarkCloud;
|
||||
pub use pin::Pin;
|
||||
pub use pivot_reversal::PivotReversal;
|
||||
pub use plus_di::PlusDi;
|
||||
pub use plus_dm::PlusDm;
|
||||
@@ -908,6 +913,7 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf
|
||||
pub use tower_top_bottom::TowerTopBottom;
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_sign_autocorrelation::TradeSignAutocorrelation;
|
||||
pub use trade_volume_index::TradeVolumeIndex;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
@@ -1452,6 +1458,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Vpin",
|
||||
"AmihudIlliquidity",
|
||||
"RollMeasure",
|
||||
"TradeSignAutocorrelation",
|
||||
"Pin",
|
||||
"HasbrouckInformationShare",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1615,6 +1624,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 485, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 488, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! PIN — Probability of Informed Trading (single-window EKOP estimate).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// PIN — the **Probability of Informed Trading**, estimated from the buy/sell order
|
||||
/// imbalance over a rolling window of trades.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `window` trades: B = buys, S = sells (B + S = window)
|
||||
/// PIN ≈ |B − S| / (B + S) ∈ [0, 1]
|
||||
/// ```
|
||||
///
|
||||
/// The Easley-Kiefer-O'Hara-Paperman (EKOP) model splits order flow into an
|
||||
/// uninformed component (balanced buys and sells, rate `ε` per side) and an
|
||||
/// informed component that trades one-directionally when private information
|
||||
/// arrives (rate `μ`, probability `α`). The probability that any given trade is
|
||||
/// information-motivated is `PIN = αμ / (αμ + 2ε)`. Estimated over a single window,
|
||||
/// the informed flow shows up as the **net imbalance** `|B − S|` and the uninformed
|
||||
/// flow as the balanced remainder, giving the moment estimator above. A high PIN
|
||||
/// flags a one-sided, likely-informed market; a low PIN flags balanced, uninformed
|
||||
/// flow.
|
||||
///
|
||||
/// This is distinct from [`Vpin`](crate::Vpin), the volume-synchronised variant
|
||||
/// that buckets by volume and uses bulk-volume classification; here trades are
|
||||
/// counted in event time and classified by their tagged aggressor side. The full
|
||||
/// PIN is fit by maximum likelihood over many periods — this single-window
|
||||
/// estimator is the streaming moment approximation. The output is in `[0, 1]`; the
|
||||
/// first value lands after `window` trades.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Pin, Side, Trade};
|
||||
///
|
||||
/// let mut indicator = Pin::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // All buys -> maximally one-sided -> PIN 1.
|
||||
/// last = indicator.update(Trade::new(100.0, 1.0, Side::Buy, i).unwrap());
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pin {
|
||||
window: usize,
|
||||
sides: VecDeque<f64>,
|
||||
buy_count: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
/// Construct a PIN estimator over `window` trades.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `window == 0`.
|
||||
pub fn new(window: usize) -> Result<Self> {
|
||||
if window == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
window,
|
||||
sides: VecDeque::with_capacity(window),
|
||||
buy_count: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of trades.
|
||||
pub const fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Pin {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
let is_buy = trade.side.sign() > 0.0;
|
||||
if self.sides.len() == self.window {
|
||||
let old = self.sides.pop_front().expect("non-empty");
|
||||
if old > 0.0 {
|
||||
self.buy_count -= 1;
|
||||
}
|
||||
}
|
||||
self.sides.push_back(if is_buy { 1.0 } else { 0.0 });
|
||||
if is_buy {
|
||||
self.buy_count += 1;
|
||||
}
|
||||
if self.sides.len() < self.window {
|
||||
return None;
|
||||
}
|
||||
// The window is full and `window >= 1` (zero is rejected at
|
||||
// construction), so the trade count is always positive — `|B - S| / N`
|
||||
// needs no zero guard.
|
||||
let buys = self.buy_count as f64;
|
||||
let sells = self.window as f64 - buys;
|
||||
let total = self.window as f64;
|
||||
let pin = (buys - sells).abs() / total;
|
||||
self.last = Some(pin);
|
||||
Some(pin)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sides.clear();
|
||||
self.buy_count = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PIN"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn buy() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
|
||||
}
|
||||
|
||||
fn sell() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_window() {
|
||||
assert!(matches!(Pin::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = Pin::new(20).unwrap();
|
||||
assert_eq!(p.window(), 20);
|
||||
assert_eq!(p.warmup_period(), 20);
|
||||
assert_eq!(p.name(), "PIN");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = Pin::new(4).unwrap();
|
||||
let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_flow_is_one() {
|
||||
let mut p = Pin::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
|
||||
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_flow_is_zero() {
|
||||
let mut p = Pin::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { buy() } else { sell() })
|
||||
.collect();
|
||||
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut p = Pin::new(16).unwrap();
|
||||
let trades: Vec<Trade> = (0..200)
|
||||
.map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() })
|
||||
.collect();
|
||||
for v in p.batch(&trades).into_iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = Pin::new(4).unwrap();
|
||||
p.batch(&[buy(), buy(), sell(), buy()]);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.update(buy()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..120)
|
||||
.map(|i| if i % 3 == 0 { sell() } else { buy() })
|
||||
.collect();
|
||||
let batch = Pin::new(16).unwrap().batch(&trades);
|
||||
let mut b = Pin::new(16).unwrap();
|
||||
let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Trade-Sign Autocorrelation — lag-1 persistence of the trade-aggressor side.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trade-Sign Autocorrelation — the lag-1 autocorrelation of the **trade sign**
|
||||
/// (`+1` buy, `−1` sell), measuring how strongly signed order flow persists.
|
||||
///
|
||||
/// ```text
|
||||
/// s_t = +1 if the trade is a buy, −1 if a sell
|
||||
/// ρ1 = mean over the window of ( s_t · s_{t−1} ) ∈ [−1, +1]
|
||||
/// ```
|
||||
///
|
||||
/// In real markets trade signs are strongly **positively** autocorrelated: a buy
|
||||
/// tends to be followed by another buy (and a sell by a sell), because large
|
||||
/// parent orders are split into many child trades and because of order-splitting
|
||||
/// and herding. A high reading therefore indicates persistent directional pressure
|
||||
/// — a footprint of informed or algorithmic execution — while a reading near zero
|
||||
/// signals balanced, uninformed flow and a negative reading signals alternating
|
||||
/// (bid-ask bounce) flow.
|
||||
///
|
||||
/// The output is the mean product of consecutive signs, bounded in `[−1, +1]`. The
|
||||
/// first value lands after `period` trades. Each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Side, Trade, TradeSignAutocorrelation};
|
||||
///
|
||||
/// let mut indicator = TradeSignAutocorrelation::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
/// last = indicator.update(Trade::new(100.0, 1.0, side, i).unwrap());
|
||||
/// }
|
||||
/// // Perfectly alternating signs -> autocorrelation -1.
|
||||
/// assert!((last.unwrap() + 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeSignAutocorrelation {
|
||||
period: usize,
|
||||
signs: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TradeSignAutocorrelation {
|
||||
/// Construct a trade-sign autocorrelation over `period` trades.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a lag-1 product needs two
|
||||
/// trades).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "trade-sign autocorrelation needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
signs: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of trades.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TradeSignAutocorrelation {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
if self.signs.len() == self.period {
|
||||
self.signs.pop_front();
|
||||
}
|
||||
self.signs.push_back(trade.side.sign());
|
||||
if self.signs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut product_sum = 0.0;
|
||||
let mut prev: Option<f64> = None;
|
||||
for &s in &self.signs {
|
||||
if let Some(p) = prev {
|
||||
product_sum += s * p;
|
||||
}
|
||||
prev = Some(s);
|
||||
}
|
||||
let rho = product_sum / (self.period as f64 - 1.0);
|
||||
self.last = Some(rho);
|
||||
Some(rho)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.signs.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TradeSignAutocorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn buy() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
|
||||
}
|
||||
|
||||
fn sell() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
TradeSignAutocorrelation::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(TradeSignAutocorrelation::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TradeSignAutocorrelation::new(20).unwrap();
|
||||
assert_eq!(t.period(), 20);
|
||||
assert_eq!(t.warmup_period(), 20);
|
||||
assert_eq!(t.name(), "TradeSignAutocorrelation");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut t = TradeSignAutocorrelation::new(4).unwrap();
|
||||
let out = t.batch(&[buy(), buy(), buy(), buy(), buy()]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_flow_is_one() {
|
||||
let mut t = TradeSignAutocorrelation::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
|
||||
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_flow_is_minus_one() {
|
||||
let mut t = TradeSignAutocorrelation::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { buy() } else { sell() })
|
||||
.collect();
|
||||
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut t = TradeSignAutocorrelation::new(16).unwrap();
|
||||
let trades: Vec<Trade> = (0..200)
|
||||
.map(|i| if (i * 7 % 13) < 6 { buy() } else { sell() })
|
||||
.collect();
|
||||
for v in t.batch(&trades).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TradeSignAutocorrelation::new(4).unwrap();
|
||||
t.batch(&[buy(), buy(), buy(), buy()]);
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
assert_eq!(t.update(buy()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..120)
|
||||
.map(|i| if i % 3 == 0 { sell() } else { buy() })
|
||||
.collect();
|
||||
let batch = TradeSignAutocorrelation::new(16).unwrap().batch(&trades);
|
||||
let mut b = TradeSignAutocorrelation::new(16).unwrap();
|
||||
let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user