Metadata-Version: 2.4
Name: zenith-trade-client
Version: 0.2.5
Summary: Python client for accessing Zenith-Trade backend
Author-email: Shubham Pandey <shubhampandey@teesta.co>
License: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: websockets>=12.0
Requires-Dist: pydantic>=1.10
Provides-Extra: data
Requires-Dist: pyarrow>=12; extra == "data"
Requires-Dist: pandas>=1.5; extra == "data"
Dynamic: license-file

# Zenith Trade Client

Python client for the **Zenith-Trade** market data backend: find data, check where it is,
download it, stream it live, and replay history.

It is built for both interactive use (readable `print()` output) and scripts / workflows
(plain data via `.to_dict()`, typed errors, no prompts).

```bash
pip install -U zenith-trade-client
```

## Quickstart

```python
from zenith_trade_client import ZenithTradeClient

zt = ZenithTradeClient()

avail = zt.check_availability(
    exchange="binance",
    symbols=["BTCUSDT", "ETHUSDT"],
    data_types=["trades", "book_snapshot_25"],
    from_date="2026-09-01",
    to_date="2026-09-07",
)
print(avail)
```

```
binance · 2026-09-01 → 2026-09-07 (7 days)
Status: partial

  Symbol   Data type         Status   Days  Where
  BTCUSDT  trades            ready    7/7   server SSD
  BTCUSDT  book_snapshot_25  ready    7/7   server SSD
  ETHUSDT  trades            partial  1/7   server SSD
  ETHUSDT  book_snapshot_25  missing  0/7   Tardis (downloadable)

Files: 15 (readable here)  →  avail.files
Downloadable from Tardis: ~114 MB, ~3 - 5 sec  →  avail.download()
```

## Contents

- [Connecting](#connecting)
- [Discover: `exchanges()`, `exchange_info()`, `symbols()`](#discover)
- [Check availability: `check_availability()`](#check-availability)
- [Download: `download()`, `DownloadJob`, `my_downloads()`](#download)
- [Getting data directly (ClickHouse & Postgres): `get_data()`, `iter_data()`, `plan_data()`](#getting-data-directly-clickhouse--postgres)
- [Live data: `stream_live()`](#live-data)
- [Replay history: `replay()`](#replay-history)
- [Using results in scripts and workflows](#using-results-in-scripts-and-workflows)
- [Errors](#errors)
- [Upgrading](#upgrading)

---

## Connecting

```python
ZenithTradeClient(host=None, port=None, user=None, quiet=False, timeout=30.0)
```

| Argument | Default | Meaning |
|---|---|---|
| `host` | found automatically | Backend host name or IP, or a full URL (`"https://…"`) |
| `port` | `8000` | Backend port |
| `user` | your OS login | Name to show in Zenith's request history for requests from the server itself |
| `quiet` | `False` | Don't print the connection line or download progress |
| `timeout` | `30.0` | Seconds to wait for each HTTP request |

The client prints one line saying where results can be used:

```
Connected to Zenith. You're on the server, so every file path in results can be opened directly.
```

```
Connected to Zenith. You're on a remote machine, so file paths in results point to the server's disk.
SQL queries (ClickHouse, Postgres) work from anywhere.
```

To connect to a specific address, pass `host=` / `port=` or set the `ZENITH_URL` environment
variable (e.g. `export ZENITH_URL=http://<host>:<port>`).

```python
zt = ZenithTradeClient(quiet=True)          # no output (e.g. in a scheduled job)
zt = ZenithTradeClient(host="<host>")       # a specific backend
```

---

## Discover

### `exchanges()`

All exchanges Zenith knows, as a sorted list of names.

```python
zt.exchanges()
# ['binance', 'binance-futures', 'bybit', 'deribit', ...]
```

### `exchange_info(exchange)`

A summary of one exchange across all of Zenith's sources.

| Key | Meaning |
|---|---|
| `exchange` | Exchange name |
| `data_types` | Data types available for the exchange |
| `available_since`, `available_to` | Date range (`"YYYY-MM-DD"`) |
| `sources` | Where data comes from: `tardis` (downloadable), `internal` (Zenith's own collection), `postgres` |
| `symbols_count` | Number of symbols |

```python
info = zt.exchange_info("binance")
print(info["data_types"], info["available_since"], "→", info["available_to"])
# ['book_snapshot_25', 'book_snapshot_5', 'book_ticker', 'incremental_book_L2', 'quotes', 'trades'] 2019-03-30 → 2026-09-30
```

An unknown name raises `InvalidRequestError` with close matches.

### `symbols(exchange, search="", limit=100)`

Symbols on an exchange with their data types and date range.

| Argument | Meaning |
|---|---|
| `exchange` | Exchange name, e.g. `"binance"` |
| `search` | Filter by part of the symbol (for Polymarket: market question, event or outcome) |
| `limit` | Maximum results |

Returns a list of dicts: `id`, `data_types`, `available_since`, `available_to`
(Polymarket results also carry a readable `label`).

Some exchanges list one id per market however their databases spell it: csx is `BTCINR`
(ClickHouse stores `BTC/INR`, the NAS `BTC_INR`, Postgres `BTCINR`). Any of those spellings is
accepted in requests, and each source is queried with its own.

```python
for s in zt.symbols("binance", search="BTCUSDT", limit=3):
    print(s["id"], s["data_types"], s["available_since"], "→", s["available_to"])
```

---

## Check availability

### `check_availability(exchange, symbols, data_types, from_date, to_date)`

Where the requested data is, per symbol and data type, and how to get it.
**Checking never downloads anything.**

| Argument | Meaning |
|---|---|
| `exchange` | Exchange name |
| `symbols` | One symbol or a list |
| `data_types` | One data type or a list, e.g. `"trades"`, `"book_snapshot_25"`, `"incremental_book_L2"` |
| `from_date`, `to_date` | `"YYYY-MM-DD"`, `date` or `datetime` (inclusive) |

Returns an `Availability`:

| Field / method | What it gives |
|---|---|
| `print(avail)` | Readable table per symbol and data type |
| `avail.is_ready` | `True` when everything is available for the whole range |
| `avail.status` | `"ready"`, `"partial"`, `"missing"` or `"unavailable"` |
| `avail.items` | One dict per symbol × data type: `symbol`, `data_type`, `status`, `source`, `days_available`, `days_total`, `missing_dates`, `files`, `can_download` |
| `avail.files` | Paths of stored files (on the server / NAS) |
| `avail.queries` | SQL only, per database: `{"clickhouse": [...], "postgres": [...], "duckdb": [...]}` |
| `avail.connections` | Where those queries run, e.g. `{"postgres": {"host", "port", "database"}}` |
| `avail.duckdb_python` | Python (DuckDB) code for Parquet files on the NAS, when there are any |
| `avail.missing_dates` | Days missing for at least one item |
| `avail.can_download` | Whether Tardis can supply what's missing |
| `avail.download_estimate` | `{"size_mb", "time"}` for downloading what's missing |
| `avail.paths_readable_here` | Whether the file paths exist on this machine |
| `avail.download()` | Start downloading what's missing (see below) |
| `avail.in_database` / `avail.fetch(...)` | The data itself when it's in ClickHouse / Postgres (see [`get_data()`](#getting-data-directly-clickhouse--postgres)) |
| `avail.to_dict()` | Everything as JSON-serializable data |

Example — use stored files:

```python
avail = zt.check_availability(exchange="binance", symbols="BTCUSDT", data_types="trades",
                              from_date="2026-09-01", to_date="2026-09-07")
if avail.is_ready:
    for path in avail.files:
        print(path)
```

Example — data held in databases:

```python
avail = zt.check_availability(exchange="<exchange>", symbols=["<symbol>"],
                              data_types=["trades", "orderbook"],
                              from_date="2026-09-20", to_date="2026-09-21")
sql = avail.queries["clickhouse"][0]         # plain SQL, ready to run
conn = avail.connections["clickhouse"]       # where to run it
```

Example — per-item detail:

```python
for item in avail.items:
    if item["status"] != "ready":
        print(item["symbol"], item["data_type"], "missing:", item["missing_dates"])
```

---

## Download

Downloads fetch data from Tardis onto the server; days already stored are skipped.
Exchanges Zenith collects itself are served from its databases instead (use `avail.queries`).

### `avail.download()` / `download(exchange, symbols, data_types, from_date, to_date)`

Both start a download and return a `DownloadJob`. `avail.download()` downloads exactly
what you checked; `zt.download(...)` takes the same arguments as `check_availability()`.

```python
avail = zt.check_availability(exchange="binance", symbols="ETHUSDT", data_types="trades",
                              from_date="2026-09-01", to_date="2026-09-07")
if not avail.is_ready and avail.can_download:
    job = avail.download()
    job.wait()
    print(job.files)
```

```python
job = zt.download(exchange="binance", symbols="ETHUSDT", data_types="trades",
                  from_date="2026-09-01", to_date="2026-09-07")
```

### `DownloadJob`

| Field / method | What it does |
|---|---|
| `job.wait(poll_seconds=2, timeout=None, show_progress=None)` | Block until finished (progress on stderr unless `quiet`); raises if the download failed |
| `job.status` | `"queued"`, `"downloading"`, `"completed"`, `"failed"`, `"canceled"` |
| `job.is_done` | Finished (successfully or not) |
| `job.files_done`, `job.files_total` | Progress |
| `job.refresh()` | Fetch the latest status |
| `job.cancel()` | Stop the download |
| `job.retry()` | Run a failed or canceled download again |
| `job.files` | Stored file paths once complete |
| `job.to_dict()` | The job as JSON-serializable data |

### `my_downloads(limit=20)`

Your recent downloads, newest first, as `DownloadJob`s.

```python
for job in zt.my_downloads(limit=5):
    print(job.to_dict())
```

---

## Getting data directly (ClickHouse & Postgres)

Trades and order books from Zenith's databases, straight into your program:

- **ClickHouse** — trades and order books of the exchanges Zenith collects itself, **last ~14 days**.
- **Postgres** — trades for `csx`, `dcx`, `wazirx`, `zebpay`.

For Tardis exchanges and older days use [`check_availability()`](#check-availability) (files / NAS).

```bash
pip install -U 'zenith-trade-client[data]'   # pyarrow + pandas, for DataFrames
```

### `get_data(exchange, symbols, data_type="trades", from_time, to_time, *, columns=None, depth=None, sides="both", derived=None, every=None, layout="wide", raw=False, output="dataframe", limit=None)`

| Argument | Meaning |
|---|---|
| `exchange`, `symbols` | Exchange name; one symbol or a list |
| `data_type` | `"trades"` or `"orderbook"` |
| `from_time`, `to_time` | Dates (`"2026-09-28"` = whole day) or times (`"2026-09-28 06:30"`), UTC; `date` / `datetime` also work |
| `columns` | Only these columns (default: all). A column the table doesn't have raises `InvalidRequestError` listing the available ones |
| `depth` | Order book levels per side |
| `sides` | `"both"`, `"bids"` or `"asks"` |
| `derived` | Extra order book columns: `"mid"`, `"spread"`, `"spread_bps"` |
| `every` | Downsample: `"1s"`, `"5m"`, `1000` (ms). Trades become OHLCV bars; order books keep the last book per interval |
| `layout` | Order book shape: `"wide"` (`bid_price_0`, `ask_price_0`, …), `"long"` (one row per level) or `"nested"` |
| `raw` | Keep the table's own column names (e.g. `qty` instead of `amount`) and values (`side` as the table stores it: `BUY` / `SELL` or `buy` / `sell`) instead of the normalized ones |
| `output` | `"dataframe"` (pandas), `"polars"`, `"arrow"` (`pyarrow.Table`), `"dict"` (`{column: [values]}`), `"records"` (list of dicts), `"json"` (JSON text of the records) |
| `limit` | At most this many rows |

Timestamps are UTC: tz-aware in DataFrames and Arrow, ISO strings (`"2026-10-01T06:30:41.085881Z"`)
in `dict` / `records` / `json`. `dataframe` / `polars` / `arrow` arrive as one Arrow stream (up to 20M
rows); `dict` / `records` / `json` follow the server's pages (100k rows each) for you.

Trades to a DataFrame:

```python
df = zt.get_data("bitkub", "BTC_THB", "trades", "2026-09-30 06:00", "2026-09-30 07:00")
#                   timestamp   symbol side       price    amount
# 0 2026-09-30 06:00:03+00:00  BTC_THB  buy  2799021.09  0.000009
```

Trade columns are `timestamp, symbol, side, price, amount`, plus `trade_id` for exchanges whose table
stores one (not bitkub, delta, indodax or valr). Asking for a column the table doesn't have is an error.

Top of book with mid and spread:

```python
top = zt.get_data("bitkub", "BTC_THB", "orderbook", "2026-09-30 06:00", "2026-09-30 06:05",
                  depth=1, derived=["mid", "spread"])
# columns: timestamp, symbol, bid_price_0, bid_amount_0, ask_price_0, ask_amount_0, mid, spread
```

5 levels, one book per second, for a whole day:

```python
book = zt.get_data("bitkub", "BTC_THB", "orderbook", "2026-09-30", "2026-09-30", depth=5, every="1s")
```

One-minute bars from trades (`open`, `high`, `low`, `close`, `volume`, `buy_volume`, `trades`, `vwap`):

```python
bars = zt.get_data("bitkub", "BTC_THB", "trades", "2026-09-30", "2026-09-30", every="1m")
```

Only some columns, as plain Python data (Postgres trades):

```python
cols = zt.get_data("dcx", "BTCINR", "trades", "2026-09-30", "2026-09-30",
                   columns=["timestamp", "price", "amount"], output="dict")
cols["price"][:3]

rows = zt.get_data("dcx", "BTCINR", "trades", "2026-09-30", "2026-09-30", output="records")
rows[0]   # {'timestamp': '2026-09-30T00:00:56.454000Z', 'symbol': 'BTCINR', 'side': 'sell', 'price': ..., ...}
```

The table's own columns, unchanged:

```python
raw = zt.get_data("bitkub", "BTC_THB", "trades", "2026-09-30 06:00", "2026-09-30 07:00", raw=True)
```

From a check:

```python
avail = zt.check_availability(exchange="dcx", symbols="BTCINR", data_types="trades",
                              from_date="2026-09-29", to_date="2026-09-30")
if avail.in_database:
    df = avail.fetch()            # same options as get_data(), e.g. avail.fetch(output="records")
```

#### What was fetched: `zt.last_fetch`

After each `get_data()` / `iter_data()`, `zt.last_fetch` describes the result:

| Key | Meaning |
|---|---|
| `source`, `table` | `"clickhouse"` / `"postgres"` and the table read |
| `columns`, `layout`, `rows` | What came back |
| `from`, `to` | The window served |
| `coverage` | Requested vs. stored range; `not_in_database` and `nas` when days are older than the database keeps |
| `notes` | The server's remarks (e.g. why a window is empty) |
| `pages`, `truncated` | Pages followed; `True` when `limit` cut the result short |

#### The 14-day window and NAS files

ClickHouse keeps about the last 14 days. Older days in the window are **not returned as data**;
you get a `DataCoverageWarning` saying how many days were left out, and their NAS Parquet
paths are in `zt.last_fetch["coverage"]["nas"]["files_path"]` (for `dataframe` / `polars` /
`arrow`, in `zt.plan_data(...)["coverage"]["nas"]`). Read them with DuckDB / pandas, or use
`check_availability(...)` for a ready query. A window entirely older than the database raises
`NotAvailableError` (paths in `error.details["coverage"]["nas"]`).

```python
import warnings
from zenith_trade_client import DataCoverageWarning

warnings.simplefilter("error", DataCoverageWarning)   # e.g. make partial windows fail in a job
```

ClickHouse loads in batches and runs a few minutes behind live: for the latest minutes use
[`stream_live()`](#live-data).

### `plan_data(...)`

Same arguments as `get_data()` (without `output`); fetches nothing. Returns `source`, `table`,
`layout`, `columns`, `from`, `to`, `estimated_rows`, `coverage`, `notes`, `query`, `limits`.

```python
plan = zt.plan_data("bitkub", "BTC_THB", "orderbook", "2026-09-29", "2026-09-30", depth=5, every="1s")
print(plan["estimated_rows"], plan["coverage"].get("nas", {}).get("files_path"))
```

### `iter_data(...)`

Same arguments as `get_data()` (without `output`): records one at a time as the server streams
them, for very large pulls with nothing extra installed.

```python
for trade in zt.iter_data("dcx", "BTCINR", "trades", "2026-09-01", "2026-09-30"):
    ...
```

### Limits

- Trades: up to 31 days per request.
- Order books: 24 hours per request at full depth; up to 7 days with `depth<=5` or `every`.
- Up to 20M rows per request; more raises `InvalidRequestError` with the server's suggestion
  (e.g. `try depth=5, every='1s'`).
- 2 fetches at a time per user; more raises `ZenithTradeError` ("Zenith is busy").

---

## Markets on both Tardis and Zenith

Some markets are listed by Tardis **and** collected by Zenith. They're one row in `symbols()` with
`listing="both"`, and each source keeps its own symbol id, data types and dates:

```python
row = zt.symbols("bithumb", "BTC/KRW")[0]
row["market"], row["listing"]          # 'BTC/KRW', 'both'
row["sources"]["zenith"]["symbol"]     # 'BTC_KRW'  -> data types: orderbook (25-level snapshots)
row["sources"]["tardis"]["symbol"]     # 'KRW-BTC'  -> trades, incremental_book_L2, book_snapshot_25, ...
zt.resolve_symbol("bithumb", "BTC_KRW", source="tardis")   # 'KRW-BTC'
```

Either spelling works everywhere. Pick where data comes from with `source`:

| `source` | Meaning |
|---|---|
| `"auto"` (default) | Zenith where it covers the dates (free, fresh), otherwise Tardis |
| `"zenith"` | Zenith's own collection only: ClickHouse (last ~14 days), NAS history, Postgres trades |
| `"tardis"` | Tardis only: downloaded CSV files (deep history, more data types) |

```python
avail = zt.check_availability("bithumb", "BTC_KRW", "orderbook", "2026-09-30", "2026-09-30")
print(avail)              # ... Sources: Zenith: ready (clickhouse) · Tardis: unavailable
avail.by_source["tardis"]["similar_data_types"]   # ['book_snapshot_25', 'incremental_book_L2', ...]

zt.check_availability("bithumb", "KRW-BTC", "book_snapshot_25", "2026-09-30", "2026-09-30",
                      source="tardis").download()        # Tardis files
df = zt.get_data("bithumb", "KRW-BTC", "orderbook", "2026-09-30 06:00", "2026-09-30 06:30",
                 depth=1, source="zenith")               # Zenith rows (Tardis spelling is fine)
```

delta `BTCUSD` has the same id in both, but different coverage: Tardis ends 2026-02-21, Zenith runs
to today, so `source="auto"` serves recent days from Zenith. An explicit `source` that can't serve a
request says so instead of switching silently.

## Live data

### `stream_live(exchange, symbols, data=("trades", "book"), book_depth=20, book_interval_ms=100, bars=None)`

Real-time data for one exchange. Iterate the result with `for` (or `async for`).

| Argument | Meaning |
|---|---|
| `exchange` | Exchange name |
| `symbols` | One symbol or a list |
| `data` | Any of `"trades"`, `"book"`, `"best_bid_ask"`, `"funding"`, `"liquidations"`, `"options"`; types an exchange doesn't offer are skipped with a notice |
| `book_depth` | Order book levels per side (with `"book"`) |
| `book_interval_ms` | Order book refresh interval (with `"book"`) |
| `bars` | Also build price bars from trades: `"1s"`, `"1m"`, `"1h"` |

```python
for msg in zt.stream_live(exchange="binance-futures", symbols="BTCUSDT", data=["trades", "book"]):
    if msg["type"] == "trade":
        print(msg["symbol"], "trade", msg["price"], msg["amount"], msg["side"])
    elif msg["type"] == "book_snapshot" and msg["bids"] and msg["asks"]:
        print(msg["symbol"], "bid", msg["bids"][0]["price"], "ask", msg["asks"][0]["price"])
```

The stream object also has `stream.status` (latest engine status), `stream.delay_seconds`
(for exchanges fed from Zenith's collectors: how far events trail the exchange) and
`stream.close()`. Leaving the `for` loop closes it.

```python
import asyncio

async def main():
    async for msg in zt.stream_live(exchange="binance", symbols="BTCUSDT", data="trades"):
        print(msg["price"])

asyncio.run(main())
```

---

## Replay history

### `replay(exchange, symbols, from_time, to_time, data=("trades", "book"), speed="max", book_depth=20, book_interval_ms=100, bars=None)`

Stored history played back in time order, with the same messages as live data, so code
written for one runs on the other.

| Argument | Meaning |
|---|---|
| `from_time`, `to_time` | Dates (`"2026-09-28"` = whole day) or times (`"2026-09-28 06:30"`), UTC |
| `data` | Any of `"trades"`, `"book"`, `"quotes"`, `"best_bid_ask"`, `"funding"`, `"liquidations"`, `"options"` |
| `speed` | `"max"` (as fast as possible — for backtests), `1` (real time), `10`, ... |
| others | As for `stream_live()` |

```python
with zt.replay(exchange="binance", symbols="BTCUSDT",
               from_time="2026-09-28 00:00", to_time="2026-09-28 06:00",
               data=["trades", "book"], speed="max") as replay:
    for msg in replay:
        ...
print(replay.progress)   # 1.0 when finished
```

Controls, usable while iterating:

| Method / field | What it does |
|---|---|
| `replay.pause()`, `replay.resume()` | Pause and continue |
| `replay.set_speed(10)` | Change speed (`"max"`, `1`, `10`, ...) |
| `replay.seek("2026-09-28 03:00")` | Jump to a time |
| `replay.step(seconds=1)` | While paused, advance by some market time |
| `replay.stop()` | End the replay |
| `replay.progress` | 0.0 – 1.0 through the range |
| `replay.position` | Current market time (ISO, UTC) |

---

## Messages

Live and replay messages use the [tardis normalized format](https://docs.tardis.dev/tardis-machine/data-types):
`trade`, `book_snapshot`, `trade_bar`, `derivative_ticker`, `liquidation`, `book_ticker`,
`option_summary`. Each is a dict with `type`, `symbol`, `exchange`, `timestamp`, `localTimestamp`
and type-specific fields (e.g. `price`, `amount`, `side` for trades; `bids`, `asks` for books).

---

## Using results in scripts and workflows

- `avail.to_dict()` and `job.to_dict()` return plain, JSON-serializable data.
- `avail.queries` contain SQL only; connection details are separate in `avail.connections`.
- `quiet=True` keeps stdout/stderr clean; errors are raised, never printed.
- Everything accepts a single value or a list for `symbols` / `data_types`.

```python
import json
from zenith_trade_client import ZenithTradeClient, ZenithTradeError

zt = ZenithTradeClient(quiet=True)
try:
    avail = zt.check_availability(exchange="binance", symbols=["BTCUSDT", "ETHUSDT"],
                                  data_types="trades", from_date="2026-09-01", to_date="2026-09-07")
    if not avail.is_ready and avail.can_download:
        avail.download().wait()
        avail = zt.check_availability(exchange="binance", symbols=["BTCUSDT", "ETHUSDT"],
                                      data_types="trades", from_date="2026-09-01", to_date="2026-09-07")
    print(json.dumps(avail.to_dict()))
except ZenithTradeError as e:
    raise SystemExit(f"zenith: {e}")
```

---

## Errors

All errors derive from `ZenithTradeError`:

| Error | When |
|---|---|
| `ZenithConnectionError` | The backend can't be reached |
| `InvalidRequestError` | Unknown exchange or symbol, bad dates, ... (message from the backend) |
| `NotAvailableError` | E.g. downloading data that isn't on Tardis, or `get_data()` for a window that's only on the NAS |

Errors from the backend carry `error.status_code` and `error.details` (its full answer, e.g.
`suggestions` for too much data). `DataCoverageWarning` (a warning, not an error) marks
`get_data()` results that leave out part of the window.

Invalid arguments (bad date format, unknown data name) raise `ValueError` / `TypeError`
before anything is sent.

---

## Upgrading

0.1 and 0.2.0 code keeps working.

| Before | Now |
|---|---|
| `ZenithTradeClient(mode="localhost")` / `(mode="tailscale", tailscale_host=...)` | `ZenithTradeClient()` (old arguments still accepted) |
| `list_exchanges()` | `exchanges()` |
| `get_exchange_info(exchange)` | `exchange_info(exchange)`; `symbols(exchange)` for per-symbol detail |
| `check_data_availability(...)` — also started downloads | `check_availability(...)` to look, `.download()` to fetch |
| 0.2.0: `check_availability(..., start=, end=)` | `from_date=`, `to_date=` (`start` / `end` still accepted) |
| 0.2.0: `replay(..., start=, end=)` | `from_time=`, `to_time=` (`start` / `end` still accepted) |

`check_data_availability()` keeps its old behaviour, including starting downloads, and warns
when used.
