Metadata-Version: 2.4
Name: betflux
Version: 0.4.1
Summary: Python client + CLI for the BetFlux data API — normalized sportsbook odds for sharp bettors.
Author: BetFlux
License-Expression: MIT
Project-URL: Homepage, https://betflux.ai
Project-URL: Documentation, https://betflux.ai/docs
Keywords: sportsbook,betting,odds,sports-data,parquet,api-client
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
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: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: click>=8.1
Requires-Dist: keyring>=25.0
Requires-Dist: platformdirs>=4.0
Requires-Dist: pyarrow>=16.0
Requires-Dist: packaging>=24.0
Requires-Dist: tomli>=2.0; python_version < "3.11"
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == "pandas"
Provides-Extra: polars
Requires-Dist: polars>=1.0; extra == "polars"
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: pandas>=2.0; extra == "test"
Dynamic: license-file

# betflux

Python client + CLI for the [BetFlux](https://betflux.ai) data API — normalized
sportsbook odds across US books, queryable for sharp bettors.

## Install

```bash
pip install betflux            # core client + CLI
pip install "betflux[pandas]"  # add .df() DataFrames
```

For an isolated CLI installation with automatic updates, use uv:

```bash
uv tool install betflux
```

If your uv tool installation predates 0.3.0, run `uv tool upgrade betflux` once
to get the updater. If your version pin excludes 0.3.0, revise it before upgrading.
Use `betflux auto-update status` to see whether this
installation supports automatic updates and whether they are enabled.

On verified Linux and macOS `uv tool install betflux` installations, BetFlux
checks about once a day and installs an eligible stable release before the
next command. It preserves the tool's version constraints and package source.
uv may also change dependency versions within those constraints; upgrades are
not atomic and do not roll back automatically.
To disable updates for one process, set `BETFLUX_NO_AUTO_UPDATE=1`; to manage
the saved preference, run `betflux auto-update off` or `betflux auto-update on`.
Use `betflux update` to update now, or `betflux update --check` to check without
installing. SDK/project and pip installations, editable developer installs
(including `bun run install-cli` from this repo), temporary `uvx` runs, and
unverifiable tool environments are not modified automatically.
Importing or using the Python SDK never triggers an update.

## Authenticate

For an existing onboarded account with API access:

```bash
betflux login
```

Open the printed link, enter the terminal code, and approve the new API key.
The CLI saves it in your system keychain. CLI commands and Python `Client()`
automatically use the saved login unless you supply an explicit key. Each login
uses one key slot; manage keys at `betflux.ai/account/api-keys`.

On headless machines, use `betflux login --no-browser --credential-store file`
to explicitly choose a protected local file. Storage is scoped to the API URL.
For local development, provide both origins:

```bash
betflux --base-url http://localhost:8800 login --auth-url http://localhost:5173
```

`betflux logout` revokes the saved key and removes it locally. If offline, use
`betflux logout --local-only` to remove it without revoking. Revocation can take
up to five minutes to propagate. Neither command changes environment keys.

If saved login is unreadable, unlock the keychain and retry, or reset with
`betflux logout --local-only` before logging in again. Use
`betflux login --credential-store file` to switch to protected file storage.
For custom API URLs, supply the same `--base-url` to both commands. Resetting
does not revoke the old server key; revoke it through your account. The CLI
warns if a keychain entry could not be removed.

You can still set an API key manually (mint one at `betflux.ai/account/api-keys`):

```bash
export BETFLUX_API_KEY=bfx_live_...
```

## The file model

Every dataset payload is a **per-game Parquet file** served at
`GET /v1/games/{game_id}/{dataset}` — the server streams typed, compressed
bytes (with HTTP Range support) and never filters. The SDK/CLI keep their
query interfaces: they discover game ids via `/v1/games`, download each
game's file, and evaluate your filters locally with pyarrow (a core
dependency). Quota is metered as rows *downloaded*, so narrow date ranges
and `--game` fetches are the cheap path — local filters don't reduce spend.

Datasets: `closing-lines`, `market-results` (graded closing lines),
`sportsbook-lines` (the full ~190k-row change-only line history per game),
`game-state-timeline` (flat `ts/field/value/source` observation rows).

A game that has not settled yet has no final file. Ask for its
`sportsbook-lines` anyway and you get them — see [Live games](#live-games).

## CLI

```bash
betflux keys check
betflux datasets
betflux games --league NBA --date-from 2026-04-01 --date-to 2026-04-07
betflux get closing-lines --league NBA --date-from 2026-04-01 --date-to 2026-04-07
betflux get closing-lines --game NBA_GSW_MIA_20260401 --format jsonl
betflux get market-results --league MLB --date-from 2026-07-01 --date-to 2026-07-07 --outcome WON
betflux get sportsbook-lines --game MLB_BOS_NYY_20260715 --output lines.parquet
betflux get game-state-timeline --game NBA_GSW_MIA_20260401
betflux live-board MLB_WAS_DET_20260922 --operator FANDUEL
betflux live-tail MLB_WAS_DET_20260922 --every 5
betflux leagues
betflux teams --league NBA
betflux players --team-id <team-id>
```

Game ids are readable — `LEAGUE_AWAY_HOME_YYYYMMDD` (ET date; a `_2` suffix
marks doubleheaders), case-insensitive, discoverable via `betflux games`.

`closing-lines` and `market-results` accept a date range or `--game`;
`game-state-timeline` and `sportsbook-lines` are `--game` only
(sportsbook-lines over a range would debit ~190k quota rows per game).
Filters (`--operator`, `--market-type`, `--team`, `--player-id`, `--side`,
`--outcome`) run locally after download; a filter the dataset has no column
for is rejected up front. `--limit N` stops fetching once N rows have been
yielded. With `--format jsonl|csv` rows stream as each game's file arrives.

`--output PATH` (with `--game`) saves the raw Parquet file for any dataset —
no parsing, one summary line with the row count:

```
wrote lines.parquet — sportsbook-lines for MLB_BOS_NYY_20260715: 190,412 rows, 8,214,567 bytes
```

Timestamp columns are real Parquet timestamps and render as ISO 8601 in
every output format.

Output defaults to a compact table showing a curated column subset (the gold
datasets are wide). Widen or reshape it:

- `--wide` — every column in the table
- `--columns game_date,operator,side,closing_odds` — pick columns
- `--format record` — vertical `key: value` blocks, ideal for one wide row
- `--format json|jsonl|csv` — machine formats (always full-fidelity)

`betflux keys check` validates the key and reports your plan:

```
key valid (https://api.betflux.ai)
tier: Beta — 120 requests/min
usage: 12,345 rows this month (no row cap on this plan)
```

Tiers with a configured row cap also print the percentage used and the reset
date.

## Library

```python
from betflux import Client

with Client() as bf:
    for row in bf.closing_lines.iter(
        league="NBA", date_from="2026-04-01", date_to="2026-06-30",
        operator="FANDUEL",  # local filters: operator/market_type/team/player_id/side/outcome
        max_rows=10_000,     # stop fetching once this many rows yielded; default None = all
    ):
        print(row["market_key"], row["closing_odds"])

    games = bf.games(league="NBA", date_from="2026-04-01", date_to="2026-04-07")
    df = bf.closing_lines.df(league="NBA", date_from="2026-04-01", date_to="2026-04-07")
    game_rows = bf.market_results.game(games[0]["id"], outcome="WON")
    observations = bf.state_timeline(games[0]["id"])  # flat rows; ts is epoch-ms
    raw = bf.sportsbook_lines.raw(games[0]["id"])     # the Parquet bytes (verbatim once settled)
```

Datasets hang off the client as `closing_lines`, `market_results`,
`sportsbook_lines`, and `game_state_timeline` (or `bf.dataset("closing-lines")`
by public name). Timestamp/date columns come back as Python `datetime`/`date`
objects (pyarrow decodes the Parquet types), **not** ISO strings. Filters that
name a column the dataset lacks raise `ValueError`; `team` matches home or
away, `player_id` matches the market/selection player-id list columns.

Errors are typed (`AuthError`, `PaymentRequiredError`, `RateLimitError`,
`QuotaExceededError`, `NetworkError`, …); rate limits, 5xx, and transport
failures retry automatically with backoff that honors `Retry-After` (capped).

### Agent plugin installation

Run the command for each agent you use, specifying one agent per invocation:

```bash
betflux plugin install --claude
betflux plugin install --codex
```

These register `betflux/skills` and install `betflux@betflux` through the
agent's own CLI. Claude uses user scope. The selected agent must already be on
PATH; installation needs network access but no BetFlux API key. Rerun the
command to refresh the repository, update the plugin, and enable it.
Manage removal through the agent, and start a new session after installation or updating.

`betflux plugin install --cursor` provides manual local-plugin instructions
and exits nonzero; it does not install anything. The wrapper never writes
agent configuration or copies skills.

`betflux agent-guide` prints the SDK's bundled guide offline, independently
of any plugin installation. For direct installation through your agent, see
<https://github.com/betflux/skills>.

### Breaking changes (Parquet cutover)

- `iter()` lost `page_size` — there is no server pagination to tune anymore.
- `Client.state_timeline()` returns the flat observation rows
  (`list[dict]`) instead of the old `{..., observations: [...]}` envelope.
- Timestamps in rows are `datetime` objects, not ISO strings.

### Power user: DuckDB straight at the files

The dataset endpoints are plain authenticated Parquet URLs with Range
support, so DuckDB can query them directly — predicate pushdown means it
reads only the byte ranges it needs:

```sql
CREATE SECRET betflux (
    TYPE http,
    EXTRA_HTTP_HEADERS MAP {'Authorization': 'Bearer bfx_live_...'}
);
SELECT operator, market_type, odds, timestamp
FROM read_parquet('https://api.betflux.ai/v1/games/MLB_BOS_NYY_20260715/sportsbook-lines')
WHERE market_type = 'MONEYLINE'
ORDER BY timestamp;
```

Note: quota is debited for the file's full row count per request, partial
read or not.

## Live games

A game's `sportsbook-lines` history only becomes a single final file once the
game settles. Until then the API publishes it as an append-only series of
Parquet **segments** plus a current **board** (one file per operator).

**You do not have to care.** Every ordinary call works on a live game: the SDK
follows the API's pointer, downloads the segments, concatenates them in order
and returns the same columns the final file would have.

```python
rows = bf.sportsbook_lines.game("MLB_WAS_DET_20260922")   # just works
raw  = bf.sportsbook_lines.raw("MLB_WAS_DET_20260922")    # Parquet bytes, re-encoded by the SDK
bf.sportsbook_lines.is_live("MLB_WAS_DET_20260922")       # True, if you want to know
```

The one visible difference: live rows are **provisional** — the final build may
revise them. `query_game()` reports it as `.provisional`, and `betflux get`
prints one line to stderr (stdout is byte-identical to a settled game's, so
pipes never notice):

```
provisional: MLB_WAS_DET_20260922 is live; data may be revised by the final build
```

What it costs: listings are free, and each row of a live segment is charged
once per account per month — reading a segment again costs nothing, and the
growing open tail costs only the rows it gained since you last read it. So
calling `game()` on a live game again charges only what was published since
the last call, though it still downloads every segment each time; for
anything that repeats, poll.

### Polling, for people who want it

`live_segments` takes an opaque cursor, lists only what is new since it, and
downloads the segments that listing names:

```python
cursor = None                            # or live_cursor(game): start from now, skip the history
while True:
    step = bf.sportsbook_lines.live_segments("MLB_WAS_DET_20260922", cursor)
    cursor = step.cursor                 # "0:0" until the first segment exists
    for row in step.rows:                # also step.table (pyarrow)
        print(row["operator"], row["selection_name"], row["odds"])
    time.sleep(5)                        # listings are edge-cached for 5 s
```

The listing is free and incremental. Each segment it names is downloaded
whole — the open tail on every poll in which it grew — but each row is charged
only once per account per month, so a poll costs only the rows it brings in.
Polling faster than the ~5 s edge cache just re-downloads the same bytes. A
first poll with no cursor backfills — and is charged for — everything
published so far; `live_cursor()` is one free listing that returns the current
position, for following from now on.

The cursor is a position, not a timestamp — a slower operator's file can land
after a faster one's later-stamped rows, so a timestamp would silently drop
them. Two things can go wrong with it, both typed. `LiveCursorResetError`:
the game's live log was rebuilt under the cursor — start again without one;
rows already consumed may be superseded. `LiveInconsistentError`: the listing
and a segment body kept disagreeing for the whole bounded retry the SDK runs
(the edge cache can serve the two from moments apart) — nothing was returned
and the cursor did not move, so just call again.

`betflux live-tail <game>` is that loop as a command (`--every N`, `--output
FILE`, `--format jsonl|csv`); it stops by itself once the game settles.
`--output` appends, so a rerun should start from the cursor the previous run
printed: `--cursor SEQ:ROWS` resumes there and `--from-now` skips the history;
with neither, the first poll backfills the whole history (charged, unless this
account already read it this month).

For "what is quotable right now" rather than history, read the board — one row
per selection, with `quote_state` (`live` / `stale_suspect`), `last_changed_at`
and `operator_observed_through` on top of the usual columns:

```python
board = bf.sportsbook_lines.live_board("MLB_WAS_DET_20260922")              # every operator
board = bf.sportsbook_lines.live_board("MLB_WAS_DET_20260922", "FANDUEL")   # or one
```

`betflux live-board <game> [--operator X]` renders it, or saves it with
`--output board.parquet`. Board reads are metered at a weight the API sets
(free during Beta); the listing behind them is free.

### Live segments in DuckDB

`live_urls()` hands you the segment URLs, which are ordinary authenticated
Parquet URLs with Range support:

```python
urls = bf.sportsbook_lines.live_urls("MLB_WAS_DET_20260922")
```

```sql
CREATE SECRET betflux (
    TYPE http,
    EXTRA_HTTP_HEADERS MAP {'Authorization': 'Bearer bfx_live_...'}
);
SELECT operator, market_type, odds, timestamp
FROM read_parquet(['https://api.betflux.ai/v1/games/.../segments/0?rows=3800',
                   'https://api.betflux.ai/v1/games/.../segments/1?rows=1204'])
ORDER BY timestamp;
```

Use the URLs as listed, `?rows=N` included: it is the row count the listing
promised, and a segment that has fallen behind it answers 409
`live-segment-behind` rather than a short file — list again. Closed segments
are immutable (cached 24 h); the highest one is the open tail, rewritten in
place as rows are appended. Each row is charged once per account per month,
however often it is read.

A game that is neither live nor settled raises `NotFoundError` from all of
these, as it always did. Low-level callers driving `Client.get_bytes`
themselves see the live case as `GameLiveError` (a `NotFoundError` subclass)
carrying `game_id`, `segments_url` and `board_url`, and a listed segment URL
that has fallen behind its listing as `LiveSegmentBehindError` (`seq`,
`rows`) — the dataset methods handle both for you.
