Metadata-Version: 2.4
Name: blockmetric
Version: 0.3.0
Summary: Official Blockmetric API client: real-time crypto market data (Hyperliquid on-chain positions plus cross-exchange aggregates). Standard library only.
License-Expression: Apache-2.0
Keywords: blockmetric,crypto,liquidations,hyperliquid,binance,bybit,funding,api,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
License-File: TRADEMARKS.md
Dynamic: license-file

# blockmetric (Python)

Official Python client of the Blockmetric API for real-time crypto market data: liquidation maps and heatmaps from
real Hyperliquid on-chain positions, whales, order flow, plus funding, open interest and liquidation totals from the
exchanges whose data the API may redistribute. Standard library only, Python 3.9+, fully typed (TypedDicts generated
from the OpenAPI document).

> Not on PyPI yet. Until it is published, install it from this repository: `pip install -e sdk/python`
> (or put `sdk/python` on `PYTHONPATH`). Once published: `pip install blockmetric`.

## 60-second quickstart

1. Create a key on **/account/api** (every account has a personal developer key; the full key is shown once).
2. Set it (and, until the SDK ships with the production URL baked in, the base URL):

   ```bash
   export BLOCKMETRIC_API_KEY=bm_live_...
   export BLOCKMETRIC_BASE_URL=https://<domain>/api/v1
   ```

3. First request:

   ```python
   from blockmetric import Blockmetric

   client = Blockmetric()  # reads BLOCKMETRIC_API_KEY and BLOCKMETRIC_BASE_URL
   res = client.liquidation_summary(hours=24)
   s = res["data"]
   print(f"24h liquidations: ${s['totalUsd']:,.0f} (longs ${s['longUsd']:,.0f}, shorts ${s['shortUsd']:,.0f})")
   print(res.last_response.rate_limit.remaining, "requests left this minute")
   ```

Every method returns the API envelope `{"data": ..., "meta": ...}` as a plain `dict` (JSON-serialisable), typed as the
matching TypedDict (`blockmetric.types.LiquidationSummaryResponse`, …). The HTTP details are attached as
`res.last_response`: `status`, `request_id`, `rate_limit` (`limit`, `remaining`, `reset` per minute; `limit_day`,
`remaining_day`, `reset_day` and `overage` when the server sends them), `etag`, `not_modified`, `deprecation`, `sunset`,
`api_version`, `key_expires_at`, `headers`. `client.last_response` holds those of the most recent request.

## Endpoints

<!-- generated:endpoints (scripts/devx-generate.ts) -->
| Method | API | |
|---|---|---|
| `markets(venue=, fields=, format=)` | `GET /markets` | Market state of a venue |
| `funding(coin=, limit=, fields=, format=)` | `GET /funding` | Cross-venue funding board |
| `coins(fields=, format=)` | `GET /coins` | Hyperliquid coins |
| `long_short(coin=, limit=, fields=, format=)` | `GET /long-short` | Long/short positioning |
| `liquidations(coin=, venue=, side=, min_usd=, since_minutes=, from_=, to=, after=, limit=, fields=, format=)` | `GET /liquidations` (paginated) | Liquidations feed |
| `liquidation_summary(coin=, hours=)` | `GET /liquidations/summary` | Liquidation totals |
| `liqmap(coin)` | `GET /liqmap/{coin}` | Liquidation map (current) |
| `candles(coin, tf=, from_=, to=, limit=, fields=, format=)` | `GET /candles/{coin}` | Price candles (OHLC) |
| `heatmap(coin, tf=, hours=, end=, columns=, clusters=, venue=, step_minutes=, layer=)` | `GET /heatmap/{coin}` | Liquidation heatmap over time |
| `heatmap_open_interest(coin, hours=, end=, venue=, band_pct=)` | `GET /heatmap/{coin}/open-interest` | Open interest added per price band |
| `heatmap_wallets(coin, lo=, hi=, price=, clusters=, side=, min_usd=, limit=)` | `GET /heatmap/{coin}/wallets` | Wallets with their liquidation price in a band |
| `vaults(sort=, min_tvl=, closed=, q=, limit=)` | `GET /vaults` | Vault leaderboard |
| `hyperliquid_protocol(days=, recent=)` | `GET /hyperliquid/protocol` | Hyperliquid protocol state |
| `correlation(coins=, limit=)` | `GET /correlation` | Correlation and beta to BTC |
| `scoreboard(coin=, limit=)` | `GET /scoreboard` | Cluster hit rates (scoreboard) |
| `top_positions(coin=, side=, min_usd=, limit=, venue=, fields=, format=)` | `GET /positions/top` (paginated) | Largest positions |
| `whales(sort=, coin=, labelled=, limit=, venue=, fields=, format=)` | `GET /whales` | Whale leaderboard |
| `whale_feed(coin=, min_usd=, since=, limit=, fields=, format=)` | `GET /whales/feed` (paginated) | Whale position changes |
| `wallet(address, limit=)` | `GET /wallets/{address}` | Wallet detail |
| `whale_stops(coin=)` | `GET /whales/stops` | Whale stop / TP map |
| `traders(sort=, search=, style=, min_account_value=, limit=, fields=, format=)` | `GET /traders` (paginated) | Trader leaderboard |
| `cohorts(coin=, dim=, cohort=, limit=)` | `GET /cohorts` | Cohort positioning |
| `smart_money(coin=, limit=)` | `GET /smart-money` | Smart-money convergence |
| `orderbook(coin, venue=, hours=)` | `GET /orderbook/{coin}` | Order book liquidity and walls |
| `flow(coin, venue=, hours=, step_minutes=)` | `GET /flow/{coin}` | Taker flow (CVD) and volume profile |
| `tape(coin=, venue=, side=, min_usd=, since_minutes=, from_=, to=, after=, limit=, fields=, format=)` | `GET /tape` (paginated) | Large-trade tape |
| `screener(coin=, sort=, dir=, min_oi_usd=, limit=, fields=, format=)` | `GET /screener` | Screener (every coin) |
| `predicted_funding(coin=, limit=, fields=, format=)` | `GET /funding/predicted` | Predicted next funding |
| `funding_spreads(coin=, basis=, min_oi_usd=, limit=, fields=, format=)` | `GET /funding/spreads` | Cross-venue funding spreads |
| `sentiment(days=)` | `GET /sentiment` | Blockmetric Sentiment index |
| `radar_events(kinds=, min_severity=, coin=, since=, limit=, fields=, format=)` | `GET /radar/events` (paginated) | Risk radar events |
| `security_incidents(origin=, type=, chain=, min_severity=, limit=, fields=, format=)` | `GET /security/incidents` (paginated) | Security incidents |
| `security_wallets(category=, network=, source=, q=, moved_days=, limit=, fields=, format=)` | `GET /security/wallets` (paginated) | Listed wallets (OFAC, FBI, post-mortems) |
| `security_wallet(address, limit=)` | `GET /security/wallets/{address}` | Listed wallet detail |
| `movements(type=, chain=, asset=, min_usd=, labelled=, address=, since=, limit=, fields=, format=)` | `GET /movements` (paginated) | Whale movements (on-chain + Hyperliquid) |
| `exchange_flows(hours=, exchange=, asset=)` | `GET /exchange-flows` | Exchange on-chain flows |
| `stats()` | `GET /stats` | Coverage and freshness |
| `me()` | `GET /me` | The calling key: plan, limits, usage |
| `status()` | `GET /status` (public, no key) | API status (public) |
| `stream(channel=, kinds=, min_severity=, venue=, min_usd=, coin=, last_event_id=)` | `GET /stream` (API Pro, Server-Sent Events) | Live event stream (SSE) |
<!-- /generated:endpoints -->

Parameters are keyword-only; snake_case (`since_minutes`) and the API's camelCase (`sinceMinutes`) both work, an
unknown one raises `TypeError` before any request. Lists become comma-separated values (`kinds=["hack", "depeg"]`),
booleans `true` / `false`. Units: times ISO 8601 UTC, money in USD, funding rates, distances and changes as fractions
(0.01 = 1 %).

## Liquidation heatmap

```python
from blockmetric import Blockmetric, heatmap_clusters

hm = client.heatmap("BTC", tf="15m", hours=24)["data"]
c = heatmap_clusters(hm, top=3)
for x in c["above"]:
    print(f"shorts liquidate at {x['price']:,.0f} (+{x['distance']:.1%}): ${x['usd']:,.0f}")
for x in c["below"]:
    print(f"longs liquidate at {x['price']:,.0f} ({x['distance']:.1%}): ${x['usd']:,.0f}")
```

`tf` is the column interval (1m · 5m · 15m · 1h · 4h · 12h · 1d, as your plan allows), `end=` pages back in time.

## Pagination

```python
for liq in client.paginate("liquidations", since_minutes=24 * 60, coin="BTC", limit=1000):
    print(liq["ts"], liq["venue"], liq["side"], liq["usd"])
```

`paginate` follows `meta.nextCursor` until the last page (every operation marked "paginated" above); `max_pages=`
stops earlier. `pages()` yields the full envelope of each page instead of the items.

## New items as they arrive (any key)

```python
import threading

stop = threading.Event()  # stop.set() from another thread ends the loop
for liq in client.follow("liquidations", coin="BTC", limit=200, interval=15, stop_event=stop):
    if (liq["usd"] or 0) >= 100_000:
        print("big liquidation", liq["side"], liq["usd"], liq["price"])
```

`follow` polls a feed (`liquidations`, `whale_feed`, `radar_events`, `tape`, `movements`), reads back until the newest
item it has already seen and yields every new item once, oldest first. Retryable errors go to `on_error` and the loop
continues. Mind your per-minute limit when choosing `interval`.

## Live stream (API Pro)

```python
for event in client.stream(kinds=["big_liquidation", "liq_cascade"], min_severity=2, max_duration=3600):
    print(event["type"], event["data"]["severity"], event["data"]["title"])
```

Each item is the webhook envelope `{"id": "evt_…", "type", "created", "data"}`. The client reconnects on its own
(honouring the server's `retry:`, also after `idle_timeout` seconds without the 15 s heartbeat) and resumes with
`Last-Event-ID`, so nothing is lost inside the server's 15-minute resume window. It stops on `break`, after
`max_duration` seconds or when `stop_event` (a `threading.Event`) is set (also from another thread); the open
connection is closed right away. `on_ready`, `on_gap` (events outside the window: backfill with `radar_events()`) and
`on_reconnect` are optional callbacks. Without API Pro, `follow("radar_events")` gives the same events by polling.

## Webhooks (API Dev, API Pro)

Verify the raw body exactly as received, never re-serialised JSON:

```python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from blockmetric import construct_event, BlockmetricError

SECRET = os.environ["BLOCKMETRIC_WEBHOOK_SECRET"]  # whsec_..., shown once when you create the endpoint


class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        try:
            event = construct_event(raw, self.headers.get("Blockmetric-Signature"), SECRET)
        except BlockmetricError:
            self.send_response(400)
            self.end_headers()
            return
        self.send_response(204)  # answer within 10 s, then work
        self.end_headers()
        print(event["id"], event["type"], event["data"]["title"])  # deduplicate on event["id"] (retries reuse it)


HTTPServer(("", 8080), Hook).serve_forever()
```

`verify_webhook(body, header, secret, tolerance=300)` returns a bool instead (constant-time comparison, any `v1`
matches: during a secret rotation the old secret keeps working for 24 h). `sign_webhook(body, secret)` produces a
header for your own tests.

## Errors and retries

Every failure raises `blockmetric.BlockmetricError` with `code` (see the error catalogue at `doc_url`), `status`,
`param` (the parameter it is about, when the API names one), `request_id` (quote it to support), `retry_after`,
`retryable` and `message`.

```python
from blockmetric import Blockmetric, BlockmetricError

try:
    Blockmetric().heatmap("SOL", tf="15m")
except BlockmetricError as e:
    if e.code == "plan_required":
        print("needs a higher plan:", e.message)
    elif e.code == "source_restricted":
        print("not redistributed through the API:", e.message)
    else:
        raise
```

`429`, `5xx`, timeouts and network errors are retried (`max_retries=3`) with exponential backoff and jitter, honouring
`Retry-After` (seconds or an HTTP date) up to `max_retry_delay=60` seconds; a longer wait (the daily limit) raises
immediately. `etag_cache=True` keeps the last body per URL and revalidates with `If-None-Match` (a `304` still counts
against the limit but transfers no body). `user_agent="my-app/1.0"` names your app in front of the SDK's User-Agent,
`on_retry` and `on_warning` (every `meta.warnings` entry) are hooks for logging.

## Plans and limits

<!-- generated:plans (scripts/devx-generate.ts) -->
| Access | Requests | History | Licence | Realtime |
|---|---|---|---|---|
| No key (`X-Blockmetric-Access: public`) | 10 / min, 300 / day per IP address | 24 h | public (non-commercial) | - |
| Free | 30 / min, 1,000 / day | 30 days | personal use | - |
| Pro | 40 / min, 5,000 / day | 1 year | personal use | - |
| Desk | 60 / min, 10,000 / day | 3 years | personal use | - |
| API Dev | 60 / min, 50,000 / day | 1 year | internal use (1 organisation) | webhooks |
| API Pro | 300 / min, 400,000 / day | 3 years | display in your product | live stream, webhooks |

Limits apply per account across all its keys; every plan is real time. Every key gets the heatmap of every coin: free keys the last 24 hours, paid plans their plan history. Every response names its licence (`meta.license`) and the credit to show (`meta.attribution`). Current prices: **/pricing** on the site. Data from public sources, without guarantee.
<!-- /generated:plans -->

## Versioning

The package follows semantic versioning (`blockmetric.__version__`) and is generated against one API contract
(`blockmetric.API_VERSION`, sent in the User-Agent). Within `/api/v1` the API only adds fields and parameters, so an
older SDK keeps working; `last_response.api_version` shows the contract that answered. Changes: `sdk/CHANGELOG.md`.

## Licence

The package is licensed under the [Apache License 2.0](LICENSE) (see also [`NOTICE`](NOTICE)). The licence covers the
code, not the name: "Blockmetric" and the logo are not licensed, so forks must be renamed; "works with Blockmetric" is
fine ([`TRADEMARKS.md`](TRADEMARKS.md)). Data you receive from the API is governed by the API terms
(`/terms#api-license` on the site; `meta.license` in every response); market data of third-party exchanges stays subject
to that exchange's terms (`/terms#third-party-data`). The examples in `examples/` and the code snippets in the API docs
are MIT-0 (copy them into your own code without attribution). No warranty for the data.

## Development

```bash
python -m unittest discover -s sdk/python/tests -v   # local fake API, no network
npx tsx scripts/devx-generate.ts                     # regenerates blockmetric/types.py, _config.py and this README's tables
```

Publishing is a manual operator step: set `SITE_URL` to the production https URL, run
`npx tsx scripts/devx-generate.ts --release`, remove the `Private :: Do Not Upload` classifier from `pyproject.toml`,
check the copyright holder in `NOTICE` (exact registered company name), then build (setuptools ≥ 77.0.3, PEP 639
licence metadata) and upload. Examples: `examples/`.
