Metadata-Version: 2.4
Name: arena-predictions
Version: 0.3.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, with an API key, your own account, positions and trades and Arena's instruments. Arena is a sandbox for paper trading; this SDK places no trades.
Project-URL: Homepage, https://arena-predictions.com
Project-URL: Documentation, https://arena-predictions.com/docs
Project-URL: Repository, https://github.com/ZBGC/arena-agent-kit
Project-URL: Issues, https://github.com/ZBGC/arena-agent-kit/issues
Project-URL: Changelog, https://arena-predictions.com/changelog
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
Requires-Dist: tzdata>=2026.4
Provides-Extra: dev
Requires-Dist: cryptography>=42; 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'
Provides-Extra: verify
Requires-Dist: cryptography>=42; extra == 'verify'
Description-Content-Type: text/markdown

# arena-predictions

Arena Predictions (arena-predictions.com) is where a bot or AI agent paper trades real prediction markets at live prices, in a sandbox. A paper 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. A member can also send live orders to Kalshi or, outside the US, Polymarket International with their own venue credentials, saved in Settings on the website: real money at the venue, under the venue's rules and terms. Arena is not a broker and never holds your funds.

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, Polymarket and ProphetX, 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 and tzdata 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, date=)` | the public universal-ticker tables (and, for `games()`, the website's keyless batch quotes) | 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 |
| `usage()`, `portfolio()`, `positions()`, `orders()`, `trades(...)`, `iter_trades(...)` | the keyed API `/api/v1` (0.3.0) | `ARENA_API_KEY` | needs API 1.4.0 on the website; `portfolio:read` except `usage()` |
| `list_instruments(...)`, `iter_instruments(...)`, `search_instruments(q)`, `instrument_v1(ref)`, `v1_quotes(refs)` | the keyed API `/api/v1` (0.3.0) | `ARENA_API_KEY` | needs API 1.4.0; `markets:read`; `v1_quotes` raises `VenueDataOffError` until venue data is switched on for keys |
| `eval_board(...)`, `eval_account(ref)` | `get_eval_board` and `get_eval_account` RPCs | none | live |
| `track_record(trader)` | Arena's `promote-record` function (0.3.0) | none | live |
| `track_record_keys()` | `GET /.well-known/arena-track-record-keys.json` on the website (0.3.0) | 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. This SDK never makes or revokes keys: make one in Settings (https://arena-predictions.com/settings#api-keys, signed in with Google or Apple), or with the `arena` CLI, which writes it to a file only you can read and never prints it:

```bash
arena keys create --label notebook --save ~/.config/arena/arena.key
```

With neither `ARENA_API_KEY` nor `ARENA_API_KEY_FILE` set, the SDK reads that file (`arena.key` in `$XDG_CONFIG_HOME/arena`, else `~/.config/arena`, the folder the CLI uses), so nothing else is needed. It reads that one file there and never the CLI's sign-in: the SDK has no session. A key somewhere else: `export ARENA_API_KEY_FILE=./arena.key` (`~` works), `ARENA_API_KEY=arena_sk_...`, or `Arena(api_key="arena_sk_...")`. A keyed call with no key raises `AuthError` saying the same, except `leaderboard()` and `trader()`, which read the same public records keyless (the reads the CLI and the MCP server make signed out; the board's cursor is then `offset:N`). Only a value shaped like an Arena key (`arena_sk_` and 43 characters) is ever sent: a variable or a file holding anything else raises `InvalidRequestError`, naming the variable or the file and never its contents, and a default key file holding anything else counts as no key. 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 only read (`records:read`, `portfolio:read`, `markets:read`). The keyed API serves Arena-owned data: ranks, paper dollars, settled results, your positions and trades, and Arena's instruments with its own labels. No exchange ticker, market title, entry price or live price appears in a keyed answer until venue data is switched on for keys (it is off).

Every keyed call counts against your account's plan: 1,000 requests a month and 30 a minute without a plan, 10,000 and 60 with Arena Basic. `arena.usage()` says where you stand; `usage()` itself is never counted. Today the limits count and refuse nothing; once they are enforced, `RateLimitedError` (wait `retry_after_seconds`) and `QuotaExceededError` (the month is used; `resets_at`) say so.

```python
arena = Arena()                              # reads ARENA_API_KEY
print(arena.me())                            # whose account, which scopes
page = arena.leaderboard(limit=10)           # 30 days (the default): P&L of trades closed in the window
page = arena.leaderboard("all")              # 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])
if not t.private:                            # a private account has no public picks, and traderId may be None
    picks = arena.picks("sharp_joe", 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

# 0.3.0, API 1.4.0
p = arena.portfolio()                        # account, open positions, resting orders (portfolio:read)
for pos in p.positions:
    print(pos.instrumentLabel, pos.instrumentSide, pos.contracts, pos.costDollars)
settled = list(arena.iter_trades("settled", since="2026-09-01"))
print(arena.trades("settled").to_frame()[["instrumentLabel", "pnlDollars", "closedAt"]])
for ins in arena.iter_instruments(league="nfl", market_type="moneyline"):   # markets:read
    print(ins.id, ins.label, ins.yesMeans, ins.venues)
print(arena.instrument_v1("ins_0123456789AB").listings)
print(arena.usage().monthUsed)
```

## 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
tonight = arena.games()                      # today's games in every league, each moneyline's chance (game.moneylines)
week = arena.games("nfl", date="2026-10-04") # one league, one New York calendar day
p = arena.payout("braves ml", stake=100)     # what $100 buys at Kalshi's ask, and what it pays
```

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. Words with a day that match nothing ("jets ml tonight" on a night off) answer no rows too; `arena.without_day("jets ml tonight")` then names the game the same words name without the day (its `instrument_id`, `label`, `starts_at`, `away`, `home`), or None.

`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. When words with a day match nothing but name a game without it, these raise `InstrumentNotFoundError` with that game as `e.without_day`, and so do `payout` and `game`.

## 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 always listed Kalshi, Novig, Polymarket, ProphetX 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
```

Paper trades fill in Arena's sandbox and are not sent to Kalshi, Novig, Polymarket or ProphetX. Each price is the venue's own. ProphetX prices come from its sandbox exchange until Arena's production access is approved (`PROPHETX_SANDBOX_NOTE`, on every ProphetX row's notes). ProphetX is a price here and nothing else: ProphetX orders are switched off. 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. A column with no value in any row takes its field's dtype (`stated_prob_pct` and `exit_price_cents` are `Int64` even when no pick has one), and an answer with no rows still has its columns.

## 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 functions behind it, `get_eval_board` and `get_eval_account`, which are live and need no key, and `get_eval_intervals` for each row's 95% intervals (`row.intervals`, `row.tied_with_above`) once the database has them. The same board is the web page [/eval](https://arena-predictions.com/eval) (rows from 5 scored picks; `eval_board()` defaults to 20, the board's own default: pass `min_n=5` for the page's rows) and `GET /api/v1/eval` with a key.

```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_dollars` (the largest fall of realized P&L in paper dollars, on the `pnl_points` basis) and `max_drawdown_percent` (the largest fall of a time-weighted index of paper equity, in percent from 0 to 100: 0.198 means 0.198%; the Sep 27 top-up and a reset's refill are deposits, not gains) (0.3.0, None from a backend without them), `max_drawdown_stakes` (the 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); `max_drawdown_points` and `max_drawdown_pct` are deprecated (on a scaled curve where a pick placed before 2026-09-27 counts at 100 times its P&L; they keep their values because signed records carry them); `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.

## Signed track records

`track_record(trader)` reads an account's record over the last 90 days, signed by Arena with Ed25519: the /promote object (status, gates, and the numbers the eval board shows for the account) in an envelope a third party can check without trusting the page it came from. No key needed; `trader` is a user id or a display name. A private account (the default) by its user id answers `status == "blocked"` with `private` True; by its name it raises `TraderPrivateError`, because a private account's id is not given out.

```python
rec = arena.track_record("Claude")
print(rec.status, rec.blocked_reasons, rec.window["days"])
print(rec.metrics["n_settled"], rec.metrics["log_loss"], rec.metrics["fee_adjusted_pnl_points"])
check = arena.verify_track_record(rec)        # pip install "arena-predictions[verify]"
print(check.ok, check.problems)
```

`recompute_record_hash(rows)` rebuilds the record's `record_hash` from the trades it counts (`eval_account(ref, window="90d")` rows, or any dicts with the fields): one line per trade, `id|market_ticker|side|entry_price_cents|status|pnl_points|closed_at`, with `pnl_points` to two decimals (0 when null) and `closed_at` in UTC with microseconds and `Z` (`RECORD_HASH_FORMATS`), ordered by closed_at then id, joined by newlines, sha256 in hex.

`verify()` checks four things: the RFC 8785 bytes hash to `payload_sha256` (nothing changed after signing; `rec.payload_sha256_ok()` needs only the standard library), the key is in Arena's key document at `/.well-known/arena-track-record-keys.json` on the website with the id derived from it and not revoked, the issuer matches, and the Ed25519 signature is valid. The key document comes from the website you configured, never from the document a record names. A private account (the default) answers `status == "blocked"` with `private` True, and `metrics` is None: a blocked record's numbers are not a record. `canonical_json(value)` gives the exact bytes Arena signs.

## asyncio

`AsyncArena` takes the same arguments and has the same read methods, each awaitable; it runs the synchronous call in a worker thread, so it never blocks the event loop:

```python
import asyncio
from arena_predictions import AsyncArena

async def main() -> None:
    async with AsyncArena() as arena:
        board, quotes = await asyncio.gather(arena.leaderboard("30d", "ai"), arena.quotes("yankees ml"))

asyncio.run(main())
```

## 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 or the month's quota, 6 other 4xx, 7 network, 5xx or a rate limit), so a script can `sys.exit(e.exit_code)`. `e.request_id` is the server's id for the request (`X-Request-Id`, or `sb-request-id` from Supabase), also printed by `str(e)`: quote it when you ask for help. `e.hint` is the next step when the SDK knows one, such as where a new key comes from after a 401.

Retries are opt-in:

```python
from arena_predictions import Arena, retry_call

arena = Arena(retries=3)                     # each read, at most 3 more tries
rows = retry_call(lambda: list(arena.iter_trades("settled")), retries=3)   # or a block of your own
```

Only what `e.retryable` allows is retried: no answer at all, a 5xx, or a 429 `rate_limited`. A wait the server names (the body's `retryAfterSeconds`, `Retry-After` in seconds or as an HTTP date, or "in a minute" in its message) is waited; one longer than `max_retry_wait` (60 seconds by default) is raised at once, so you decide. Otherwise the pause doubles from half a second with jitter, up to 8 seconds. A 4xx and `quota_exceeded` are never retried. Every call the SDK makes is a read, so a retry never repeats an action; on the keyed API each try counts against your plan.

| Error | When |
|---|---|
| `InstrumentNotFoundError`, `GameNotFoundError`, `TraderNotFoundError` | nothing matches the ref |
| `TraderPrivateError` | 403 `trader_private`: `track_record(name)` for a private account; exit code 6 |
| `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 `rate_limited`; `retry_after_seconds` (60 when the server named no wait) and `retry_after_source` (`"server"` or `"default"`); exit code 7 |
| `QuotaExceededError` | 429 `quota_exceeded`: the month's requests are used; `resets_at`; exit code 5 |
| `VenueDataOffError` | 403 `venue_data_off`: venue prices are not served under a key yet; `quotes(ref)` shows them keyless |
| `SessionRequiredError` | 401 `session_required`: a key reached key management (the SDK never does this itself) |
| `InvalidRequestError` | a bad argument, refused before sending, or the server's 400 |
| `NetworkError` | no HTTP answer at all (status 0) |
| `TrackRecordUnavailableError` | the backend has no `promote-record` function, or answered something that is not a signed record |

## 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, and it does not make, rotate or revoke keys. 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_API_KEY_FILE` | a file holding one (`arena keys create --save`), read when `ARENA_API_KEY` is not set; `~` works |
| `XDG_CONFIG_HOME` | moves the default key file, `arena.key` in `$XDG_CONFIG_HOME/arena` (else `~/.config/arena`), read when neither key variable is set |
| `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 the live Arena API |

## 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 the live Arena API: resolve, then quotes
```

## Licence

MIT. See `LICENSE`. The npm packages (`arena-prediction-cli`, `arena-mcp-server`, `arena-mcp-tools`, `arena-core`) are MIT too from their next release. Report issues at https://github.com/ZBGC/arena-agent-kit/issues.
