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 |
required |
method
|
str
|
|
'lee_ready'
|
quotes
|
DataFrame
|
Required for |
None
|
Returns:
| Type | Description |
|---|---|
Series
|
Ordered categorical |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If method is unknown, if |
tick_rule ¶
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: |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
lee_ready ¶
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 ( |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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 |
required |
bucket_volume
|
float
|
Total volume per bucket, in the instrument's own units (BTC, shares,
contracts), whether |
required |
sigma
|
float
|
Standard deviation of bucketed price changes, in the quote currency
whether |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per completed bucket with columns |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If required columns are missing, the sizes are integer lots and the
frame carries no |
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 adirectionthe trade already has, and classify every trade whose value is neither"buy"nor"sell"(or every trade, when there is nodirectioncolumn). 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 quotesNone, the classifier is fallback.sign_method="tick"/"lee_ready": always classify every trade with that method, replacing any existingdirection.
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 |
required |
sign_method
|
str or None
|
|
required |
quotes
|
DataFrame or None
|
Quote frame for Lee-Ready (e.g. a pipeline |
required |
context
|
str
|
Caller name, used in the messages. |
required |
fallback
|
str or None
|
What |
'tick'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
trades with a |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If sign_method is |
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 ¶
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 |
required |
quotes
|
DataFrame
|
Quote frame with |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
For each row of rows, in order, the position (as for |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If either frame lacks |
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 |
required |
quotes
|
DataFrame
|
Quote frame with |
required |
context
|
str
|
Caller name, used in the error message. |
'mid_before'
|
mid_column
|
str
|
Column to read the midpoint from, e.g. |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The midpoint per row of rows, in order, |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If quotes lacks |
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 ( |
required |
quotes
|
DataFrame
|
Quote frame with |
required |
context
|
str
|
Caller name, used in the error message. |
'prevailing_mid'
|
mid_column
|
str
|
Column to read the midpoint from, e.g. |
None
|
require_covered
|
bool
|
Whether an instant past the newest readable quote is |
False
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The midpoint per instant, |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If quotes lacks |
The volume-bucket analytics cut their trades into buckets through
volume_buckets, which checks the bucket size through check_bucket_count.
volume_buckets ¶
volume_buckets(
trades: DataFrame,
bucket_volume: float,
context: str,
*,
advice: str | None = None,
) -> pd.DataFrame
Cut trades into buckets of equal volume, in their row order.
The trades fill one bucket of bucket_volume at a time. A trade that
crosses a bucket's edge is split: its first part fills that bucket and the
rest goes into the next one. Only full buckets are returned, so the volume
after the last full bucket is left out. These are the volume bars of
:func:bulk_volume_classification and
:func:~ob_analytics.flow_toxicity.compute_vpin.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trades
|
DataFrame
|
Trades with a |
required |
bucket_volume
|
float
|
Volume per bucket, in the instrument's own units (BTC, shares,
contracts), whether |
required |
context
|
str
|
Name of the caller, used in error messages. |
required |
advice
|
str
|
What to do when bucket_volume makes too many buckets; see
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per part of a trade in a bucket, in the order the trades fill
the buckets, with columns |
Raises:
| Type | Description |
|---|---|
ConfigError
|
If the sizes are integer lots and the frame carries no |
check_bucket_count ¶
check_bucket_count(
trades: DataFrame,
bucket_volume: float,
context: str,
*,
scale: float,
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 |
required |
bucket_volume
|
float
|
Volume per bucket, in the instrument's own units. Must be positive. |
required |
context
|
str
|
Name of the caller, used in the error message. |
required |
scale
|
float
|
Units of |
required |
advice
|
str
|
What to do about it, ending the error message. |
None
|
Raises:
| Type | Description |
|---|---|
ConfigError
|
If the trades' total volume divided by bucket_volume is more than
:data: |