Idea 3 — cross-venue funding spread¶
Claim. Hyperliquid and Binance fund the same perp at different rates. Long the cheaper venue, short the richer, delta-neutral, and collect the difference.
Result. True on average and not tradeable as stated. The mean spread is +3.95% annualized and the median is exactly zero: a third of windows sit at both venues' floor rate, and the top 10% of windows carry 94% of the total. Costs push breakeven out past two weeks.
Spec: [2026-08-04-cross-venue-funding-spread.md]
(../docs/superpowers/specs/2026-08-04-cross-venue-funding-spread.md)
import json
from pathlib import Path
from statistics import fmean, median
import plotly.io as pio
import polars as pl
from hypertrade_core.funding import FundingInterval, annualized_rate
from hypertrade_research.charts import (
spread_concentration_figure,
spread_distribution_figure,
)
from hypertrade_research.venue_spread import aligned_spread
# Static HTML, not a live kernel: the published site has no Python behind it, so a
# figure has to carry its own data. `notebook_connected` loads plotly.js from the CDN
# once per page instead of inlining ~3 MB into every figure.
pio.renderers.default = "notebook_connected"
FIXTURES = Path("../packages/research/tests/fixtures")
HOUR_MS = 3_600_000
# Both venues' settlements, already captured. Nothing here calls an exchange.
HL_STEM = "carry_history_HYPE_4h_2024-12-05"
BINANCE_STEM = "binance_funding_HYPEUSDT_4h"
The window is 430 days, not 608¶
Hyperliquid has funded HYPE for 607 days. Binance did not list HYPEUSDT until 2025-05-30, so the spread only exists over the overlap. Quoting the longer window would compare Hyperliquid against a venue that had not listed the contract.
hl = pl.read_parquet(FIXTURES / f"{HL_STEM}_funding.parquet")
binance = pl.read_parquet(FIXTURES / f"{BINANCE_STEM}.parquet")
meta = json.loads((FIXTURES / f"{BINANCE_STEM}_meta.json").read_text())
def _hour_keyed(frame: pl.DataFrame) -> dict[int, float]:
"""Snap settlements to their hour.
Neither venue stamps its settlements on the hour: the archive carries
``16:00:23.592`` and Binance stamps a few ms past. Comparing raw timestamps finds
no shared window at all, which is a silent empty result rather than an error.
"""
return {
int(event_ms) // HOUR_MS * HOUR_MS: float(rate)
for event_ms, rate in zip(
frame["event_ms"].to_list(), frame["rate"].to_list(), strict=True
)
}
hourly = _hour_keyed(hl)
periodic = _hour_keyed(binance)
print(f"Hyperliquid: {len(hourly):>6} hourly settlements ({len(hourly) / 24:.0f} days)")
print(f"Binance: {len(periodic):>6} settlements at {meta['interval_hours']}h")
Hyperliquid: 14558 hourly settlements (607 days) Binance: 2586 settlements at 4h
Binance funds HYPE every 4 hours, not 8¶
Eight hours is the flagship default and the number a naive comparison assumes.
Measured across every month of the window, HYPEUSDT settles 4-hourly throughout,
with one missing settlement in 2026-06. FundingInterval.FOUR_HOURLY already
existed in packages/core, and its docstring already said Binance must be read per
symbol rather than assumed — this is the symbol that proves it.
Hyperliquid's four hourly settlements are summed across each Binance window. That is realized funding over identical elapsed time, not a normalization: comparing one hourly rate against one 4-hourly rate understates Hyperliquid by 4×.
interval = FundingInterval(meta["interval_hours"])
spread = aligned_spread(hourly=hourly, periodic=periodic, periodic_interval=interval)
windows = sorted(spread)
values = [spread[key] for key in windows]
span_days = (windows[-1] - windows[0]) / 86_400_000
dropped = len(periodic) - len(spread)
print(f"{len(spread)} comparable windows over {span_days:.0f} days")
print(f"{dropped} dropped: a window missing an hour is refused, never prorated")
2582 comparable windows over 430 days 4 dropped: a window missing an hour is refused, never prorated
The mean is real and the median is zero¶
This is the finding. A bar chart of the mean would state the opposite of the conclusion, so both statistics are marked on the distribution.
mean_bps = fmean(values) * 10_000
median_bps = median(values) * 10_000
ann = [annualized_rate(value, interval) for value in values]
print(f"per 4h window: mean {mean_bps:+.3f} bps median {median_bps:+.3f} bps")
ann_mean_pct = fmean(ann) * 100
ann_median_pct = median(ann) * 100
richer_pct = sum(v > 0 for v in values) / len(values) * 100
print(f"annualized: mean {ann_mean_pct:+.2f}% median {ann_median_pct:+.2f}%")
print(f"Hyperliquid richer in {richer_pct:.1f}% of windows")
print(
f"windows at exactly zero: {sum(v == 0 for v in values) / len(values) * 100:.1f}%"
)
per 4h window: mean +0.181 bps median +0.000 bps annualized: mean +3.95% median +0.00% Hyperliquid richer in 46.4% of windows windows at exactly zero: 32.3%
spread_distribution_figure(spread)
Where the spread comes from¶
A third of windows are exactly zero — both venues at their floor. If the remaining spread were spread evenly the curve below would follow the diagonal. It does not: the gap between the curve and the dashed line is the concentration, and it is what makes the mean unholdable as a carry.
top_decile = sum(sorted(values, reverse=True)[: len(values) // 10]) / sum(values)
print(f"top 10% of windows carry {top_decile * 100:.0f}% of the total spread")
worst_bps = min(values) * 10_000
best_bps = max(values) * 10_000
print(f"worst window {worst_bps:+.2f} bps vs best {best_bps:+.2f} bps")
top 10% of windows carry 94% of the total spread worst window -21.06 bps vs best +7.01 bps
spread_concentration_figure(spread)
Costs make it a hold, not a trade¶
The trade crosses four times: open and close on both venues. Hyperliquid's perp
taker fee is 4.40 bps, measured from the venue's own userCrossRate. Binance's
5.0 bps is the published VIP0 rate, not measured on our account — a real
account may pay less.
Windows are 4 hours, so a hold of N days spans 6N of them. Independent samples are counted in non-overlapping holds, because 3,000 overlapping windows are not 3,000 pieces of evidence.
HL_TAKER_BPS = 4.40
BINANCE_TAKER_BPS = 5.00
ROUND_TRIP_BPS = 2 * (HL_TAKER_BPS + BINANCE_TAKER_BPS)
WINDOWS_PER_DAY = 24 // interval.hours
print(f"round trip: {ROUND_TRIP_BPS:.2f} bps across four fills\n")
for days in (7, 14, 30, 90):
span = WINDOWS_PER_DAY * days
holds = [sum(values[i : i + span]) for i in range(len(values) - span)]
gross = median(holds) * 10_000
print(
f"{days:>3}d {len(holds) // span:>3} indep gross {gross:+8.1f} "
f"net {gross - ROUND_TRIP_BPS:+8.1f} bps "
f"win {sum(h > 0 for h in holds) / len(holds) * 100:.1f}%"
)
round trip: 18.80 bps across four fills 7d 60 indep gross +8.3 net -10.5 bps win 79.1% 14d 29 indep gross +16.0 net -2.8 bps win 81.9% 30d 13 indep gross +32.6 net +13.8 bps win 82.0% 90d 3 indep gross +103.3 net +84.5 bps win 84.0%
Breakeven is around 17 days. Note the win rates against those medians: roughly 80% of holds are gross-positive at every horizon, yet the median 7-day hold still loses after costs. High win rate and negative expectancy coexist because the round trip is charged once regardless of how flat the window was.
Verdict¶
Not rejected, not tradeable as a spread desk on HYPE. Positive expectancy appears only past ~17 days, is carried by 10% of windows, and nets +1.68% APR at 30 days on 13 independent samples — before slippage, which is not in the 18.80 bps.
Three risks this measurement does not price:
- Collateral denomination. Hyperliquid margins USDC; HYPEUSDT is USDT-settled. USDC printed ~$0.87 during the March 2023 SVB scare, which would open a hole in a supposedly neutral book exactly when transfers congest.
- Margin does not move across venues in time. The Binance leg can bleed toward liquidation in seconds while the offsetting Hyperliquid profit is 15–60 minutes away.
- US access. Binance bans US persons. If that applies, the hedge leg moves to Kraken US perps at 8-hourly funding, and every cost number here changes.
The reusable part is aligned_spread, which handles any hourly-vs-periodic venue
pair. The second-tier names, where wider spreads are expected, are where this goes
next — not HYPE.