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, in the instrument's own units (BTC, shares, contracts). It means the same amount whether trades["volume"] holds integer lots (with trades.attrs["lot_size"], as on a pipeline result) or display sizes (see :func:~ob_analytics.visualization.display_result). This is highly instrument-specific, so size it from the data. 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, in the instrument's own units
  • sell_volume — total sell volume in the bucket, likewise
  • 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, in the instrument's own units
  • 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, or the sizes are integer lots and the frame carries no lot_size. Also if trades is empty. Also if bucket_volume is not positive, would make more than :data:~ob_analytics.trade_sign.MAX_VOLUME_BUCKETS buckets, or 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 instrument's own units (BTC, shares, contracts), ready to pass to :func:compute_vpin as bucket_volume.

Raises:

Type Description
ConfigError

If required columns are missing, or the sizes are integer lots and the frame carries no lot_size. Also if trades is empty. Also 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",
    *,
    sign_method: str | None = None,
    quotes: DataFrame | None = None,
    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.

λ is in the quote currency per unit of size (for example USD per BTC), whether the trades hold integer ticks and lots (with tick_size and lot_size on the frame's attrs, as on a pipeline result) or display prices and sizes. regression_df is in the same units.

Works on feeds without a native aggressor side. When the trades frame has no direction, the side is inferred with a trade-sign classifier (see sign_method), as for :func:compute_vpin and :func:order_flow_imbalance.

Parameters:

Name Type Description Default
trades DataFrame

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

required
window str

Length of each regression window, a fixed duration such as "30s" or "5min". Default "5min". Windows are the time bars of :func:~ob_analytics.bars.bars: they count from the Unix epoch, and a window in which nothing traded is left out.

'5min'
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
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, window is not a fixed duration, or the prices or sizes are integers and the frame carries no tick_size or lot_size. Also if trades is empty. Also 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).

Each window is one time bar of :func:~ob_analytics.bars.bars, so ofi is that bar's signed_volume / volume. Windows count from the Unix epoch, and a window in which nothing traded is left out. A window whose trades all have zero size is kept, with ofi set to NaN.

Parameters:

Name Type Description Default
trades DataFrame

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

required
window str

Length of each window, a fixed duration such as "7s" or "1min". 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 (the start of the window), buy_volume, sell_volume, net_volume (in the instrument's own units, BTC, shares or contracts, whether the trades hold integer lots or display sizes), ofi.

Raises:

Type Description
ConfigError

If required columns are missing, window is not a fixed duration, or the sizes are integer lots and the frame carries no lot_size. Also if trades is empty.

ofi_by_horizon

ofi_by_horizon(
    trades: DataFrame,
    horizons: tuple[str, ...] = OFI_HORIZONS,
    grid: str = "5s",
    sign_method: str | None = None,
    quotes: DataFrame | None = None,
) -> pd.DataFrame

Compute order flow imbalance over several trailing horizons on one grid.

:func:order_flow_imbalance measures one window at a time. This measures each of horizons at every step of a common time grid, so short- and long-horizon pressure can be compared at the same instant. Each value is a trailing window ending at the grid step, not a block of the grid, so a long horizon changes slowly and a short one changes quickly.

Parameters:

Name Type Description Default
trades DataFrame

Trades with timestamp and volume. A direction column is used when present; otherwise it is inferred — see sign_method.

required
horizons tuple of str

Fixed durations, one per horizon. Each is rounded to a whole number of grid steps, with a minimum of one. Default :data:OFI_HORIZONS.

OFI_HORIZONS
grid str

Step of the time grid, a fixed duration. Default "5s". Grid steps count from the Unix epoch, as the windows of :func:order_flow_imbalance do.

'5s'
sign_method optional

How to obtain the buy/sell split, as for :func:order_flow_imbalance.

None
quotes optional

How to obtain the buy/sell split, as for :func:order_flow_imbalance.

None

Returns:

Type Description
DataFrame

One row per grid step, with timestamp and one column per horizon, named by its offset string. Each value is OFI in [-1, +1] (buy pressure positive), or NaN when nothing traded in that window.

Raises:

Type Description
ConfigError

If required columns are missing, or grid or a horizon is not a fixed duration. Also 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 in the quote currency per unit of signed order flow in the instrument's units (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, in the quote currency and the instrument's units.

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

compute_vpin refuses a bucket size that would make more than MAX_VOLUME_BUCKETS buckets.

OFI_HORIZONS module-attribute

OFI_HORIZONS: tuple[str, ...] = ("5s", "15s", "60s", "300s")