Metadata-Version: 2.5
Name: predictefy
Version: 1.0.0b7
Summary: Official Python client + CLI for the Predictefy unified prediction-market API.
Project-URL: Homepage, https://predictefy.com
Author: Predictefy Inc.
License: MIT
License-File: LICENSE
Keywords: api,kalshi,polymarket,predictefy,prediction-markets,sdk
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# predictefy — Python SDK + CLI

The official Python client for **Predictefy's unified prediction-market intelligence
and execution infrastructure**. Integrate once against one normalized contract, then
change the venue client or `venue` parameter to access a different prediction-market
venue.

The Python client covers the normalized data surface across all 16 served product venues
(Polymarket, Kalshi, Opinion, Myriad, Gemini,
Hyperliquid, Limitless, Polymarket US, Rain, PredictFun, SX Bet, Pascal, XO Market,
PRED, PredictStreet, and Novig), plus the cross-venue router, history, market relationships,
and capability-qualified Trader Intelligence. Smarkets is implemented but dark on this
deployment — every `/api/smarkets/…` read answers `404 EXCHANGE_NOT_AVAILABLE` — so it is not
counted among the served venues. Current execution and client-side signing
coverage is documented separately per SDK and venue.

This Python client exposes Pascal, XO Market, and PRED as read integrations: catalog reads and
order books on all three, plus a public trades tape on Pascal only. None has a hosted account or
venue-history lane. Their execution lanes — like every other venue's — are reachable only through
the separate execution service (`client.exec`, see below), and only where the deployment has
armed them; `client.exec.list_venues()` is the honest answer for any given deployment.

Thin by design: **one runtime dependency (`httpx`)**, Python 3.10+, synchronous. All venue
logic lives server-side; this SDK is a typed wrapper over the hosted REST API.

## Install

> **Release status:** the package is published on PyPI as a beta. Pin an exact version while
> the beta line moves; repository users can also install the local package.

```bash
pip install predictefy
```

## Quickstart

```python
from predictefy import Predictefy

client = Predictefy(api_key="pk_...")  # or set PREDICTEFY_API_KEY
markets = client.polymarket.fetch_markets({"limit": 5, "query": "fed"})
for m in markets:
    print(m["marketId"], m["title"])
print(f"{len(markets)} of {markets.page['total']} total")
```

Venue clients use the same normalized method names, but each verb remains capability-qualified:

```python
# Books, the tape, and candles are outcome-keyed: resolve an outcomeId from that
# venue's own catalog first. A venue-native ticker or symbol is not resolved for you.
markets = client.kalshi.fetch_markets({"limit": 1, "status": "active"})
outcome_id = markets[0]["outcomes"][0]["outcomeId"]

book_response = client.kalshi.fetch_order_book(outcome_id)
book = book_response["data"]
candle_response = client.kalshi.fetch_ohlcv({
    "outcomeId": outcome_id,
    "resolution": "1h",
    "limit": 500,
})
candles = candle_response["data"]
history_provenance = candle_response.get("meta")

# exchange(id) is the dynamic form of the same verbs on any venue.
hyperliquid = client.exchange("hyperliquid")
hl_markets = hyperliquid.fetch_markets({"limit": 1, "status": "active"})
hyperliquid.fetch_trades(hl_markets[0]["outcomes"][0]["outcomeId"])

client.router.fetch_markets({"query": "election", "status": "active"})  # all venues
client.polymarket.fetch_categories()
client.router.fetch_tags({"category": "sports"})
client.kalshi.has()                                   # the venue's per-verb capability map
client.kalshi.fetch_event_metadata("kalshi:KXFED-26MAR")  # venue-native metadata (Kalshi only)
```

`fetch_markets_paginated` and `fetch_events_paginated` are the documented aliases of
`fetch_markets` / `fetch_events` (same handler, same page envelope). `has()` returns `True`,
`False`, or `"emulated"` per verb — an emulated book is reconstructed, never a native feed.
`fetch_event_metadata` passes Kalshi's body through under `raw`; every other venue honestly
raises `NotSupportedError`.

## Cross-venue router and qualification

`client.router` now matches the TypeScript SDK's complete router REST surface. The router
includes match discovery, indicative price comparisons, verified relationship edges, pure
caller-supplied filters, matched-cluster projections, and live-book arbitrage assessment:

```python
matches = client.router.fetch_market_matches({"marketId": "polymarket:btc"})
pairs = client.router.fetch_matched_markets({"category": "crypto"})
prices = client.router.compare_market_prices("polymarket:btc")
hedges = client.router.fetch_hedges({"slug": "bitcoin-100k"})
related = client.router.fetch_related_markets("polymarket:btc")
filtered = client.router.filter_markets(markets, {"liquidity": {"min": 1000}})
assessments = client.router.fetch_arbitrage({"contracts": 100})
```

`fetch_matches` and `fetch_matched_prices` remain deprecated wire-compatible aliases of
`fetch_market_matches` and `fetch_matched_markets`. Rows stay explicitly indicative unless
the live evidence supports the stronger label.

For one stored discrepancy, request the API's fail-closed eight-check qualification evidence:

```python
qualification = client.qualify_discrepancy({
    "clusterId": "cluster:btc",
    "size": 250,
    "venues": ["polymarket", "kalshi"],
})
print(qualification["label"], qualification["disqualifiedBy"])
```

## Streaming availability

WebSocket streaming helpers are **TypeScript-only today**; the Python SDK is synchronous REST
and does not expose `watch_*` or subscription methods. Python users can poll
`fetch_order_book` / `fetch_order_books` for depth, `fetch_trades` for tape updates,
`client.router.compare_market_prices` for cross-venue prices, and
`client.router.fetch_arbitrage` for the current assessed surface.

**Beta return-shape change:** `fetch_ohlcv` and `fetch_order_book` return the full
`{"success", "data", "meta"?}` response envelope. Before this fix they returned only
`data`, which discarded history provenance and archive coverage/truncation evidence.
For `fetch_order_book`, `data` is one order-book dictionary except for a `since` +
`until` archive range, where it is an ascending list of order-book dictionaries.

List verbs return a `PageList` — an ordinary `list` of dicts with `.page`, `.meta`,
`.next_cursor`, and `.total_count` attached when the endpoint supplies those hints. To
walk an entire catalog, `iterate_markets` follows the cursor for you:

```python
for market in client.polymarket.iterate_markets({"status": "active"}):
    ...  # transparently pages via nextCursor until exhausted
```

## Auth

Pass `api_key`, or set the `PREDICTEFY_API_KEY` environment variable. The key is sent as
`Authorization: Bearer <key>`, is never logged, and is redacted from every error field
(`message`, `code`, `exchange`).

```python
client = Predictefy(
    api_key="pk_...",
    base_url="https://data.predictefy.com",  # the default
    exec_base_url=None,  # opt in to client.exec; execution is a separate service
    retry_on_429=True,  # auto-retry a GET once on 429 (honoring Retry-After) unless the envelope says retryable: false
)
```

`client.fetch_usage()` reads the calling account's own credit balance, this billing period's
totals with a per-route breakdown, and one page of its credit ledger. The verified key supplies
the account, so no tenant can be selected. Only the ledger is paged, so `hasMore` and `nextCursor`
ride on the returned snapshot alongside `balance` and `period`:

```python
usage = client.fetch_usage(limit=25)
print(usage["balance"], usage["period"]["credits"])
for entry in usage["ledger"]:
    print(entry["createdAt"], entry["reason"], entry["effect"])
```

Reconcile spend on each entry's `effect`, never on `delta`: an execution billing marker stores
`delta: 0` because the trade already decremented the balance directly, and `billingKind` is what
tells that marker from a genuine operator adjustment.

Webhook endpoint management and delivery polling use `client.webhooks`:

```python
endpoint = client.webhooks.create({
    "url": "https://hooks.example.com/predictefy",
    "events": ["execution.status.changed"],
})
endpoints = client.webhooks.list()
page = client.webhooks.deliveries(endpoint["id"], {"after": None, "limit": 50})
client.webhooks.delete(endpoint["id"])
```

The create response is the only response containing the signing secret. Delivery pages are
oldest-to-newest; pass `nextCursor` back as `after`.

## Hosted Account Intelligence

The singular `client.account` namespace reads only the hosted public account lanes. It
never accepts venue credentials and never performs owner-authenticated venue calls:

```python
capabilities = client.account.fetch_capabilities("polymarket")
snapshot = client.account.fetch_snapshot("polymarket", "0xabc")
balances = client.account.fetch_balances("polymarket", "0xabc", limit=20)
positions = client.account.fetch_positions("polymarket", "0xabc", limit=20, cursor=None)
orders = client.account.fetch_open_orders("polymarket", "0xabc", limit=20, cursor=None)
fills = client.account.fetch_fills("polymarket", "0xabc", limit=20, cursor=None)
```

Account lists preserve `.meta` provenance/cache fields and `.next_cursor`. Balances are
not cursor-paginated and expose the exact wire `totalCount` as `.total_count`. Snapshot
resource dictionaries preserve their `totalCount` fields verbatim. Local venue-credential
and owner-authenticated account lanes are intentionally deferred.

On `429 Too Many Requests`, a GET is retried **once**, honoring the `Retry-After` header
(bounded to 30s); if it is still rate-limited, a `RateLimitError` is raised. A 429 whose envelope
says `retryable: false` is raised immediately — the server's flag is authoritative and the SDK
never retries against it. POSTs (batch books, execution price, checkout) are never auto-retried.

## Execution — pre-signed artifacts only

Execution lives on a **separate service**, and the reads API deliberately does not proxy it.
Opt in with `exec_base_url`; without it, `client.exec` raises `EXEC_BASE_URL_REQUIRED`. Every
execution route needs an API key with the `trade` scope.

> **This SDK never signs anything.** It never accepts, transports, or derives a private key,
> mnemonic, or seed. `build_*` returns the venue-shaped **unsigned** artifact; you sign it with
> your own wallet or tooling, and `submit_order` carries the resulting signature back. Where a
> lane instead authenticates with your own venue API credentials, those are passed through for
> that single request exactly as the route contract defines, and are never persisted.

```python
from predictefy import Predictefy, PREDICTEFY_EXEC_BASE_URL

client = Predictefy(api_key="pk_...", exec_base_url=PREDICTEFY_EXEC_BASE_URL)

client.exec.list_venues()                      # lanes armed on this deployment

built = client.exec.build_order("polymarket", {
    "outcome": "42", "outcomeSide": "YES",
    "isBuy": True, "price": 0.52, "size": 10,
    "owner": "0x1111111111111111111111111111111111111111",
})
for warning in built.get("warnings", []):      # e.g. a region-blocked hosted relay
    print(warning)

signature = sign_however_you_like(built["unsigned"])   # OUTSIDE this SDK
client.exec.submit_order("polymarket", {
    "executionId": built["executionId"],
    "signature": signature,
    "owner": "0x1111111111111111111111111111111111111111",
})
```

| Method                                                     | Route                                        |
| ---------------------------------------------------------- | -------------------------------------------- |
| `exec.list_venues()`                                        | `GET /v1/exec/venues`                        |
| `exec.build_order(venue, params)`                           | `POST /v1/exec/{venue}/orders/build`         |
| `exec.precheck_order(venue, params)`                        | same route with `dryRun` (persists nothing)  |
| `exec.submit_order(venue, params)`                          | `POST /v1/exec/{venue}/orders/submit`        |
| `exec.fetch_order(venue, execution_id)`                     | `GET /v1/exec/{venue}/orders/{executionId}`  |
| `exec.build_cancel(venue, execution_id, params=None)`       | `POST .../orders/{executionId}/cancel`       |
| `exec.build_modify(venue, execution_id, params)`            | `POST .../orders/{executionId}/modify`       |
| `exec.refresh_order_status(venue, execution_id, params)`    | `POST .../orders/{executionId}/refresh`      |
| `exec.fetch_open_orders / fetch_closed_orders / fetch_all_orders(venue)` | `GET /v1/exec/{venue}/orders`   |
| `exec.fetch_my_trades(venue)`                               | `GET /v1/exec/{venue}/trades`                |
| `exec.fetch_positions(venue)`                               | `GET /v1/exec/{venue}/positions`             |
| `exec.fetch_balance(venue)`                                 | `GET /v1/exec/{venue}/balance`               |

Build, cancel, modify, and submit each require a unique `Idempotency-Key`; the SDK mints one
per call unless you pass `idempotency_key=`. Refresh takes none — it only reads venue state.

`precheck_order` returns `{"ok": True, "result": preview}` when the builder accepts the market,
or `{"ok": False, "refusal": error}` for a typed server refusal; a transport failure still
raises, because no decision arrived. A cancel or modify build returns **its own** new
`executionId` — sign and submit that one, not the target order's. `acked` means the venue
accepted and relayed the artifact, not that it is resting or filled: confirm with `fetch_order`
or `refresh_order_status`. `fetch_positions` discloses a fills-derived fallback as
`result.meta["derivation"] == "fills"`, and `fetch_balance` raises `NotSupportedError` while no
lane exposes a native balance reader.

## Conditional orders (TP/SL)

A conditional order attaches a **trigger rule** to an execution you have already built, so the
order waits for a price instead of resting on a venue. Arming builds nothing, signs nothing and
relays nothing — it records what should happen to a still-`built` execution later. Live since
2026-09-11 for **Pro and above**; Free and Builder receive `403 PLAN_REQUIRED` naming Pro, and
the cap on orders armed at once is 25 (Pro), 200 (Scale), uncapped (Enterprise).

```python
from predictefy import Predictefy, PREDICTEFY_EXEC_BASE_URL

client = Predictefy(
    api_key="pk_live_YOUR_KEY",
    exec_base_url=PREDICTEFY_EXEC_BASE_URL,
)

armed = client.exec.arm_conditional_order(
    "kalshi",
    execution_id,
    {
        "fireMode": "client",
        "trigger": {"tpsl": "sl", "price": 0.40},
        "expiresAt": "2026-11-01T00:00:00.000Z",
    },
    idempotency_key="9d2f0f1c-arm-1",  # reuse this exact key on a retry
)
```

`idempotency_key` is required and is never minted for you here: a fresh key per retry would arm a
**second** conditional on the same execution, so reuse the same one and the stored order comes
back. `expiresAt` is required too, and every bound — the `(0, 1)` price on the `1e-6` tick grid,
`maxSpread`, `confirmations` — is a rejection rather than a silent clamp.

Which mode a venue can serve is derived from what its own submit requires, and
`exec.list_venues()` reports it per venue as `conditional: {native, hosted, client}`. `client`
works everywhere: Predictefy watches the book, you submit with your own credentials. `hosted`
(Limitless alone) replays a body you already signed, and hosted **firing is off** — a hosted order
still arms and still triggers, but nothing fires it automatically. `native` is Hyperliquid's own
venue-held trigger and does not use this family at all.

In client mode, `serve_conditional` is the fire loop: it polls for your triggered client-mode
orders and hands each to your callback exactly once for as long as the loop runs.

```python
import threading

stop = threading.Event()


def on_triggered(order):
    submitted = client.exec.submit_order(
        order["venue"],
        {"executionId": order["executionId"], "signature": signature, "owner": owner},
    )
    client.exec.report_conditional_order(
        order["id"],
        execution_id=submitted["executionId"],
        venue_ref=submitted["venueRef"],
    )


client.exec.serve_conditional(on_triggered, poll_s=2.0, stop=stop)
```

Setting the stop event ends the wait immediately instead of sitting out the rest of the poll gap,
and dedupe is in memory for the life of the call, so a restarted loop sees still-triggered orders
again. `report_conditional_order` is what closes a triggered order: `outcome="fired"` (the server
default) links the execution your submit became, and `outcome="not_submitted"` says the venue never
accepted it and settles the order `fire_failed`. `list_conditional_orders`, `get_conditional_order`
(which returns the `events` trail that says why an order fired) and `cancel_conditional_order`
complete the family.

| Method                                                       | Route                                         |
| ------------------------------------------------------------ | --------------------------------------------- |
| `exec.arm_conditional_order(venue, execution_id, params)`      | `POST .../orders/{executionId}/arm`           |
| `exec.list_conditional_orders(status=, venue=, fire_mode=)`    | `GET /v1/exec/conditional`                    |
| `exec.get_conditional_order(conditional_id)`                   | `GET /v1/exec/conditional/{id}`               |
| `exec.cancel_conditional_order(conditional_id)`                | `POST /v1/exec/conditional/{id}/cancel`       |
| `exec.report_conditional_order(conditional_id, outcome=)`      | `POST /v1/exec/conditional/{id}/report`       |
| `exec.serve_conditional(on_triggered)`                         | the client-fire poll loop over the list route |

See [Conditional orders](https://docs.predictefy.com/guides/conditional-orders/) for the full
trigger vocabulary, OCO groups, hosted arming, and the error table.

## Paper trading

`place_paper_order` and its eight siblings simulate a strategy against the **real** venue books
Predictefy already streams: the same order parameters as live execution, no venue call, and no
money at risk. Live on every plan since 2026-09-11, and the only per-plan difference is how many
paper orders may rest at once (Free 10, Builder 50, Pro 200, Scale 1,000, Enterprise uncapped). A
full allowance returns `409 PAPER_WORKING_ORDER_CAP` naming the cap and your plan. Every route
needs an API key with the `trade` scope, which new keys do not carry by default.

```python
from predictefy import Predictefy

client = Predictefy(api_key="pk_live_YOUR_KEY")

order = client.place_paper_order(
    "kalshi",
    "PRES-2028-DEM",
    "kalshi:PRES-2028-DEM:yes",
    "buy",
    0.62,
    100,
    tif="GTC",
    idempotency_key="9d2f0f1c-order-1",  # reuse this exact key on a retry
)

working = client.list_paper_orders(status="open")
portfolio = client.paper_portfolio()
```

`idempotency_key` is required and is never minted for you — a fresh per-retry key would turn a
retry into a second order, so reuse the same one and the stored order comes back instead. An
outcome with no live captured book is refused with `409 PAPER_NO_LIVE_BOOK` rather than rested
against stale prices. The rest of the family is `paper_account`, `reset_paper_account`,
`get_paper_order`, `cancel_paper_order`, `list_paper_fills`, and `paper_positions`. See
[Paper trading](https://docs.predictefy.com/guides/paper-trading/) for the fill model, what it
deliberately does not simulate, and the full error table.

## Funding & bridges

`client.funding` reads the non-custodial funding registry and returns **unsigned** material for
your own wallet to review, sign, and broadcast. Predictefy never holds funds or keys.

```python
client.funding.get_requirements("polymarket")

steps = client.funding.build_funding_steps("polymarket", {
    "amount": "25", "sourceAsset": "usdce", "exchange": "ctf",
    "recipient": "0x1111111111111111111111111111111111111111",
})
for step in steps["steps"]:                    # sign and broadcast in index order
    print(step["index"], step["description"], step["unsignedTransaction"])

quote = client.funding.get_bridge_quote({
    "fromChain": "42161", "fromToken": "0x...", "fromAmount": "25000000",
    "fromAddress": "0x1111111111111111111111111111111111111111", "toVenue": "hyperliquid",
})
print(quote["meta"]["provenance"])             # which provider answered: glide or lifi
```

| Method                                                     | Route                                          |
| ---------------------------------------------------------- | ---------------------------------------------- |
| `funding.get_requirements(venue)`                           | `GET /v1/funding/{venue}/requirements`         |
| `funding.build_funding_steps(venue, params)`                | `POST /v1/funding/{venue}/steps`               |
| `funding.get_bridge_quote(params)`                          | `GET /v1/bridge/quote`                         |
| `funding.create_bridge_session(params)`                     | `POST /v1/bridge/session`                      |
| `funding.get_bridge_session(session_id)`                    | `GET /v1/bridge/session/{sessionId}`           |
| `funding.update_bridge_session_payment(session_id, params)` | `POST /v1/bridge/session/{sessionId}/payment`  |
| `funding.get_bridge_status(params)`                         | `GET /v1/bridge/status`                        |

The four bridge methods return the **full response envelope**, because `meta.provenance` is the
only record of which provider answered. Quote first, sign and broadcast the session's
`unsignedTransaction` yourself, report the hash with `update_bridge_session_payment`, then poll
`get_bridge_session` with backoff. Bridge status is richer than PENDING/DONE/FAILED — `REFUNDED`
and `PARTIAL` are real outcomes — and there are no completion webhooks, so never assume a
completion time.

`get_bridge_quote` takes **either** `toVenue` **or** an explicit `toChain` + `toToken` pair, never
both. The explicit form is not restricted to Predictefy venues — it forwards to LI.FI unrestricted,
so any supported chain and token is a valid destination, including a chain's native gas token
through the zero-address sentinel:

```python
# Arbitrum USDC -> native POL on Polygon. Verified against production on 2026-08-18.
gas = client.funding.get_bridge_quote({
    "fromChain": "42161", "fromToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
    "fromAmount": "10000000", "fromAddress": "0x1111111111111111111111111111111111111111",
    "toChain": "137", "toToken": "0x0000000000000000000000000000000000000000",
})
```

That matters because bridging leaves you holding a token on a chain where you may have no gas, and
the approval and order that follow need native gas on **both** chains. Quote gas to the destination
first, then bridge the collateral. Cross-VM destinations such as Solana additionally require
`toAddress`, the recipient on the destination chain; without it LI.FI defaults the recipient to the
EVM `fromAddress` and rejects the quote. It is forwarded untouched and not address-validated,
because valid formats differ per VM.

**There is no withdrawal or bridge-out route, in this SDK or in the API.** Each venue is a custody
island: funds leave only by that venue's own rails, and no balance moves between venues through
Predictefy. That is the deliberate no-escrow posture — Predictefy never holds the funds — and
unified funding remains roadmap. Hyperliquid in particular withdraws to **Arbitrum only**, because
the venue's `withdraw3` action is its Arbitrum bridge; reaching another chain means withdrawing to
Arbitrum first and then bridging onward with `get_bridge_quote`.

## Clusters & indicative price discrepancies

```python
clusters = client.fetch_clusters({"limit": 20})
cluster = client.fetch_cluster("cl_abc123")           # one cluster + its per-venue members
gaps = client.fetch_discrepancies(live=True)          # indicative price discrepancies
```

`fetch_discrepancies` returns **indicative price discrepancies** — the cross-venue price
gap between matched markets. These are _indicative_, not executable trades: they do not
account for live executable depth, fees, or resolution edge cases. Use them for signal, not
as a guaranteed profit.

`fetch_discrepancy_history` reads the persisted trail behind that live list — what a cluster's gap
looked like and when:

```python
trail = client.fetch_discrepancy_history(cluster_id="cl_abc123", limit=50)
print(trail.page["total"], trail.page["hasMore"])
```

A row is written only when the content CHANGES, so the series is event-spaced rather than evenly
sampled. This route is OFFSET-paged, not cursor-paged: walk it with `.page["hasMore"]` and a
growing `offset`. The window defaults to the last 24 hours and a span over 90 days is rejected.

## History replay

`replay_history` serves the rows underneath a candle — top-of-book ticks, printed trades, or
stored candles for one outcome — as an iterator over the whole window. It follows each page's
cursor for you and yields the parsed NDJSON rows, so nothing buffers a whole page and abandoning
the iterator stops the download.

```python
for row in client.replay_history(
    "kalshi",
    outcome_id,
    kind="trades",
    since="2026-01-01T00:00:00Z",
    until="2026-01-08T00:00:00Z",
    limit=5_000,  # rows per page, 1..20,000 (server default 5,000)
):
    print(row["ts"], row["price"])
```

One `kind` per request — `tob`, `trades`, or `candles` (which also needs a `resolution` of `1s`,
`5s`, `10s`, `30s`, `1m`, `1h` or `1d`) — because the three keysets cannot share one cursor;
`predictefy.backtest.ReplayFeed` merges kinds client-side. `outcome_id` accepts the canonical or
the venue-native id, with `market_id` disambiguating a native id shared by several markets.
Windows are capped at **31 days** and each page at **20,000 rows**, and every page is metered as
one history read at 5 credits. The route is **Builder and above** since 2026-09-11: a Free key
gets `403 PLAN_REQUIRED`. A page that arrives without its closing cursor line was truncated in
transport and raises `NetworkError` (`INVALID_RESPONSE`) rather than reporting a short window as a
complete one.

For the book internals beneath the tape, `fetch_book_events` reads the order-book capture
events — `snapshot`, `delta`, `gap` and `heartbeat` — one cursor page at a time. The `gap` rows are
the point: capture discontinuities are recorded rather than papered over.

```python
page = client.fetch_book_events(
    "kalshi",
    native_outcome_id,  # this route is keyed by the VENUE-NATIVE id, not the canonical one
    since="2026-01-01T00:00:00Z",
    until="2026-01-02T00:00:00Z",
)
print(page.meta["truncated"], page.next_cursor)
```

`since` and `until` are both required here, `limit` is 1..5,000 (default 500), and the cursor
continues the immutable tape strictly after the last returned row. An empty FIRST page raises
`NotFoundError` (`MARKET_NOT_FOUND`) because nothing was captured; an empty page behind a cursor is
simply the end of the window.

## Backtesting

`predictefy.backtest` replays that history through a strategy. It is imported separately from the
client surface, so a plain API user pays nothing for it. Two properties it exists to protect: **no
look-ahead** (events arrive in one order keyed by when each row first became knowable, and an order
placed while handling event N can fill no earlier than event N+1) and **no invented liquidity**
(matching is Fill Model v1, the same pessimistic rules the paper engine runs, pinned to the same
golden vectors).

```python
from predictefy import Predictefy
from predictefy.backtest import Backtest, ReplayFeed, Strategy


class BuyTheDip(Strategy):
    """Take a lot when the ask falls to 40c; give it back at 60c."""

    LOT = 25.0

    def on_event(self, ev, ctx):
        # One event at a time is all a strategy ever sees — there is no "next bar" handle.
        if ev.k != "tob" or ctx.open_orders:
            return
        if ctx.position == 0 and ev.ask is not None and 0 < ev.ask <= 0.40:
            # Fills no earlier than the NEXT event, never against the tick that triggered it.
            ctx.buy(size=self.LOT, price=ev.ask)
        elif ctx.position > 0 and ev.bid is not None and ev.bid >= 0.60:
            ctx.sell(size=ctx.position, price=ev.bid)


client = Predictefy(api_key="pk_live_YOUR_KEY")

feed = ReplayFeed(
    client,
    "kalshi",
    "OUTCOME_ID",
    "2026-01-01T00:00:00Z",
    "2026-01-08T00:00:00Z",
    kinds=("tob", "trades"),  # add "candles" only with resolution="1m"
)

result = Backtest(feed, BuyTheDip(), venue="kalshi", initial_cash=10_000).run()
report = result.report()

print(report["final_equity"], report["total_return"], report["max_drawdown"])
print(report["fills"], "fills,", report["stale_skips"], "stale skips")
for note in report["assumptions"]:
    print("-", note)
```

`NdjsonFileFeed` runs the same backtest from rows you already saved, with no network and no
credits. See [Backtest a strategy](https://docs.predictefy.com/guides/cookbook/backtest-on-candles/)
for the row shapes, the cursor line, and everything the engine assumes.

## Errors

Every failure raises a subclass of `PredictefyError`; the server's `code` and `message` are
preserved on the exception.

| Class                      | HTTP    | Meaning                                              |
| -------------------------- | ------- | ---------------------------------------------------- |
| `ValidationError`          | 400     | Bad input / missing required param                   |
| `AuthenticationError`      | 401     | Missing / unknown / revoked key                      |
| `InsufficientCreditsError` | 402     | Balance can't cover the request (`.top_up_hint`)     |
| `ScopeError`               | 403     | Key lacks scope for this route                       |
| `NotFoundError`            | 404     | Unknown exchange/market/event/outcome/cluster        |
| `NotSupportedError`        | 400/501 | Venue/param honestly can't do that (`NOT_SUPPORTED`) |
| `RateLimitError`           | 429     | Rate-limited (raised after the retry is exhausted)   |
| `FeatureDisabledError`     | 503     | A documented route is dark on this deployment        |
| `ServerError`              | 5xx     | Transient platform failure                           |
| `NetworkError`             | —       | Transport failure / unparseable body                 |

```python
from predictefy import Predictefy, InsufficientCreditsError

try:
    client.polymarket.fetch_markets()
except InsufficientCreditsError as err:
    print(err.code, err.top_up_hint)
```

## CLI

The package installs a `predictefy` console command:

```bash
export PREDICTEFY_API_KEY=pk_...

# Ids below are placeholders: resolve real ones with `predictefy markets <venue>` first.
predictefy markets polymarket --limit 10 --q fed
predictefy market kalshi KXFED-26MAR-T4.00
predictefy discrepancies --limit 10 --live  # --live is capped at 10 rows server-side
predictefy clusters --limit 20
predictefy account capabilities polymarket
predictefy account snapshot polymarket 0xabc
predictefy account balances polymarket 0xabc --limit 20
predictefy account positions polymarket 0xabc --limit 20 --cursor next-page
```

Add `--json` to any command for raw JSON. `--api-key` and `--base-url` override the
`PREDICTEFY_API_KEY` / `PREDICTEFY_BASE_URL` environment variables.

## License

MIT — see [LICENSE](./LICENSE).
