Metadata-Version: 2.4
Name: betflux
Version: 0.2.0
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/developer
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: pyarrow>=16.0
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
```

## Authenticate

Set your API key (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.
