# ─────────────────────────────────────────────────────────────────────
# Kresmion Python SDK - single-file build (GENERATED, DO NOT EDIT).
#
# Source of truth: sdk/python/kresmion/client.py
# Regenerate:      python sdk/python/build_singlefile.py
#
# Install by download:
#   curl -O https://kresmion.com/sdk/kresmion.py
# then `import kresmion` from the same directory. Requires `requests`.
# ─────────────────────────────────────────────────────────────────────
"""Kresmion API - official Python client.

A single-module, dependency-light client for the Kresmion financial
intelligence API (https://kresmion.com). Talks to the versioned ``/v1``
surface: prediction markets, on-chain crypto flows, equities signals,
macro regime, cross-market signals, and bulk historical downloads.

This module is the ONE source of truth for the SDK. The installable
package (``sdk/python/kresmion/``) re-exports it, and the single-file
build served at https://kresmion.com/sdk/kresmion.py is a verbatim copy
produced by ``sdk/python/build_singlefile.py``.

Quick start
-----------
    from kresmion import Kresmion

    k = Kresmion(api_key="krm_your_key_here")   # or set KRESMION_API_KEY
    markets = k.prediction.markets(category="crypto", limit=20)
    for m in markets:
        print(m["question"], m["yes_probability"])
    print("as of", markets.as_of, "of", markets.count, "markets")

Only the standard library plus ``requests`` are required. Output is
printed data, never financial advice.
"""
from __future__ import annotations

import hashlib
import hmac
import os
import random
import time
from datetime import date, datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Callable, Dict, Optional, Union

try:
    import requests
except ImportError as _exc:  # pragma: no cover - environment guard
    raise ImportError(
        "The Kresmion SDK requires the 'requests' library. "
        "Install it with:  pip install 'requests>=2.28'"
    ) from _exc

__version__ = "0.2.1"

__all__ = [
    "Kresmion",
    "ApiList",
    "ApiDict",
    "KresmionError",
    "KresmionAuthError",
    "KresmionRateLimitError",
    "KresmionAPIError",
    "verify_webhook",
    "__version__",
]

DEFAULT_BASE_URL = "https://kresmion.com/api"

# HMAC signature header emitted by the Kresmion webhook dispatcher.
# Matches backend/app/services/webhook_service.py exactly:
#   signature = "sha256=" + hmac_sha256(secret, raw_body).hexdigest()
#   header    = X-Kresmion-Signature
WEBHOOK_SIGNATURE_HEADER = "X-Kresmion-Signature"


# ─── Exceptions ────────────────────────────────────────────────────────

class KresmionError(Exception):
    """Base class for every error raised by this SDK."""


class KresmionAuthError(KresmionError):
    """Raised on HTTP 401 - the API key is missing, malformed, revoked,
    or over its quota. Not retried."""

    def __init__(self, detail: Any = None) -> None:
        self.status = 401
        self.detail = detail
        super().__init__(f"authentication failed (401): {detail}")


class KresmionRateLimitError(KresmionError):
    """Raised on HTTP 429 after the retry budget is exhausted.

    Attributes
    ----------
    retry_after : Optional[float]
        Seconds the server asked the caller to wait (from the
        ``Retry-After`` header), or ``None`` if it did not say.
    rate_status : dict
        Snapshot of the rate-limit headers at the time of the failure
        (``limit_minute``, ``remaining_minute``, ``monthly_limit``,
        ``monthly_used``).
    """

    def __init__(
        self,
        retry_after: Optional[float] = None,
        rate_status: Optional[Dict[str, Optional[int]]] = None,
        detail: Any = None,
    ) -> None:
        self.status = 429
        self.retry_after = retry_after
        self.rate_status = rate_status or {}
        self.detail = detail
        wait = f" retry after {retry_after}s" if retry_after is not None else ""
        super().__init__(f"rate limit exceeded (429){wait}: {detail}")


class KresmionAPIError(KresmionError):
    """Raised on any other non-success response (4xx / 5xx) or on a
    connection error after retries are exhausted.

    Attributes
    ----------
    status : Optional[int]
        The HTTP status code, or ``None`` for a transport-level failure.
    detail : Any
        The parsed ``detail`` field from the error envelope when present,
        otherwise the raw body or exception text.
    """

    def __init__(self, message: str, status: Optional[int] = None, detail: Any = None) -> None:
        self.status = status
        self.detail = detail
        super().__init__(message)


# ─── Envelope wrappers ─────────────────────────────────────────────────

class ApiList(list):
    """A ``list`` of records that also carries the envelope metadata.

    Behaves exactly like a plain ``list`` (iterate, index, slice) with
    two extra attributes:

    - ``.count`` - the server-reported total count for the query.
    - ``.as_of`` - ISO-8601 timestamp of the underlying data snapshot.
    """

    count: Optional[int]
    as_of: Optional[str]

    def __init__(self, iterable=(), count: Optional[int] = None, as_of: Optional[str] = None) -> None:
        super().__init__(iterable)
        self.count = count if count is not None else len(self)
        self.as_of = as_of

    def __repr__(self) -> str:  # pragma: no cover - cosmetic
        return f"ApiList(count={self.count}, as_of={self.as_of!r}, items={list.__repr__(self)})"


class ApiDict(dict):
    """A ``dict`` record that also carries the envelope ``as_of`` snapshot
    timestamp. Behaves exactly like a plain ``dict``."""

    as_of: Optional[str]

    def __init__(self, mapping=(), as_of: Optional[str] = None) -> None:
        super().__init__(mapping)
        self.as_of = as_of

    def __repr__(self) -> str:  # pragma: no cover - cosmetic
        return f"ApiDict(as_of={self.as_of!r}, {dict.__repr__(self)})"


# ─── Webhook verification ──────────────────────────────────────────────

def verify_webhook(
    secret: Union[str, bytes],
    body: Union[str, bytes],
    signature_header: Optional[str],
) -> bool:
    """Verify the signature on an inbound Kresmion webhook POST.

    Kresmion signs every webhook delivery with HMAC-SHA256 over the raw
    request body and sends the result in the ``X-Kresmion-Signature``
    header as ``sha256=<hex>``. This function recomputes that signature
    from your shared ``secret`` and the raw bytes you received and does a
    constant-time comparison against the header.

    Parameters
    ----------
    secret : str | bytes
        The webhook secret handed to you once at webhook-create time.
    body : str | bytes
        The EXACT raw request body bytes as received. Do not re-serialize
        parsed JSON - a single byte of difference invalidates the
        signature.
    signature_header : str | None
        The value of the ``X-Kresmion-Signature`` request header, e.g.
        ``"sha256=ab12..."``.

    Returns
    -------
    bool
        ``True`` if the signature is valid, ``False`` otherwise (including
        a missing header). Never raises on a bad signature.

    Example
    -------
        raw = request.get_data()                     # bytes
        sig = request.headers.get("X-Kresmion-Signature")
        if not verify_webhook(my_secret, raw, sig):
            abort(400)
    """
    if signature_header is None or secret is None or body is None:
        return False
    if isinstance(secret, str):
        secret = secret.encode("utf-8")
    if isinstance(body, str):
        body = body.encode("utf-8")
    # Mirrors webhook_service._sign_payload exactly.
    expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header.strip())


# ─── Internal helpers ──────────────────────────────────────────────────

def _clean(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
    """Drop ``None``-valued query params so they are not serialized."""
    if not params:
        return None
    return {k: v for k, v in params.items() if v is not None}


def _date_str(value: Union[str, date, datetime]) -> str:
    """Normalize a date-ish value to ``YYYY-MM-DD``."""
    if isinstance(value, datetime):
        return value.date().isoformat()
    if isinstance(value, date):
        return value.isoformat()
    return str(value)


def _iso_str(value: Union[str, date, datetime]) -> str:
    """Normalize a timestamp-ish value to an ISO-8601 string.

    A ``datetime`` keeps its full time-of-day precision; a ``date`` becomes
    ``YYYY-MM-DD``; a string is passed through unchanged (so the ``next_since``
    cursor a caller already holds round-trips exactly)."""
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, date):
        return value.isoformat()
    return str(value)


def _parse_retry_after(value: Optional[str]) -> Optional[float]:
    """Parse a ``Retry-After`` header (delta-seconds or an HTTP-date)."""
    if value is None:
        return None
    value = value.strip()
    try:
        return max(0.0, float(value))
    except (TypeError, ValueError):
        pass
    try:
        dt = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    if dt is None:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())


def _unwrap(body: Any) -> Any:
    """Turn a response envelope into an :class:`ApiList` / :class:`ApiDict`.

    ``{"data": [...], "count": n, "as_of": iso}`` -> ApiList
    ``{"data": {...}, "as_of": iso}``             -> ApiDict
    Anything without a ``data`` key is returned unchanged (e.g. the raw
    OpenAPI document).
    """
    if not isinstance(body, dict) or "data" not in body:
        return body
    data = body["data"]
    as_of = body.get("as_of")
    if isinstance(data, list):
        return ApiList(data, count=body.get("count"), as_of=as_of)
    if isinstance(data, dict):
        return ApiDict(data, as_of=as_of)
    return data


# ─── Client ────────────────────────────────────────────────────────────

class Kresmion:
    """Client for the Kresmion ``/v1`` API.

    Parameters
    ----------
    api_key : str, optional
        Your ``krm_...`` API key. Falls back to the ``KRESMION_API_KEY``
        environment variable when omitted.
    base_url : str
        API root. Defaults to ``https://kresmion.com/api``; the client
        appends the ``/v1`` version prefix to each path.
    timeout : float
        Per-request timeout in seconds (default 30).
    max_retries : int
        Maximum number of retries (in addition to the first attempt) for
        429 and 5xx / connection failures (default 3).

    The client keeps a persistent HTTP session (connection reuse) and
    retries transient failures with exponential backoff and jitter,
    honoring a server ``Retry-After`` exactly on a 429.

    After every call, :attr:`last_rate_status` holds the latest
    rate-limit header snapshot.

    Namespaces
    ----------
    - :attr:`prediction` - prediction-market data (+ execution cost, calendar,
      options-implied divergence).
    - :attr:`crypto` - on-chain flows, ETF flows, derivatives, options,
      cross-exchange funding, liquidations, stablecoins, unlocks.
    - :attr:`equities` - signals, insider clusters, congress, 13F, gamma,
      smart-money composite.
    - :attr:`macro` - regime (+ history), correlations, COT, TIC, BIS.
    - :attr:`signals` - cross-market signal feed (callable) + ``.accuracy()``.
    - :attr:`track_record` - per-signal-type forward-return track record.
    - :attr:`confluence` - cross-family confluence + divergence composites.
    - :attr:`bulk` - bulk historical CSV downloads.
    - :attr:`billing` - paid API-quota top-ups settled in USDC on Solana.

    The top-level :meth:`brief` method returns a compound cross-product
    digest for one symbol, and :meth:`changes` returns a cross-family delta
    feed for cheap polling.
    """

    _BACKOFF_BASE = 0.5
    _BACKOFF_MAX = 30.0

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = DEFAULT_BASE_URL,
        timeout: float = 30,
        max_retries: int = 3,
    ) -> None:
        self.api_key = api_key or os.environ.get("KRESMION_API_KEY")
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.max_retries = max(0, int(max_retries))
        self.last_rate_status: Dict[str, Optional[int]] = {
            "limit_minute": None,
            "remaining_minute": None,
            "monthly_limit": None,
            "monthly_used": None,
        }
        self._session = requests.Session()

        # Namespaced accessors.
        self.prediction = _Prediction(self)
        self.crypto = _Crypto(self)
        self.equities = _Equities(self)
        self.macro = _Macro(self)
        self.signals = _Signals(self)
        self.track_record = _TrackRecord(self)
        self.confluence = _Confluence(self)
        self.bulk = _Bulk(self)
        self.billing = _Billing(self)

    # -- transport -------------------------------------------------------

    def _headers(self, accept: str = "application/json") -> Dict[str, str]:
        headers = {
            "Accept": accept,
            "User-Agent": f"kresmion-python/{__version__}",
        }
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"
        return headers

    def _sleep_backoff(self, attempt: int, retry_after: Optional[float] = None) -> None:
        if retry_after is not None:
            time.sleep(retry_after)
            return
        base = self._BACKOFF_BASE * (2 ** attempt)
        base = min(base, self._BACKOFF_MAX)
        time.sleep(base + random.uniform(0, base * 0.25))

    def _capture_rate_status(self, resp: "requests.Response") -> None:
        h = resp.headers

        def _int(name: str) -> Optional[int]:
            v = h.get(name)
            if v is None:
                return None
            try:
                return int(v)
            except (TypeError, ValueError):
                return None

        self.last_rate_status = {
            "limit_minute": _int("X-RateLimit-Limit-Minute"),
            "remaining_minute": _int("X-RateLimit-Remaining-Minute"),
            "monthly_limit": _int("X-Kresmion-Monthly-Limit"),
            "monthly_used": _int("X-Kresmion-Monthly-Used"),
        }

    @staticmethod
    def _detail(resp: "requests.Response") -> Any:
        try:
            body = resp.json()
        except ValueError:
            return (resp.text or "")[:500] or None
        if isinstance(body, dict) and "detail" in body:
            return body["detail"]
        return body

    def _raw_request(
        self,
        method: str,
        path: str,
        params: Optional[Dict[str, Any]] = None,
        stream: bool = False,
        accept: str = "application/json",
        json_body: Optional[Dict[str, Any]] = None,
    ) -> "requests.Response":
        """Execute one HTTP request with retry/backoff and typed errors.

        Returns the raw :class:`requests.Response` (used directly by the
        streaming bulk downloader); JSON callers go through :meth:`_get` /
        :meth:`_post`. ``json_body`` is sent as the request body only when set
        (the single POST endpoint, billing quote); GET callers leave it None.
        """
        url = self.base_url + path
        last_exc: Optional[Exception] = None
        for attempt in range(self.max_retries + 1):
            try:
                resp = self._session.request(
                    method,
                    url,
                    params=params,
                    json=json_body,
                    headers=self._headers(accept),
                    timeout=self.timeout,
                    stream=stream,
                )
            except requests.RequestException as exc:
                last_exc = exc
                if attempt < self.max_retries:
                    self._sleep_backoff(attempt)
                    continue
                raise KresmionAPIError(
                    f"connection error after {attempt + 1} attempt(s): {exc}",
                    status=None,
                    detail=str(exc),
                ) from exc

            self._capture_rate_status(resp)
            status = resp.status_code

            if status == 429:
                retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
                if attempt < self.max_retries:
                    self._sleep_backoff(attempt, retry_after=retry_after)
                    continue
                raise KresmionRateLimitError(
                    retry_after=retry_after,
                    rate_status=dict(self.last_rate_status),
                    detail=self._detail(resp),
                )
            if status == 401:
                raise KresmionAuthError(self._detail(resp))
            if status >= 500:
                if attempt < self.max_retries:
                    self._sleep_backoff(attempt)
                    continue
                raise KresmionAPIError(
                    f"server error {status}", status=status, detail=self._detail(resp)
                )
            if status >= 400:
                raise KresmionAPIError(
                    f"request failed {status}", status=status, detail=self._detail(resp)
                )
            return resp

        # Only reachable if the loop exits without returning/raising.
        raise KresmionAPIError(
            "request failed after retries", status=None, detail=str(last_exc)
        )

    def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
        """GET a JSON endpoint and unwrap its envelope."""
        resp = self._raw_request("GET", path, params=_clean(params))
        try:
            body = resp.json()
        except ValueError as exc:
            raise KresmionAPIError(
                "invalid JSON in response",
                status=resp.status_code,
                detail=(resp.text or "")[:500],
            ) from exc
        return _unwrap(body)

    def _post(
        self,
        path: str,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> Any:
        """POST a JSON body to an endpoint and unwrap its envelope.

        The only mutating call in the SDK (the billing top-up quote). Shares the
        same retry/backoff, typed errors, and envelope handling as :meth:`_get`.
        """
        resp = self._raw_request("POST", path, params=_clean(params), json_body=json_body)
        try:
            body = resp.json()
        except ValueError as exc:
            raise KresmionAPIError(
                "invalid JSON in response",
                status=resp.status_code,
                detail=(resp.text or "")[:500],
            ) from exc
        return _unwrap(body)

    # -- top-level endpoints ---------------------------------------------

    def status(self) -> Any:
        """Return API service status and your current quota snapshot
        (``GET /v1/status``)."""
        return self._get("/v1/status")

    def openapi(self) -> Any:
        """Return the raw OpenAPI 3 document describing the ``/v1`` API
        (``GET /v1/openapi.json``). Not enveloped."""
        return self._get("/v1/openapi.json")

    def brief(self, asset_class: str, symbol: str) -> ApiDict:
        """Compound cross-product digest for one symbol
        (``GET /v1/brief/{asset_class}/{symbol}``).

        One call assembles the full Kresmion digest for a single symbol from
        every relevant product family at once, so you do not have to stitch
        the family endpoints together yourself.

        Parameters
        ----------
        asset_class : str
            ``"crypto"`` (symbol BTC/ETH/SOL: derivatives + funding,
            liquidations, on-chain/stablecoin flows, ETF flows, related
            prediction markets, and the macro regime) or ``"equity"``
            (symbol a ticker: signals, insider + congress, 13F, modeled
            dealer gamma, and related prediction markets).
        symbol : str
            The symbol to profile (e.g. ``"BTC"`` or ``"NVDA"``).

        Each sub-section carries its own ``as_of`` so you can tell which legs
        are fresh. Descriptive and event-framed, never advice.
        """
        return self._get(f"/v1/brief/{asset_class}/{symbol}")

    def changes(
        self,
        since: Union[str, date, datetime],
        families: Optional[Union[str, list]] = None,
        symbol: Optional[str] = None,
        limit_per_family: int = 200,
    ) -> ApiDict:
        """Cross-family delta feed: only what changed since a cursor
        (``GET /v1/changes``).

        One poll returns, per product family, only the rows that are strictly
        newer than your ``since`` cursor, so an agent that already holds a
        snapshot stays current for a tiny token cost instead of re-fetching whole
        family payloads every tick. The endpoint holds no server-side cursor: you
        keep it and pass it back.

        Parameters
        ----------
        since : str | date | datetime
            The cursor (required). Rows whose timestamp is strictly after this
            are returned. On the FIRST poll pass a recent timestamp; on every
            poll after that pass back the previous result's ``.as_of`` (see
            below). Must be within the last 7 days: this endpoint is for frequent
            polling, not backfill, so a ``since`` older than 7 days is rejected
            with a 400. For deeper history use the per-family endpoints or
            :meth:`bulk.download`.
        families : str | list, optional
            Which families to poll, as a comma-separated string or a list of
            names. Valid: ``signals``, ``whale``, ``prediction``, ``funding``,
            ``etf_flow``, ``gamma``, ``confluence``. Omit for the default set
            (``signals``, ``whale``, ``prediction``, ``confluence``).
        symbol : str, optional
            Case-insensitive symbol filter (e.g. ``"BTC"`` or ``"NVDA"``). It is
            ignored for families with no per-symbol column (``etf_flow``); a note
            saying so is added to the response ``meta``.
        limit_per_family : int
            Maximum rows per family, 1 to 500 (default 200). When a family hits
            this cap ``meta.truncated[family]`` is true, meaning more rows are
            pending for that family, so narrow the window or raise the limit.

        Returns
        -------
        ApiDict
            A dict keyed by family name, each value a list of the new rows for
            that family (an empty list means nothing changed there). The returned
            dict's ``.as_of`` attribute is the next cursor: it equals the
            response ``meta.next_since`` (the freshest timestamp served, or your
            own ``since`` when nothing changed), so pass ``result.as_of`` straight
            back as ``since`` on your next poll and no row is ever re-delivered.

        Example
        -------
            snap = k.changes(since="2026-07-17T00:00:00Z", families=["signals", "whale"])
            for sig in snap["signals"]:
                print(sig["asset_symbol"], sig["signal_type"])
            cursor = snap.as_of          # feed this back next time
            snap = k.changes(since=cursor, families=["signals", "whale"])
        """
        if isinstance(families, (list, tuple)):
            families = ",".join(str(f) for f in families)
        return self._get(
            "/v1/changes",
            {
                "since": _iso_str(since),
                "families": families,
                "symbol": symbol,
                "limit_per_family": limit_per_family,
            },
        )

    def close(self) -> None:
        """Close the underlying HTTP session."""
        self._session.close()

    def __enter__(self) -> "Kresmion":
        return self

    def __exit__(self, *exc: Any) -> None:
        self.close()


# ─── Namespaces ────────────────────────────────────────────────────────

class _Namespace:
    def __init__(self, client: Kresmion) -> None:
        self._client = client


class _Prediction(_Namespace):
    """Prediction-market data: markets, order books (+ history), execution
    cost, algo-flow flags, flow, movers, consensus, cross-venue divergence,
    options-implied divergence, calibration, resolutions, and the resolution
    calendar."""

    def markets(
        self,
        category: Optional[str] = None,
        min_volume: Optional[float] = None,
        q: Optional[str] = None,
        limit: int = 50,
    ) -> ApiList:
        """List prediction markets (``GET /v1/prediction/markets``).

        Parameters
        ----------
        category : str, optional
            Filter by market category (e.g. ``"crypto"``, ``"macro"``).
        min_volume : float, optional
            Only markets with at least this traded volume.
        q : str, optional
            Free-text search over market questions.
        limit : int
            Maximum markets to return (default 50).
        """
        return self._client._get(
            "/v1/prediction/markets",
            {"category": category, "min_volume": min_volume, "q": q, "limit": limit},
        )

    def market(self, market_id: str) -> ApiDict:
        """Fetch a single market by id (``GET /v1/prediction/markets/{id}``)."""
        return self._client._get(f"/v1/prediction/markets/{market_id}")

    def history(self, market_id: str, days: int = 7) -> ApiList:
        """Price history for a market
        (``GET /v1/prediction/markets/{id}/history``).

        Parameters
        ----------
        market_id : str
            The market id.
        days : int
            Look-back window in days (default 7).
        """
        return self._client._get(
            f"/v1/prediction/markets/{market_id}/history", {"days": days}
        )

    def book(self, market_id: str) -> ApiDict:
        """Current order book for a market
        (``GET /v1/prediction/markets/{id}/book``)."""
        return self._client._get(f"/v1/prediction/markets/{market_id}/book")

    def book_history(self, market_id: str, hours: int = 24) -> ApiList:
        """Order-book depth / spread history for a market
        (``GET /v1/prediction/markets/{id}/book/history``).

        Parameters
        ----------
        market_id : str
            The market id.
        hours : int
            Look-back window in hours (default 24).
        """
        return self._client._get(
            f"/v1/prediction/markets/{market_id}/book/history", {"hours": hours}
        )

    def execution_cost(
        self, market_id: str, notionals: Optional[list] = None
    ) -> ApiList:
        """Modeled slippage / execution cost for taking size in a market
        (``GET /v1/prediction/markets/{id}/execution-cost``).

        Parameters
        ----------
        market_id : str
            The market id.
        notionals : list of float, optional
            USDC order sizes to price the fill for (default
            ``[100, 1000, 10000]``). Each is walked against one side of the
            STORED top-5 order-book snapshots (no live CLOB call): the result
            is a one-leg cost estimate (fill VWAP, ``slippage_bps``), never a
            round trip and never inclusive of fees. ``meta.summary`` carries
            the window median / p90 slippage per size. Book coverage is the
            top ~150 live markets by 24h volume only.
        """
        sizes = notionals if notionals is not None else [100, 1000, 10000]
        return self._client._get(
            f"/v1/prediction/markets/{market_id}/execution-cost",
            {"notional": sizes},
        )

    def algo_flags(self, market_id: str) -> ApiList:
        """Detected algorithmic-execution flags on a market's tape + book
        (``GET /v1/prediction/markets/{id}/algo-flags``, last 48h): TWAP-like
        clips, resting ladders, and price-level absorption, each with its
        evidence. Inference from public data ("consistent with"), never an
        assertion of who or why."""
        return self._client._get(
            f"/v1/prediction/markets/{market_id}/algo-flags"
        )

    def flow(self, market_id: str) -> ApiList:
        """Recent trade / order flow for a market
        (``GET /v1/prediction/markets/{id}/flow``)."""
        return self._client._get(f"/v1/prediction/markets/{market_id}/flow")

    def movers(self, limit: int = 50) -> ApiList:
        """Biggest recent price movers across markets
        (``GET /v1/prediction/movers``)."""
        return self._client._get("/v1/prediction/movers", {"limit": limit})

    def consensus(self) -> ApiList:
        """Cross-venue consensus probabilities
        (``GET /v1/prediction/consensus``)."""
        return self._client._get("/v1/prediction/consensus")

    def divergence(self, executable_size: Optional[float] = None) -> ApiList:
        """Markets whose price diverges across venues
        (``GET /v1/prediction/divergence``).

        Parameters
        ----------
        executable_size : float, optional
            USD notional floor: keep only pairs whose cross-venue spread
            survives walking both venues' books at this size (drops spreads
            that live only in size-less top-of-book quotes).
        """
        return self._client._get(
            "/v1/prediction/divergence", {"executable_size": executable_size}
        )

    def calendar(self, days: int = 14) -> ApiList:
        """Upcoming market resolutions + scheduled events, soonest first
        (``GET /v1/prediction/calendar``).

        Parameters
        ----------
        days : int
            Look-ahead window in days (default 14). Dates are scheduled close
            dates, not guaranteed actual resolution times.
        """
        return self._client._get("/v1/prediction/calendar", {"days": days})

    def calibration(self) -> ApiDict:
        """Historical calibration of market-implied probabilities vs
        realized outcomes (``GET /v1/prediction/calibration``)."""
        return self._client._get("/v1/prediction/calibration")

    def resolutions(self, limit: int = 50) -> ApiList:
        """Recently resolved markets and their outcomes
        (``GET /v1/prediction/resolutions``)."""
        return self._client._get("/v1/prediction/resolutions", {"limit": limit})

    def options_divergence(
        self,
        currency: str = "ALL",
        min_abs_spread: float = 0.0,
        market_kind: str = "all",
        limit: int = 50,
    ) -> ApiList:
        """Polymarket vs options-implied probability spreads
        (``GET /v1/prediction/options-divergence``).

        For active Polymarket BTC / ETH price-threshold markets, this pairs the
        crowd's YES probability with the Deribit options-implied risk-neutral
        probability of the SAME event and reports their spread
        (``pm_prob - opt_prob``), ranked by absolute spread. The options leg is a
        Black-76 digital N(d2) built from the Deribit mark-IV chain. A negative
        spread means the options chain implies a higher probability than the
        crowd.

        Parameters
        ----------
        currency : str
            ``"BTC"``, ``"ETH"``, or ``"ALL"`` (default ``"ALL"``).
        min_abs_spread : float
            Keep only rows whose absolute spread is at least this, in
            probability points from 0 to 1 (default 0, keep all).
        market_kind : str
            ``"terminal"`` (``"above $X on <date>"``, uses the probability
            directly), ``"touch"`` (``"reach $X"``, ``"dip to $X"``, applies a
            driftless reflection approximation ``opt_prob = min(1, 2 * terminal)``
            and is model-approximate), or ``"all"`` (default).
        limit : int
            Maximum rows to return, 1 to 200 (default 50).

        A spread is information about two separate order books, not an arbitrage
        instruction. The Deribit expiry used can differ from the market close by
        up to seven days, reported per row as ``expiry_gap_days``. Pairs with
        :meth:`divergence` (cross-venue Polymarket vs Kalshi) and
        :meth:`execution_cost` (Polymarket book depth).
        """
        return self._client._get(
            "/v1/prediction/options-divergence",
            {
                "currency": currency,
                "min_abs_spread": min_abs_spread,
                "market_kind": market_kind,
                "limit": limit,
            },
        )

    def options_divergence_history(self, market_id: str, days: int = 30) -> ApiList:
        """History of the Polymarket vs options-implied spread for one market
        (``GET /v1/prediction/options-divergence/history/{market_id}``).

        Oldest-first time series of the snapshot for one market: ``pm_prob``,
        ``opt_prob``, ``opt_prob_terminal``, ``spread``, the underlying, the
        mark-IV used, and the expiry gap. Structural data only.

        Parameters
        ----------
        market_id : str
            The Polymarket market id.
        days : int
            Look-back window in days, 1 to 90 (default 30).
        """
        return self._client._get(
            f"/v1/prediction/options-divergence/history/{market_id}",
            {"days": days},
        )


class _Crypto(_Namespace):
    """On-chain crypto data: whale transfers, exchange holdings, ETF
    flows, derivatives, options, cross-exchange funding, liquidations,
    stablecoin supply, and token unlocks."""

    def whales(self, min_usd: float = 1_000_000, hours: int = 24, limit: int = 100) -> ApiList:
        """Recent large on-chain transfers (``GET /v1/crypto/whales``).

        Parameters
        ----------
        min_usd : float
            Minimum USD value of a transfer (default 1,000,000).
        hours : int
            Look-back window in hours (default 24).
        limit : int
            Maximum rows to return (default 100).
        """
        return self._client._get(
            "/v1/crypto/whales", {"min_usd": min_usd, "hours": hours, "limit": limit}
        )

    def exchange_holdings(self) -> ApiList:
        """Current tracked exchange wallet holdings
        (``GET /v1/crypto/exchanges/holdings``)."""
        return self._client._get("/v1/crypto/exchanges/holdings")

    def exchange_history(self, exchange: str, days: Optional[int] = None) -> ApiList:
        """Balance history for one exchange
        (``GET /v1/crypto/exchanges/history/{exchange}``).

        Parameters
        ----------
        exchange : str
            Exchange identifier (e.g. ``"binance"``).
        days : int, optional
            Look-back window in days.
        """
        return self._client._get(
            f"/v1/crypto/exchanges/history/{exchange}", {"days": days}
        )

    def etf_flows(self) -> ApiList:
        """Latest crypto ETF creation / redemption flows
        (``GET /v1/crypto/etf/flows``)."""
        return self._client._get("/v1/crypto/etf/flows")

    def etf_history(self, ticker: str, days: Optional[int] = None) -> ApiList:
        """Flow history for one ETF ticker
        (``GET /v1/crypto/etf/history/{ticker}``)."""
        return self._client._get(f"/v1/crypto/etf/history/{ticker}", {"days": days})

    def derivatives(self, symbol: str) -> ApiDict:
        """Current derivatives snapshot (funding, open interest, basis)
        for a symbol (``GET /v1/crypto/derivatives/{symbol}``)."""
        return self._client._get(f"/v1/crypto/derivatives/{symbol}")

    def funding_history(self, symbol: str, days: Optional[int] = None) -> ApiList:
        """Funding-rate history for a symbol
        (``GET /v1/crypto/derivatives/{symbol}/funding-history``)."""
        return self._client._get(
            f"/v1/crypto/derivatives/{symbol}/funding-history", {"days": days}
        )

    def oi_history(self, symbol: str, days: Optional[int] = None) -> ApiList:
        """Open-interest history for a symbol
        (``GET /v1/crypto/derivatives/{symbol}/oi-history``)."""
        return self._client._get(
            f"/v1/crypto/derivatives/{symbol}/oi-history", {"days": days}
        )

    def options(self, currency: str) -> ApiDict:
        """Options-market summary for a currency
        (``GET /v1/crypto/options/{currency}``).

        Parameters
        ----------
        currency : str
            Underlying currency (e.g. ``"BTC"``, ``"ETH"``).
        """
        return self._client._get(f"/v1/crypto/options/{currency}")

    def funding(self, symbol: Optional[str] = None) -> Union[ApiList, ApiDict]:
        """Cross-exchange perpetual funding: the surface, or one symbol's
        history.

        With no ``symbol`` (``GET /v1/crypto/funding``) returns the funding
        surface: the latest funding rate per symbol across every covered
        exchange. With a ``symbol`` (``GET /v1/crypto/funding/{symbol}``)
        returns that symbol's funding-rate history.

        Parameters
        ----------
        symbol : str, optional
            ``"BTC"`` / ``"ETH"`` / ``"SOL"``. Omit for the surface.
        """
        if symbol:
            return self._client._get(f"/v1/crypto/funding/{symbol}")
        return self._client._get("/v1/crypto/funding")

    def liquidations(self, symbol: Optional[str] = None, hours: int = 24) -> ApiList:
        """Recent perpetual liquidations, long vs short
        (``GET /v1/crypto/liquidations``).

        Parameters
        ----------
        symbol : str, optional
            ``"BTC"`` / ``"ETH"`` / ``"SOL"``. Omit for all tracked symbols.
        hours : int
            Look-back window in hours (default 24).

        Coverage is Binance-only: a large single-venue sample, so read it as a
        proxy for market-wide forced flow, not a total.
        """
        return self._client._get(
            "/v1/crypto/liquidations", {"symbol": symbol, "hours": hours}
        )

    def stablecoins(self) -> ApiList:
        """Aggregate stablecoin supply and net issuance / redemption
        (``GET /v1/crypto/stablecoins``). Rising aggregate supply is dry
        powder entering the ecosystem; net redemption is the reverse."""
        return self._client._get("/v1/crypto/stablecoins")

    def unlocks(self, symbol: Optional[str] = None, days: int = 90) -> ApiList:
        """Forward token-unlock (vesting cliff) calendar
        (``GET /v1/crypto/unlocks``).

        Parameters
        ----------
        symbol : str, optional
            Restrict to one token symbol (uppercased). Omit for the whole
            hand-curated universe.
        days : int
            Forward horizon in days (default 90, max 365). Each tranche
            carries its token amount, share of supply, category, and cliff.
            Coverage is hand-curated from primary sources; rows not
            re-verified within 120 days are flagged ``stale``.
        """
        return self._client._get(
            "/v1/crypto/unlocks", {"symbol": symbol, "days": days}
        )


class _Equities(_Namespace):
    """Equities data: cross-signal feed, insider clusters, congressional
    trades, institutional (13F) holdings, price history, modeled dealer
    gamma, and the smart-money positioning composite."""

    def signals(self, limit: int = 50) -> ApiList:
        """Equity signals feed (``GET /v1/equities/signals``)."""
        return self._client._get("/v1/equities/signals", {"limit": limit})

    def insider_clusters(self, limit: int = 50) -> ApiList:
        """Clustered insider-transaction activity
        (``GET /v1/equities/insider-clusters``)."""
        return self._client._get("/v1/equities/insider-clusters", {"limit": limit})

    def congress(self, limit: int = 50) -> ApiList:
        """Congressional trading disclosures
        (``GET /v1/equities/congress``)."""
        return self._client._get("/v1/equities/congress", {"limit": limit})

    def institutional(self, limit: int = 50) -> ApiList:
        """Institutional (13F) holdings and changes
        (``GET /v1/equities/institutional``)."""
        return self._client._get("/v1/equities/institutional", {"limit": limit})

    def price_history(self, symbol: str, days: Optional[int] = None) -> ApiList:
        """Daily price history for a symbol
        (``GET /v1/equities/prices/{symbol}/history``)."""
        return self._client._get(
            f"/v1/equities/prices/{symbol}/history", {"days": days}
        )

    def gamma(self, ticker: Optional[str] = None) -> Union[ApiList, ApiDict]:
        """Modeled dealer gamma exposure: the surface, or one ticker.

        With no ``ticker`` (``GET /v1/equities/gamma``) returns the gamma
        surface across tracked names (net dealer gamma, flip level, long/short
        gamma). With a ``ticker`` (``GET /v1/equities/gamma/{ticker}``) returns
        that name's detail: gamma by strike, the zero-gamma / flip price, and
        the nearest call/put walls.

        Gamma is MODELED from the listed options chain plus an assumed
        dealer-positioning convention: it is an estimate, not disclosed
        positioning.
        """
        if ticker:
            return self._client._get(f"/v1/equities/gamma/{ticker}")
        return self._client._get("/v1/equities/gamma")

    def smart_money(
        self,
        min_families: int = 2,
        direction: str = "all",
        min_abs_score: float = 0.0,
        limit: int = 50,
    ) -> ApiList:
        """Per-ticker smart-money positioning composite, ranked by absolute
        score (``GET /v1/equities/smart-money``).

        One signed number in -100 to +100 per ticker that blends up to four
        public filing legs: insider clusters, congressional trades, 13F
        institutional holdings, and FINRA short-sale volume. A positive score
        leans long, a negative score leans short, and every leg is listed with
        its own weight, contribution, and ``as_of`` date so you can see what
        drove the number. When a leg is absent its weight is redistributed across
        the legs that are present, so ``families_present`` tells you how many legs
        actually contributed.

        Parameters
        ----------
        min_families : int
            Minimum number of contributing legs, 1 to 4 (default 2). Raise it to
            require agreement across several independent legs.
        direction : str
            ``"long"`` (score > 0), ``"short"`` (score < 0), or ``"all"``
            (default), by score sign.
        min_abs_score : float
            Minimum absolute score, 0 to 100 (default 0).
        limit : int
            Maximum tickers to return, 1 to 200 (default 50).

        Reads the daily snapshot table. Filing legs lag their events by the
        disclosure window, so this is a description of past filings, not advice.
        """
        return self._client._get(
            "/v1/equities/smart-money",
            {
                "min_families": min_families,
                "direction": direction,
                "min_abs_score": min_abs_score,
                "limit": limit,
            },
        )

    def smart_money_ticker(self, ticker: str) -> ApiDict:
        """Smart-money composite for one ticker, recomputed live
        (``GET /v1/equities/smart-money/{ticker}``).

        The composite recomputed from the current filing rows, plus up to the
        last 30 daily snapshot scores under ``history`` for trend. The ``legs``
        list carries each present leg (insider, congress, institutional,
        short_volume) with its signed value, redistributed weight, contribution,
        evidence, and its own ``as_of`` date, and names any absent leg with the
        reason it did not contribute.

        Parameters
        ----------
        ticker : str
            The equity ticker (case-insensitive).

        Raises a :class:`KresmionAPIError` with status 404 when the ticker has no
        leg at all. Descriptive summary of public filings, not advice.
        """
        return self._client._get(f"/v1/equities/smart-money/{ticker}")


class _Macro(_Namespace):
    """Macro data: cross-asset regime (+ history), cross-asset correlations,
    COT positioning, TIC flows, BIS."""

    def regime(self) -> ApiDict:
        """Current cross-asset Risk-On/Off regime score and factors
        (``GET /v1/macro/regime``)."""
        return self._client._get("/v1/macro/regime")

    def regime_history(self, days: int = 365) -> ApiList:
        """Daily cross-asset regime-score history
        (``GET /v1/macro/regime/history``).

        Parameters
        ----------
        days : int
            Look-back window in days (default 365). Charts how the regime
            score and its factor sub-scores trended, including transitions.
        """
        return self._client._get("/v1/macro/regime/history", {"days": days})

    def correlations(
        self,
        min_delta: float = 0.0,
        only_breaks: bool = True,
        limit: int = 50,
    ) -> ApiList:
        """Cross-asset correlation BREAKS from the latest snapshot
        (``GET /v1/macro/correlations``), ranked by ``|delta|``.

        Each row is a pair whose latest 30-day rolling correlation has moved
        from its trailing-12-month baseline: ``baseline``, ``current_corr``,
        ``delta``, ``sign_flip``, ``is_break``, and a ``reason``.

        Parameters
        ----------
        min_delta : float
            Minimum absolute correlation move to include (0..2, default 0).
        only_breaks : bool
            Only rows flagged ``is_break`` (default True).
        limit : int
            Maximum pairs to return (default 50).

        The universe is a fixed ~45-symbol set (crypto + ETF/index/forex/vol);
        pairs outside it do not exist here.
        """
        return self._client._get(
            "/v1/macro/correlations",
            {"min_delta": min_delta, "only_breaks": only_breaks, "limit": limit},
        )

    def cot(self) -> ApiList:
        """CFTC Commitments of Traders positioning
        (``GET /v1/macro/cot``)."""
        return self._client._get("/v1/macro/cot")

    def tic(self) -> ApiList:
        """US Treasury International Capital (TIC) flows
        (``GET /v1/macro/tic``)."""
        return self._client._get("/v1/macro/tic")

    def bis(self) -> ApiList:
        """Bank for International Settlements (BIS) series
        (``GET /v1/macro/bis``)."""
        return self._client._get("/v1/macro/bis")


class _Signals(_Namespace):
    """The cross-market signal feed. Call the namespace directly for the
    feed, or use :meth:`accuracy` for the calibration report.

        k.signals()             # -> ApiList of signals
        k.signals.accuracy()    # -> ApiDict of accuracy stats
    """

    def __call__(self, limit: int = 50, severity: Optional[str] = None) -> ApiList:
        """Fetch the cross-market signals feed (``GET /v1/signals``).

        Parameters
        ----------
        limit : int
            Maximum signals to return (default 50).
        severity : str, optional
            Filter by severity (``"low"``, ``"medium"``, ``"high"``,
            ``"critical"``).
        """
        return self._client._get("/v1/signals", {"limit": limit, "severity": severity})

    def accuracy(self) -> ApiDict:
        """Historical signal accuracy / calibration
        (``GET /v1/signals/accuracy``)."""
        return self._client._get("/v1/signals/accuracy")


class _TrackRecord(_Namespace):
    """Signal forward-return track record: per signal type and direction, how
    the live production signals were actually followed by price at 1, 7, and 30
    days. Unfiltered, so losing signal types publish their negative statistics
    identically to winning ones. Entry is the next daily close after the signal
    day, which avoids look-ahead. Descriptive history, not a forecast."""

    def aggregates(
        self,
        signal_type: Optional[str] = None,
        asset_class: Optional[str] = None,
        symbol: Optional[str] = None,
        direction: Optional[str] = None,
        days: int = 365,
        min_n: int = 5,
        limit: int = 100,
    ) -> ApiList:
        """Forward-return aggregates per signal type and direction
        (``GET /v1/track-record``).

        Each group reports, at the 1, 7, and 30 day horizons, its median and mean
        return, win rate (share of positive returns), direction hit rate (a bull
        group counts a rise, a bear group counts a fall, neutral is excluded),
        best and worst, and a baseline median (the naive same-symbol same-window
        median across all signal types, a rough random-entry reference, not a
        matched control). Returns are simple percentages stored as decimal
        fractions, so 0.0123 means plus 1.23 percent. ``coverage_pct`` is the
        share of occurrences for which a tradeable entry price existed. Nothing
        is filtered out: losing signal types show their negative medians too.

        Parameters
        ----------
        signal_type : str, optional
            Restrict to one signal type (exact match). See :meth:`types`.
        asset_class : str, optional
            ``"equity"``, ``"crypto"``, ``"forex"``, ``"macro"``, or
            ``"commodity"``.
        symbol : str, optional
            Restrict to one asset symbol (upper-cased).
        direction : str, optional
            ``"bull"``, ``"bear"``, or ``"neutral"``.
        days : int
            Look-back on when each signal was detected, in days, 1 to 1600
            (default 365).
        min_n : int
            Minimum occurrences for a group to be published, 1 to 100
            (default 5). Raise it to drop thin, noisy groups.
        limit : int
            Maximum groups to return, 1 to 500 (default 100).

        The sample is in-sample and young, and price coverage is partial, so read
        the medians as description, not as a validated edge or advice.
        """
        return self._client._get(
            "/v1/track-record",
            {
                "signal_type": signal_type,
                "asset_class": asset_class,
                "symbol": symbol,
                "direction": direction,
                "days": days,
                "min_n": min_n,
                "limit": limit,
            },
        )

    def types(self) -> ApiList:
        """The distinct tracked signal types (``GET /v1/track-record/types``).

        Each row carries the signal type, its occurrence count, first and last
        detected-at timestamps, and coverage (the share of occurrences with a
        tradeable entry price). Use it to see which detectors have enough history
        to be worth aggregating before you call :meth:`aggregates` or
        :meth:`for_type`.
        """
        return self._client._get("/v1/track-record/types")

    def for_type(
        self,
        signal_type: str,
        symbol: Optional[str] = None,
        days: int = 365,
        limit: int = 50,
    ) -> ApiDict:
        """Track record for one signal type, broken down by symbol
        (``GET /v1/track-record/{signal_type}``).

        Returns a per-symbol breakdown carrying the same horizon statistics and
        baseline as :meth:`aggregates`, plus the most recent individual
        occurrences (under ``recent``) with their own 1, 7, and 30 day returns so
        the losses are visible one by one.

        Parameters
        ----------
        signal_type : str
            The signal type to profile (exact match).
        symbol : str, optional
            Restrict to one asset symbol (upper-cased).
        days : int
            Look-back on when each signal was detected, in days, 1 to 1600
            (default 365).
        limit : int
            Maximum recent occurrences to return, 1 to 200 (default 50).

        Raises a :class:`KresmionAPIError` with status 404 when the signal type
        has no track record. Descriptive history, not advice.
        """
        return self._client._get(
            f"/v1/track-record/{signal_type}",
            {"symbol": symbol, "days": days, "limit": limit},
        )


class _Confluence(_Namespace):
    """Cross-family confluence and divergence composites. A confluence row means
    three or more INDEPENDENT signal families (on-chain whale flow, funding-rate
    stress, prediction-market repricing, crypto-options skew, and the
    deterministic signal engine) lined up on one symbol inside a rolling window;
    a divergence row means strong families pointed in opposite directions. Every
    contributing leg is returned under ``legs`` so you can judge the
    independence of the sources yourself. Broader context, never a prediction."""

    def list(
        self,
        symbol: Optional[str] = None,
        conviction: Optional[str] = None,
        min_score: int = 0,
        include_expired: bool = False,
        hours: int = 48,
        limit: int = 50,
    ) -> ApiList:
        """Active confluence + divergence composites, newest first
        (``GET /v1/confluence``).

        Symbols where three or more independent signal families currently agree
        on a direction (confluence), plus symbols where strong families disagree
        (divergence). Each row lists every contributing leg under ``legs``.

        Parameters
        ----------
        symbol : str, optional
            Restrict to one symbol (case-insensitive).
        conviction : str, optional
            ``"HIGH"``, ``"MEDIUM"``, or ``"FORMING"``.
        min_score : int
            Minimum composite score, 0 to 100 (default 0).
        include_expired : bool
            Include inactive / expired rows (default False, active only).
        hours : int
            Look-back window on when a row fired, in hours, 1 to 720
            (default 48).
        limit : int
            Maximum rows to return, 1 to 200 (default 50).

        Independent sources agreeing is broader context, not a prediction, and
        the absence of a family leg reflects that family's symbol coverage rather
        than disagreement.
        """
        return self._client._get(
            "/v1/confluence",
            {
                "symbol": symbol,
                "conviction": conviction,
                "min_score": min_score,
                "include_expired": include_expired,
                "hours": hours,
                "limit": limit,
            },
        )

    def for_symbol(self, symbol: str, hours: int = 168, limit: int = 100) -> ApiList:
        """Current and recent confluence history for one symbol
        (``GET /v1/confluence/{symbol}``).

        Current and recent confluence + divergence rows for one symbol, newest
        first, over the look-back window. Expired rows are included so the
        history is visible, and each row lists every contributing leg under
        ``legs``.

        Parameters
        ----------
        symbol : str
            The symbol to profile (case-insensitive).
        hours : int
            Look-back window on when a row fired, in hours, 1 to 720
            (default 168).
        limit : int
            Maximum rows to return, 1 to 500 (default 100).

        Raises a :class:`KresmionAPIError` with status 404 when the symbol has no
        confluence history in the window. Broader context, not a prediction.
        """
        return self._client._get(
            f"/v1/confluence/{symbol}",
            {"hours": hours, "limit": limit},
        )


class _Bulk(_Namespace):
    """Bulk historical downloads - gzip-compressed CSV streams."""

    def download(
        self,
        dataset: str,
        from_date: Union[str, date, datetime],
        to_date: Union[str, date, datetime],
        dest_path: str,
        progress: Optional[Callable[[int, Optional[int]], None]] = None,
        chunk_size: int = 1 << 16,
    ) -> str:
        """Stream a bulk dataset to disk (``GET /v1/bulk/{dataset}``).

        The endpoint returns a gzip-compressed CSV stream for the date
        range. This writes the raw gzip bytes to ``dest_path`` without
        buffering the whole file in memory.

        Parameters
        ----------
        dataset : str
            Dataset name (e.g. ``"prediction_markets"``, ``"whales"``).
        from_date, to_date : str | date | datetime
            Inclusive date range; normalized to ``YYYY-MM-DD``.
        dest_path : str
            Local path to write the ``.csv.gz`` stream to.
        progress : callable, optional
            Called as ``progress(bytes_written, total_bytes_or_None)``
            after each chunk. ``total_bytes`` is ``None`` when the server
            does not send a ``Content-Length``.
        chunk_size : int
            Streaming chunk size in bytes (default 64 KiB).

        Returns
        -------
        str
            ``dest_path``, for convenience.

        Example
        -------
            import gzip, csv
            k.bulk.download("prediction_markets", "2026-01-01",
                            "2026-06-30", "pm.csv.gz")
            with gzip.open("pm.csv.gz", "rt") as fh:
                for row in csv.DictReader(fh):
                    print(row)
        """
        params = {"from": _date_str(from_date), "to": _date_str(to_date)}
        resp = self._client._raw_request(
            "GET",
            f"/v1/bulk/{dataset}",
            params=params,
            stream=True,
            accept="application/gzip",
        )
        try:
            total_raw = resp.headers.get("Content-Length")
            total = int(total_raw) if total_raw and total_raw.isdigit() else None
            written = 0
            with open(dest_path, "wb") as fh:
                for chunk in resp.iter_content(chunk_size=chunk_size):
                    if not chunk:
                        continue
                    fh.write(chunk)
                    written += len(chunk)
                    if progress is not None:
                        progress(written, total)
        finally:
            resp.close()
        return dest_path


class _Billing(_Namespace):
    """Paid API-quota top-ups, settled in USDC on Solana.

    When your key hits its monthly quota (a ``429`` /
    :class:`KresmionRateLimitError` whose detail names the MONTHLY limit, not
    the per-minute window), buy more calls by paying USDC on Solana mainnet.
    The flow is four steps:

    .. code-block:: python

        import time

        plans = k.billing.plans()               # catalog: plan, usdc, calls, days
        q = k.billing.quote("boost_100k")       # -> payment_id, amount_usdc, ...

        # Send EXACTLY q["amount_usdc"] USDC (full 6-decimal precision) to
        # q["recipient"] on Solana mainnet, from ANY wallet or exchange; the
        # exact amount is what identifies your payment. Then poll until
        # credited:
        while k.billing.status(q["payment_id"])["status"] != "credited":
            time.sleep(5)

    Three things to know before paying: payments are NON-REFUNDABLE, a quote
    EXPIRES 30 minutes after creation, and only USDC on Solana MAINNET is
    accepted. Kresmion never holds your keys: this SDK does not sign or send the
    transfer. Send it from any wallet or exchange, or programmatically with a
    Solana wallet library (for example ``solders`` or ``solana-py``). Including
    ``reference`` as an extra account in the transfer is optional and merely
    speeds attribution. The boosted quota applies to the key that created the
    quote the moment its status reads ``credited``.
    """

    def plans(self) -> ApiList:
        """List the top-up plan catalog (``GET /v1/billing/solana/plans``).

        Each row carries the plan key (``plan``), its whole-USDC price
        (``usdc``), the same amount in base units (``amount_base_units``; USDC
        has 6 decimals), how many extra ``calls`` it grants, and how many
        ``days`` the boost lasts. Pass a plan key to :meth:`quote`. If top-ups
        are disabled or unconfigured on the server, :meth:`quote` raises a
        :class:`KresmionAPIError` with status 503.
        """
        return self._client._get("/v1/billing/solana/plans")

    def quote(self, plan: str) -> ApiDict:
        """Create a payment for a plan (``POST /v1/billing/solana/quote``).

        Parameters
        ----------
        plan : str
            A plan key from :meth:`plans` (e.g. ``"boost_100k"``).

        Returns an :class:`ApiDict` carrying ``payment_id``, ``amount_usdc`` (a
        unique full-6-decimal amount, e.g. ``5.000731``, that identifies this
        payment), ``recipient`` (a Solana address), ``reference`` (an optional
        extra account that merely speeds attribution), ``memo``,
        ``solana_pay_url``, ``expires_at``, and ``instructions``.

        Send EXACTLY ``amount_usdc`` (full 6-decimal precision) of USDC (SPL
        mint ``EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v``) to ``recipient``
        on Solana mainnet within 30 minutes, from any wallet or exchange; the
        exact amount is what matches the transfer to this quote. Including
        ``reference`` as a read-only extra account is optional and speeds
        attribution. Then poll :meth:`status`. This SDK does not send the
        transfer for you. The payment is non-refundable and is credited to the
        API key that created it.
        """
        return self._client._post("/v1/billing/solana/quote", json_body={"plan": plan})

    def status(self, payment_id: str) -> ApiDict:
        """Poll a payment's status (``GET /v1/billing/solana/status/{id}``).

        Parameters
        ----------
        payment_id : str
            The ``payment_id`` returned by :meth:`quote`.

        ``status`` walks ``pending`` -> ``confirmed`` -> ``credited``; the quota
        boost applies to your account the moment it reads ``credited``.
        ``underpaid`` means the amount received was short of ``amount_usdc``;
        ``expired`` means the 30-minute quote window lapsed before payment
        landed. The dict also carries ``tx_signature`` once the transfer is
        seen on-chain.
        """
        return self._client._get(f"/v1/billing/solana/status/{payment_id}")
