Skip to main content

Markout P&L analysis for Python. Polars-native. Instrument-agnostic. Statistically rigorous.

Project description

markoutlib

Markout P&L analysis for Python. Polars-native. Instrument-agnostic. Statistically rigorous.

Why this library?

  1. There's no good open-source implementation. Markout computation is conceptually simple -- an asof join and some arithmetic. But a correct implementation accumulates edge cases fast: session boundaries, stale quotes, auction states, null propagation when forward data is unavailable. This library makes those decisions explicit and documented.

  2. Three clock domains matter. Wall-clock, trade-clock, and tick-clock markouts answer different questions. Comparing across all three on the same dataset with consistent segmentation is where you actually learn something about flow characteristics.

  3. Point estimates aren't enough. A markout curve without confidence intervals is a number, not a conclusion. Block bootstrap CIs, permutation tests for segment differences, and decay half-life estimation are built in.

  4. The gap between weighted and unweighted markout is a signal. When equal-weighted and size-weighted markouts diverge, that tells you something about the relationship between trade size and information content. The library makes this comparison trivial.

  5. No magic. You provide the mid. You provide the side. The library never infers, constructs, or guesses. When forward data isn't available, you get null -- not a silent fallback.

Quickstart

import polars as pl
import markoutlib as mo

result = mo.compute(
    trades=trades,
    quotes=quotes,
    horizons=mo.seconds(1, 5, 30),
)

result.curve()
result.plot.curve()

Full example

import markoutlib as mo

# Multiple horizon types in one pass
result = mo.compute(
    trades=trades,
    quotes=quotes,
    horizons=mo.seconds(1, 5, 30) + mo.trades(10, 50) + mo.ticks(100),
    unit="bps",
)

# Markout curve with bootstrap CIs and Newey-West t-stats
result.curve()

# Segment by any column in your trades DataFrame
result.curve(by="counterparty")

# Size-weighted markout
result.curve(weight="size")

# Weighted vs unweighted side-by-side
result.compare(weight="size")

# Exponential decay fit -> half-life in horizon units
result.half_life()

# Permutation test: does markout differ across counterparties?
result.test("counterparty")

# Pairwise tests with Benjamini-Hochberg correction
result.test("counterparty", pairwise=True)

# Pre-trade baselines — detect information leakage
result = mo.compute(
    trades=trades,
    quotes=quotes,
    horizons=mo.seconds_range(-30, 30, step=1),
)
result.plot.curve()  # Full crossing-zero curve

# Spread decomposition: effective = realized + price impact
result.spread_decomposition(horizon=mo.seconds(5))
result.effective_spread()
result.realized_spread(horizon=mo.seconds(5))

# Visualization
result.plot.curve()
result.plot.curve(by="counterparty")
result.plot.heatmap(by="symbol")
result.plot.distribution(horizon=mo.seconds(5).single())
result.plot.comparison(by="counterparty")
result.plot.scatter(x="size", horizon=mo.seconds(5).single())

# Export
df = result.to_polars()
pdf = result.to_pandas()  # requires pip install markoutlib[pandas]

Concepts

Horizon types

Wall-clock (mo.seconds(1, 5, 30)) -- measure the mid N seconds after each trade. Uses an asof join against the quote stream. Stale quote protection nulls out matches where the nearest quote is more than 2x the horizon away. Use wall-clock when you care about real-time P&L impact.

Trade-clock (mo.trades(10, 50)) -- measure the mid at the Nth subsequent trade. No quote data required. Use trade-clock when you want to control for activity rate -- a 10-trade markout means the same thing in a liquid name and an illiquid one.

Tick-clock (mo.ticks(100)) -- measure the mid at the Nth subsequent quote update. Use tick-clock when you want to normalize by information arrival rate rather than calendar time.

All three can be combined in a single compute() call via + on horizon sets.

Sign convention

Positive markout = price moved in the direction of the analyzed party's trade.

  • side=1 (buy): markout is positive when the mid rose after the trade
  • side=-1 (sell): markout is positive when the mid fell after the trade

Formula: side * (future_mid - mid), scaled to bps by default.

Perspective

By default, markouts are from the taker's perspective. Pass perspective="maker" to flip the sign so that positive markout means the liquidity provider profited:

result = mo.compute(trades, quotes, horizons=mo.seconds(5), perspective="maker")

Units

  • unit="bps" (default): side * (future_mid - mid) / mid * 10000
  • unit="price": side * (future_mid - mid)

Negative offsets and pre-trade baselines

Horizon values can be negative. A horizon of -5 seconds looks up the mid 5 seconds before each trade, giving you a pre-trade baseline. This is the standard technique for detecting information leakage: if the markout curve is already non-zero before time zero, someone is trading on stale information or the signal is arriving before the trade timestamp suggests.

Use seconds_range(-30, 30, step=1) to generate a full crossing-zero curve. The half_life() method automatically filters out negative horizons since decay fitting only makes sense on the post-trade portion.

Spread decomposition

The Huang-Stoll identity decomposes the effective half-spread into realized spread and price impact:

effective_spread = realized_spread + price_impact

The effective spread measures execution cost at the moment of the trade. The realized spread measures what the liquidity provider actually earns after prices move. The price impact measures the permanent information content of the trade. When price impact dominates, the flow is informed. When realized spread dominates, the liquidity provider is profiting from transient effects.

The mid column contract

You provide the mid. The library never constructs it from bid/ask, NBBO, or any other source. This is deliberate -- mid calculation varies by instrument, venue, and use case. Bring your own.

Required columns on trades: timestamp, side, price, mid. Required columns on quotes: timestamp, mid.

Any additional columns (e.g. counterparty, symbol, size) pass through and are available for segmentation and weighting.

API reference

mo.compute(trades, quotes, *, horizons, unit="bps", by=None)

Core computation. Returns a MarkoutResult. Accepts Polars or pandas DataFrames (pandas is converted internally).

Parameter Type Description
trades DataFrame Trade records with timestamp, side, price, mid
quotes DataFrame | None Quote records with timestamp, mid. Required for wall/tick horizons
horizons HorizonSet Built via mo.seconds(), mo.trades(), mo.ticks(), composable with +
unit str "bps" or "price"
by str | list[str] | None Partition column(s) for per-symbol or per-venue computation
perspective str "taker" or "maker". Maker perspective negates the markout

Horizon constructors

Function Clock domain Requires quotes
mo.seconds(*values) Wall-clock Yes
mo.trades(*values) Trade-clock No
mo.ticks(*values) Tick-clock Yes

Range constructors generate uniform grids with inclusive stop:

mo.seconds_range(start=1, stop=60, step=5)    # 1, 6, 11, ..., 56
mo.seconds_range(start=-30, stop=30, step=5)   # Crosses zero
mo.trades_range(start=1, stop=100, step=10)
mo.ticks_range(start=1, stop=50, step=5)

MarkoutResult methods

Method Returns Description
.curve(by=, weight=) DataFrame Mean, median, quantiles, bootstrap CI, Newey-West t-stat per horizon
.half_life(by=) DecayFitResult | DataFrame Exponential decay fit with half-life, time constant, R-squared
.test(column, pairwise=) DataFrame Permutation test for markout differences across segments
.compare(weight=) DataFrame Weighted vs unweighted mean side-by-side
.plot.curve(by=) Figure Markout decay curve with CIs
.plot.heatmap(by=) Figure Horizon x segment heatmap
.plot.distribution(horizon=, by=) Figure Markout distribution at a single horizon
.plot.comparison(by=) Figure Segment comparison chart
.plot.scatter(x=, horizon=) Figure Markout vs continuous variable
.effective_spread(by=) DataFrame Effective half-spread per trade
.realized_spread(horizon=, by=) DataFrame Realized half-spread at given horizon
.price_impact(horizon=, by=) DataFrame Price impact component at given horizon
.spread_decomposition(horizon=, by=) DataFrame Full Huang-Stoll decomposition
.to_polars() DataFrame Raw per-trade markout data
.to_pandas() pd.DataFrame Pandas export (requires markoutlib[pandas])

Examples

See the examples/ directory for Jupyter notebooks:

  • 01_quickstart.ipynb — Synthetic data API walkthrough
  • 02_crypto_markouts.ipynb — Binance BTCUSDT markout analysis (maker perspective)
  • 03_spread_decomposition.ipynb — AAPL effective/realized spread (LOBSTER data)
  • 04_information_leakage.ipynb — Pre-trade baseline detection (Binance data)

Non-goals

  • Constructing the mid. You know your instrument better than this library does.
  • Trade classification. Side must be provided. Use Lee-Ready, bulk volume classification, or whatever applies to your data.
  • Tick data storage or retrieval. This is a computation library, not a data pipeline.
  • Real-time / streaming computation. Batch only. If you need streaming markouts, you need different architecture.
  • Multi-asset portfolio markout. This operates on a flat table of trades. Portfolio-level aggregation is your responsibility.

Roadmap

  • Rust-accelerated tick-clock joins via PyO3
  • Async-aware session boundary handling
  • Configurable stale quote tolerance
  • Additional bootstrap methods (BCa, studentized)
  • LaTeX report generation
  • Markout attribution decomposition (spread vs alpha components)

Installation

pip install markoutlib           # v0.2

With pandas support:

pip install markoutlib[pandas]

Requires Python 3.11+. Polars is the only required dependency.

Optional: Rust accelerator

markoutlib includes an optional Rust extension that accelerates tick-clock computation. It is not required — the library falls back to numpy automatically.

To build and install (requires Rust toolchain + maturin):

pip install maturin
cd rust
maturin develop --release

Verify it's active:

from markoutlib._compute import _USE_NATIVE
print(_USE_NATIVE)  # True if Rust extension is loaded

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

markoutlib-0.3.0.tar.gz (123.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

markoutlib-0.3.0-py3-none-any.whl (29.0 kB view details)

Uploaded Python 3

File details

Details for the file markoutlib-0.3.0.tar.gz.

File metadata

  • Download URL: markoutlib-0.3.0.tar.gz
  • Upload date:
  • Size: 123.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for markoutlib-0.3.0.tar.gz
Algorithm Hash digest
SHA256 c3af50480ce31a7f72685663a762277c0df761072803ac0b15b0585bdabb4ff4
MD5 91516d678f1ec8093fde2c1e43246e0f
BLAKE2b-256 bcc14b82374fcf0579bbefc7d24fa4b9b42530c18fd1a3aba8a85428e9a7866a

See more details on using hashes here.

Provenance

The following attestation bundles were made for markoutlib-0.3.0.tar.gz:

Publisher: publish.yml on jgehunter/markoutlib

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file markoutlib-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: markoutlib-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 29.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for markoutlib-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d66058744ae4b0cb24e6dca51119adac3dbbfe3ab59d121117dcea8d149cd20f
MD5 b8a45b3be932e3286707a6c2aa70ab6d
BLAKE2b-256 1a97c2cc21648f77bbda8006ddc062a961fc2450492de21b290cb1cc68611b2e

See more details on using hashes here.

Provenance

The following attestation bundles were made for markoutlib-0.3.0-py3-none-any.whl:

Publisher: publish.yml on jgehunter/markoutlib

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page