Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Betwatch Python SDK

PyPI - Version PyPI - Python Version

Public /v2 REST + SSE client. 2.0.0b2 on the beta branch — not the GraphQL 1.x get_races client.

Agents: read AGENTS.md first.

Install

pip install betwatch==2.0.0b2
# or, from the beta branch:
# pip install "betwatch @ git+https://github.com/betwatch/betwatch-sdk-python@beta"

Secrets live in a gitignored fnox.toml, encrypted to this machine's age key (~/.config/fnox/age.txt). Ciphertext is not committed.

fnox exec -- uv run examples/live_check.py          # local → http://127.0.0.1:8888
fnox exec --profile prod -- uv run examples/watch_event.py

Or set BETWATCH_API_KEY yourself. Optional BETWATCH_API_URL (default https://api-beta.betwatch.com). FNOX_PROFILE=prod selects the hosted API.

Usage

See examples — the same use cases as 1.x (get_races, get_race_prices, subscriptions), plus firehose.py for every code with a resumable cursor, and tui.py for a Textual raceday grid.

Discover, then price, then follow

The API is built around one workflow, and so is this client. Find races, ask for their prices, then attach a stream at exactly the position the price read returned.

from betwatch import Betwatch, OddsFrame

with Betwatch() as client:
    # 1. Discover. /v2/odds and friends refuse an unscoped read, so start here.
    page = client.events.list(sport="thoroughbred", country="au", limit=5)
    event = page[0]
    print(event.name, event.start_at, event.racing.race_number)

    # 2. Price. The snapshot carries a stream cursor captured *before* it read,
    #    so nothing changes in the gap between pricing and following.
    card = client.events.snapshot(event.id)
    print(card.best_price(card.entrants[0]))

    # 3. Follow. follow() sends that cursor as Last-Event-ID with snapshot=none.
    with client.follow(card) as live:
        for frame in live:
            if isinstance(frame, OddsFrame):
                print(frame.data.source.id, frame.data.price)

client.watch(event_id) does all three in one call when you do not need the snapshot yourself.

snapshot(..., include="history") fills Odds.history with each source's fluctuations. It is honoured as of contract 1.0.0 and doubles the call's quota cost, so ask for it only when you use it.

Stream instead of polling

Stream frames are not metered. Polling /v2/odds on a timer is the expensive way to stay current and the slowest to see a move; bootstrapping once and following costs nothing beyond that first read. A single filtered connection covers a whole raceday — client.stream(sport="thoroughbred", country="au") — and a connection per race is the shape to avoid, which your plan's concurrent-stream cap will enforce with StreamLimitError.

Following a whole scope

One call returns the card, the prices and the cursor to follow them:

snap = client.snapshot(sport="thoroughbred", country="au")
with client.follow(snap) as live:
    for frame in live:
        ...

Every page returns the same stream.cursor, captured before the first page was read, so paging to the end and then following cannot miss a change to a race you read earlier. Follow from any page; page the rest with after=snap.next.

Hydration costs roughly half a second per race, so for a large scope take a small first page, attach, and read the rest while already live — you are following in about five seconds instead of fifty, and the cursor guarantees the races you have not read yet still replay.

Paging a collection

Every collection has iter(), which follows next until it stops coming:

for venue in client.venues.iter(country="au"):
    print(venue.name)

A cursor belongs to the collection that issued it — a next from /v2/venues is not valid on /v2/meetings. iter() feeds each cursor back to the endpoint that produced it, so this cannot go wrong by accident. Cursors are opaque: do not decode, build, or edit one.

Handling failures

Every failure is an RFC 9457 problem document with a stable code. Branch on the code (or on the exception type, which is selected from it), never on prose:

import time

from betwatch import QuotaExceededError, RateLimitError

try:
    page = client.odds.list(event=event.id)
except RateLimitError as err:
    time.sleep(err.retry_after or 1)      # short window; worth waiting out
except QuotaExceededError as err:
    alert(f"monthly quota spent, resets {err.rate_limit.monthly_reset}")

Ids are opaque, and merges are the reason that matters: a stored id (evt_, ent_, cmp_, mtg_, ven_) resolves to the surviving record after a merge, while a derived id (mkt_, out_, odd_) gets a NotFoundError because the resource genuinely has a new identity. Re-read the event and take the new ids.

QuotaExceededError is deliberately not a subclass of RateLimitError: one resets in seconds, the other in weeks. The client retries rate_limited and the 5xx codes for you, and fails fast on everything the docs mark as non-retryable. Every exception carries code, detail, errors, request_id, and trace_id — quote the last two to support.

Operations

The client groups operations by resource, which is the Python idiom. The mapping to the contract's operationIds is one-to-one:

operationId SDK
listEvents / getEvent / getEventSnapshot client.events.list / .retrieve / .snapshot
listEntrants / getEntrant client.entrants.list / .retrieve
getCompetitor client.competitors.retrieve
listOdds / getOdds client.odds.list / .retrieve
listMeetings / getMeeting client.meetings.list / .retrieve
listVenues / getVenue client.venues.list / .retrieve
listSources client.sources.list
streamRacing client.stream / .watch / .follow

Dump to pandas without caring that the backend is msgspec:

import pandas as pd

card = client.events.snapshot(event.id)
df = pd.DataFrame.from_records(card.to_records())

Sync and async clients share one resource tree (AsyncBetwatch).

Reads retry twice by default, driven by the problem code rather than the HTTP status — the status cannot tell rate_limited from quota_exceeded, and both are 429. Set max_retries=0 to disable retries or another non-negative value to change the budget. Stream reconnect is separate: SSE reconnects only after transport interruption, while HTTP, cursor, server frame, and decode failures surface immediately.

client.rate_limit holds the budget headers from the last response — both the per-minute window and the monthly quota, including when the quota resets.

The contract only grows. Unknown response fields (including $schema) are ignored, unknown SSE frame names are no-ops, and a vocabulary value newer than this release reads as "unknown" rather than failing to decode.

An event snapshot carries a required server-issued stream continuation. Pass the complete snapshot to client.follow(card); do not copy its cursor into a new stream with reconstructed filters.

TUI

examples/tui.py is a Textual demo (not part of the installed package). Left pane is the raceday list ordered by time-to-jump; right pane is the runner × bookmaker grid. The selected race follows live /v2/stream.

uv sync
fnox exec -- uv run examples/tui.py
fnox exec -- uv run examples/tui.py --sport harness --country au

1/2/3 switch code, n jumps to the next race, w/p is win/place, / filters tracks, ? is help.

Development

uv sync
uv run ruff check
uv run ty check
uv run pytest

The SDK's error codes, budget-header names, and operation coverage are pinned against a committed copy of the published contract at tests/contract/openapi.json. When the API ships a new spec:

uv run tests/contract/sync_openapi.py     # or: BETWATCH_OPENAPI=/path/to/openapi.json
uv run pytest tests/test_contract_spec.py

A failure there names exactly what moved — a new error code with no retry decision, a budget header nothing parses, an operation with no method.

Measuring the stream

tools/stream_timing.py reports how long /v2/stream takes to bootstrap for a given filter scope: time to ready, time to the first data frame, the silence between them, and time to sync. A broad snapshot=full sends nothing for tens of seconds while the server builds the snapshot, so the silence is the number worth watching.

fnox exec --profile prod -- uv run tools/stream_timing.py --sport thoroughbred --country au
fnox exec --profile prod -- uv run tools/stream_timing.py --json --require-sync 60

--require-sync exits non-zero if the bootstrap takes longer than that, so it works as a regression gate.

Live check against a local API:

export BETWATCH_API_KEY=bw_...
export BETWATCH_API_URL=http://localhost:8888
uv run examples/live_check.py run-1

Releasing

Tag the exact version in src/betwatch/__about__.py (v2.0.0b2) and push the tag. .github/workflows/release.yml builds that commit, checks the tag matches the package version, and publishes with PyPI Trusted Publishing. Do not bump versions from CI.

Changelog locally: uv run git-cliff --unreleased.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

betwatch-2.0.0b2.tar.gz (89.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

betwatch-2.0.0b2-py3-none-any.whl (50.9 kB view details)

Uploaded Python 3

File details

Details for the file betwatch-2.0.0b2.tar.gz.

File metadata

  • Download URL: betwatch-2.0.0b2.tar.gz
  • Upload date:
  • Size: 89.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for betwatch-2.0.0b2.tar.gz
Algorithm Hash digest
SHA256 e12ab7291712c0577ec24955035f804d30258cfcb9925b3e76c80a6cbfe9e8be
MD5 3a95ee661f1789e6100ed4b9b09e69e2
BLAKE2b-256 36bb5b1d4b2afd33a063dbc64eba936510247d4960db4e950faa0f86a8859ab0

See more details on using hashes here.

Provenance

The following attestation bundles were made for betwatch-2.0.0b2.tar.gz:

Publisher: release.yml on betwatch/betwatch-sdk-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file betwatch-2.0.0b2-py3-none-any.whl.

File metadata

  • Download URL: betwatch-2.0.0b2-py3-none-any.whl
  • Upload date:
  • Size: 50.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for betwatch-2.0.0b2-py3-none-any.whl
Algorithm Hash digest
SHA256 cd1e64420eb47bd0694114eb1a2719612894426bc3c70b0837c4a17d59d29e2f
MD5 c970c1c86880c37b632c5ed48e1813c9
BLAKE2b-256 e5aab134e4458c5ac65426b8c5b6714c91c163a25e6eb840b26510c1b2b794ec

See more details on using hashes here.

Provenance

The following attestation bundles were made for betwatch-2.0.0b2-py3-none-any.whl:

Publisher: release.yml on betwatch/betwatch-sdk-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

2.0.0b2 This release

2 files

1.7.4

2 files

1.7.3

2 files

1.7.2

2 files

1.7.0

2 files

1.6.0

2 files

1.5.1

2 files

1.5.0

2 files

1.4.7

2 files

1.4.6

2 files

1.4.5

2 files

1.4.3

2 files

1.4.2

2 files

1.4.1

2 files

1.4.0

2 files

1.3.4

2 files

1.3.3

2 files

1.3.2

2 files

1.3.1

2 files

1.3.0

2 files

1.2.3

2 files

1.2.2

2 files

1.2.1

2 files

1.2.0

2 files

1.1.18

2 files

1.1.17

2 files

1.1.16

2 files

1.1.15

2 files

1.1.12

2 files

1.1.10

2 files

1.1.9

2 files

1.1.8

2 files

1.1.7

2 files

1.1.6

2 files

1.1.5

2 files

1.1.4

2 files

1.1.3

2 files

1.1.2

2 files

1.1.1

2 files

1.1.0

2 files

1.0.8

2 files

1.0.7

2 files

1.0.6

2 files

1.0.5

2 files

1.0.4

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

0.9.31

2 files

0.9.30

2 files

0.9.29

2 files

0.9.28

2 files

0.9.27

2 files

0.9.26

2 files

0.9.25

2 files

0.9.24

2 files

0.9.23

2 files

0.9.22

2 files

0.9.21

2 files

0.9.20

2 files

0.9.19

2 files

0.9.18

2 files

0.9.16

2 files

0.9.15

2 files

0.9.14

2 files

0.9.13

2 files

0.9.12

2 files

0.9.11

2 files

0.9.10

2 files

0.9.9

2 files

0.9.8

2 files

0.9.7

2 files

0.9.6

2 files

0.9.5

2 files

0.9.4

2 files

0.9.3

2 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.6

2 files

0.8.5

2 files

0.8.4

2 files

0.8.3

2 files

0.8.2

2 files

0.8.0

2 files

0.7.11

2 files

0.7.10

2 files

0.7.9

2 files

0.7.8

2 files

0.7.7

2 files

0.7.6

2 files

0.7.5

2 files

0.7.4

2 files

0.7.3

2 files

0.7.2

2 files

0.7.1

2 files

0.7.0

2 files

0.6.9

2 files

0.6.8

2 files

0.6.7

2 files

0.6.6

2 files

0.6.5

2 files

0.6.4

2 files

0.6.3

2 files

0.6.2

2 files

0.6.1

2 files

0.5.5

2 files

0.5.4

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.20

2 files

0.4.19

2 files

0.4.18

2 files

0.4.17

2 files

0.4.16

2 files

0.4.15

2 files

0.4.14

2 files

0.4.13

2 files

0.4.12

2 files

0.4.11

2 files

0.4.10

2 files

0.4.9

2 files

0.4.8

2 files

0.4.7

2 files

0.4.6

2 files

0.4.5

2 files

0.4.3

2 files

0.4.2

2 files

0.4.1

2 files

0.4.0

2 files

0.3.1

2 files

0.3.0

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page