# ArbTrader

A local web app that watches Kalshi, Polymarket, and sportsbook
moneylines for arbitrage-style spreads (same market, YES + NO < $1; a
matched pair across Kalshi/Polymarket priced inconsistently; or the
same game priced inconsistently across two sportsbooks), reports them
fast, and links you straight to each leg so you can take the trade
yourself. It also gives you a combined, read-only profit & loss view —
a P&L curve, open positions, and fill history — pulled from your own
Kalshi and Polymarket accounts, plus a compact 50-state guide to where
each platform and sportsbook is available and how it's taxed.

**ArbTrader never trades on its own, on any plan.**

- **Base** reads market data and odds, and — if you connect your
  accounts — your own positions and trade history. It has no way to
  place anything: you take every trade by hand, on the platform.
- **Pro** adds *order tickets* for Kalshi and Polymarket opportunities.
  You click **Place trade now**, review live prices, choose a size, tick a
  confirmation, and press Send — and only then are that ticket's orders
  sent, from your machine, with your keys. There is no auto-trade mode,
  and nothing on a timer or in an alert can send an order. See
  "Pro: order tickets" below for every safeguard.
- **Sportsbook bets are never placed by ArbTrader**, on any plan.
  Sportsbooks don't offer a betting API, and most prohibit automated
  wagering.

## What it does

- Streams Kalshi's and Polymarket's order books in real time over
  WebSocket when available (sub-second updates), falling back
  automatically to REST polling every `POLL_INTERVAL_SECONDS` when it
  isn't — see "Real-time feeds" below. Sportsbook moneylines come from
  The Odds API on their own, much slower loop (see "Sportsbook lines").
- Computes three kinds of opportunity:
  - **Same-market** (high confidence): one platform, one market, all
    outcomes summed cost less than $1. This is arbitrage by
    construction — same contract, same resolution rules. On Kalshi/
    Polymarket that's YES + NO; on sportsbooks it's the same game's
    outcomes, each priced at the best line found across bookmakers.
  - **Cross-platform** (confidence depends on you): a Kalshi market
    and a Polymarket market you've manually confirmed are "the same
    real-world event," priced so buying the cheap side of each nets
    a guaranteed profit. Unconfirmed title-similarity guesses are
    shown separately under the *Matches* tab.
  - **Cross-bookmaker** (high confidence, sportsbook only): the same
    game's moneyline priced inconsistently across two sportsbooks —
    e.g. DraftKings' price on Team A plus FanDuel's price on Team B
    costs less than $1 combined. This reuses the same same-market
    detection logic as Kalshi/Polymarket, since all bookmakers'
    outcomes on one game share an event id the same way Kalshi's
    YES/NO share a ticker.
- Shows every opportunity in a dashboard, refreshed live over a
  WebSocket, with a link straight to each leg (Kalshi/Polymarket, or
  the bookmaker's own site when available) so you can act on it in
  your own account.
- **Profit & loss curve** (Opportunities and Portfolio tabs): realized
  P&L on an average-cost basis — buys don't move the line; sells,
  Kalshi settlements and Polymarket redemptions do — with a final "now"
  point that adds the unrealized P&L of what you still hold (Kalshi
  marked at the best bid, Polymarket at its reported cash P&L). If your
  trades go back further than the history the platforms return, the
  chart says so ("partial history").
- **Portfolio tab:** the P&L curve, an open-positions
  table, and a combined fill/trade log, built from your Kalshi
  positions/fills and your Polymarket wallet's public trade history.
  Entirely read-only — refreshing this tab never sends a request that
  could place, modify, or cancel anything.
- **Portfolio summary strip:** a small P&L sparkline, your last-24h
  P&L change, and your open-position count, pinned above the tabs on
  every panel (not just Portfolio) — so "how am I doing" is answered
  the moment you open the dashboard without burying the time-sensitive
  Opportunities feed behind a click.
- **Spread per bundle:** each opportunity shows the spread in percent
  and in dollars per bundle (one contract per leg), plus roughly what
  it's worth at the maximum size the books show.
- **State rules (dashboard dropdown):** pick your state to see, at a
  glance, whether online sportsbooks are live, the minimum age, the
  operator tax, Kalshi's and Polymarket US's status, and how winnings
  are taxed — with sources. It's a research snapshot (see
  `config/state_guide.json`, dated as of 2026-09), not legal advice:
  these rules change often and some entries are marked unverified.
- **Accounts & API keys tab:** paste your license key and your Kalshi,
  Polymarket, The Odds API and Discord details in the app instead of
  editing `.env` by hand. Saved to `.env` (permissions 0600) and the
  Kalshi key file under `.arbtrader/`; secrets are never shown back.
  Changes apply without a restart.
- **Discord alerts (optional):** the instant a WebSocket-driven update
  produces an opportunity at or above your configured spread threshold,
  ArbTrader posts it to a Discord webhook — so you don't have to be
  staring at the dashboard when a spread appears. See "Alerts" below.

## Why this runs on your own machine, not in the cloud

Reading your live positions needs your Kalshi API key (and, if you
live prices, network access to Kalshi/Polymarket/The Odds API
directly). None of that belongs inside
a hosted, shared web page — so this ships as code you run yourself,
locally or on a server you control, where your keys never leave your
machine except to go straight to the relevant API over HTTPS.
Polymarket needs even less: only your wallet's public address, which
isn't secret at all.

## Setup

```bash
cd arbtrader
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
```

Start it:

```bash
uvicorn backend.main:app --host 127.0.0.1 --port 8000
```

Then open http://127.0.0.1:8000 and go to **Accounts & API keys** to
paste your license key and connect your data sources.

**Live data only, by default.** Polymarket's public market data works
with no keys at all. Kalshi prices need your Kalshi API key, and
sportsbook lines need a The Odds API key. The header badges show each
source's state (live / not set up / error). If a live fetch fails,
ArbTrader keeps the last good prices for up to 60 seconds and then
clears them — it never quietly swaps in sample data, and order tickets
are blocked for any platform that isn't live.

**Just want to look around?** Set `DEMO_MODE=true` in `.env` to run on
built-in sample data (clearly badged as demo data). Order tickets are
never available on demo data.

## Connecting your accounts and data sources

### Kalshi

1. Log into Kalshi → Settings → API Keys → create a key. Kalshi gives
   you an API Key ID and an RSA private key file — save the `.pem`
   file next to this project.
2. In `.env`, set `KALSHI_API_KEY_ID` and `KALSHI_PRIVATE_KEY_PATH`.
3. That's it for Base. The key is used for market data plus
   `GET /portfolio/positions`, `/fills`, `/balance` for the Portfolio
   tab. On Pro, with `EXECUTION_ENABLED=true`, it also signs the orders
   you confirm on an order ticket (`POST /portfolio/orders`) — so for
   that it needs trading permission. If you stay on Base, or leave
   execution off, nothing ever sends that request.

### Polymarket

1. Find your Polygon wallet's **public address** (the one that starts
   `0x...`). For scanning and the Portfolio tab that's all ArbTrader
   needs — no private key. (Pro order tickets need a signing key; see
   "Pro: order tickets".)
2. Set `POLYMARKET_WALLET_ADDRESS` in `.env`.
3. Positions and trade history come from Polymarket's public
   `data-api.polymarket.com`, keyed by that address — no signing, no
   authentication, nothing else required.

### Sportsbook lines (The Odds API)

1. Get a free API key at [the-odds-api.com](https://the-odds-api.com).
   Unlike Kalshi/Polymarket, there's no unauthenticated tier — every
   call needs this key.
2. Paste it in **Accounts & API keys** (or set `ODDS_API_KEY` in
   `.env`). Leave it blank to skip sportsbook lines (with
   `DEMO_MODE=true` the dashboard shows a sample example instead).
3. **Credit budget, so you don't blow through the free tier:** each
   poll costs `num_markets x num_regions` credits. The defaults
   (`ODDS_API_SPORTS=americanfootball_nfl`, one sport; `markets=h2h`,
   moneyline only; `ODDS_API_REGIONS=us`, one region) cost **1 credit
   per poll**. The free plan is 500 credits/month (~16.6/day), so
   `ODDS_API_POLL_INTERVAL_SECONDS` defaults to **5400 (90 minutes)** —
   16 polls/day, comfortably inside budget. If you add more sports
   (comma-separated in `ODDS_API_SPORTS`) or regions, multiply the
   credit cost accordingly and lengthen the interval to match, or
   you'll exhaust the free tier before the month is out.
4. Sportsbook odds poll on their own loop, completely separate from
   the fast 3-second Kalshi/Polymarket loop, precisely so a short
   `POLL_INTERVAL_SECONDS` for Kalshi/Polymarket never accidentally
   drives the (paid-by-credit) odds polling rate up too.

## Real-time feeds (WebSocket)

Kalshi and Polymarket both publish real-time order-book WebSocket
feeds. ArbTrader uses them to cut latency from "every few seconds" to
"sub-second," well beyond what polling can do — but there's an
important asymmetry between the two platforms, and a hard limit on
what "fast" can actually buy you here:

- **Polymarket's feed needs no authentication at all.** It runs
  automatically for every user, regardless of whether
  `POLYMARKET_WALLET_ADDRESS` is set.
- **Kalshi's feed requires the same signed handshake as private
  endpoints, even for channels that only carry public market data**
  (this is Kalshi's own design, confirmed in their docs — not a
  limitation of this app). So the Kalshi WebSocket only runs when
  `KALSHI_API_KEY_ID`/`KALSHI_PRIVATE_KEY_PATH` are set. Without a key,
  Kalshi market data still works fine, just over REST polling.
- Either feed reconnects automatically (with backoff) if it drops, and
  the REST poll loop keeps running underneath the whole time as a
  safety net — so a WebSocket outage degrades you to "a few seconds
  slower," never to "no data."
- **What speed can't fix:** ArbTrader never trades on its own — by
  design (see the top of this README). On Base you click through and
  place each leg yourself; on Pro you still review and confirm every
  order ticket. Sub-second data delivery meaningfully improves your
  odds of catching a spread before it closes; it doesn't make the whole
  pipeline sub-second, because a human decision is always part of it.

**This integration is doc-verified only, not live-tested** — no
Kalshi or Polymarket account with real credentials was available while
building it, and this sandbox couldn't even reach Polymarket's
unauthenticated endpoint to test that half. Every message shape in
`backend/clients/kalshi_ws_client.py` and `polymarket_ws_client.py` is
sourced from Kalshi's and Polymarket's own published docs (cited
inline in each file's module docstring), and both clients are written
defensively around documented gaps (e.g. Polymarket's own issue tracker
describes a "connected but looks healthy, silently stopped delivering
data" failure mode — both feeds include a data-staleness watchdog, not
just a ping/pong check, specifically because of that). The first time
you turn either on for real: check the Settings tab's "Real-time feeds"
status shows "connected," and spot-check a market's WS-derived price
against the same market's number from `/api/markets/kalshi` or
`/api/markets/polymarket` (both still work off REST) to confirm they
agree.

## Alerts (Discord)

Set `DISCORD_WEBHOOK_URL` in `.env` (create one in Discord: Server
Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL)
and ArbTrader posts a message the moment an opportunity's spread meets
`ALERT_MIN_SPREAD_PCT` (default 2%) — driven off the same reactive loop
the WebSocket feeds feed into, so an alert can fire well under a second
after the underlying price actually moved, not just on the next REST
poll tick. `ALERT_COOLDOWN_SECONDS` (default 120) stops a spread that
stays open from re-alerting every time the feed recomputes. Leave the
webhook URL blank to disable alerts entirely; nothing else about the
app changes either way. The message links straight to each leg (same
links as the dashboard) and repeats the same "review and act yourself"
framing — the alert is a notification, not an instruction to act
automatically, and there is no code path that could turn it into one.

## Your license (Base or Pro)

After checkout, your account page at arbtrader.co shows a **license
key** (`arbt_...`). Put it in `.env`:

```
LICENSE_KEY=arbt_...
```

On startup, and every 6 hours after, ArbTrader sends that key — and
nothing else — to `arbtrader.co/api/license`, which answers with your
plan (Base or Pro) and whether the subscription is active. Settings →
License shows the result, with a **Check again** button (use it right
after upgrading). If arbtrader.co can't be reached, a previously
confirmed Pro license keeps working for `LICENSE_OFFLINE_GRACE_HOURS`
(72 by default). Scanning, alerts and the Portfolio tab work on every
plan, and with no key at all.

## Pro: order tickets

Order tickets let you send a Kalshi/Polymarket opportunity from the
dashboard instead of clicking through to each platform. They are
**off by default** and need all of these:

1. A Pro license (`LICENSE_KEY`, confirmed active).
2. `EXECUTION_ENABLED=true` in `.env`, and a `MAX_ORDER_COST_USD` you're
   comfortable with (the most any single ticket can spend at its limit
   prices, before exchange fees; default 50).
3. **Kalshi legs:** your Kalshi API key with trading permission.
4. **Polymarket legs:** `pip install -r requirements-pro.txt` (installs
   Polymarket's official `py-clob-client-v2`; it needs Python 3.9.10 or
   newer — the Python that ships with macOS is older, so use one from
   python.org or Homebrew), then set `POLYMARKET_PRIVATE_KEY`,
   `POLYMARKET_SIGNATURE_TYPE`, and (for proxy/Safe/deposit wallets)
   `POLYMARKET_FUNDER_ADDRESS`. See `.env.example` for which type you
   are. Your wallet needs pUSD (Polymarket's collateral since its CLOB V2
   upgrade) and, for plain wallets, trading allowances set.

Restart after changing `.env`. Settings → Order tickets tells you what,
if anything, is still missing.

### What happens when you click Place trade now

1. **Preview — nothing is sent.** ArbTrader checks the opportunity is
   still showing, is high-confidence (a confirmed match, never the demo
   example), and uses live data. It reads each leg's live order book
   and opens a ticket: the limit price for each leg (the price you were
   shown — it can't be raised), how many bundles are available at those
   prices, and the total cost. If the ticket has a Polymarket leg, it also
   connects your Polymarket account and checks Polymarket's geoblock
   endpoint from your machine now, so a setup or location problem stops
   the ticket here instead of after another leg has filled. For
   same-market Polymarket tickets it confirms every outcome belongs to the
   same market. The ticket expires after `TICKET_TTL_SECONDS` (30 by
   default). On fine-tick Polymarket markets sizes may need to be a
   multiple of 10 or 100 so the dollar amount stays in whole cents.
2. **You choose a size, tick the confirmation, and press Send.**
3. **Re-check, then send.** Every leg's order book is read again; if any
   leg no longer has enough size at your limit (or a tick size changed),
   nothing is sent. Then legs go out **one at a time, as fill-or-kill
   limit orders** — Polymarket legs first (they have more ways to be
   refused), then the thinnest book. Each leg fills in full at your limit
   or better, or not at all. **If a leg doesn't fill, or its result is
   unclear, the remaining legs are not sent.** Nothing is retried
   automatically, only one ticket can be sending at a time, and each
   ticket can be sent once.
4. **You see each leg's result** — filled, not filled, not sent, or
   unclear (a timeout, gateway error, or response that doesn't say how
   much filled — treated as "may have gone through", never as "didn't").
   Every ticket, every leg just before it's sent, and every result is
   written to `.arbtrader/orders.jsonl`.

### The risk you still carry

Separate orders on separate exchanges can't be made to fill as one.
Sending the thinnest leg first and stopping on the first miss keeps
this rare, but if a later leg fails after an earlier one filled, **you
hold that filled leg unhedged**. The ticket says so plainly; closing or
completing the position is up to you, on the exchange. If a leg's result
is "unclear", check that exchange before doing anything else. Fees
aren't included in the ticket's spread or in `MAX_ORDER_COST_USD`, and
on fee-charging Polymarket markets a fee can leave you with slightly
fewer shares than the other leg.

On Kalshi, buying YES and NO on the same market nets out: Kalshi settles
the pair against each other, which locks in the same spread.

### Polymarket availability

Order tickets use Polymarket's international exchange
(clob.polymarket.com). It restricts trading from the US and some other
jurisdictions — confirm you're eligible before adding a signing key.
Polymarket US is a separate exchange with a different API, and isn't
supported by order tickets.

### Security

- Orders are signed on your machine and sent straight to Kalshi or
  Polymarket. Your keys never go to arbtrader.co.
- The dashboard only accepts requests addressed to localhost, and every
  state-changing request needs a per-run token embedded in the page this
  server serves — so a website open in another browser tab can't send
  orders through it. Orders and every other change (confirming matches,
  settings) are only accepted from this computer, even if you bind to
  `0.0.0.0`. Don't put a tunnel or reverse proxy in front of the
  dashboard: to this check, it would look local.
- Anyone who can use this computer, or read `.env`, can use your trading
  keys. Treat `.env` like a password file. If you'd rather never hold
  keys that can trade, stay on Base or leave `EXECUTION_ENABLED` off.

### Tests

`python3 -m unittest discover -s tests -v` runs the Pro tests: license
checks, every ticket safeguard above, the exact Kalshi and Polymarket
order requests, and the request guard. All exchanges are faked; the
tests never contact a real exchange or place an order.

The order code is built from Kalshi's and Polymarket's published API
references (Kalshi `POST /portfolio/orders` with
`time_in_force=fill_or_kill`; Polymarket CLOB V2 `FOK` orders via
`py-clob-client-v2`) and has **not** been run against a funded live
account. Start with the smallest size and a low `MAX_ORDER_COST_USD`,
and compare each result with what the exchange shows.

## A note on how this was tested

- **Live-verified against the real APIs:** Kalshi's `/markets` list
  (the field names the live spread feed depends on —
  `yes_ask_dollars`/`no_ask_dollars`/etc. — were fetched from the real
  endpoint and confirmed), Polymarket's Gamma API (`/markets`) and CLOB
  orderbook (`/book`), and the shape of Polymarket's public
  `data-api.polymarket.com` `/positions` and `/trades` endpoints.
- **Not yet live-verified:** Kalshi's authenticated `/portfolio/
  positions`, `/portfolio/fills`, and `/portfolio/balance` endpoints.
  The request signing (RSA-PSS/SHA256) uses the same scheme already
  confirmed to work for market data, but exercising the endpoints
  themselves needs a real, funded Kalshi key, which wasn't available
  while building this. If a field in your Portfolio tab looks wrong
  once you connect a real key, that's the first place to check —
  compare against [Kalshi's API reference](https://trading-api.readme.io/reference)
  and fix the field names in `backend/clients/kalshi_client.py`
  (`_position_from_row` / `_fill_from_row`).
- **The Odds API integration (`backend/clients/odds_api_client.py`) is
  built strictly from its published documentation, not verified
  against a live key** — no key was available while building this
  either. The request/response shapes (event → bookmakers → markets →
  outcomes, American-odds `price` field, `x-requests-remaining`
  header) come straight from their docs, and the client is written
  defensively around what the docs don't fully pin down (exact error
  body shape; which non-200 statuses beyond 429 can occur) — any
  non-2xx response or unexpected shape is shown as a sportsbook data
  error rather than crashing. Worth a quick sanity check against your
  own key the first time you turn it on: hit `/api/markets/sportsbook`
  and confirm the outcome names, venues, and odds look right for a
  game you know is on the board.
- **The Kalshi/Polymarket WebSocket feeds (`kalshi_ws_client.py`,
  `polymarket_ws_client.py`) are doc-verified only, not live-tested** —
  see "Real-time feeds" above for exactly what that means and how to
  spot-check them yourself. What WAS verified without a live connection:
  every message-parsing function (orderbook snapshot/delta application
  for Kalshi, book/price_change handling for Polymarket) was unit-tested
  against synthetic messages built to match the documented shapes
  exactly, including the YES/NO best-bid-to-best-ask derivation math and
  a full delta application (level removal + best-price recalculation).
  The Discord alert logic (spread threshold, cooldown, and the stable
  opportunity fingerprint that survives across recomputes) was
  unit-tested the same way. Both `_ws_client.py` files import the
  `websockets` package lazily and catch `ImportError` specifically so
  that a missing dependency disables just that one feed (falling back to
  REST) rather than crashing the whole app on startup — worth knowing if
  you ever see a "the 'websockets' package isn't installed" log line.
- The sandbox this was built in has no outbound network route to
  `kalshi.com`, `polymarket.com`, or `the-odds-api.com`, and its Python
  install is missing the optional `websockets` library
  `uvicorn[standard]` normally brings in — so the live-push
  `/ws/opportunities` endpoint couldn't be opened there either (nor
  could the real-time Kalshi/Polymarket feeds, for the same reason —
  see above for how that was worked around for testing purposes).
  `pip install -r requirements.txt` on a normal machine pulls that in
  automatically; if it's ever missing, the dashboard still works, it
  just falls back to polling the REST endpoint every few seconds
  instead of getting instant pushes (already built into the frontend,
  nothing to configure).

## Things worth knowing before you rely on this

1. **Cross-platform "arbitrage" is only as good as your market
   matching.** Two markets that sound like the same event can resolve
   differently — different deadline, different source of truth,
   different tie-breaking rule. That turns a "hedge" into an unhedged
   bet you didn't mean to take. An unconfirmed match is never treated
   as high-confidence; when you click "Confirm match" in the Matches
   tab, that's you personally vouching that you read both platforms'
   rules and they match.
2. **Legs aren't atomic — by hand or with a Pro ticket.** By the
   time you click through to the second leg (or a ticket's second
   order goes out), the price may have moved or the size may be gone. Treat a displayed spread as a live
   snapshot, not a guarantee — re-check both legs right before you
   submit either one. This applies doubly to sportsbook lines, which
   move fast, especially close to game time. The real-time WebSocket
   feeds and Discord alerts shrink the time between "a spread exists"
   and "you know about it" to well under a second, but they don't (and
   by design can't) remove the decision between "you know about it" and
   "both legs are placed" — that part is always you.
3. **Displayed spreads already subtract a small estimated fee/slippage
   buffer**, but it's a rough constant (see `FEE_BUFFER_PER_LEG` in
   `backend/arbitrage.py`), not each platform's actual current fee
   schedule or a sportsbook's vig. Check real terms before sizing up.
4. **Capital gets tied up until the market resolves.** A 2% locked-in
   spread on a market that resolves in four months is a very different
   return than 2% overnight — judge opportunities by annualized return,
   not the raw percentage shown. (Sportsbook moneylines typically
   resolve fast — by game end — so this matters much less there.)
5. **Legality and tax treatment of prediction-market trading and
   sports betting vary by state/country and change often.** Confirm
   you're eligible to use each platform/sportsbook from your location.
   Nothing here is financial or legal advice.
6. **What's deliberately NOT in this version:** other prediction-market
   platforms (e.g. PredictIt, Manifold) were evaluated and intentionally
   left out — each had its own blocker (approval-gated access, unofficial/
   unstable API endpoints, or play-money markets with no real arbitrage
   to capture). Also out of scope: matching a Kalshi sports contract to
   a sportsbook game for a true cross-platform (not just cross-bookmaker)
   arb — the two don't share an id or even consistent naming, and fuzzy-
   matching them reliably enough to trust with money is a meaningfully
   bigger project than the title-similarity matching already used for
   Kalshi ⇄ Polymarket. See "Extending it" below if you want to take a
   run at either.

## Project layout

```
backend/
  main.py                 Starlette app: REST + WebSocket + background pollers + reactive loop
  config.py                Reads .env into a Settings object
  license.py               Base/Pro license check against arbtrader.co (sends the key only)
  execution.py             Pro order tickets: preview + confirmed submit (the only code that sends orders)
  security.py              Local request guard (Host check, per-run token, loopback-only orders)
  models.py                 Shared data shapes (MarketQuote, Opportunity, ArbLeg, Position, Fill)
  arbitrage.py               Same-market and cross-platform spread detection
  matching.py                  Fuzzy match suggestions + the confirmed-pin store
  mock_data.py                  Sample data, used only when DEMO_MODE=true
  pnl.py                         Profit & loss curve (average cost, settlements, unrealized)
  credentials.py                 Accounts & API keys tab: validated, atomic .env writes
  alerts.py                      Discord webhook alerts (spread threshold + cooldown)
  clients/
    kalshi_client.py            REST market data, positions/fills/balance, and Pro fill-or-kill orders
    kalshi_ws_client.py          Real-time Kalshi order book over WebSocket (needs a key)
    polymarket_client.py         REST market data + read-only positions/trades (public wallet lookup)
    polymarket_trader.py         Pro fill-or-kill orders via py-clob-client-v2 (only loaded when used)
    polymarket_ws_client.py      Real-time Polymarket order book over WebSocket (no auth needed)
    odds_api_client.py            Sportsbook moneylines via The Odds API (read-only)
frontend/
  index.html               The whole UI (single file, no build step)
tests/
  test_pro.py              License, order-ticket safeguards, order request shapes, request guard
  test_app.py              P&L, credentials store, live-data fallback, state guide
config/
  market_map.json            Your confirmed cross-platform market pins (created on first run)
  state_guide.json           50-state availability / tax snapshot shown in the dashboard
```

## Extending it

- Tighten `FEE_BUFFER_PER_LEG` once you know your real fill quality.
- Add more platforms by writing a client that returns a list of
  `MarketQuote` objects — `arbitrage.py` doesn't care where quotes
  came from. `Platform.SPORTSBOOK` quotes are grouped by `market_id`
  (the event id) the same way Kalshi's YES/NO pair are, which is what
  lets sportsbook arb reuse `same_market_opportunities()` unchanged —
  a new platform that shares outcomes under one id can do the same.
- Add more sports/regions/markets to the Odds API integration by
  extending `ODDS_API_SPORTS`/`ODDS_API_REGIONS` and the `markets`
  argument in `OddsApiClient.fetch_odds` — just remember to redo the
  credit-budget math in the "Sportsbook lines" section above first.
- The matching step is intentionally manual-first; if you want to
  automate it further (including a real Kalshi ⇄ sportsbook match),
  the honest way is to pull each source's structured event metadata
  (dates, teams, resolution source) and diff those fields, not just
  titles.
