# sportfeed

Fair probabilities for sports markets, for Python 3.11 and later: every current
price, then each change, one row per market. The library keeps the stream's rules
for you: it keeps the state, checks every message, reconnects, and tells you when
a price must not be used.

## Install

Not yet published: the package name is still to be decided. Meanwhile,
install it from a checkout of the repository:

```sh
pip install ./clients/python
```

Once published, it will be `pip install <name>`. Its only dependency is
`websockets` (17 or later).

## Example

```python
from sportfeed import Feed

feed = Feed(host="api.example.com", key="sf_live_...")
for p in feed.prices(sport="football"):
    print(p.event.name, p.market.type, p.market.line, p.state, [(o.name, o.prob) for o in p.outcomes])
```

The loop first gets every current row, then each row that changes. It runs until
you leave it or call `feed.close()`.

With `asyncio`:

```python
async with Feed(host="api.example.com", key="sf_live_...") as feed:
    async for p in feed.aprices(sport="football", market_types=["1x2", "total"]):
        quote(p)
```

Leaving the `async with` block closes the feed.

## Filters

`prices()` and `aprices()` take `sport`, `competition`, `event` and `market_types`,
each one value or a non-empty list. Fields combine with AND, values within a field
with OR; leave a field out to get everything (an empty list is refused, rather
than read as everything). A subscription is at most 4 KiB, which holds about 120
event IDs; a longer filter raises `ValueError`.

| Filter | Values |
|---|---|
| `sport` | `football`, `tennis`, `basketball`, `ice_hockey`, ... |
| `competition` | competition IDs, `cp_...` |
| `event` | event IDs, `ev_...` |
| `market_types` | `1x2`, `moneyline`, `total`, `handicap` |

You get the markets of events that are scheduled or live when you connect, and
the library follows each of them to its end and its results.

## The row

Each row is a `Price`, the same row as `GET /v1/prices`:

| Field | Meaning |
|---|---|
| `market_id` | The market's ID, `mk_...` |
| `market.type` | `1x2`, `moneyline`, `total` or `handicap`; more may be added (see below) |
| `market.period` | `match` |
| `market.line` | For example `2.5`, or `-1.5` for the home side of a handicap; `None` without a line |
| `market.status` | `active`; `removed` when withdrawn: drop the row, no price follows; `settled` once it has a result |
| `event.id` | The event's ID, `ev_...` |
| `event.name` | For example `Arsenal vs Chelsea` |
| `event.sport` | For example `football` |
| `event.competition_id` | The competition's ID, `cp_...` |
| `event.competition` | The competition's name |
| `event.status` | `scheduled`, `live`, `ended`, `resolved`, `postponed`, `cancelled` or `abandoned` |
| `event.scheduled_start` | RFC 3339 in UTC, for example `2026-10-24T14:00:00.000Z` |
| `state` | `open`, `caution` or `suspended` |
| `reason` | Why the state is not `open`; `None` when `open` |
| `uncertainty` | How far the probability may be from the truth, in probability units; `None` while suspended |
| `ts` | The time of the newest input behind the price |
| `outcomes` | In the market's order: `key` (`home`, `draw`, `away`, `over`, `under`), `name`, `prob` and `fair_odds` (`1/prob`); `prob` and `fair_odds` are `None` while suspended |
| `result` | `None` until the market has a result; then `winner` (an outcome key, `None` when void), `void`, `revision` (1, then one more per correction) and `resolved_at` |

The classes `Price`, `Event`, `Market`, `Outcome` and `Result` are frozen
dataclasses. Times are the strings the feed sends.

`state`:

- `open`: the price is reliable;
- `caution`: the price is usable, but its uncertainty is raised: widen margins or
  reduce limits;
- `suspended`: do not use the price.

`reason`: `match_event` (something happened in the match, or the match has started
in a sport priced before the start only), `unstable`, `low_confidence`,
`insufficient_data`, `starting`, `event_not_active`, `maintenance`, and the
library's own `disconnected`.

### Using the rows

- Use a price only while its `state` is `open` or `caution`.
- Drop a row whose `market.status` is `removed`: the market was withdrawn. Its last
  row keeps its last price, as the feed shows it, or, after a reconnect, comes
  without a price.
- Ignore a market whose `market.type`, `market.period` or outcome `key` you do not
  know. New market types are added as the feed grows, and come through the library
  as they are; a new type can follow rules of its own.
- Settle from `result`; a later row with a higher `revision` corrects an earlier
  result.

### The `disconnected` reason

The feed never sends `disconnected`: the library does. As soon as it loses its
connection, it gives you every row it has given you again, once, as `suspended`
with reason `disconnected` (the constant `sportfeed.DISCONNECTED`). When it is
connected again and has the new state, it waits for the feed's next heartbeat
after the snapshot, up to about one second, and gives you every row again.

## What the library does

1. It connects to `wss://<host>/v1/stream` with your key in the `Authorization`
   header and subscribes with your filter. It never follows a redirect, which
   could take the key elsewhere: it retries instead.
2. It keeps every event and market of the subscription. Once it has the whole
   state, it waits for the feed's next heartbeat after the snapshot, up to about
   one second, and then gives you every row once: only that heartbeat shows the
   feed's prices are live, and until it comes the rows stay as they were
   (`disconnected` after a reconnect). After that, it
   gives you a market's row whenever it changes. A change of an event's name,
   status, start time or competition gives you every row of that event. A change
   of the score or the clock gives you no row. Outcome names come from the market
   and probabilities from the price, in the market's outcome order.
3. A market's first result and its settlement usually come as one row, with
   `market.status` `settled` and the `result`; if another message comes between
   the result and the settlement, or the connection drops, the result first comes
   on its own. A correction comes as its own row.
4. Every message has a sequence number. When one is missing, the library
   reconnects.
5. The feed sends a heartbeat every second. After 3 seconds without any message,
   the library reconnects.
6. It reconnects with a backoff that grows from 1 to 30 seconds, with jitter, and
   starts again after a connection that held (it delivered the whole state and
   lasted 30 seconds). It subscribes again and replaces its whole state with the
   new one.
7. While it has no connection, every row is reported `suspended` with reason
   `disconnected`, once (see above).
8. It ignores fields and message types it does not know. Markets of a type,
   period or outcome key it does not know come through as they are, for you to
   ignore (see "Using the rows"). A state it does not know is reported as
   `suspended`; an event status it does not know makes the event's rows
   `suspended` with reason `event_not_active`.
9. When the feed closes the connection because the client reads too slowly (close
   code 4008), it reconnects. After too many client messages (4029) or too many
   connections for the key (4009), it waits longer (15 to 30 seconds) before it
   reconnects. When the key is refused (HTTP 401 or 403 when connecting) or
   disabled during the connection (close code 4003), it stops with an `AuthError`.
   When the feed refuses the subscription, it stops with a `FeedError`. Anything
   else, it retries.
10. A removed market gives one last row, with `market.status` `removed` and its
    last price: drop it. A market listed later comes with its first price.
11. After a reconnect, a market you have a row for that the new state does not
    have comes as removed, without a price (`suspended`, reason `disconnected`).
    An event that ended or was cancelled while the library was disconnected is not
    in the new state: the library asks the feed for it again, one request at a
    time and within the feed's limits, gives you its rows with their final status
    and results, and follows it to its last result. Until then its rows stay
    `disconnected`. An event the feed no longer has comes as removed; if the feed
    refuses the request, the rows stay `disconnected` and the library asks again
    on its next connection.
12. An event that has finished, with a result for every market, is kept only to
    give you a later result correction; it is not reported on a disconnect. The
    library keeps the last 2,000 such events.
13. It never logs your key. It logs reconnects and their reasons as warnings on
    the logger `sportfeed`.

The library reads the stream as fast as your loop takes rows. Keep the loop
quick: a loop that falls a few seconds behind is disconnected by the feed, and the
library reconnects.

## Errors

- `AuthError`: the key was refused or disabled. Retrying would not help; check the
  key or contact support.
- `FeedError` (the base class of `AuthError`): the feed refused the subscription.

Both are raised from the loop. Every other failure is retried.

## Options

```python
Feed(host, key, *, url=None, ssl=None, silence_limit=3.0, backoff_min=1.0,
     backoff_max=30.0, stable_after=30.0, open_timeout=10.0)
```

- `url`: the full stream URL instead of `host`, for example a local test server.
- `ssl`: an `ssl.SSLContext` for a `wss://` URL, for example one that trusts your
  own certificate authority.
- The timers, in seconds: keep the defaults, which follow the feed's heartbeat and
  reconnection rules.

Each `prices()` or `aprices()` opens its own connection; `close()` ends them all.

## Development

```sh
python3 -m venv .venv && .venv/bin/pip install -e .
.venv/bin/python -m unittest discover -s tests
```
