hypertrade a carry-and-spread desk on Hyperliquid

← all hypotheses

Idea 1 — spot-vs-perp basis carry

Claim. Long HYPE spot, short the perp, collect funding. Delta-neutral, so the price does not matter.

Result. Prediction 1 is falsified as stated: the trade is not profitable at every holding period. It turns positive somewhere past two weeks, and only because cost is fixed while income accrues.

Spec: 2026-08-04-basis-carry-hypothesis.md

Charts below are interactive Plotly figures. GitHub strips the script that draws them, so read this page on hypertrade.vsh852.com to see them.

In [ ]:
import json
from pathlib import Path

import plotly.io as pio
import polars as pl
from hypertrade_research.backtest import (
    entry_distribution,
    excursion_quantiles,
    holding_period_sweep,
)
from hypertrade_research.charts import excursion_figure, holding_period_figure
from hypertrade_research.features.carry_cost import CapitalCost, Execution, FeeSchedule

# Required for the published site, not cosmetic. Without an explicit renderer a
# figure is stored only as `application/vnd.plotly.v1+json`, which nbconvert cannot
# represent -- the notebook still executes green and the exported page shows *no
# chart at all*. `notebook_connected` also emits HTML, loading plotly.js from the CDN
# once per page rather than inlining ~3 MB per figure.
pio.renderers.default = "notebook_connected"

FIXTURES = Path("../packages/research/tests/fixtures")
window = json.loads(
    (FIXTURES / "carry_window_HYPE_2026-07-05_2026-08-04.json").read_text()
)

# The full history the venue still serves, at the widest bar that reaches it.
HISTORY_STEM = "carry_history_HYPE_4h_2024-12-05"

SPOT, PERP = "@107", "HYPE"
HYPE_MAX_LEVERAGE = 10  # margin table 52, measured -- see the spec

# Our own tier from `userFees`. Spot costs 56% more than perp, so the legs are
# never priced alike.
FEES = FeeSchedule(
    spot_cross_bps=6.86,
    spot_add_bps=3.43,
    perp_cross_bps=4.40,
    perp_add_bps=1.20,
    referral_discount=0.04,
)

What each holding period pays

The three bars sum to the line. Funding is what the window actually paid — replayed hour by hour, not a mean rate times hours — and cost is the full round trip on both legs, paid once whatever the hold. Capital is priced against the median 30-day adverse move, measured below.

In [ ]:
# Measured from the live book at the intended size, not a rule of thumb.
SPOT_SLIP_BPS, PERP_SLIP_BPS = 1.09, 0.65

CAPITAL = CapitalCost(
    perp_max_leverage=HYPE_MAX_LEVERAGE,
    adverse_move=excursion_quantiles(window["candles"][PERP], hold_days=30)["median"],
    opportunity_cost_annual=0.0431,  # 3-month T-bill, measured
)

sweep = holding_period_sweep(
    window["candles"],
    window["funding"],
    spot_coin=SPOT,
    perp_coin=PERP,
    fees=FEES,
    capital=CAPITAL,
    spot_slip_bps=SPOT_SLIP_BPS,
    perp_slip_bps=PERP_SLIP_BPS,
    holds_days=(3, 7, 14, 30),
)
holding_period_figure(sweep, execution="all_taker")

Two things the chart makes plain that the table does not. Cost is a constant slab, so extending the hold is the only lever that does not depend on a forecast. And capital grows with the hold at almost the rate funding does, which is why measuring the lending rate rather than assuming zero changed the conclusion.

The same window, executed as a maker

Every fee halves. It is the single largest controllable term, and it moves the crossing point in by more than a week.

In [ ]:
holding_period_figure(sweep, execution="all_maker")

How much evidence is under those bars

Every bar above is one hold, entered at the window's first aligned bar. That was deliberate — the claim under test is that holding period matters and entry timing does not — but it means the 30-day figure is a single sample path, and this window admits no second one: 720 hourly bars leave exactly one feasible 30-day entry.

Sweeping every entry the window can price shows the fixed entry was flattering rather than neutral.

In [ ]:
pl.DataFrame(
    [
        {"hold_days": days}
        | entry_distribution(
            window["candles"],
            window["funding"],
            spot_coin=SPOT,
            perp_coin=PERP,
            hold_days=days,
            execution=Execution.ALL_TAKER,
            fees=FEES,
            capital=CAPITAL,
            spot_slip_bps=SPOT_SLIP_BPS,
            perp_slip_bps=PERP_SLIP_BPS,
        )
        for days in (3, 7, 14)
    ]
).select(
    "hold_days", "entries", "independent", "first", "median", "p5", "p95", "win_rate"
)
Out[ ]:
shape: (3, 8)
hold_daysentriesindependentfirstmedianp5p95win_rate
i64f64f64f64f64f64f64f64
3649.010.0-17.423499-21.052512-31.626679-11.7668410.0
7553.04.0-7.162107-16.421796-25.280972-4.9820680.005425
14385.02.02.006531-5.634378-13.2362021.8804010.137662

first is the number the chart drew; median is what a typical entry got. The gap is 3 to 8 bps in the chart's favour at every horizon, and the win rate says almost no entry on this window cleared the full cost stack.

independent is the column that matters most. 553 seven-day entries drawn from 30 days re-use the same hours ~138 times over, so they are 4 independent observations wearing 553 labels. Point estimates survive that; intervals and tail quantiles do not.

The same measurement over 608 days

The cure for a thin sample is a longer sample. candleSnapshot caps at ~5000 rows and returns the newest, and the cap is retention rather than paging — an hourly window ending at the cap edge returns zero rows. So reach is bought with bar width: at 4h the venue serves 3641 bars back to 2024-12-05, which is 20 independent 30-day windows against 1.

In [ ]:
history_bars = pl.read_parquet(FIXTURES / f"{HISTORY_STEM}_bars.parquet")
history_funding = pl.read_parquet(FIXTURES / f"{HISTORY_STEM}_funding.parquet")

history_candles = {
    coin: [
        {"t": row["open_ms"], "T": row["close_ms"], "c": str(row["close"])}
        for row in history_bars.filter(pl.col("coin") == coin).iter_rows(named=True)
    ]
    for coin in history_bars["coin"].unique()
}
history_rates = [
    {"time": row["event_ms"], "fundingRate": str(row["rate"])}
    for row in history_funding.iter_rows(named=True)
]

long_run = pl.DataFrame(
    [
        {"hold_days": days, "execution": execution.value}
        | entry_distribution(
            history_candles,
            history_rates,
            spot_coin=SPOT,
            perp_coin=PERP,
            hold_days=days,
            execution=execution,
            fees=FEES,
            capital=CAPITAL,
            spot_slip_bps=SPOT_SLIP_BPS,
            perp_slip_bps=PERP_SLIP_BPS,
        )
        for days in (7, 14, 30)
        for execution in (Execution.ALL_TAKER, Execution.ALL_MAKER)
    ]
)
long_run.select(
    "hold_days",
    "execution",
    "entries",
    "independent",
    "median",
    "p5",
    "p95",
    "win_rate",
)
Out[ ]:
shape: (6, 8)
hold_daysexecutionentriesindependentmedianp5p95win_rate
i64strf64f64f64f64f64f64
7"all_taker"3598.086.0-7.941852-32.80291994.8867320.369094
7"all_maker"3598.086.08.267748-16.593319111.0963320.695942
14"all_taker"3556.043.06.800002-30.656916213.257280.615861
14"all_maker"3556.043.023.009602-14.447316229.466880.853768
30"all_taker"3460.020.046.080407-20.154295407.0510960.857225
30"all_maker"3460.020.062.290007-3.944695423.2606960.93526

The conclusion moves with the evidence, and it moves in favour of the strategy while resting on more:

  • a 7-day hold is confidently negative, which one quiet month could not show;
  • 14 days straddles zero and is not a result in either direction;
  • 30 days is confidently positive at roughly +39 bps median, against +18.58 from a single path.

Median basis across 3,460 entries is under 1 bp against 109 of funding. That is the whole thesis in one row: this is a funding trade, and holding period is the design variable.

Why the buffer is a distribution

The short leg needs margin held against it, and the size of that margin is what the capital term above is priced on. A median-sized buffer funds a liquidation at the p95.

In [ ]:
excursion_figure(excursion_quantiles(window["daily_perp_candles"], hold_days=30))

501 daily bars, 16 months to 2026-08-04, including HYPE's 9.31 → 77.00 run. The worst 30-day move against a short is 130.1% — a short's adverse direction is up, and up is unbounded. Measured, not assumed: the 20–50% figure this replaced was a guess, and it was wrong by 2.6×.