Metadata-Version: 2.5
Name: quantumsignals-client
Version: 0.12.0
Summary: Python client for Quantum Signals APIs (signal streaming, catalog, backtest)
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: httpx-sse>=0.4.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic-settings>=2.10.1
Requires-Dist: pydantic>=2.11.5
Requires-Dist: structlog>=24.1.0
Description-Content-Type: text/markdown

# Quantum Signals Client

Python client library for Quantum Signals APIs.

## Overview

This package provides a Python client for interacting with Quantum Signals services:

- **Streaming**: SSE-based signal streaming
- **Inference**: Model catalog and prediction endpoints
- **Backtest**: Backtest results and metadata

## Configuration

The API key is passed to the client; the library does not read it from the
environment itself (the example scripts under `examples/` read
`QUANTUMSIGNALS_API_KEY` and pass it on):

```python
from quantumsignals.client import Client
client = Client(api_key="foobar")
client.set_api_key("foobar")  # rotate later
```

The base URL defaults to production and can be overridden by argument or by
the `QUANTUMSIGNALS_BASE_URL` environment variable:

```bash
# Optional, defaults to https://api.quantumsignals.ai/q
export QUANTUMSIGNALS_BASE_URL=http://localhost:8000
```

## Usage

### Init

```python
from quantumsignals.client import Client
client = Client()
```

### Fetching Available Models

```python
models = client.get_model_catalog()
```

### Streaming Signals

```python
# Stream real-time signals via SSE
for signal in client.stream_signals():
    print(f"{signal.signal_time_utc} | {signal.symbol}: {signal.signal}")
```

#### Filtering by Signal

`stream_signals` and `get_recent_signals` both take a `signals` filter. Pass one
signal id or several — a bare string is treated as a single id, not as a list of
characters:

```python
# One signal
for signal in client.stream_signals("SP500_T21Bx"):
    print(signal)

# Several
for signal in client.stream_signals(["5min_S&P500", "5min_nasdaq100"]):
    print(signal)

# Same convention on the pull endpoint
page = client.get_recent_signals("SP500_T21Bx", last=20)
```

Omitting the filter streams every signal you are subscribed to. Note that being
able to *see* a signal in `get_signal_catalog()` is not the same as being
subscribed to it — the catalog is visible more broadly than the stream, so a
signal you can list may still yield no events until access is granted.

`signals` is the **only** filter these methods take. There is no `symbols=` or
`models=` argument — `stream_signals` is
`(signals, auto_reconnect, replay_day_from_start)` and `get_recent_signals` is
`(signals, last, since, since_cursor, actionable_only)`.

Ids must be **canonical signal ids**, like `SP500_T21Bx`. The client streams
from `/v3/signals`, which accepts those and nothing else; the older
`model/symbol` form belongs to `/v2/signals`, which this client does not use.
Passing a value containing `/` is rejected by the server with a 400 saying so:

    /v3 only accepts canonical signal_id slugs; use /v2 for legacy
    model/symbol filters (got 'Pythia-bB8ES/ES')

Two other rejections to expect, both 400: an id the catalog does not know, and
an id that has been retired. An id you are simply not subscribed to is *not* an
error — it is silently dropped from the filter, which is why a request can
succeed and still yield no events.

The two endpoints differ in what they carry, not just how they are filtered:
`/v2/signals` emits per-model predictions keyed by model and symbol, while
`/v3/signals` emits composed product signals keyed by signal id. The intended
direction is for `/v2` to go away, with `/v3` covering both cases — until then,
this client speaks `/v3` only.

#### Replay from Start of Trading Day

Use `replay_day_from_start=True` to replay all signals from the beginning of the
trading day (9:30 AM ET) as fast as possible, then continue with live streaming.
This is useful when connecting mid-day to catch up on all signals that were
generated earlier.

```python
# Replay all signals from market open, then continue live
for signal in client.stream_signals(replay_day_from_start=True):
    print(f"{signal.signal_time_utc} | {signal.symbol}: {signal.signal}")

# Combine with a signal filter
for signal in client.stream_signals(
    "SP500_T21Bx",
    replay_day_from_start=True
):
    print(signal)
```

The server sends a `replay_status` event when replay completes and transitions
to live streaming. The client handles this automatically - you'll simply see
historical signals arrive rapidly, followed by live signals at their normal pace.

#### Handling Null Signals

When the market is closed but data is still arriving, you'll receive "null signals"
with `signal=None` and a non-zero `code`. These provide visibility into model
activity even when not actively trading.

```python
from quantumsignals.client.models import SignalCode

for signal in client.stream_signals():
    if signal.is_null_signal():
        # Market is closed, no prediction made
        print(f"Null signal: {signal.code_message}")
        continue

    # Process normal trading signal
    print(f"{signal.symbol}: {signal.signal}")
```

Signal codes:
- `0` (NORMAL): Normal trading signal with valid prediction
- `1` (MARKET_CLOSED): Outside trading hours - no prediction made
- `2` (STALE_CACHE): Inference cache stale for this model's market - predictor refused to serve
- `3` (MODEL_TRADING_END): Model has finished its trading day - past the published signal's end_window.start, before market close
- `4` (UNROUTABLE_MODEL): Model cannot be served - its published `datamodule_version` names no inference pipeline this service has. Unlike the others this does not clear on its own; it repeats every tick until the model is republished.
- `5` (INSUFFICIENT_COVERAGE): The intraday buffer supplied too little of this model's input window to serve it. Self-resolving - the next tick serves normally once the missing minutes arrive.
- `6` (HISTORY_WINDOW_UNAVAILABLE): The historical data window this model needs could not be obtained for this interval. Distinct from `2`: a different store and a different remedy, and it does not resolve as the session fills.
- `7` (INCOMPLETE_MEMBERS): A signal composed from several models could not be composed for this bar — members failed for differing reasons, or one never reported at all. Only composed signals emit it. When every failing member failed for the *same* reason the composed event carries that reason instead, so this code specifically means "the members disagree about why".

Older client versions that pre-date a new code value still deserialize successfully (`signal.code` is a plain integer). `is_null_signal()` returns `True` for any non-`NORMAL` code, and `signal.code_message` falls back to `"Unknown code: N"` so unrecognised codes degrade gracefully — upgrade the client at your leisure to get the human-readable description.

#### Automatic Reconnection

The client automatically reconnects with exponential backoff if the connection
is lost. On reconnection, it sends a `Last-Event-ID` header to recover any
missed events from the server's buffer (last ~100 events).

```python
# Reconnection is enabled by default
for signal in client.stream_signals(auto_reconnect=True):  # default
    print(signal)

# Disable if you want to handle reconnection yourself
for signal in client.stream_signals(auto_reconnect=False):
    print(signal)
```

### Recent Signals (pull)

For agents (or anything that prefers a request/response loop over a held-open
stream), `get_recent_signals()` pulls the current trading day's signals as a
JSON page instead of SSE:

```python
# Catch up: the most recent page
page = client.get_recent_signals()
for signal in page.signals:
    print(signal.signal_id, signal.signal)

# Incremental polling — pass the previous next_cursor back as since_cursor,
# which costs work proportional to new signals, not the whole day.
cursor = page.next_cursor
while True:
    page = client.get_recent_signals(since_cursor=cursor)
    for signal in page.signals:
        handle(signal)
    if page.next_cursor is not None:
        cursor = page.next_cursor
    time.sleep(30)

# Other filters (composable):
client.get_recent_signals(last=20)                       # most recent 20 (a tail)
client.get_recent_signals(actionable_only=True)          # drop null / non-actionable
client.get_recent_signals(
    since=datetime.now(timezone.utc) - timedelta(hours=1)  # must be tz-aware
)

# Restrict to specific signals by canonical slug (empty = all permitted).
# Non-empty returns only these, intersected with your permissions.
client.get_recent_signals(signals=["5min_S&P500", "5min_nasdaq100"])
```

Only signals from the current Eastern-Time trading day that you're permitted on
are returned. When a page is truncated, `page.has_more` is `True` — keep paging
forward with `since_cursor=page.next_cursor`.

### Backtest Operations

```python
# Get backtest metadata
metadata = client.get_backtest_metadata(
    model_family="Pythia",
    symbol="AAPL"
)

# Get historical predictions (JSON, CSV, or Parquet)
results = client.get_historical_predictions(
    model_family="Pythia-aB24X",
    symbol="NVDA",
    format="json",
    page=1,
    page_size=100
)

# Get daily backtest summaries
daily = client.get_daily_backtests(
    model_family="Pythia-aB24X",
    symbol="NVDA",
    format="json"
)
```

### Context Manager Usage

```python
# Properly close HTTP connections
with Client() as client:
    models = client.get_model_catalog()
    # Client automatically closes on exit
```

## Development

This is a workspace package in the QS1 monorepo. See the main repository README for development setup.
