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 |
ObAnalyticsError
|
If trades is empty. |
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 (instrument-specific), in the units of
|
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 |
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: |
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 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 |
required |
bucket_volume
|
float
|
Volume per bucket, in the units of |
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
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the trades' total volume divided by bucket_volume is more than
:data: |