Skip to content

Flow Toxicity Metrics

Market microstructure measures for detecting informed trading and quantifying price impact.

Functions

compute_vpin

compute_vpin(
    trades: DataFrame,
    bucket_volume: float | None = None,
    n_buckets: int = 50,
    sign_method: str | None = None,
    quotes: DataFrame | None = None,
) -> pd.DataFrame

Compute the Volume-Synchronized Probability of Informed Trading.

Partitions cumulative trade volume into equal-sized buckets and measures the normalised buy/sell imbalance within each bucket. The trailing average of vpin over n_buckets is the headline VPIN metric.

Works on feeds without a native aggressor side. When the trades frame has no direction, the buy/sell split is inferred with a trade-sign classifier (see sign_method), so VPIN runs on L2 / aggregated captures too.

Parameters:

Name Type Description Default
trades DataFrame

Trades with at least timestamp, price, and volume. A direction column ("buy" / "sell", the taker side) is used when present; otherwise it is inferred — see sign_method.

required
bucket_volume float

Total volume per bucket. This is highly instrument-specific. When left out, it is picked by :func:vpin_bucket_volume (average daily volume ÷ 50, with a 24-hour trading day).

None
n_buckets int

Window length (in buckets) for the trailing VPIN average. Default is 50, following the original paper. Fewer complete buckets than this is reported in attrs["diagnostics"].

50
sign_method str

How to obtain the buy/sell split when there is no native direction. None (default) uses an existing direction if present, else falls back to a per-trade classifier (Lee–Ready when quotes are given, otherwise the tick rule). "tick" / "lee_ready" force a per-trade classifier (:func:~ob_analytics.trade_sign.classify_trade_sign), overriding any native direction. "bvc" splits each volume bar with bulk volume classification (:func:~ob_analytics.trade_sign.bulk_volume_classification) — the VPIN-native estimator, which needs no per-trade sign at all.

None
quotes DataFrame

Quote frame for sign_method="lee_ready" (or the None fallback when quotes are available) — passed through to :func:~ob_analytics.trade_sign.classify_trade_sign.

None

Returns:

Type Description
DataFrame

One row per completed bucket (zero rows, same columns and dtypes, when the trades fill no bucket) with columns:

  • bucket — zero-based bucket index
  • timestamp_start — first trade timestamp in the bucket
  • timestamp_end — last trade timestamp in the bucket
  • buy_volume — total buy volume in the bucket
  • sell_volume — total sell volume in the bucket
  • vpin — |buy_volume - sell_volume| / bucket_volume
  • vpin_avg — trailing mean of vpin over n_buckets

The frame's attrs record how it was computed, so the settings can be reported next to the number:

  • attrs["bucket_volume"] — the bucket size used
  • attrs["bucket_volume_rule"] — "given" when passed in, "adv/50" when picked by :func:vpin_bucket_volume
  • attrs["n_buckets"] — the trailing window, in buckets
  • attrs["diagnostics"] — a tuple of reasons the result should not be relied on; empty when there are none. Today the one check is whether there are at least n_buckets complete buckets, since vpin_avg is not a full trailing average before that. The same condition also raises a :class:UserWarning, since diagnostics is easy to miss on a frame that otherwise looks fine.

Raises:

Type Description
ConfigError

If required columns are missing.

ObAnalyticsError

If trades is empty.

ValueError

If bucket_volume is not positive, or it is left out and the trades span no time (see :func:vpin_bucket_volume).

vpin_bucket_volume

vpin_bucket_volume(
    trades: DataFrame,
    buckets_per_day: int = VPIN_BUCKETS_PER_DAY,
    trading_day: str | Timedelta = "24h",
) -> float

Pick a VPIN bucket_volume from the trades: average daily volume ÷ 50.

Average daily volume is the traded volume per unit of time, scaled to one trading_day::

daily volume = total volume × trading_day / (last timestamp − first timestamp)

The same formula covers every session length. A 30-minute capture is scaled up to a full day at the rate it traded; a week-long one is averaged down to a day. On a short capture the result is therefore a large bucket, and VPIN will fill only a few of them, which :func:compute_vpin then reports in its diagnostics.

Parameters:

Name Type Description Default
trades DataFrame

Trades with at least timestamp and volume.

required
buckets_per_day int

How many buckets one average day of volume fills. Default 50.

VPIN_BUCKETS_PER_DAY
trading_day str or Timedelta

Length of one trading day, on the same clock as the span between the first and last trade. Default "24h", which is right for venues that trade around the clock and for any capture that runs over several days, closed hours included. Use a session length, for example "6.5h" for US equities, only when the capture falls inside a single session.

'24h'

Returns:

Type Description
float

The bucket volume, in the units of trades["volume"].

Raises:

Type Description
ConfigError

If required columns are missing.

ObAnalyticsError

If trades is empty.

ValueError

If the trades span no time, or buckets_per_day or trading_day is not positive.

compute_kyle_lambda

compute_kyle_lambda(
    trades: DataFrame,
    window: str = "5min",
    *,
    n_boot: int = 1000,
    ci_level: float = 0.95,
    seed: int | Generator | None = 0,
) -> KyleLambdaResult

Estimate Kyle's Lambda via OLS regression.

For each time window, computes:

  • ΔPrice = last trade price − first trade price
  • signed_volume = Σ(buy volume) − Σ(sell volume)

Then regresses ΔPrice on signed_volume across all windows. The slope (λ) measures how much the price moves per unit of net order flow — a proxy for market illiquidity and adverse selection.

ΔPrice is read directly from trades["price"], which is an integer tick count, so λ is in ticks per unit volume. It scales with the price unit — a run at a finer tick_size reports a proportionally larger λ; multiply by tick_size to express it in the quote currency.

Parameters:

Name Type Description Default
trades DataFrame

Trades with timestamp, price, volume, direction. Unlike :func:compute_vpin and :func:order_flow_imbalance, the aggressor side is required rather than inferred, so this needs a feed that labels it (or a direction attached beforehand with :func:~ob_analytics.trade_sign.classify_trade_sign). Individual rows the venue left blank are filled with the tick rule.

required
window str

Pandas frequency string for grouping trades. Default "5min".

'5min'
n_boot int

Number of bootstrap resamples for the confidence interval. Default 1000; 0 skips the interval. Each resample draws blocks of consecutive windows with replacement (a moving block bootstrap, with blocks of ceil(n_windows ** (1/3)) windows), so correlation between neighbouring windows is kept, and refits the slope. The interval is the percentile range of those slopes.

1000
ci_level float

Coverage of the interval, strictly between 0 and 1. Default 0.95.

0.95
seed int or Generator

Seed or generator for the bootstrap. Default 0, so the same trades always give the same interval; pass None for fresh randomness.

0

Returns:

Type Description
KyleLambdaResult

Frozen dataclass with lambda_, t_stat, r_squared, n_windows, regression_df, the interval ci_low / ci_high, and significant / diagnostics, which say when λ rests on too little data to rely on.

Raises:

Type Description
ConfigError

If required columns are missing.

ObAnalyticsError

If trades is empty.

ValueError

If ci_level is not between 0 and 1, or n_boot is negative.

order_flow_imbalance

order_flow_imbalance(
    trades: DataFrame,
    window: str = "1min",
    sign_method: str | None = None,
    quotes: DataFrame | None = None,
) -> pd.DataFrame

Compute normalised order flow imbalance per time window.

For each window:

  • ofi = (buy_volume − sell_volume) / (buy_volume + sell_volume)

Values range from −1 (all sells) to +1 (all buys). Zero indicates balanced flow.

Works on feeds without a native aggressor side: when the trades frame has no direction, it is inferred with a per-trade trade-sign classifier (see sign_method).

Parameters:

Name Type Description Default
trades DataFrame

Trades with timestamp and volume. A direction column ("buy" / "sell") is used when present; otherwise it is inferred — see sign_method (which additionally requires price).

required
window str

Pandas frequency string. Default "1min".

'1min'
sign_method str

How to obtain the buy/sell split when there is no native direction. None (default) uses an existing direction if present, else falls back to a per-trade classifier (Lee–Ready when quotes are given, otherwise the tick rule). "tick" / "lee_ready" force a per-trade classifier, overriding any native direction. ("bvc" is VPIN-native; use :func:compute_vpin.)

None
quotes DataFrame

Quote frame for sign_method="lee_ready" — passed through to :func:~ob_analytics.trade_sign.classify_trade_sign.

None

Returns:

Type Description
DataFrame

Columns: timestamp, buy_volume, sell_volume, net_volume, ofi.

Raises:

Type Description
ConfigError

If required columns are missing.

ObAnalyticsError

If trades is empty.

Models

KyleLambdaResult dataclass

KyleLambdaResult(
    lambda_: float,
    t_stat: float,
    r_squared: float,
    n_windows: int,
    regression_df: DataFrame = pd.DataFrame(),
    ci_low: float = float("nan"),
    ci_high: float = float("nan"),
    ci_level: float = 0.95,
)

Result of a Kyle's λ OLS regression.

Attributes:

Name Type Description
lambda_ float

Slope — price change per unit signed order flow (higher = less liquid).

t_stat float

t-statistic for lambda_.

r_squared float

Fraction of ΔPrice variance explained by signed order flow.

n_windows int

Number of time windows in the regression.

regression_df DataFrame

Per-window timestamp/delta_price/signed_volume data.

ci_low, ci_high float

Bounds of a block-bootstrap confidence interval for lambda_. NaN when the bootstrap was turned off or the fit is undefined.

ci_level float

Coverage of that interval, for example 0.95.

diagnostics property

diagnostics: tuple[str, ...]

Reasons lambda_ should not be relied on; empty when there are none.

Checks the fit is defined, that there are at least :data:KYLE_MIN_WINDOWS windows, and that |t_stat| reaches :data:KYLE_MIN_T_STAT.

significant property

significant: bool

True when :attr:diagnostics found nothing wrong.

Thresholds

KYLE_MIN_T_STAT module-attribute

KYLE_MIN_T_STAT = 2.0

KYLE_MIN_WINDOWS module-attribute

KYLE_MIN_WINDOWS = 30

VPIN_BUCKETS_PER_DAY module-attribute

VPIN_BUCKETS_PER_DAY = 50