Skip to content

Trade-Sign Classification

Infer the aggressor side of each trade when the feed doesn't label it. L3 crypto (Bitstamp) ships buy_order_id / sell_order_id, so the trades frame carries a real direction; L2 / aggregated feeds and many CCXT sources don't, so the signed-flow metrics (compute_vpin, order_flow_imbalance) have nothing to work with. These classifiers fill that gap and are wired in as an automatic fallback.

Functions

classify_trade_sign

classify_trade_sign(
    trades: DataFrame,
    method: str = "lee_ready",
    quotes: DataFrame | None = None,
) -> pd.Series

Classify the aggressor side of each trade.

A drop-in source of the direction column for feeds that don't label the aggressor. Sorts trades chronologically, applies the chosen per-trade classifier, and returns the result realigned to the original index.

Parameters:

Name Type Description Default
trades DataFrame

Trades with at least timestamp and price.

required
method str

"tick" (:func:tick_rule) or "lee_ready" (:func:lee_ready, the default). "bvc" is not a per-trade classifier — it labels volume bars; use :func:bulk_volume_classification or :func:~ob_analytics.flow_toxicity.compute_vpin with sign_method="bvc" instead.

'lee_ready'
quotes DataFrame

Required for method="lee_ready". A quote frame with timestamp plus either a mid column (mid / midprice / mid_price) or a bid/ask pair (best_bid_price / best_ask_price, best_bid / best_ask, or bid / ask) — e.g. a pipeline depth_summary. Each trade is measured against the midpoint of the quote it arrived into: the last readable quote strictly before it (:func:mid_before). The quote stamped at the trade's own instant is not used, because on a feed whose trades and book share one clock it is the book after the trade.

None

Returns:

Type Description
Series

Ordered categorical "buy" / "sell" values named "direction", the trades schema's dtype, indexed like trades.

Raises:

Type Description
ConfigError

If method is unknown, if "bvc" is requested here, if required columns are missing, or if lee_ready is requested without usable quotes.

ObAnalyticsError

If trades is empty.

tick_rule

tick_rule(prices: ArrayLike) -> np.ndarray

Classify trade signs by the tick rule.

Signs each trade from the sign of its price change relative to the previous trade: an uptick is buyer-initiated (+1), a downtick seller-initiated (-1). A zero tick (unchanged price) inherits the last non-zero sign — the classic Lee–Ready convention.

prices must already be in trade order (chronological). A leading run of zero ticks (before the first price move) is back-filled from the first determinable sign; a perfectly flat series has no information and defaults to +1.

Parameters:

Name Type Description Default
prices array - like

Trade prices in chronological order. Anything :func:numpy.asarray accepts: an ndarray, a Series, or a sequence.

required

Returns:

Type Description
ndarray

int8 array of +1 (buy) / -1 (sell), one per trade.

lee_ready

lee_ready(prices: ArrayLike, mid: ArrayLike) -> np.ndarray

Classify trade signs by the Lee–Ready quote-midpoint test.

A trade above the prevailing mid is buyer-initiated (+1), below it seller-initiated (-1). A trade at the mid — or one with no prevailing quote (mid is NaN) — falls back to the :func:tick_rule.

prices and mid must be equal-length and in chronological trade order; mid is the midpoint of the quote each trade arrived into, the last one strictly before it (:func:mid_before). The quote stamped at the trade's own instant can be the book after the trade, and reading it flips signs. :func:classify_trade_sign aligns quotes to trades this way.

Parameters:

Name Type Description Default
prices array - like

Trade prices in chronological order.

required
mid array - like

Prevailing quote midpoint per trade (NaN where unknown).

required

Returns:

Type Description
ndarray

int8 array of +1 (buy) / -1 (sell), one per trade.

bulk_volume_classification

bulk_volume_classification(
    trades: DataFrame,
    bucket_volume: float,
    *,
    sigma: float | None = None,
) -> pd.DataFrame

Split volume bars into buy / sell fractions (Easley–LdP–O'Hara BVC).

Partitions cumulative trade volume into equal-sized buckets (the same volume bars :func:~ob_analytics.flow_toxicity.compute_vpin uses; a trade straddling a boundary is split proportionally) and estimates each bucket's buy fraction as

buy_fraction = Φ(ΔP / σ)

where ΔP is the bucket's close-to-close price change and σ the standard deviation of those changes. Unlike a per-trade classifier this labels volume, so it needs neither the aggressor side nor quotes — the VPIN-native method for feeds that carry only trade prints.

Parameters:

Name Type Description Default
trades DataFrame

Trades with timestamp, price, and volume.

required
bucket_volume float

Total volume per bucket (instrument-specific), in the units of trades["volume"]: integer lots on a pipeline result.

required
sigma float

Standard deviation of bucketed price changes. Estimated from the data (sample std of the bucket ΔP series) when omitted.

None

Returns:

Type Description
DataFrame

One row per completed bucket with columns bucket, timestamp_start, timestamp_end, close, delta_price, buy_fraction, buy_volume, sell_volume. Empty (no rows) if the trades don't fill a single bucket.

Raises:

Type Description
ConfigError

If required columns are missing.

ObAnalyticsError

If trades is empty.

ValueError

If bucket_volume is not positive or would make more than :data:MAX_VOLUME_BUCKETS buckets, or sigma is not positive.

Shared helpers

The pipeline and the signed-flow analytics fill a missing aggressor side through resolve_direction, rather than each with its own rule.

resolve_direction

resolve_direction(
    trades: DataFrame,
    sign_method: str | None,
    quotes: DataFrame | None,
    context: str,
    *,
    fallback: str | None = "tick",
) -> pd.DataFrame

Return trades with a buy/sell direction on every trade.

This is the package's one rule for filling a trade's missing aggressor side. The pipeline uses it for trades the venue left unlabelled, and the signed-flow analytics use it for theirs. L3 feeds state the side; L2 and aggregated feeds often do not, so a trade-sign classifier (:func:classify_trade_sign) estimates it.

  • sign_method=None: keep a direction the trade already has, and classify every trade whose value is neither "buy" nor "sell" (or every trade, when there is no direction column). With quotes, the classifier is Lee-Ready, against the quote each trade arrived into (:func:mid_before); a trade with no quote before it falls back to the tick rule there. With quotes None, the classifier is fallback.
  • sign_method="tick" / "lee_ready": always classify every trade with that method, replacing any existing direction.

The frame is only copied when a direction column is written, so a feed that already labels every trade is passed straight through. The column written is the trades schema's ordered buy/sell categorical.

With the default fallback, the returned column holds only "buy" and "sell". That matters because every consumer reads it as == "buy" and treats everything else as a sell: an unlabelled trade left in place is not dropped by them, it is counted on the wrong side.

Parameters:

Name Type Description Default
trades DataFrame

Trades with at least timestamp and price.

required
sign_method str or None

None, "tick" or "lee_ready". See above.

required
quotes DataFrame or None

Quote frame for Lee-Ready (e.g. a pipeline depth_summary).

required
context str

Caller name, used in the messages.

required
fallback str or None

What sign_method=None does when quotes is None. "tick" (the default) classifies the unlabelled trades by the tick rule. None leaves them empty and warns: this is the pipeline's rule for a run that produced no quotes at all.

'tick'

Returns:

Type Description
DataFrame

trades with a direction column.

Raises:

Type Description
ConfigError

If sign_method is "bvc", which labels volume rather than individual trades, or fallback is not "tick" or None.

A trade, or an order, is measured against the quote it arrived into: the last quote strictly before it. On a feed whose trades and book share one clock, the quote stamped at a trade's own instant is the book after the trade, so it does not count. quote_before is that rule, and mid_before reads the mid of that quote, skipping quotes that are not readable. prevailing_mid is the mid at an instant, the instant included, such as the mid one horizon after a trade.

quote_before

quote_before(
    rows: DataFrame, quotes: DataFrame
) -> np.ndarray

Find the quote each row arrived into: the last one strictly before it.

This is the package's one rule for "the quote standing when something arrived". Lee-Ready (:func:classify_trade_sign), the effective spread (:func:~ob_analytics.cost.transaction_costs), the spread a hidden trade printed inside (:func:~ob_analytics.hidden_liquidity.hidden_trades) and order aggressiveness (:func:~ob_analytics.analytics.order_aggressiveness) all read their quote through it.

"Before" is the canonical total order (:func:~ob_analytics.schemas.time_order_keys): timestamp first, then the tie-break keys that both frames carry, such as event_id. A quote that carries a row's own keys is the book after that row: on a feed whose trades and book events share one clock, the quote stamped at a trade's own instant already shows the touch the trade took. So it does not count. event_id is not a clock (a loader may number its events in another order, such as by order id), so it only decides between rows at the same instant. A trades frame carries no event_id, so a trade reads the last quote stamped strictly before its own instant. A row with a missing tie-break key is read the same way.

The quotes are taken as they are. To read only quotes that make sense as a price, pass :func:~ob_analytics.depth.readable_quotes of them, as :func:mid_before does.

Parameters:

Name Type Description Default
rows DataFrame

The rows to look up, with timestamp and any of the tie-break keys.

required
quotes DataFrame

Quote frame with timestamp, such as a pipeline depth_summary.

required

Returns:

Type Description
ndarray

For each row of rows, in order, the position (as for iloc) of its quote in quotes, or -1 when no quote stands before it or the row has no timestamp.

Raises:

Type Description
ConfigError

If either frame lacks timestamp, or one frame's timestamps carry a time zone and the other's do not.

mid_before

mid_before(
    rows: DataFrame,
    quotes: DataFrame,
    context: str = "mid_before",
    *,
    mid_column: str | None = None,
) -> np.ndarray

Midpoint of the quote each row arrived into.

Reads the last readable quote strictly before each row, by :func:quote_before. A quote that is not readable (:func:~ob_analytics.depth.readable_quotes: an empty side or a crossed book) is skipped, so the row reaches back to the last quote that was. A row with no such quote before it gets NaN: Lee-Ready falls back to the tick rule there, and the cost measures leave the row unmeasured.

Parameters:

Name Type Description Default
rows DataFrame

The rows to price, usually trades, with timestamp. They need not be sorted.

required
quotes DataFrame

Quote frame with timestamp plus a mid column (mid / midprice / mid_price) or a bid/ask pair (best_bid_price / best_ask_price, best_bid / best_ask, or bid / ask). A pipeline depth_summary can be passed straight in.

required
context str

Caller name, used in the error message.

'mid_before'
mid_column str

Column to read the midpoint from, e.g. "micro_price" for the size-weighted mid (:func:~ob_analytics.depth.micro_price). None (default) takes the first of the accepted mid spellings present, and otherwise averages a bid/ask pair.

None

Returns:

Type Description
ndarray

The midpoint per row of rows, in order, NaN where none exists.

Raises:

Type Description
ConfigError

If quotes lacks timestamp or any recognised price columns.

prevailing_mid

prevailing_mid(
    timestamps: ndarray,
    quotes: DataFrame,
    context: str = "prevailing_mid",
    *,
    mid_column: str | None = None,
    require_covered: bool = False,
) -> np.ndarray

Midpoint standing at each (sorted) instant, the instant included.

A backward as-of join of timestamps against the readable quotes (:func:~ob_analytics.depth.readable_quotes): each instant gets the midpoint of the last readable quote published at or before it. This is the mid at an instant, such as the mid one horizon after a trade. The mid a trade arrived into is a different question, with a different answer on a feed whose trades and book share one clock: see :func:mid_before. Instants before the first readable quote get NaN.

Any of the accepted quote-column spellings works: a mid column (mid / midprice / mid_price) or a bid/ask pair (best_bid_price / best_ask_price, best_bid / best_ask, or bid / ask). So a pipeline depth_summary can be passed straight in.

Parameters:

Name Type Description Default
timestamps ndarray

Instants to price, sorted ascending (merge_asof requires it).

required
quotes DataFrame

Quote frame with timestamp plus a mid or bid/ask pair.

required
context str

Caller name, used in the error message.

'prevailing_mid'
mid_column str

Column to read the midpoint from, e.g. "micro_price" for the size-weighted mid (:func:~ob_analytics.depth.micro_price). None (default) takes the first of the accepted mid spellings present, and otherwise averages a bid/ask pair.

None
require_covered bool

Whether an instant past the newest readable quote is NaN rather than that quote's mid. Default False returns the last mid known. A backward join cannot tell "the state at this instant" from "the last state before the data ran out", and a caller measuring over a fixed wait needs to: reusing the final quote reports a shorter reach as though it were the full one.

False

Returns:

Type Description
ndarray

The midpoint per instant, NaN where none exists yet.

Raises:

Type Description
ConfigError

If quotes lacks timestamp or any recognised price columns.

The volume-bucket analytics check their bucket size through this one.

check_bucket_count

check_bucket_count(
    trades: DataFrame,
    bucket_volume: float,
    context: str,
    *,
    advice: str | None = None,
) -> None

Refuse a bucket_volume that cuts the trades into too many buckets.

Parameters:

Name Type Description Default
trades DataFrame

Trades with a volume column.

required
bucket_volume float

Volume per bucket, in the units of trades["volume"]. Must be positive.

required
context str

Name of the caller, used in the error message.

required
advice str

What to do about it, ending the error message. None (default) says which units bucket_volume is in, the usual cause.

None

Raises:

Type Description
ValueError

If the trades' total volume divided by bucket_volume is more than :data:MAX_VOLUME_BUCKETS.

MAX_VOLUME_BUCKETS module-attribute

MAX_VOLUME_BUCKETS = 1000000