Perpetual funding on Hyperliquid looks easy to forecast. It's sticky: today's hourly rate is a good guess for tomorrow's. But "tomorrow ≈ today" is a base rate, not a forecast — anyone can read it off the last price. The real question is what's left after you subtract that stickiness.
This is a follow-along tutorial. You'll need the Python SDK and a Pro API key (funding is a Pro dataset). By the end you'll have pulled real hourly funding, measured exactly how sticky it is, and built the innovation series — funding minus its own trailing average — which is where any genuine forecast has to live.
Set up
Install the SDK and export your key:
pip install "tessera-api[polars]"
export TESSERA_API_KEY="..." # from https://tesseralytics.devFunding is a Pro dataset — a free key raises tessera.ForbiddenError on the read below. Get a Pro key
from pricing.
Pull hourly funding
gold_funding_1h is hourly funding, partitioned per (coin,
month). Read BTC, ETH and SOL for six months, projecting just the columns we need:
import polars as pl
import tessera
client = tessera.TesseraClient() # reads $TESSERA_API_KEY
funding = client.read(
"gold_funding_1h",
coin=["BTC", "ETH", "SOL"],
month=tessera.MonthSpan("2026-01", "2026-06"),
columns=["time", "coin", "funding_rate"],
)
# Autocorrelation of hourly funding at increasing lags, per coin.
lags = [1, 6, 12, 24, 72, 168] # hours
def autocorr(s: pl.Series, h: int) -> float:
# Pearson correlation of the series against itself, lagged by h rows.
df = pl.DataFrame({"x": s.slice(0, s.len() - h), "y": s.slice(h)})
return df.select(pl.corr("x", "y")).item()
rows = []
for coin in funding["coin"].unique().to_list():
s = funding.filter(pl.col("coin") == coin).sort("time")["funding_rate"]
rows.append({"coin": coin, **{f"{h}h": round(autocorr(s, h), 3) for h in lags}})
pl.DataFrame(rows)Close to 0.9 at a one-hour lag, fading to almost nothing within a week. That slow decay is the base rate: "tomorrow ≈ today".
Subtract the stickiness
Define the innovation as funding minus its own 7-day trailing mean (168 hourly bars). Persistence lives in the level; strip it off and see how much survives:
d = (
funding.sort("coin", "time")
.with_columns(
pl.col("funding_rate")
.rolling_mean(window_size=168, min_samples=1)
.over("coin")
.alias("trailing_7d")
)
.with_columns(
(pl.col("funding_rate") - pl.col("trailing_7d")).alias("innovation")
)
.drop_nulls()
)
for name in ("funding_rate", "innovation"):
s = d.filter(pl.col("coin") == "BTC").sort("time")[name]
print(f"{name:>13} 24h: {autocorr(s, 24):.3f} 72h: {autocorr(s, 72):.3f}")At the one-day lag the subtraction barely moves the number — 0.285 → 0.234: the innovation still carries a little
momentum of its own. At three days the picture flips — the raw level still autocorrelates at 0.122, while the innovation is 0.001. Past a day or so, essentially all of the level's
stickiness was the trailing average; strip it and nothing multi-day survives.
What that means
"Tomorrow ≈ today" is doing almost all of the work. Persistence is a free base rate anyone can compute; subtract it and the forecastable leftover is small — and short-lived, with the innovation's autocorrelation dead inside about three days. That near-zero number is the honest size of the surprise: a feature to stack on a model you already have, not a buy/sell button. Everything above runs on one dataset — the funding tile — pulled through the SDK.