Backtest cohort positioning without lookahead
Compare the smart cohort's hourly BTC bias with the next hour's return, using point-in-time labels so the cohort is the one that existed that hour.
The question: when the 3x smart cohort is net long BTC in hour H, does BTC rise in hour H+1? The script
answers it with a correlation and the mean next-hour return after net-long and net-short hours. It is a
diagnostic that shows how to fetch and align the two series honestly; it does not claim an edge.
It makes two calls over one pinned week, 2026-08-10 to 2026-08-17:
/coins/{coin}/cohort-metrics for the hourly matrix and
/ohlcv for the hourly candles.
export VIKE_API_KEY=...
python hl-cohort-backtest.py # real run: needs the key
python hl-cohort-backtest.py --dry-run # prints the requests it would make, calls nothing"""Does the smart cohort's BTC positioning say anything about the next hour's return? A point-in-time check.
Run: VIKE_API_KEY=... python hl-cohort-backtest.py (--dry-run lists the requests and makes none)
"""
import os
import sys
from datetime import datetime
from statistics import correlation, mean
from urllib.parse import urlencode
NAME = "hl-cohort-backtest"
BASE = "https://data.vike.io"
DRY = "--dry-run" in sys.argv
COHORT = "3x smart"
HOUR_MS = 3_600_000
def fail(why):
sys.exit(f"{NAME}: {why}")
def call(path, method="GET", **params):
url = BASE + path + ("?" + urlencode(params) if params else "")
if DRY:
print(method, url)
return None
key = os.environ.get("VIKE_API_KEY") or fail("set VIKE_API_KEY")
import requests
r = requests.request(method, url, headers={"X-API-Key": key}, timeout=60)
if r.status_code != 200:
fail(f"{method} {path} answered HTTP {r.status_code}: {r.text[:200]}")
return r.json()
def main():
# labelBasis=point_in_time: each row carries the label the wallet had THAT hour. The default
# resolves labels at query time, which hands a backtest today's grading (lookahead).
matrix = call(
"/v1/hyperliquid/coins/BTC/cohort-metrics",
axis="pnl", labelBasis="point_in_time", hours=200,
start="2026-08-10T00:00:00Z", end="2026-08-17T00:00:00Z",
)
candles = call(
"/v1/hyperliquid/ohlcv",
symbol="BTC", interval="1h", limit=200,
start="2026-08-10T00:00:00Z", end="2026-08-17T02:00:00Z",
)
if DRY:
return
if matrix["label_basis"] != "point_in_time":
fail("labels are not point-in-time; a backtest on them would look ahead")
ret = {c["ts"]: c["close"] / c["open"] - 1 for c in candles["candles"]}
pairs = [] # (bias in hour H, return of hour H+1): positioning is read before the move it is compared to
for m in matrix["metrics"]:
if m["cohort"] != COHORT or m["bias"] is None:
continue
hour_ms = int(datetime.fromisoformat(m["ts"]).timestamp() * 1000)
if hour_ms + HOUR_MS in ret:
pairs.append((m["bias"], ret[hour_ms + HOUR_MS]))
if len(pairs) < 10:
fail(f"only {len(pairs)} usable hours for cohort {COHORT!r}")
long_side = [r for b, r in pairs if b > 0]
short_side = [r for b, r in pairs if b <= 0]
print(f"{COHORT}, BTC, {len(pairs)} hours (labels: point_in_time)")
print(f" corr(bias, next-hour return) = {correlation([b for b, _ in pairs], [r for _, r in pairs]):+.3f}")
if long_side:
print(f" next-hour return after net-long hours : {mean(long_side) * 1e4:+.1f} bp over {len(long_side)} hours")
if short_side:
print(f" next-hour return after net-short hours: {mean(short_side) * 1e4:+.1f} bp over {len(short_side)} hours")
if __name__ == "__main__":
try:
main()
except Exception as e: # a shape the recipe does not expect fails by name, not by traceback
fail(f"{type(e).__name__}: {e}")Why each choice is there
labelBasis=point_in_time. By default thepnlcohort is resolved from the dictionary at query time, so a historical row carries today's grading: correct for "who is smart now", lookahead for a backtest. The script also refuses to continue unless the response sayslabel_basisispoint_in_time.- The window starts after 2026-08-06. Across that date the point-in-time labels change resolution, which a
turnover count would read as a regime change. The
endpoint page has the detail and the
grading=realized-pitcontrol. - Bias at H against the return of H+1. The recipe's own choice: positioning is read from an hour that has closed, then compared with the hour after it, so the signal cannot see the move it is paired with.
hours=200is above the 168 hours in the window. The matrix is paged by hour, not by row, and anextCursorappears when a page holds as many hours ashours. A window of 168 hours withhours=200is one page; a longer window needs the cursor loop in Paging large reads.
Point-in-time rows drop five fields
The point-in-time response omits position_count, position_count_long, total_unrealized_pnl,
total_position_size and total_position_size_long, and lists them in unavailable_fields. This recipe uses
only ts, cohort and bias, which are present; anything you add that needs a position count needs a different
labelBasis, and the lookahead that comes with it.
Find top traders and read their record
Rank Hyperliquid traders by 30-day realized PnL, then pull the best one's trade statistics, with the median beside the mean.
Count liquidations around the biggest move
Find the largest BTC hourly candle of a day, then total the long and short liquidations that landed inside that hour.