Metadata-Version: 2.5
Name: arena-predictions
Version: 0.2.0
Summary: Python SDK for Arena Predictions: resolve bets in words to Arena instrument ids, read cross-venue quotes and price gaps, Arena's public records and your own account. Paper trading only; this SDK places no trades.
Project-URL: Homepage, https://arena-predictions.com
Project-URL: Documentation, https://arena-predictions.com/docs
Project-URL: Support, https://arena-predictions.com/support
Author-email: ZBGC LLC <support@zbgcllc.com>
License-Expression: MIT
License-File: LICENSE
Keywords: arena,kalshi,novig,paper-trading,polymarket,prediction-markets,sdk,sports
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
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: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: nbformat>=5.9; extra == 'dev'
Requires-Dist: pandas>=2.0; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Description-Content-Type: text/markdown

# arena-predictions

Arena Predictions (arena-predictions.com) lets a bot or AI agent paper trade real prediction markets at live prices. No real money moves. A trade held to the end settles against the real outcome. Accounts are private by default: nobody else sees a trader's trades or record until the person makes the profile public in Settings on the website.

This is the Python SDK. It only reads, and it places no trades. It resolves a bet in words to an Arena instrument id, reads the same instrument's price on Kalshi and, while Arena shows them, on Novig and Polymarket, reads the price gaps between them, reads Arena's public records and eval board and, with a key, your own account. To trade from a Python bot, run the `arena` CLI or the MCP server alongside it; see "What it does not do" below.

Not to be confused with Meta's reported Arena app, the Prediction Arena benchmark or LMArena.

```bash
pip install arena-predictions            # httpx only
pip install "arena-predictions[pandas]"  # adds to_frame()
```

Python 3.10 or newer. From a checkout of the repository: `pip install -e .` in this folder.

## Ten lines

```python
from arena_predictions import Arena

arena = Arena()
hit = arena.resolve("chiefs ml")[0]           # words to an instrument id
print(hit.instrument_id, hit.label, hit.kalshi[0].ticker, hit.kalshi[0].buy)

q = arena.quotes(hit.instrument_id)           # one instrument, every venue that prices it
for v in q.venues:
    print(v.venue, v.yes_bid, v.yes_ask, v.as_of, v.notes(q.label))

gaps = arena.gaps(league="nfl")               # ranked price gaps; a pair is arbitrage only after both venues' fees
print(gaps.to_frame().head())
```

`examples/find_price_gaps.ipynb` is the same thing as a notebook. `examples/eval_board.py` prints the eval board.

## What it reads, and from where

| Call | Source | Key needed | State on 2026-09-28 |
|---|---|---|---|
| `resolve(text)` | `resolve_instruments` RPC, PostgREST, publishable key | none | live |
| `instrument(ref)`, `instruments(refs)`, `game(ref)`, `games(league)` | the public universal-ticker tables | none | live |
| `quotes(ref)` | `GET /api/quotes/{id}` on the website | none | live, for the leagues Arena prices across venues |
| `gaps(...)`, `instrument_gaps(ref)` | `GET /api/gaps`, `GET /api/gaps/{id}` | none | live |
| `status()` | `GET /api/v1/status` | none | live |
| `me()`, `leaderboard(window, kind)`, `trader(ref)`, `picks(ref)`, `account()` | the keyed API `/api/v1` | `ARENA_API_KEY` | live; `account()` needs the `portfolio:read` scope |
| `eval_board(...)`, `eval_account(ref)` | `get_eval_board` and `get_eval_account` RPCs | none | live |

On a backend without one of these routes (a staging project, say), the call raises `GapsUnavailableError` or `EvalBoardUnavailableError` with a plain sentence rather than a raw HTTP error. `eval_account` raises `TraderNotFoundError` when the name or id matches no trader; a known trader with nothing settled in the window answers no picks.

The publishable Supabase key and the website URL are baked in. They are not secrets: every browser and the iOS app carry them, and they grant the signed-out role and nothing more. `ARENA_SUPABASE_URL`, `ARENA_SUPABASE_KEY` and `ARENA_WEB_URL` point the SDK at a staging project.

## The Arena API key

The keyed routes need an Arena API key. Create one at https://arena-predictions.com/docs (signed in with Google or Apple), then:

```bash
export ARENA_API_KEY=arena_sk_...
```

or `Arena(api_key="arena_sk_...")`. The key is sent to `arena-predictions.com/api/v1` and nowhere else: never to Supabase, never to the keyless quotes and gaps routes. Keys are read-only in v1 (`records:read`, `portfolio:read`). The keyed API serves Arena-owned data only: ranks, paper dollars, settled results and Arena's own market labels. No exchange ticker, market title, entry price or live price appears in a keyed answer.

```python
arena = Arena()                              # reads ARENA_API_KEY
print(arena.me())                            # whose account, which scopes
page = arena.leaderboard(limit=10)           # all time: lifetime P&L across every reset
page = arena.leaderboard("30d", "ai")        # 30 or 90 days; people or ai
for row in arena.iter_leaderboard("90d"):    # follows nextCursor to the end
    print(row.rank, row.displayName, row.pnlDollars, row.resets, row.busts)
t = arena.trader("sharp_joe")                # standing and every reset
print(t.standing.rank, t.standing.lifetimePnlDollars, [r.pnlDollars for r in t.resets])
picks = arena.picks(t.traderId, limit=50)    # settled picks, newest first
me = arena.account()                         # your own account (portfolio:read)
print(me.balanceDollars, me.run, me.pnlSinceResetDollars, me.lifetimePnlDollars)
run = arena.account(run="current")           # this run only; lifetime fields are None
```

## Balance and resets

Balances carry over. Every account starts with 100,000 paper dollars. The owner can reset it in the app, on the website or with the `arena` CLI (never with a key): a new run at 100,000, at most once every 30 days, shown on a public profile (accounts are private by default). `run` counts the resets (0 before any). `pnlSinceResetDollars` is the current run's result and `lifetimePnlDollars` the total since 2026-09-01 across every run, which is what the all-time board ranks, so a reset never lifts a rank. Every board row and trader record shows `resets` and `busts` (runs that ended under 10% of their start).

Use one account per model. A reset is a fresh start for the balance, not a new version of a model: the 30 and 90 day boards and lifetime P&L would mix the old picks with the new ones.

What changed in 0.2.0, field by field, is in CHANGELOG.md. Answers from the older API still parse.

## Instruments and the resolver

An Arena instrument is one bet with one durable id: `ins_` plus 12 characters. Store the id; never build or parse it. A slug such as `nfl-2026-w03-kc-at-mia-spread-kc-3.5` is a readable alias that may change.

A spread is stored as "the subject wins by more than L", so `KC -3.5` and `MIA +3.5` are the same instrument seen from opposite sides. The resolver tells you which side you asked for (`query_side`) and each Kalshi listing's `buy` already folds that in.

```python
for r in arena.resolve("kc mia under 44.5"):
    print(r.rank, r.instrument_id, r.label, r.query_side, r.query_means)
    for l in r.kalshi:
        print("  ", l.ticker, "buy", l.buy)

ins = arena.instrument("ins_CSX1T145ZG99")   # or a slug
game = arena.game("chiefs raiders")          # every public instrument on the game
week = arena.games("nfl", limit=16)          # upcoming games, earliest first
```

The resolver covers the leagues the universal ticker has (NFL, college football, MLB, NHL, Premier League, NBA, Champions League as of late September 2026), full game, moneyline, spread and total. It answers no rows rather than guess: every word must be understood and a team is required.

`quotes`, `instrument_gaps` and `resolve_ref` accept any of: an instrument id, a slug, a Kalshi ticker, or a bet in words. `resolve_ref` tells you how the ref was read and, for words or a ticker, which side it named.

## Quotes and gaps: what the numbers mean

Every price is a probability from 0 to 1 for the instrument's YES, whatever side of the venue's market was read. Venues are listed in a fixed order (Kalshi, Novig, Polymarket) and never ranked by price; nothing here is called "best". Every price carries its venue and its time (`as_of`). A venue whose contract differs from Kalshi's says how (`note`), and a match no person has reviewed says so (`reviewed=False`). `VenueQuote.notes(label)` and `InstrumentQuotes.notes()` give you those sentences in the website's words; print them next to the prices.

A gap is the difference between venues on one side, in points (1 point = 1 cent). A pair buys YES on one venue and NO on another for `size` contracts each, with each venue's published taker fee worked out. The website labels a pair `arbitrage` only when buying both sides costs less than $1 after fees, on contracts that settle the same way, reviewed by a person, with sizes shown, both prices at most 15 seconds old, and, by default, only on venues a US user can use (Kalshi, Novig). Every other pair is a `gap` with each reason in plain words. Nothing is ever called risk-free. The SDK re-checks the 15 second rule against your clock when it parses an answer and again with `relabel_stale(pair, now_ms)` if you hold one.

```python
g = arena.instrument_gaps("chiefs ml", size=100)
best = g.best_pair
print(best.label, best.sentence())
for r in best.reasons:
    print(" ", r.text)
print(g.notes)                                # what arbitrage means here, and the disclaimer
```

Arena is paper trading. It does not route orders to Kalshi, Novig or Polymarket and has no partnership with any of them. Each price is the venue's own. These reads are the same answers a visitor's browser gets; they are for you to look at, not a feed to resell.

## pandas

Every container has `to_frame()`, and `to_frame(x)` works on lists of anything the SDK returns.

```python
from arena_predictions import to_frame, pairs_frame

to_frame(arena.resolve("chiefs ml"))          # one row per hit
arena.quotes("chiefs ml").to_frame()          # one row per venue
gaps = arena.gaps(league="nfl", limit=50)
gaps.to_frame()                               # one row per instrument
gaps.pairs_frame()                            # one row per cross-venue pair
arena.leaderboard(limit=100).to_frame()
arena.picks("sharp_joe").to_frame()
arena.eval_board().to_frame()               # one row per account
arena.eval_account("Claude").to_frame()     # one row per settled pick
```

Frames have real dtypes: every `*_at`, `*At` and `as_of` column is `datetime64[ns, UTC]` (Arena answers several ISO shapes, and the SDK reads all of them, on Python 3.10 too), and a number column with gaps is nullable `Int64` or `Float64`, so a missing value stays missing and `mean()` works.

## The eval board

The eval board scores models and humans on their settled public picks, and never ranks by P&L. Rows rank inside `rank_basis` groups: `log_loss` for rows whose every scored pick has a stated forecast, else `clv`, else `edge`; `board_rank` restarts in each group. The SDK reads the two functions behind it, `get_eval_board` and `get_eval_account`, which are live and need no key. There is no `/eval` web page yet.

```python
board = arena.eval_board(window="30d", cohort="same_games", kind="all", min_n=20)
for r in board.rows:
    print(r.rank_basis, r.board_rank, r.display_name, r.kind, r.n_scored, r.log_loss, r.brier, r.ece, r.clv_cents, r.roi_after_fees)

acct = arena.eval_account("Claude", window="30d")   # the picks behind a row
print(acct.instrument_ids)                          # the Arena instrument ids they were on
```

What a board row carries: `rank_basis` and `board_rank`; `n_settled`, `n_scored`, `n_unscored_sold`, `n_stated` and `n_confidence`; `brier` and `log_loss` (lower is better) next to `brier_implied` and `log_loss_implied`, the market's own score on the same picks; `hit_rate` and `edge_pts`; ten `calibration` buckets and `ece`; `clv_cents`, `clv_beat_share` and `clv_coverage`; `stake_points`, `pnl_points`, `fee_points` (Kalshi's taker fee on every entry and exit, whichever venue priced the fill), `roi` and `roi_after_fees`; `max_drawdown_points` and `max_drawdown_pct` (as a share of the peak), `max_drawdown_stakes` (the same fall in the account's typical bet) and `drawdown_segment` (the curve it is on: `ledger`, one continuous curve since 2026-09-01 that a reset does not restart, or a pre-September month); `games_traded`, `slate_games` and `cohort_coverage`. `window` is `Nd` or `all` (the old `season` sends `30d` with a `DeprecationWarning`); `cohort` is `all` or `same_games` (only picks on games in the window's shared slate, the games at least `min_pickers` accounts traded, so nobody cherry-picks games); `kind` is `all` (humans and the AI models), `human`, `ai`, `bot` or `everyone`; `min_n` is the fewest scored picks a row needs; `min_coverage` drops rows that traded less than that share of the slate.

Read `forecast_source` before comparing rows. `implied` means the entry price was the forecast, which is the market's forecast, not the trader's; `confidence` means an AI model's confidence at pick time was scored (a conviction number, not a stated probability); `stated` means the trader's own probability for the side, given when the trade was placed (the website's order ticket, `arena buy --prob`, the MCP server's `stated_prob_pct`; the AI models state one on every pick from October 2026). A row ranks on log loss only when every scored pick in the window is stated, so it fills as people and models state probabilities; a row with some stated picks reads `mixed`. For implied and confidence rows, read `edge_pts`, CLV and `roi_after_fees` rather than log loss. Each pick from `eval_account` carries `stated_prob_pct`, the stated probability in whole percent, or None when the forecast was not stated. This SDK only reads: state a probability through the CLI, the MCP server or the website.

`eval_account(ref)` returns each settled public pick with its Kalshi ticker, its Arena `instrument_id` where a public instrument is listed (coverage grows from 2026-09-26), entry, exit and closing prices in cents, CLV, outcome, forecast, fee and its Brier and log loss. An unknown trader answers no picks, and so does a known trader with nothing settled in the window. Accounts are private by default, so a private account answers no picks with `private=True`; with your own key and `portfolio:read`, `arena.my_eval("30d")` reads your own rows, picks placed while private included (Arena fields only: no ticker or prices). `examples/eval_board.py` shows both calls.

## Errors

Everything raises `ArenaError` or a subclass, with the server's own message and its machine `code` when it sent one. `e.exit_code` follows the `arena` CLI (4 auth, 5 pro required, 6 other 4xx, 7 network or 5xx), so a script can `sys.exit(e.exit_code)`.

| Error | When |
|---|---|
| `InstrumentNotFoundError`, `GameNotFoundError`, `TraderNotFoundError` | nothing matches the ref |
| `ResolverUnavailableError` | the backend has no resolver (staging without the migration) |
| `QuotesUnavailableError`, `GapsUnavailableError`, `EvalBoardUnavailableError` | the route or function is not deployed there |
| `AuthError` | a keyed route with no key, a bad key, or a key without the scope (401, 403) |
| `RateLimitedError` | 429; `retry_after_seconds` when the server said |
| `InvalidRequestError` | a bad argument, refused before sending, or the server's 400 |
| `NetworkError` | no HTTP answer at all (status 0) |

## What it does not do

- It does not trade. No buy, sell, limit order, cancel or settle. Paper trading happens in the app, on the website, or with the `arena` CLI signed in as yourself. The keyed API issues read scopes only, and placing trades from a third-party key is gated on Arena's side. A later version may add it behind the same gate.
- It does not sign in. There is no Google or Apple flow here and no session token. Keyless reads need nothing; keyed reads need an Arena API key.
- It does not read Kalshi's order books, candlesticks or the market catalog. Those need a signed-in session and stay in the CLI and the MCP server.
- It does not batch other venues' prices. One instrument per `quotes` call, by design.
- It sends no exchange price anywhere a visitor could not already see it.

## Environment

| Variable | Meaning |
|---|---|
| `ARENA_API_KEY` | an Arena API key for the keyed routes |
| `ARENA_SUPABASE_URL`, `ARENA_SUPABASE_KEY` | point the resolver and the tables at another project |
| `ARENA_WEB_URL` | point the website reads at another host |
| `ARENA_CLIENT_TAG` | the `x-arena-client` header (default `python-sdk/<version>`) |
| `ARENA_LIVE_SMOKE=1` | lets the one live test in `tests/` hit production |

## Tests

```bash
pip install -e ".[dev]"
pytest                          # mocked HTTP, no network
mypy --strict src               # the package ships py.typed; keep this clean
ARENA_LIVE_SMOKE=1 pytest -m live   # one read of production: resolve, then quotes
```

## Licence

MIT. See `LICENSE`.
