> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polaris.supply/llms.txt
> Use this file to discover all available pages before exploring further.

# Liquidity / Microstructure

> Compare spread distributions, size-dependent slippage, order-book imbalance, and L2 depth across venues.

Use this workflow to compare how liquidity behaves across venues. You will
analyze tick-by-tick and time-sampled spreads, measure directional slippage at
several order sizes, and profile the latest reconstructed L2 books.

The example compares the BTC perpetual on Hyperliquid and Pacifica over the
same public interval. It resolves the exact datasets through Catalog and does
not require an API key.

<Warning>
  Displayed liquidity can be canceled before you trade. Treat BBO, depth
  metrics, and L2 profiles as historical observations, not fill guarantees.
</Warning>

## Install the dependencies

```bash theme={null}
pip install "polaris-data[dataframe]" numpy pandas matplotlib
```

## Configure the analysis

Use a short window for tick-level and reconstructed-book analysis. The row caps
stop the example instead of silently returning a partial sample.

```python theme={null}
from itertools import islice

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from polaris_data import PolarisClient

venues = [
    {
        "label": "Hyperliquid",
        "key": "hyperliquid",
        "source": "hyperliquid",
        "market": "BTC",
    },
    {
        "label": "Pacifica",
        "key": "pacifica",
        "source": "pacifica",
        "market": "BTC",
    },
]

analysis_window = pd.Timedelta(minutes=2)
probe_window = pd.Timedelta(minutes=15)
search_steps = 24 * 60 // int(probe_window / pd.Timedelta(minutes=1))
tick_row_cap = 100_000
book_row_cap = 50_000
slippage_notionals = (10_000, 50_000, 100_000)
depth_pct = 0.01
```

## Resolve an active shared window

Catalog defines the exact source, market, access state, and coverage bounds.
Clamp preview datasets to their public UTC day, then search backward for a
window where both venues publish BBO observations.

```python theme={null}
def as_utc(value):
    timestamp = pd.Timestamp(value)
    return (
        timestamp.tz_localize("UTC")
        if timestamp.tzinfo is None
        else timestamp.tz_convert("UTC")
    )


def accessible_bounds(market_info):
    start = as_utc(market_info["start"])
    end = as_utc(market_info["end"])
    access = market_info.get("access") or {}
    cutoff = access.get("public_cutoff_date")
    if access.get("status") == "preview" and cutoff:
        public_day = as_utc(cutoff)
        start = max(start, public_day)
        end = min(end, public_day + pd.Timedelta(days=1))
    if start >= end:
        raise ValueError("The venue has no accessible analysis interval")
    return start, end


catalog_rows = {}
with PolarisClient() as client:
    for venue in venues:
        result = client.catalog(
            source=venue["source"],
            market=venue["market"],
        )
        if not result["markets"]:
            raise ValueError(
                f"Catalog has no row for {venue['source']}:{venue['market']}"
            )
        row = result["markets"][0]
        if (row.get("instrument") or {}).get("base") != "BTC":
            raise ValueError(f"{venue['label']} does not resolve to BTC")
        catalog_rows[venue["key"]] = row

bounds = {
    key: accessible_bounds(row)
    for key, row in catalog_rows.items()
}
common_start = max(start for start, _ in bounds.values())
common_end = min(end for _, end in bounds.values())
if common_start >= common_end:
    raise ValueError("The selected venues have no overlapping public coverage")
```

Use one-second BBO samples only to locate a shared active interval. The
tick-level query comes next.

```python theme={null}
def fetch_sampled_bbo(venue, start, end):
    with PolarisClient() as client:
        return client.bbo(
            source=venue["source"],
            market=venue["market"],
            from_=start,
            to=end,
            interval="1s",
            allow_gaps=True,
            output="dataframe",
        )


window_start = None
window_end = None
for offset in range(search_steps):
    probe_end = common_end - offset * probe_window
    probe_start = max(common_start, probe_end - probe_window)
    if probe_start >= probe_end:
        break
    sampled = {
        venue["key"]: fetch_sampled_bbo(venue, probe_start, probe_end)
        for venue in venues
    }
    if any(frame.empty for frame in sampled.values()):
        continue
    overlap_start = max(frame["timestamp"].min() for frame in sampled.values())
    overlap_end = min(frame["timestamp"].max() for frame in sampled.values())
    if overlap_end - overlap_start >= analysis_window:
        window_end = overlap_end
        window_start = window_end - analysis_window
        break

if window_start is None:
    raise RuntimeError(
        "No shared active BBO window was found in public coverage"
    )

print(f"Analysis window: {window_start} -> {window_end}")
```

## Measure tick-by-tick spreads

Set `changes_only=True` to keep observations where the best price or quantity
changed. This preserves quote-event behavior while suppressing deep-book
updates that leave BBO unchanged.

```python theme={null}
def bounded_rows(iterator, limit):
    rows = list(islice(iterator, limit + 1))
    truncated = len(rows) > limit
    close = getattr(iterator, "close", None)
    if close is not None:
        close()
    if truncated:
        raise RuntimeError(
            f"The query exceeded the {limit:,}-row cap. Shorten analysis_window."
        )
    return rows


def normalize_timestamps(frame):
    if pd.api.types.is_numeric_dtype(frame["timestamp"]):
        frame["timestamp"] = pd.to_datetime(
            frame["timestamp"],
            unit="ms",
            utc=True,
        )
    else:
        frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True)
    return frame


def add_quote_metrics(frame):
    frame = frame.copy()
    frame = frame.loc[
        (frame["bid_price"] > 0)
        & (frame["ask_price"] > frame["bid_price"])
        & (frame["bid_quantity"] > 0)
        & (frame["ask_quantity"] > 0)
    ].copy()
    frame["mid_price"] = (
        frame["bid_price"] + frame["ask_price"]
    ) / 2
    frame["spread_bps"] = (
        (frame["ask_price"] - frame["bid_price"])
        / frame["mid_price"]
        * 10_000
    )
    frame["bid_top_notional"] = (
        frame["bid_price"] * frame["bid_quantity"]
    )
    frame["ask_top_notional"] = (
        frame["ask_price"] * frame["ask_quantity"]
    )
    return frame


tick_frames = []
for venue in venues:
    with PolarisClient() as client:
        iterator = client.bbo(
            source=venue["source"],
            market=venue["market"],
            from_=window_start,
            to=window_end,
            allow_gaps=True,
            changes_only=True,
        )
        rows = bounded_rows(iterator, tick_row_cap)
    frame = normalize_timestamps(pd.DataFrame(rows))
    frame["venue"] = venue["label"]
    tick_frames.append(add_quote_metrics(frame))

tick_quotes = pd.concat(tick_frames, ignore_index=True)
if tick_quotes.empty:
    raise RuntimeError("No valid two-sided BBO observations were returned")
```

Summarize the distribution and displayed top-level notional:

```python theme={null}
tick_summary = (
    tick_quotes.groupby("venue")
    .agg(
        quote_updates=("spread_bps", "size"),
        median_spread_bps=("spread_bps", "median"),
        p90_spread_bps=("spread_bps", lambda values: values.quantile(0.90)),
        p99_spread_bps=("spread_bps", lambda values: values.quantile(0.99)),
        median_bid_top_notional=("bid_top_notional", "median"),
        median_ask_top_notional=("ask_top_notional", "median"),
    )
    .sort_values("median_spread_bps")
)
print(tick_summary)
```

Tick-weighted statistics give more influence to venues that update more often.
Build a second summary from non-empty one-second buckets for a closer
clock-time comparison. Empty buckets remain absent rather than being
forward-filled:

```python theme={null}
sampled_frames = []
for venue in venues:
    frame = fetch_sampled_bbo(venue, window_start, window_end)
    frame = normalize_timestamps(frame)
    frame["venue"] = venue["label"]
    sampled_frames.append(add_quote_metrics(frame))

sampled_quotes = pd.concat(sampled_frames, ignore_index=True)
sampled_summary = (
    sampled_quotes.groupby("venue")
    .agg(
        sampled_seconds=("spread_bps", "size"),
        median_spread_bps=("spread_bps", "median"),
        p90_spread_bps=("spread_bps", lambda values: values.quantile(0.90)),
        median_bid_top_notional=("bid_top_notional", "median"),
        median_ask_top_notional=("ask_top_notional", "median"),
    )
    .sort_values("median_spread_bps")
)
print(sampled_summary)
```

Plot the empirical spread distribution without choosing histogram bins:

```python theme={null}
fig, ax = plt.subplots(figsize=(9, 5))
for venue, frame in tick_quotes.groupby("venue"):
    values = np.sort(frame["spread_bps"].dropna().to_numpy())
    cumulative_probability = np.arange(1, len(values) + 1) / len(values)
    ax.plot(values, cumulative_probability, label=venue)

ax.set_title("Tick-by-tick BBO spread distribution")
ax.set_xlabel("Spread (bps)")
ax.set_ylabel("Cumulative probability")
ax.set_xlim(left=0)
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()
```

## Compare depth and directional slippage

`depth_metrics(...)` reconstructs the book and calculates depth within
`depth_pct`, plus buy and sell slippage for one target notional. Query several
notionals to build an execution-cost curve.

```python theme={null}
depth_frames = []
for venue in venues:
    for notional in slippage_notionals:
        with PolarisClient() as client:
            frame = client.depth_metrics(
                source=venue["source"],
                market=venue["market"],
                from_=window_start,
                to=window_end,
                depth_pct=depth_pct,
                slippage_notional=notional,
                allow_gaps=True,
                output="dataframe",
            )
        if frame.empty:
            continue
        frame = normalize_timestamps(frame)
        frame["venue"] = venue["label"]
        depth_frames.append(frame)

if not depth_frames:
    raise RuntimeError("No depth metrics were returned for the selected window")

depth = pd.concat(depth_frames, ignore_index=True)
required_depth_columns = {
    "bid_depth_notional",
    "ask_depth_notional",
    "depth_imbalance",
    "buy_slippage_bps",
    "sell_slippage_bps",
}
missing_depth_columns = required_depth_columns - set(depth.columns)
if missing_depth_columns:
    raise ValueError(
        f"Depth metrics are missing columns: {sorted(missing_depth_columns)}"
    )
```

Sample at one-second intervals before comparing venues so faster books do not
dominate the result:

```python theme={null}
depth_samples = []
for (venue, notional), frame in depth.groupby(
    ["venue", "slippage_notional"]
):
    sampled = (
        frame.sort_values("timestamp")
        .set_index("timestamp")
        .resample("1s")
        .last()
        .dropna(subset=["mid_price"])
        .reset_index()
    )
    sampled["venue"] = venue
    sampled["slippage_notional"] = notional
    depth_samples.append(sampled)

depth_sampled = pd.concat(depth_samples, ignore_index=True)
depth_sampled["worst_side_slippage_bps"] = depth_sampled[
    ["buy_slippage_bps", "sell_slippage_bps"]
].max(axis=1)

depth_summary = (
    depth_sampled.groupby(["venue", "slippage_notional"])
    .agg(
        sampled_seconds=("timestamp", "size"),
        median_bid_depth=("bid_depth_notional", "median"),
        median_ask_depth=("ask_depth_notional", "median"),
        median_imbalance=("depth_imbalance", "median"),
        median_buy_slippage_bps=("buy_slippage_bps", "median"),
        p90_buy_slippage_bps=(
            "buy_slippage_bps",
            lambda values: values.quantile(0.90),
        ),
        median_sell_slippage_bps=("sell_slippage_bps", "median"),
        p90_sell_slippage_bps=(
            "sell_slippage_bps",
            lambda values: values.quantile(0.90),
        ),
        median_worst_side_slippage_bps=(
            "worst_side_slippage_bps",
            "median",
        ),
    )
    .reset_index()
)
print(depth_summary)
```

Plot the median cost on the less liquid side at each target notional:

```python theme={null}
fig, ax = plt.subplots(figsize=(9, 5))
for venue, frame in depth_summary.groupby("venue"):
    frame = frame.sort_values("slippage_notional")
    ax.plot(
        frame["slippage_notional"],
        frame["median_worst_side_slippage_bps"],
        marker="o",
        label=venue,
    )

ax.set_title("Median size-dependent slippage")
ax.set_xlabel("Target notional (USD)")
ax.set_ylabel("Worst-side slippage (bps)")
ax.legend(loc="best")
plt.tight_layout()
plt.show()
```

## Profile reconstructed L2 books

Use `l2_snapshots(...)` when you need the complete price ladder rather than a
derived metric. Retain only the latest book in the interval to keep memory use
bounded.

```python theme={null}
def latest_book(venue, start, end, row_cap):
    latest = None
    count = 0
    with PolarisClient() as client:
        for book in client.l2_snapshots(
            source=venue["source"],
            market=venue["market"],
            from_=start,
            to=end,
            allow_gaps=True,
        ):
            count += 1
            if count > row_cap:
                raise RuntimeError(
                    f"{venue['label']} exceeded the {row_cap:,}-book cap. "
                    "Shorten analysis_window."
                )
            latest = book
    if latest is None:
        raise RuntimeError(f"No reconstructed book for {venue['label']}")
    return latest, count


latest_books = {}
for venue in venues:
    book, count = latest_book(
        venue,
        window_start,
        window_end,
        book_row_cap,
    )
    latest_books[venue["key"]] = book
    print(f"{venue['label']}: processed {count:,} reconstructed books")
```

Measure displayed notional within 1, 5, 10, and 25 basis points of each latest
midpoint:

```python theme={null}
depth_bands_bps = (1, 5, 10, 25)
profile_rows = []

for venue in venues:
    book = latest_books[venue["key"]]
    bids = pd.DataFrame(book["data"]["bids"])
    asks = pd.DataFrame(book["data"]["asks"])
    if bids.empty or asks.empty:
        raise RuntimeError(f"{venue['label']} returned a one-sided book")

    best_bid = bids["price"].max()
    best_ask = asks["price"].min()
    midpoint = (best_bid + best_ask) / 2

    bids["distance_bps"] = (midpoint - bids["price"]) / midpoint * 10_000
    asks["distance_bps"] = (asks["price"] - midpoint) / midpoint * 10_000
    bids["notional"] = bids["price"] * bids["quantity"]
    asks["notional"] = asks["price"] * asks["quantity"]

    book_time = pd.to_datetime(
        book["collector_timestamp"],
        unit="ms",
        utc=True,
    )
    for band in depth_bands_bps:
        profile_rows.append(
            {
                "venue": venue["label"],
                "book_timestamp": book_time,
                "band_bps": band,
                "bid_notional": bids.loc[
                    bids["distance_bps"] <= band,
                    "notional",
                ].sum(),
                "ask_notional": asks.loc[
                    asks["distance_bps"] <= band,
                    "notional",
                ].sum(),
            }
        )

book_profile = pd.DataFrame(profile_rows)
print(book_profile)
```

Confirm that book timestamps are close enough for your comparison. A wide time
gap can look like a venue difference when it is a market move.

```python theme={null}
book_time_span = (
    book_profile["book_timestamp"].max()
    - book_profile["book_timestamp"].min()
)
print(f"Latest-book timestamp span: {book_time_span}")
```

Plot cumulative displayed notional by distance from the midpoint:

```python theme={null}
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
for venue, frame in book_profile.groupby("venue"):
    frame = frame.sort_values("band_bps")
    axes[0].plot(
        frame["band_bps"],
        frame["bid_notional"],
        marker="o",
        label=venue,
    )
    axes[1].plot(
        frame["band_bps"],
        frame["ask_notional"],
        marker="o",
        label=venue,
    )

axes[0].set_title("Bid depth")
axes[1].set_title("Ask depth")
for ax in axes:
    ax.set_xlabel("Distance from midpoint (bps)")
    ax.legend(loc="best")
axes[0].set_ylabel("Displayed notional (USD)")
plt.tight_layout()
plt.show()
```

## Interpret the result carefully

* Tick-weighted distributions describe quote-event behavior. Use interval
  samples when comparing time spent at each spread.
* Compare markets with equivalent contract multipliers, quote assets, and
  minimum order sizes. The same base asset does not guarantee identical risk.
* Depth and slippage are conditional on the chosen notional, depth band, and
  observation time. Report all three with the result.
* L2 shows displayed orders. It does not reveal queue position, hidden
  liquidity, cancellation probability, or your actual fill path.
* Coverage gaps and reconnects reset reconstructed book state. Do not carry a
  book across a gap.
* Add fees, latency, and market impact before turning a liquidity comparison
  into an execution decision.

## Related documentation

* [BBO](/schemas/bbo) for quote fields, change filtering, and interval sampling
* [Depth metrics](/schemas/depth-metrics) for derived spread, depth, imbalance, and slippage
* [L2 snapshots and updates](/schemas/l2-snapshots) for reconstruction behavior and raw updates
* [Cross-Venue Analysis](/guides/cross-venue-analysis) for executable basis and funding-adjusted candidate windows
* [Perpetuals](/guides/perpetuals) for venue discovery and perpetual-market methods
