Metadata-Version: 2.4
Name: obscura-trade
Version: 0.1.1
Summary: Official Python client for the Obscura alternative-data API. Automatically routes each request between the bulk parquet export and the live Postgres endpoint.
Project-URL: Homepage, https://obscura.trade
Project-URL: Documentation, https://obscura.trade/docs
Project-URL: Issues, https://github.com/ObscuraTrade/obscura-python/issues
Project-URL: Source, https://github.com/ObscuraTrade/obscura-python
Author-email: Obscura <support@obscura.trade>
License: MIT
License-File: LICENSE
Keywords: alternative-data,market-data,obscura,parquet,research
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1.0,>=0.24
Provides-Extra: all
Requires-Dist: pandas>=1.3; extra == 'all'
Requires-Dist: pyarrow>=10.0; extra == 'all'
Provides-Extra: arrow
Requires-Dist: pyarrow>=10.0; extra == 'arrow'
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pandas>=1.3; extra == 'dev'
Requires-Dist: pyarrow>=10.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: pandas
Requires-Dist: pandas>=1.3; extra == 'pandas'
Requires-Dist: pyarrow>=10.0; extra == 'pandas'
Description-Content-Type: text/markdown

# obscura-trade

The official Python client for the [Obscura](https://obscura.trade) alternative-data API.

Obscura publishes each dataset two ways: a **parquet export** in object storage,
refreshed on a schedule, and a **live Postgres endpoint** that always holds the
newest rows. The export has a coverage watermark; anything after it exists only
in Postgres.

This library makes that split disappear. `client.get(...)` reads the watermark,
routes the historical part of your range to a locally cached parquet, routes the
recent part to the live endpoint, and joins the two into one ordered,
de-duplicated result — and tells you exactly what it did.

```python
import obscura_trade

client = obscura_trade.Client("obs_live_9f2c...")

result = client.get("congress_trades", symbols=["NVDA", "AAPL"], start="2024-01-01")

print(result.describe_route())
# congress_trades [2024-01-01 .. now] -> bulk[2024-01-01 .. 2026-07-18] live[2026-07-19 .. now]
#   (split at 2026-07-19) — requested range straddles the export watermark
#   | 41 bulk + 3 live = 44 rows; bulk served from cache

df = result.to_pandas()
```

## Install

```bash
pip install obscura-trade              # core: live queries + parquet caching
pip install 'obscura-trade[pandas]'    # + DataFrame conversion
pip install 'obscura-trade[arrow]'     # + pyarrow Tables / reading cached parquet
pip install 'obscura-trade[all]'
```

`import obscura_trade` works with nothing but `httpx` installed. pandas and pyarrow are
optional; the methods that need them raise a clear error naming the exact
`pip install` if they are missing.

## Authentication

```python
client = obscura_trade.Client("obs_live_...")        # explicit
client = obscura_trade.Client()                      # OBSCURA_API_KEY
```

Resolution order: constructor argument → `OBSCURA_API_KEY` → a masked stdin
prompt (only in a notebook or a TTY; set `OBSCURA_NO_PROMPT=1` to disable).
The endpoint defaults to `https://api.obscura.trade` and can be overridden with
`base_url=` or `OBSCURA_API_URL`.

Your key is a secret and the library treats it like one: it is sent only as the
`X-API-Key` header, redacted from every log record, and masked in every `repr`.

## The routing decision

`client.get()` is the recommended entry point.

| requested range vs. export coverage | route |
|---|---|
| entirely within coverage | all bulk (cached parquet) |
| entirely after `end_date` | all live |
| straddles `end_date` | bulk through the watermark + live after it |
| dataset has no export / null coverage | all live |

The split is disjoint by construction: bulk serves `[start, watermark]`, live
serves `[watermark + 1 day, end]`. Results are ordered by the dataset's conformed
date column, with duplicates at the seam removed.

```python
result = client.get("epa_tri", start="2020-01-01", end="2026-01-01")

result.route.sources        # ('bulk', 'live')
result.route.split_at       # datetime.date(2026, 7, 19)
result.bulk_rows, result.live_rows
result.served_from_cache    # was the parquet already on disk?
```

Force a path when you want one:

```python
client.get("epa_tri", start="2024-01-01", source="bulk")   # cached export only
client.get("epa_tri", start="2024-01-01", source="live")   # Postgres only
```

Preview the decision without fetching anything:

```python
print(client.explain("epa_tri", start="2024-01-01"))
```

### Why the cache matters

`POST /v1/download` returns a presigned URL for the **whole dataset parquet** —
the API's `filters` field is reserved and does no server-side filtering. The
client downloads the file once, caches it, and filters to your range locally, so
a loop over many windows costs exactly one download.

## Other calls

```python
# The narrow, metered single-page read (POST /v1/query).
result = client.query(dataset="congress_trades", symbols=["NVDA"], start="2024-01-01")

# Catalog and field-level ontology — the API is the source of truth for schemas.
for entry in client.catalog.list():
    print(entry.dataset, entry.start_date, entry.end_date, entry.row_count)

client.catalog.get("congress_trades")     # coverage, date/symbol columns, schema
client.ontology("congress_trades")        # what every field means

# Just give me the parquet file (no pandas or pyarrow needed).
fetched = client.download("congress_trades")
print(fetched.path, fetched.size, fetched.served_from_cache)
```

## Results

`Result` is a list of generic records plus provenance. The library never
hard-codes a dataset's columns — the schema is whatever the API returned.

```python
len(result)              # row count
result.columns           # column names present
result[0]                # a plain dict
list(result)             # all rows as dicts
result.to_records()      # same, a copy — no optional dependencies
result.to_pandas()       # DataFrame (obscura-trade[pandas])
result.df                # shorthand
result.to_arrow()        # pyarrow.Table (obscura-trade[arrow])
```

## Async

`AsyncClient` mirrors `Client` exactly, with coroutines:

```python
async with obscura_trade.AsyncClient() as client:
    result = await client.get("congress_trades", start="2024-01-01")
```

## Cache

Bulk objects live under `~/.cache/obscura` (`OBSCURA_CACHE_DIR`), keyed by their
upstream object path, each with a `.meta.json` sidecar recording size and fetch
time. A cached entry is reused when its size matches the `est_bytes` the API
reports for the current export; a new export changes that size and triggers a
re-download. Eviction is LRU by fetch time against a 20 GiB budget
(`OBSCURA_CACHE_MAX_BYTES`), and downloads commit via atomic rename, so an
interrupted transfer can never be served as a cache hit.

```python
client.cache.total_bytes()
client.cache.entries()
client.cache.clear()
client.get("epa_tri", start="2024-01-01", force_refresh=True)   # bypass it
```

## Errors

```python
from obscura_trade.errors import (
    ObscuraError,      # base for everything
    AuthError,         # 401 / 403 — bad, revoked or unauthorised key
    NotFoundError,     # 404 — unknown dataset
    RateLimitError,    # 429 — carries retry_after
    ServerError,       # 5xx
    ConfigError,       # bad client construction
    CacheError,        # unrecoverable on-disk cache state
    MissingDependencyError,  # pandas / pyarrow not installed
)
```

Requests are retried with exponential backoff and full jitter, honouring
`Retry-After`. `GET` retries on transport errors, 429 and 5xx. `POST` retries
**only** on 429 and connect-phase failures — `/v1/query` and `/v1/download` are
metered, and a 5xx may arrive after that write.

## Configuration

Every setting takes a constructor argument or an environment variable
(argument wins).

| Environment variable | Default | Meaning |
|---|---|---|
| `OBSCURA_API_KEY` | — | API key |
| `OBSCURA_API_URL` | `https://api.obscura.trade` | API endpoint |
| `OBSCURA_CACHE_DIR` | platform cache dir | Bulk parquet cache root |
| `OBSCURA_CACHE_MAX_BYTES` | 20 GiB | Cache budget (0 disables eviction) |
| `OBSCURA_MAX_RETRIES` | 3 | Retry attempts |
| `OBSCURA_LIVE_PAGE_SIZE` | 5000 | Rows per live page |
| `OBSCURA_CATALOG_TTL` | 60 | Seconds a coverage watermark is reused |
| `OBSCURA_NO_PROMPT` | — | Set to disable the interactive key prompt |

```python
client = obscura_trade.Client(
    "obs_live_...",
    base_url="https://api.obscura.trade",
    cache_dir="/fast/disk/obscura",
    cache_max_bytes=100 * 1024**3,
    max_retries=5,
)
```

## Development

```bash
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest
```

Tests run entirely against mocked HTTP — no network, no credentials.

## License

MIT. See [LICENSE](LICENSE).
