Skip to content
TrueNorth Developers
Docs

Market data API

Read-only HTTP API for market data: options flow, large spot and perp trades, macro and market indices, funding and open interest, social intelligence and a catalogue of tools.

  • Options flow Deribit, Derive and Paradex block trades · 14,973 blocks · history from 21 Apr 2026
  • Large spot & perp trades 4 venues (Binance, Hyperliquid, Lighter and Lighter Robinhood) · 1,954 markets live · $50k or more on Hyperliquid, Lighter and Lighter Robinhood; $250k or more on Binance
  • Macro & market indices 7 tools

Coverage read through the partner API in production, 21 Oct 2026 00:01 UTC; answer time over 120 calls, 19 Oct 2026 06:47 UTC.

Get started in 60 seconds

1 Your key

Send it as a bearer token on every request: Authorization: Bearer $TN_API_KEY. Paste it once below and every code sample on these pages shows and copies with it. No key yet: request an evaluation key.

No key set

Stored in this tab only; cleared on reload.

2 First call

The three newest large Bitcoin trades on Hyperliquid.

cURL
curl -s "https://tn-api.truenorth.xyz/api/prints/large?venue=hyperliquid&pair=BTC&limit=3" \
  -H "Authorization: Bearer $TN_API_KEY"

3 What comes back

The real answer of that call, two of its three rows.

First callGET /api/prints/large?venue=hyperliquid&pair=BTC&limit=3
200 · 398 ms · 21 Oct 2026 00:01 UTC
{
  "status": "ok",
  "prints": [
    {
      "id": "2fe27ab10efbb2a67317477aea477a73",
      "venue": "hyperliquid",
      "market": "perp",
      "pair": "BTC",
      "tokenAddress": "bitcoin",
      "side": "sell",
      "vwapPrice": 82885.0,
      "size": 4.82,
      "notionalUsd": 399809.89,
      "fillCount": 14,
      "txHash": "0xda317ff83099eedadbab044701e33500006c97ddcb9d0dac7dfa2b4aef9dc8c5",
      "takerAddress": "0x581c013a18f17f5ab09ad89debd16b84f72e46dd",
      "buyerAddress": null,
      "sellerAddress": "0x581c013a18f17f5ab09ad89debd16b84f72e46dd",
      "isLiquidation": null,
      "sourceTimestamp": "2026-10-21T00:01:32.535000Z"
    },
    {
      "id": "77f678e160ed33847228d555238563d7",
      "venue": "hyperliquid",
      "market": "perp",
      "pair": "BTC",
      "tokenAddress": "bitcoin",
      "side": "buy",
      "vwapPrice": 82922.0,
      "size": 9.04,
      "notionalUsd": 749448.21,
      "fillCount": 48,
      "txHash": "0xc00a38599b403b8ac183044701e1bc000045503f36435a5c63d2e3ac5a441575",
      "takerAddress": "0xce508465b243216fcf372d3146fd62e8f7a7b8e2",
      "buyerAddress": "0xce508465b243216fcf372d3146fd62e8f7a7b8e2",
      "sellerAddress": null,
      "isLiquidation": null,
      "sourceTimestamp": "2026-10-21T00:01:04.475000Z"
    }
  ],
  "meta": {
    "nextCursor": "MjAyNi0xMC0yMVQwMDowMDo1MC42ODIwMDArMDA6MDB8MTUxMjY5MXxjY2Y4YWRkYjEyYjg",
    "latestCursor": "MjAyNi0xMC0yMVQwMDowMToyMi4zMTY1MzUrMDA6MDB8MTUxMjcxMw",
    "hasMore": true,
    "count": 3,
    "generatedAt": "2026-10-21T00:01:37.547519Z",
    "population": [
      {
        "tier": "trade_history",
        "rows": "per-trade rows, no counterparties",
        "floorUsd": 250000.0,
        "from": "2026-07-23T00:01:37.547519Z",
        "to": "2026-10-17T23:08:05.627000Z"
      },
      {
        "tier": "live",
        "rows": "one row per taker order per block, with counterparties",
        "floorUsd": 50000.0,
        "from": "2026-10-17T23:10:54.903000Z",
        "to": "2026-10-21T00:01:33.966000Z"
      }
    ],
    "sort": "time",
    "window": null
  }
}

Every field is defined on the large trades page. Real production answer; values rounded for display, the API returns full precision.

tokenAddress is the canonical id, the same as the CoinGecko id, on every row; pair is the venue’s own symbol as the venue spells it. One real mapping from GET /api/tokens/all, 21 Oct 2026 00:01 UTC:

canonical id ↔ the venue's own symbol
bitcoin ↔ Binance BTCUSDT ↔ Hyperliquid BTC ↔ Ondo BTC-USD.P ↔ OKX BTC-USDT-SWAP ↔ Lighter Robinhood 1 ↔ Lighter 1

4 Run the review kit

One file, standard library only. It lists the universe, calls every route once, re-reads random prints and blocks on the venues’ own public APIs and prints pass or fail.

https://storage.googleapis.com/truenorth-partner-api-docs/partner-review-kit/explore.py

sha256: 7811570e71ae0c89949d6613ae32d3cee11d1aba7dd1df2db7032c5d75cec5ee

shell
TN_API_KEY=<your key> python3 explore.py

Datasets

Options flow

Deribit, Derive and Paradex block trades · 14,973 blocks · history from 21 Apr 2026

6 operationsAPI key requiredoptions_intelligenceGET /api/options/markets

Large spot & perp trades

4 venues (Binance, Hyperliquid, Lighter and Lighter Robinhood) · 1,954 markets live · $50k or more on Hyperliquid, Lighter and Lighter Robinhood; $250k or more on Binance

5 operationsRequest partner accessmarket_feedsGET /api/prints/large

Macro & market indices

7 tools

7 operationsAPI key requiredPOST /api/agent-tools/call
DatasetCoverageOperationsAccess
Funding & open interestFunding on 6 venues · open interest on 6 · hourly record4Request partner accessmarket_feeds
Social intelligence11 read routes11Request partner accesssocial_intelligence
Assets & market sessions3 tools3API key required
Technical analysis2 tools2API key required
Derivatives & positioning3 tools3API key required
Discovery & screeners2 tools2API key required
Company fundamentals11 tools11API key required
Prediction markets1 tool1API key required
Market indicators & flows4 tools4API key required
Risk & trading costs2 tools2API key required
Tool catalogue3 tools3No key required
API key required

Access comes from the licence each route sits behind: an ordinary key reads what says API key required; what says Request partner access needs the partner licence on your account (request access). Measured 21 Oct 2026 00:01 UTC.

What you can build

BuildWhat it doesRoutes
Large-trade alertsAlert on every taker order above a size you set, per venue, pair or token.GET /api/prints/large
GET /api/prints/stream
Funding and spread dashboardShow funding and open interest by venue on one scale, and what it costs to open and close a cross-venue pair.GET /api/funding/history
GET /api/open-interest/history
GET /api/quotes/spread
Options block-trade feedList each block with its structure, legs, premium and notional.GET /api/options/flow
GET /api/options/flow/stream
Macro panelShow the index and rates backdrop beside a market: VIX, equity indices, the dollar, Treasury yields, FX, commodities.POST /api/agent-tools/call
POST /api/agent-tools/call
Trader-call feedRender calls from X and Telegram with the trader and the source post.GET /api/social/feed
GET /api/social/positions

Streams push the same rows as they are stored; polling stays the primary path (see Integration).

How to integrate

REST poll

Poll forward with since set to meta.latestCursor. Page back with cursor set to meta.nextCursor. Name a window with from and to, at most 31 days. The whole loop, with its checkpoint and idempotent upsert: Polling & continuation.

curl
# 1. the first answer carries meta.latestCursor
# 2. send it back as since: only newer rows come back
curl -s "https://tn-api.truenorth.xyz/api/prints/large?venue=hyperliquid&pair=BTC&since=MjAyNi0xMC0yMVQwMDowMToyMi4zMTY1MzUrMDA6MDB8MTUxMjcxMw" \
  -H "Authorization: Bearer $TN_API_KEY"

Stream (server-sent events)

Each new large print or block arrives as it is stored; reconnect with the Last-Event-ID header, then fill any gap with one since read. A stream states no delivery latency, and polling stays the primary path. The stream contract.

curl
curl -N "https://tn-api.truenorth.xyz/api/prints/stream?venue=hyperliquid&minNotionalUsd=50000" \
  -H "Authorization: Bearer $TN_API_KEY" \
  -H "Accept: text/event-stream" \
  -H "Last-Event-ID: <last id you stored>"

Generated client or agent

openapi.json describes 24 of the 26 routes on these pages: generate a client with any OpenAPI generator. llms.md is the whole reference as one Markdown file for an AI agent. MCP serves the Agent API tools; these routes are plain HTTP.

shell
openapi-generator-cli generate -i https://storage.googleapis.com/truenorth-partner-api-docs/openapi.json -g python -o tn-client
shell
curl -s https://storage.googleapis.com/truenorth-partner-api-docs/llms.md

Coverage & history

VenueMarketSinceMarketsRows / 24 hNote
Deribitoptions21 Apr 2026713Block tape · Options markets
DeriveoptionsLive1224Block tape · Options markets
ParadexoptionsLive—3Block tape
VenueMarketSinceMarketsRows / 24 hNote
Hyperliquidperps29 Oct 202433824,127Size percentiles · Activity by address
Hyperliquidspot29 Nov 2024313394Size percentiles · Activity by address
LighterperpsLive1965,066Size percentiles · Activity by address · pair is the symbol here, the market id on funding and open interest
LighterspotLive83Size percentiles · Activity by address · pair is the symbol here, the market id on funding and open interest
Lighter RobinhoodperpsLive58541Size percentiles · Activity by address · pair is the symbol here, the market id on funding and open interest
BinanceUSDT-MLive53514,937
BinanceCOIN-MLive20518
BinancespotLive506291

Measured through the partner API in production, 21 Oct 2026 00:01 UTC. A record that began recently reads Live; a date appears only for a deep record.

For agents & reviewers

Limits & refusals

LimitValue
Entry tier100 requests a minute per key
Higher tiers500 and 1,200 requests a minute per key
Past the rate429 api_key_rate_limited with Retry-After
Windowat most 31 days between from and to

Every refusal carries detail.code and the same value in the X-TN-Error-Code header. Two of the refusals the review kit checks, as production answered them 19 Oct 2026 06:47 UTC:

RequestStatusCodeMessage
No key401api_key_requiredThis endpoint requires an API key or a signed-in user. Get an API key at https://app.truenorth.xyz/credits
A venue the route does not serve422market_feeds_invalid_queryInvalid query: venue: expected names from: binance, hyperliquid, lighter, lighter_robinhood.

Every coded refusal.

Evaluation & next steps

KeyOne Enterprise key, sent as a bearer token.
AccessEvery product on these pages and the full history each page states, with no windows.
Rate100 requests a minute per key on the entry tier; higher tiers on agreement.
VolumeA credit balance set with the team is the cap. When it is spent the key answers 402 credits_exhausted until it is topped up.
EndThe team closes the licences on the agreed date; nothing expires silently.
AcceptanceThe review kit (): pass or fail against the venues’ own public APIs.
To tell the teamWhich markets, which history windows, how often you poll.
ContactPartnerships

Plans & enterprise evaluation

Page as Markdown

explore.py

383 lines · Python 3.11+ · standard library only
#!/usr/bin/env python3
"""TrueNorth market-data review kit: explore the market-data endpoints and check the data against the venues' own public APIs.

    TN_API_KEY=... python3 explore.py                 # Python 3.11+, standard library only
    TN_API_KEY=... python3 explore.py --seed 7 -v     # the same sample again, with one line per row checked

What it does, in order (about 20 requests to the API, paced; the venue calls are public and need no key):
  1. Lists the universe: the large-trades coverage (venue x market) and the options underlyings.
  2. Calls each endpoint once and prints status, latency and the first row.
  3. Draws random large prints and options blocks and re-reads each against the venue itself:
     Hyperliquid (userFillsByTime), Deribit (public block-tagged trades), Binance (aggTrades).
  4. Sends three requests that must be refused and checks the coded refusal (status, detail.code, X-TN-Error-Code).
  5. Prints a pass/fail report. Exit code 0 when nothing failed, 1 otherwise.

The key is read from the environment, sent only to the API's own host, and never printed or written.
"""

from __future__ import annotations

import argparse
import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime

API = os.environ.get("TN_API_BASE", "https://tn-api.truenorth.xyz").rstrip("/")
HL_INFO = "https://api.hyperliquid.xyz/info"
DERIBIT = "https://www.deribit.com/api/v2/public"
BINANCE_AGG = {"perp": "https://fapi.binance.com/fapi/v1/aggTrades", "spot": "https://api.binance.com/api/v3/aggTrades",
               "coinm": "https://dapi.binance.com/dapi/v1/aggTrades"}
COINM_CONTRACT_USD = {"BTC": 100.0}  # a coin-margined contract is worth 100 USD for BTC and 10 USD for every other coin
COINM_DEFAULT_USD = 10.0
KEYED_PAUSE_S = 1.2  # between calls to the API: about 50 a minute
VENUE_PAUSE_S = 0.3  # between calls to a venue's public API
TOL = 1e-6  # relative tolerance for a size or a VWAP
NOTIONAL_TOL = 1e-4  # relative tolerance for a block's notional against amount x index price
DERIBIT_WINDOW_H = 22  # Deribit's public trades call serves about the last 24 hours: an older block comes back empty, which is the venue's limit, not a miss

# The documented example request of each endpoint (the docs pages show the same ones).
ENDPOINTS = (
    ("Large trades", "/api/prints/large?venue=hyperliquid&pair=BTC&limit=3"),
    ("Size percentiles", "/api/prints/stats?venue=hyperliquid&pair=BTC"),
    ("Activity by address", "/api/prints/addresses?venue=hyperliquid&limit=3"),
    ("Coverage", "/api/prints/coverage?venue=hyperliquid,lighter,lighter_robinhood,binance&limit=3"),
    ("Options markets", "/api/options/markets"),
    ("Block trades", "/api/options/flow?view=flow&limit=3"),
    ("One block trade", "/api/options/flow/{blockId}?view=read"),
    ("Block-trade summary", "/api/options/flow/summary?window=today"),
    ("Funding history", "/api/funding/history?exchangeId=binance_futures&tokenAddress=bitcoin&interval=1h&limit=3"),
    ("Executable spread", "/api/quotes/spread?longExchange=lighter_perp&longMarket=1&shortExchange=ondo_perps&shortMarket=BTC-USD.P&interval=1h"),
    ("Open interest history", "/api/open-interest/history?exchangeId=hyperliquid_perp&tokenAddress=bitcoin&interval=1h&limit=3"),
    ("Venue fee table", "/api/quotes/fees"),
    ("Gamma exposure history", "/api/options/bitcoin/gex/history?expiry={expiry}&limit=3"),
)
# Requests that must be refused: (label, path, key sent, expected status, expected detail.code or None for any coded refusal).
REFUSALS = (
    ("no key", "/api/prints/large?limit=1", False, 401, "api_key_required"),
    ("a window longer than 31 days", "/api/prints/large?venue=hyperliquid&from=2026-01-01T00:00:00Z&to=2026-03-01T00:00:00Z&limit=1", True, 422, "market_feeds_invalid_query"),
    ("a venue the route does not list", "/api/prints/large?venue=not_a_venue&limit=1", True, 422, "market_feeds_invalid_query"),
)


class NoRedirect(urllib.request.HTTPRedirectHandler):
    """A redirect is reported, never followed: urllib would copy the Authorization header onto the next host."""

    def redirect_request(self, *args, **kwargs):
        return None


OPENER = urllib.request.build_opener(NoRedirect)
KEY = ""


def redact(text: str) -> str:
    return text.replace(KEY, "<key>") if KEY else text


def http(url: str, *, keyed: bool = False, body: dict | None = None, timeout: float = 30.0) -> dict:
    """{status (0 = no answer), headers, json, ms, text}. The key goes only to the API's own host."""
    headers = {"Accept": "application/json", "User-Agent": "tn-review-kit/1"}
    if keyed:
        if not url.startswith(API + "/"):
            raise SystemExit("refusing to send the key anywhere but the API")
        headers["Authorization"] = f"Bearer {KEY}"
    data = None
    if body is not None:
        data = json.dumps(body).encode()
        headers["Content-Type"] = "application/json"
    started = time.perf_counter()
    status, raw, response_headers = 0, b"", {}
    try:
        with OPENER.open(urllib.request.Request(url, data=data, headers=headers), timeout=timeout) as response:
            status, raw, response_headers = response.status, response.read(), dict(response.headers)
    except urllib.error.HTTPError as err:
        status, raw, response_headers = err.code, err.read(), dict(err.headers)
    except (urllib.error.URLError, TimeoutError, OSError):
        pass
    ms = (time.perf_counter() - started) * 1000
    try:
        parsed = json.loads(raw) if raw else None
    except ValueError:
        parsed = None
    return {"status": status, "headers": {k.lower(): v for k, v in response_headers.items()}, "json": parsed, "ms": ms, "text": raw[:300].decode("utf-8", "replace")}


def api(path: str, *, keyed: bool = True) -> dict:
    time.sleep(KEYED_PAUSE_S)
    out = http(API + path, keyed=keyed)
    if out["status"] == 429:  # the limiter doing its job: wait what it says, once
        time.sleep(min(float(out["headers"].get("retry-after") or 20), 60))
        out = http(API + path, keyed=keyed)
    return out


def error_code(out: dict) -> str | None:
    detail = (out["json"] or {}).get("detail") if isinstance(out["json"], dict) else None
    return out["headers"].get("x-tn-error-code") or (detail.get("code") if isinstance(detail, dict) else None)


def stamp(value: str) -> int:
    return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)


def close(a: float, b: float, tol: float = TOL) -> bool:
    return abs(a - b) <= tol * max(1.0, abs(b))


class Report:
    def __init__(self, verbose: bool) -> None:
        self.rows: list[tuple[str, str, str]] = []
        self.verbose = verbose

    def add(self, status: str, name: str, detail: str = "") -> None:
        self.rows.append((status, name, detail))

    def line(self, text: str) -> None:
        print(redact(text), flush=True)

    def counts(self) -> dict[str, int]:
        return {s: sum(1 for r in self.rows if r[0] == s) for s in ("PASS", "FAIL", "SKIP")}


def first_row(body) -> str:
    """The first row of the first list in an answer, as compact JSON of its first keys; else the answer's own keys."""
    if isinstance(body, dict):
        for value in body.values():
            if isinstance(value, list) and value:
                body = value
                break
    row = body[0] if isinstance(body, list) and body else body
    if isinstance(row, dict):
        row = {k: v for k, v in list(row.items())[:6]}
    text = json.dumps(row, separators=(",", ":"), default=str)
    return text if len(text) <= 96 else text[:93] + "..."


# --- 1 and 2: the universe and one call per endpoint --------------------------------------------------------------------------------------
def explore(report: Report) -> dict:
    found: dict = {}
    report.line("UNIVERSE")
    cov = api("/api/prints/coverage?venue=hyperliquid,lighter,lighter_robinhood,binance&limit=1")
    if cov["status"] == 200:
        markets = cov["json"].get("markets", [])
        report.line(f"  large trades: {len(markets)} venue x market rows")
        for m in markets:
            report.line(f"    {m['exchangeId']:24s} {m['venue']:18s} {m['market']:5s} listed {m.get('instrumentsListed') or '-':>5}  prints 24h {m.get('rows24h') if m.get('rows24h') is not None else '-':>6}")
        found["coverage"] = markets
    else:
        report.line(f"  large trades coverage: {cov['status']} {error_code(cov) or ''}")
    opt = api("/api/options/markets")
    if opt["status"] == 200:
        rows = opt["json"].get("markets", [])
        report.line(f"  options: {len(rows)} underlyings: " + ", ".join(f"{r['symbol']}({r['exchangeId']})" for r in rows))
        found["options"] = rows
    else:
        report.line(f"  options markets: {opt['status']} {error_code(opt) or ''}")
    report.line("ENDPOINTS")
    block_id = None
    deribit = next((r for r in found.get("options") or [] if r.get("exchangeId") == "deribit" and r.get("selectedExpiry")), None)
    expiry = deribit["selectedExpiry"] if deribit else None
    for label, template in ENDPOINTS:
        if "{expiry}" in template:
            if not expiry:
                report.add("SKIP", label, "no expiry from the options markets call")
                report.line(f"  SKIP  {label:20s} no expiry from the options markets call")
                continue
            template = template.replace("{expiry}", urllib.parse.quote(expiry, safe=""))
        if "{blockId}" in template:
            if not block_id:
                report.add("SKIP", label, "no block id from the block-trades call")
                report.line(f"  SKIP  {label:20s} no block id from the block-trades call")
                continue
            path = template.replace("{blockId}", urllib.parse.quote(block_id, safe=""))
        else:
            path = template
        out = api(path)
        ok = out["status"] == 200
        code = error_code(out)
        shown = first_row(out["json"]) if ok else f"{code or out['text'][:60]}"
        report.add("PASS" if ok else "FAIL", f"{label}", f"{out['status']} {code or ''} {out['ms']:.0f} ms")
        report.line(f"  {'PASS' if ok else 'FAIL'}  {out['status']:>3} {out['ms']:5.0f} ms  {label:20s} {shown}")
        if label == "Block trades" and ok:
            blocks = out["json"].get("blocks") or []
            block_id = blocks[0].get("blockId") if blocks else None
        found[label] = out
    sanity(report, found)
    return found


def sanity(report: Report, found: dict) -> None:
    """Internal consistency of two answers, checked from the answer alone."""
    oi = (found.get("Open interest history") or {}).get("json")
    if isinstance(oi, dict) and oi.get("candles"):
        ok = (oi.get("unit") in ("usd", "coin") and oi.get("source") in ("record", "venue", "mixed")
              and all(c["low"] <= c["close"] <= c["high"] for c in oi["candles"]))
        report.add("PASS" if ok else "FAIL", "Open interest answer states its unit and source", f"unit={oi.get('unit')} source={oi.get('source')}")
    fund = (found.get("Funding history") or {}).get("json")
    if isinstance(fund, dict) and fund.get("candles"):
        ok = fund.get("unit") == "percent" and bool(fund.get("intervalS")) and all(c["low"] <= c["close"] <= c["high"] for c in fund["candles"])
        report.add("PASS" if ok else "FAIL", "Funding answer states its unit and settlement interval", f"unit={fund.get('unit')} intervalS={fund.get('intervalS')}")


# --- 3: re-read against the venues --------------------------------------------------------------------------------------------------------
def hyperliquid_case(row: dict) -> tuple[str, str]:
    t = stamp(row["sourceTimestamp"])
    if set(str(row["txHash"])[2:]) <= {"0"}:  # an all-zero hash is no transaction the venue can be asked for: listed in the report, never counted as a match or a miss
        return "unreadable", "txHash is all zeros, so the venue cannot be asked for it by hash"
    time.sleep(VENUE_PAUSE_S)
    out = http(HL_INFO, body={"type": "userFillsByTime", "user": row["takerAddress"], "startTime": t - 5000, "endTime": t + 5000})
    if not isinstance(out["json"], list):
        return "unreadable", f"venue answered {out['status']}"
    mine = [f for f in out["json"] if f.get("coin") == row["pair"] and f.get("hash") == row["txHash"] and abs(f.get("time", 0) - t) <= 1]
    if not mine:
        return "no_fill", "no fill with that hash at that instant"
    size = sum(float(f["sz"]) for f in mine)
    vwap = sum(float(f["sz"]) * float(f["px"]) for f in mine) / size
    side = "buy" if mine[0].get("side") == "B" else "sell"
    checks = {"size": close(size, row["size"]), "vwap": close(vwap, row["vwapPrice"]), "fills": len(mine) == row["fillCount"], "side": side == row["side"],
              "notional": close(row["size"] * row["vwapPrice"], row["notionalUsd"], 1e-4)}
    bad = [k for k, v in checks.items() if not v]
    return ("match" if not bad else "mismatch"), (f"{len(mine)} fills, size {size:g}, vwap {vwap:.4f}" if not bad else "differs: " + ", ".join(bad))


def binance_kind(row: dict) -> str:
    return "coinm" if str(row.get("pair", "")).endswith("USD_PERP") else row.get("market", "")


def binance_case(row: dict) -> tuple[str, str]:
    t = stamp(row["sourceTimestamp"])
    time.sleep(VENUE_PAUSE_S)
    query = urllib.parse.urlencode({"symbol": row["pair"], "startTime": t - 250, "endTime": t + 250, "limit": 1000})
    out = http(f"{BINANCE_AGG[binance_kind(row)]}?{query}")
    if not isinstance(out["json"], list):
        return "unreadable", f"venue answered {out['status']}"
    scale = 1.0
    if binance_kind(row) == "coinm":  # contracts to coins: contracts x USD per contract / price
        scale = COINM_CONTRACT_USD.get(str(row["pair"]).removesuffix("USD_PERP"), COINM_DEFAULT_USD) / row["vwapPrice"]
    found = [x for x in out["json"] if x.get("T") == t and close(float(x["q"]) * scale, row["size"]) and close(float(x["p"]), row["vwapPrice"])]
    if not found:
        return "no_fill", "no aggregate trade with that instant, size and price"
    x = found[0]
    checks = {"side": ("sell" if x["m"] else "buy") == row["side"], "fills": x["l"] - x["f"] + 1 == row["fillCount"]}
    bad = [k for k, v in checks.items() if not v]
    return ("match" if not bad else "mismatch"), (f"aggTrade id {x['a']}, {x['l'] - x['f'] + 1} fills" if not bad else "differs: " + ", ".join(bad))


def deribit_case(block: dict) -> tuple[str, str]:
    t = stamp(block["sourceTimestamp"])
    block_trade_id = str(block["blockId"]).rsplit(":", 1)[-1]  # options_flow_occurrence.v2:deribit:BLOCK-123 -> BLOCK-123
    time.sleep(VENUE_PAUSE_S)
    query = urllib.parse.urlencode({"currency": block["underlyingSymbol"], "kind": "option", "start_timestamp": t - 120000, "end_timestamp": t + 120000,
                                    "count": 1000, "include_old": "true"})
    out = http(f"{DERIBIT}/get_last_trades_by_currency_and_time?{query}")
    trades = ((out["json"] or {}).get("result") or {}).get("trades") if isinstance(out["json"], dict) else None
    if not trades:
        return "unreadable", f"venue answered {out['status']} with no trades in the window"
    mine = [x for x in trades if x.get("block_trade_id") == block_trade_id]
    if not mine:
        return "no_fill", f"no trade tagged {block_trade_id} in the venue's window"
    notional = sum(x["amount"] * x["index_price"] for x in mine)
    ok = close(notional, block["notionalUsd"], NOTIONAL_TOL) and all(abs(x["timestamp"] - t) <= 1 for x in mine)
    return ("match" if ok else "mismatch"), f"{block_trade_id}: {len(mine)} trades, amount x index {notional:,.0f} vs notional {block['notionalUsd']:,.0f}"


def cross_check(report: Report, label: str, rows: list[dict], case, want: int, rng: random.Random, found_by: str) -> None:
    """Random draws without replacement until ``want`` could be read at the venue; a draw the venue no longer serves is replaced."""
    if not rows:
        report.add("SKIP", label, f"the API returned no {found_by} to draw")
        report.line(f"  SKIP  {label}: the API returned no {found_by} to draw")
        return
    results = []
    for row in rng.sample(rows, len(rows)):
        results.append((row, *case(row)))
        if sum(1 for r in results if r[1] != "unreadable") >= want or len(results) >= want * 3:
            break
    readable = [r for r in results if r[1] != "unreadable"]
    skipped = [r for r in results if r[1] == "unreadable" and r[2].startswith("txHash is all zeros")]
    for row, _, text in skipped:
        report.line(f"        note: {row.get('pair')} at {row['sourceTimestamp']} not re-read: {text}")
    matched = sum(1 for r in readable if r[1] == "match")
    if report.verbose:
        for row, result, detail in results:
            report.line(f"      {result:10s} {row.get('pair') or row.get('blockId')} {row['sourceTimestamp']} {detail}")
    if not readable:
        report.add("SKIP", label, "the venue's public API could not be read from here")
        report.line(f"  SKIP  {label}: the venue's public API could not be read from this network ({results[0][2]})")
        return
    status = "PASS" if matched == len(readable) else "FAIL"
    detail = f"{matched}/{len(readable)} match"
    report.add(status, label, detail)
    report.line(f"  {status}  {label}: {detail}")
    for row, result, text in readable:
        if result != "match":
            report.line(f"        {result}: {row.get('pair') or row.get('blockId')} at {row['sourceTimestamp']}: {text}")


def venue_checks(report: Report, args: argparse.Namespace) -> None:
    rng = random.Random(args.seed)
    report.line(f"CROSS-CHECKS against the venues' public APIs (seed {args.seed})")
    hl = api("/api/prints/large?venue=hyperliquid&limit=100")
    rows = [r for r in (hl["json"] or {}).get("prints", []) if r.get("takerAddress") and r.get("txHash")] if hl["status"] == 200 else []
    cross_check(report, f"Hyperliquid, {args.hl} large prints (userFillsByTime)", rows, hyperliquid_case, args.hl, rng, "prints")
    flow = api("/api/options/flow?view=flow&limit=100")
    recent = time.time() * 1000 - DERIBIT_WINDOW_H * 3600 * 1000
    blocks = [b for b in (flow["json"] or {}).get("blocks", []) if b.get("exchangeId") == "deribit" and b.get("parserVersions") and stamp(b["sourceTimestamp"]) >= recent
              and all(str(v).startswith("deribit_tape.") for v in b["parserVersions"])] if flow["status"] == 200 else []
    cross_check(report, f"Deribit, {args.deribit} block trades (block-tagged public trades)", blocks, deribit_case, args.deribit, rng, f"blocks of the last {DERIBIT_WINDOW_H} h read from the exchange's tape")
    if args.binance:
        rows = []
        for market in ("perp", "spot"):
            out = api(f"/api/prints/large?venue=binance&market={market}&limit=50")
            rows += (out["json"] or {}).get("prints", []) if out["status"] == 200 else []
        cross_check(report, f"Binance, {args.binance} large prints (aggTrades)", [r for r in rows if binance_kind(r) in BINANCE_AGG], binance_case, args.binance, rng, "prints")


# --- 4: the coded refusals ----------------------------------------------------------------------------------------------------------------
def refusals(report: Report, found: dict) -> None:
    report.line("CODED REFUSALS")
    for label, path, keyed, status, code in REFUSALS:
        out = api(path, keyed=keyed)
        seen = error_code(out)
        header = out["headers"].get("x-tn-error-code")
        body_code = ((out["json"] or {}).get("detail") or {}).get("code") if isinstance(out["json"], dict) and isinstance((out["json"] or {}).get("detail"), dict) else None
        ok = out["status"] == status and seen == code and header == body_code
        report.add("PASS" if ok else "FAIL", f"Refusal: {label}", f"{out['status']} {seen}")
        report.line(f"  {'PASS' if ok else 'FAIL'}  {out['status']} {seen or '-':28s} {label} (header {header or '-'}, body {body_code or '-'})")
    seen_in_run = {(k, o["status"], error_code(o)) for k, o in found.items() if isinstance(o, dict) and "status" in o and o["status"] != 200}
    for k, status, code in sorted(seen_in_run, key=str):
        report.line(f"  seen during the run: {status} {code or '-'} on {k}")


def main(argv: list[str]) -> int:
    global KEY
    parser = argparse.ArgumentParser(description="Explore the TrueNorth market-data endpoints and check them against the venues.")
    parser.add_argument("--seed", type=int, default=None, help="seed of the random draws (default: random; printed)")
    parser.add_argument("--hl", type=int, default=10, help="large prints to re-read on Hyperliquid (default 10)")
    parser.add_argument("--deribit", type=int, default=3, help="block trades to re-read on Deribit (default 3)")
    parser.add_argument("--binance", type=int, default=3, help="large prints to re-read on Binance (default 3; 0 skips)")
    parser.add_argument("-v", "--verbose", action="store_true", help="one line per row checked")
    args = parser.parse_args(argv)
    if args.seed is None:
        args.seed = random.SystemRandom().randrange(1, 10**6)
    KEY = os.environ.get("TN_API_KEY", "").strip()
    if not KEY:
        print("Set TN_API_KEY to your partner key (export TN_API_KEY=...) and run again.", file=sys.stderr)
        return 2
    report = Report(args.verbose)
    report.line(f"TrueNorth review kit  {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}  {API}")
    found = explore(report)
    venue_checks(report, args)
    refusals(report, found)
    c = report.counts()
    report.line(f"RESULT  {c['PASS']} pass  {c['FAIL']} fail  {c['SKIP']} skipped" + ("" if not c["FAIL"] else "  <- " + "; ".join(n for s, n, _ in report.rows if s == "FAIL")))
    return 1 if c["FAIL"] else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))