> ## 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.

# Cross-Venue Analysis

> Compare executable BTC perpetual prices across venues, incorporate funding and trading costs, and identify candidate basis windows.

Use this workflow to compare the same perpetual market across venues. You will
align executable quotes, measure both trading directions, attach funding and
mark-price context, apply explicit costs, and group candidate basis windows.

The example compares the BTC perpetual on Hyperliquid and Pacifica. It resolves
the exact datasets and a shared public interval through Catalog, so it does not
require an API key.

<Warning>
  A positive calculated edge is not an executable profit. Quotes can be stale
  or too small, orders can move the book, and fees, funding, latency, collateral,
  liquidation risk, and transfer constraints can remove the opportunity.
</Warning>

## Install the dependencies

Install the Python SDK with DataFrame support and the analysis packages:

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

## Configure the comparison

Keep venue identity separate from the normalized underlying. Both datasets
represent BTC perpetuals, but their exact `source` and `market` values come from
Catalog.

```python theme={null}
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=10)
probe_window = pd.Timedelta(minutes=15)
quote_tolerance = pd.Timedelta(seconds=5)
point_tolerance = pd.Timedelta(minutes=5)
search_steps = 24 * 60 // int(probe_window / pd.Timedelta(minutes=1))
```

The cost assumptions below are illustrative. Replace them with your fee tier,
order size, expected slippage, funding convention, and holding period before
interpreting the result.

```python theme={null}
holding_hours = 1.0
minimum_net_edge_bps = 1.0

cost_assumptions = {
    "hyperliquid": {
        "round_trip_fees_bps": 5.0,
        "round_trip_slippage_bps": 2.0,
        "funding_period_hours": 1.0,
    },
    "pacifica": {
        "round_trip_fees_bps": 5.0,
        "round_trip_slippage_bps": 2.0,
        "funding_period_hours": 1.0,
    },
}
```

## Resolve a shared public interval

Catalog rows define each dataset's exact bounds and access state. Preview data
is publicly accessible only within the UTC day identified by
`public_cutoff_date`.

```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 comparison 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")

print(
    pd.DataFrame(
        [
            {
                "venue": venue["label"],
                "source": venue["source"],
                "market": venue["market"],
                "access": catalog_rows[venue["key"]]["access"]["status"],
                "available_start": bounds[venue["key"]][0],
                "available_end": bounds[venue["key"]][1],
            }
            for venue in venues
        ]
    )
)
```

## Find active quotes on both venues

Catalog coverage can extend beyond the latest locally available snapshot. Probe
backward until both venues have two-sided quotes that can be aligned without
using future information.

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


def rename_quotes(frame, key):
    return frame.rename(
        columns={
            "bid_price": f"bid_{key}",
            "bid_quantity": f"bid_quantity_{key}",
            "ask_price": f"ask_{key}",
            "ask_quantity": f"ask_quantity_{key}",
        }
    ).sort_values("timestamp")


def align_quotes(frames):
    left = rename_quotes(frames[venues[0]["key"]], venues[0]["key"])
    right = rename_quotes(frames[venues[1]["key"]], venues[1]["key"])
    right = right.drop(columns=["source", "market"], errors="ignore")
    return pd.merge_asof(
        left,
        right,
        on="timestamp",
        direction="backward",
        tolerance=quote_tolerance,
    ).dropna(
        subset=[
            f"bid_{venues[0]['key']}",
            f"ask_{venues[0]['key']}",
            f"bid_{venues[1]['key']}",
            f"ask_{venues[1]['key']}",
        ]
    )


aligned_quotes = pd.DataFrame()
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
    frames = {
        venue["key"]: fetch_bbo(venue, probe_start, probe_end)
        for venue in venues
    }
    if all(not frame.empty for frame in frames.values()):
        candidate = align_quotes(frames)
        if len(candidate) >= 30:
            aligned_quotes = candidate
            break

if aligned_quotes.empty:
    raise RuntimeError(
        "No sufficiently active aligned quote window was found in public coverage"
    )

window_end = aligned_quotes["timestamp"].max()
window_start = max(
    aligned_quotes["timestamp"].min(),
    window_end - analysis_window,
)
quotes = aligned_quotes.loc[
    aligned_quotes["timestamp"].between(
        window_start,
        window_end,
        inclusive="both",
    )
].copy()

print(f"Analysis window: {window_start} -> {window_end}")
print(f"Aligned quote rows: {len(quotes):,}")
```

The backward as-of join uses each venue's latest known quote. Rows with a quote
older than `quote_tolerance` are excluded.

## Calculate executable basis in both directions

An executable basis uses the ask on the venue you buy and the bid on the venue
you sell. Mark-to-mark differences are useful context, but you cannot trade at
a mark price.

```python theme={null}
for venue in venues:
    key = venue["key"]
    quotes[f"mid_{key}"] = (
        quotes[f"bid_{key}"] + quotes[f"ask_{key}"]
    ) / 2

quotes["reference_mid"] = (
    quotes["mid_hyperliquid"] + quotes["mid_pacifica"]
) / 2
quotes["buy_hyperliquid_sell_pacifica_bps"] = (
    (quotes["bid_pacifica"] - quotes["ask_hyperliquid"])
    / quotes["reference_mid"]
    * 10_000
)
quotes["buy_pacifica_sell_hyperliquid_bps"] = (
    (quotes["bid_hyperliquid"] - quotes["ask_pacifica"])
    / quotes["reference_mid"]
    * 10_000
)

route_columns = [
    "buy_hyperliquid_sell_pacifica_bps",
    "buy_pacifica_sell_hyperliquid_bps",
]
quotes["gross_edge_bps"] = quotes[route_columns].max(axis=1)
quotes["route"] = np.where(
    quotes[route_columns[0]] >= quotes[route_columns[1]],
    "buy Hyperliquid / sell Pacifica",
    "buy Pacifica / sell Hyperliquid",
)

print(
    quotes[
        [
            "timestamp",
            "route",
            "gross_edge_bps",
            "ask_hyperliquid",
            "bid_hyperliquid",
            "ask_pacifica",
            "bid_pacifica",
        ]
    ].nlargest(10, "gross_edge_bps")
)
```

This calculation uses top-of-book prices only. Check the displayed quantities
or use [Depth metrics](/schemas/depth-metrics) before applying the result to a
larger order.

## Add mark-price context

Fetch normalized mark prices for the same interval. Mark coverage is not
guaranteed for every source or public window, so preserve missing values and
report coverage instead of substituting a midpoint.

```python theme={null}
def fetch_point_series(venue, method_name, start, end):
    with PolarisClient() as client:
        method = getattr(client, method_name)
        return method(
            source=venue["source"],
            market=venue["market"],
            from_=start,
            to=end,
            allow_gaps=True,
            output="dataframe",
        )


mark_frames = {
    venue["key"]: fetch_point_series(
        venue,
        "mark_prices",
        window_start - point_tolerance,
        window_end,
    )
    for venue in venues
}

for venue in venues:
    key = venue["key"]
    frame = mark_frames[key]
    column = f"mark_{key}"
    if frame.empty:
        quotes[column] = np.nan
        continue
    marks = frame[["timestamp", "mark_price"]].rename(
        columns={"mark_price": column}
    ).sort_values("timestamp")
    quotes = pd.merge_asof(
        quotes.sort_values("timestamp"),
        marks,
        on="timestamp",
        direction="backward",
        tolerance=point_tolerance,
    )

quotes["mark_basis_bps"] = (
    (quotes["mark_pacifica"] - quotes["mark_hyperliquid"])
    / ((quotes["mark_pacifica"] + quotes["mark_hyperliquid"]) / 2)
    * 10_000
)

mark_coverage = quotes[
    ["mark_hyperliquid", "mark_pacifica"]
].notna().mean().mul(100)
print(mark_coverage.rename("coverage_pct"))
```

Keep `mark_basis_bps` as `NaN` when either venue lacks a mark. Executable BBO
basis remains available independently.

## Apply funding and trading costs

Attach each venue's most recent published funding rate without looking forward.
The example assumes a positive rate means longs pay shorts and scales the rate
by the configured funding period.

```python theme={null}
funding_frames = {
    venue["key"]: fetch_point_series(
        venue,
        "funding_rates",
        window_start - point_tolerance,
        window_end,
    )
    for venue in venues
}

for venue in venues:
    key = venue["key"]
    frame = funding_frames[key]
    column = f"funding_{key}"
    if frame.empty:
        quotes[column] = np.nan
        continue
    funding = frame[["timestamp", "funding_rate"]].rename(
        columns={"funding_rate": column}
    ).sort_values("timestamp")
    quotes = pd.merge_asof(
        quotes.sort_values("timestamp"),
        funding,
        on="timestamp",
        direction="backward",
        tolerance=point_tolerance,
    )

hyperliquid_funding_cost = (
    quotes["funding_hyperliquid"]
    * holding_hours
    / cost_assumptions["hyperliquid"]["funding_period_hours"]
    * 10_000
)
pacifica_funding_cost = (
    quotes["funding_pacifica"]
    * holding_hours
    / cost_assumptions["pacifica"]["funding_period_hours"]
    * 10_000
)

quotes["funding_cost_bps"] = np.where(
    quotes["route"] == "buy Hyperliquid / sell Pacifica",
    hyperliquid_funding_cost - pacifica_funding_cost,
    pacifica_funding_cost - hyperliquid_funding_cost,
)
quotes["trading_cost_bps"] = sum(
    assumptions["round_trip_fees_bps"]
    + assumptions["round_trip_slippage_bps"]
    for assumptions in cost_assumptions.values()
)
quotes["net_edge_bps"] = (
    quotes["gross_edge_bps"]
    - quotes["funding_cost_bps"]
    - quotes["trading_cost_bps"]
)
```

Verify each venue's rate sign and settlement period before using the funding
adjustment. A negative funding cost means the hedge is expected to receive net
funding under the configured assumptions.

## Flag candidate windows

Require complete funding inputs and group consecutive qualifying observations
by route. This avoids presenting every aligned quote as a separate opportunity.

```python theme={null}
eligible = quotes[
    ["funding_hyperliquid", "funding_pacifica"]
].notna().all(axis=1)
flagged = quotes.loc[
    eligible & (quotes["net_edge_bps"] > minimum_net_edge_bps)
].copy()

if flagged.empty:
    print("No candidate windows exceed the configured net-edge threshold.")
    windows = pd.DataFrame()
else:
    new_window = (
        flagged["timestamp"].diff().gt(quote_tolerance * 2)
        | flagged["route"].ne(flagged["route"].shift())
    )
    flagged["window_id"] = new_window.cumsum()
    windows = (
        flagged.groupby(["window_id", "route"], as_index=False)
        .agg(
            start=("timestamp", "min"),
            end=("timestamp", "max"),
            observations=("timestamp", "size"),
            median_gross_edge_bps=("gross_edge_bps", "median"),
            median_net_edge_bps=("net_edge_bps", "median"),
            max_net_edge_bps=("net_edge_bps", "max"),
        )
        .sort_values("max_net_edge_bps", ascending=False)
    )
    print(windows.head(20))
```

Plot gross and net edge to see how much of the apparent basis remains after
carry and trading assumptions:

```python theme={null}
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(
    quotes["timestamp"],
    quotes["gross_edge_bps"],
    label="Gross executable edge",
    alpha=0.75,
)
ax.plot(
    quotes["timestamp"],
    quotes["net_edge_bps"],
    label="Net edge after assumptions",
)
ax.axhline(
    minimum_net_edge_bps,
    color="black",
    linestyle="--",
    linewidth=1,
    label="Candidate threshold",
)
ax.set_title("Cross-venue BTC perpetual basis")
ax.set_xlabel("Time (UTC)")
ax.set_ylabel("Basis points")
ax.legend(loc="best")
plt.tight_layout()
plt.show()
```

## Interpret the result carefully

* Compare contract multipliers, settlement assets, margin rules, and funding
  conventions before treating two markets as interchangeable.
* BBO shows displayed top-of-book liquidity, not your expected average fill.
  Recalculate the edge with size-dependent slippage.
* The backward join avoids future quotes but does not remove network, collector,
  or venue latency. Tighten `quote_tolerance` for latency-sensitive analysis.
* Include capital costs, borrow, liquidation buffers, transfer delays, and the
  cost of closing both legs in `cost_assumptions`.
* Do not fill missing mark or funding rows with zero. Missing context makes a
  net-edge estimate incomplete.
* Candidate windows are research outputs, not trading instructions or evidence
  that both legs could have been filled simultaneously.

## Related documentation

* [BBO](/schemas/bbo) for top-of-book fields and sampling behavior
* [Mark prices](/schemas/mark-prices) for normalized reference prices
* [Funding rates](/schemas/funding-rates) for carry inputs
* [Depth metrics](/schemas/depth-metrics) for size-dependent execution costs
* [Liquidity / Microstructure](/guides/liquidity-microstructure) for spread, depth, slippage, and L2 profiling
* [Perpetuals](/guides/perpetuals) for venue discovery and perpetual-market methods
