Recipe 30 of 33
Did cohort positioning predict price? Backtest 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:
/symbols/{symbol}/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/hl-cohorts/symbols/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.
endat midnight keeps exactly the week. An hourly bucket is matched against the time of its last snapshot, which falls inside the hour, soend=2026-08-17T00:00:00Zkeeps 2026-08-16 23:00 as the last hour and leaves the 08-17 00:00 hour out. To keep an hour HH in your own window, end atHH:59:59.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.
Before 2026-08-07, point-in-time rows lack five fields
This week lies after 2026-08-07, so its point-in-time rows carry every field and unavailable_fields is empty.
Move the window earlier and the matrix comes from an hourly aggregate: position_count, position_count_long,
total_unrealized_pnl, total_position_size and total_position_size_long are then null, never 0, and listed in
unavailable_fields. The script uses only ts, cohort and bias, which every window carries.