Metadata-Version: 2.4
Name: volatile-forecast-sdk
Version: 0.4.0
Summary: Python client for Volatile forecast data via the public GraphQL API.
Author: Volatile
License: Apache-2.0
Project-URL: Homepage, https://api.volatile.de
Keywords: forecast,graphql,energy,volatile
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software 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: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tzdata>=2024.1
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# Volatile Forecast SDK

Python client for authenticated access to forecast **dataset queries** on the Volatile public GraphQL API. It focuses on a small, typed surface (`execute_dataset_query`, `my_datasets`, `me`, token lifecycle) with **client-side validation** so integrators pick only supported bidding zones, IANA time zones, percentile fields, and aggregation modes.

## About Volatile

[Volatile](https://www.volatile.de/) is an energy intelligence company focused on making European power-market data and forecasting workflows easier to consume and operationalize.  
This SDK is one of the tools provided to help customers integrate Volatile forecast products directly into Python-based analytics, trading, and automation pipelines.

## Install

From PyPI (recommended):

```bash
pip install volatile-forecast-sdk
```


Runtime dependency: **`tzdata`** (IANA zone database for `zoneinfo` on all platforms).

Usage examples (login, dataset listing, query execution, and token-only auth) are available in **`examples/`**.

## License and access model

This SDK is open source under the **Apache-2.0** license and is publicly installable.

API usage still requires a valid Volatile customer account and API credentials. The license for this SDK does not grant free API access, and API use is governed by your contract and applicable API Terms of Service.

## API endpoint

`VolatileForecastClient()` targets production by default (**`https://api.volatile.de`**). You can override `base_url` when needed:

```python
from volatile_forecast_sdk import VolatileForecastClient, DEFAULT_BASE_URL

client = VolatileForecastClient()  # same as VolatileForecastClient(DEFAULT_BASE_URL)
client = VolatileForecastClient("https://api.staging.example.com")
```

Existing access token, same default:

```python
client = VolatileForecastClient.from_token("eyJ…")  # optional: base_url="https://…"
```

`from_token()` accepts the token first, with `base_url` as an optional keyword argument.

## Discover parameters (PFM datasets)

For products such as **Volatile Load PFM-1**, **Volatile Spot PFM-1**, and **Volatile IDC DAS-1**, the SDK ships a structured catalog that mirrors the server rules (zone, issue time, and dataset-specific optional keys):

```python
from volatile_forecast_sdk import format_parameter_hints, list_catalog_dataset_names

print(list_catalog_dataset_names())
print(format_parameter_hints("Volatile Load PFM-1"))
```

Use **`ForecastQueryParams`** for a fluent builder with validation and tab-friendly enums:

```python
from volatile_forecast_sdk import (
    VolatileForecastClient,
    ForecastQueryParams,
    EuropeanBiddingZone,
    CommonTimeZone,
    ForecastPercentileField,
    TimeAggregation,
)

client = VolatileForecastClient()
client.login("user", "pass")

params = (
    ForecastQueryParams.for_dataset("Volatile Load PFM-1")
    .with_zone(EuropeanBiddingZone.NO_2)
    .with_time_zone(CommonTimeZone.EUROPE_BERLIN)
    .with_forecast_issue_latest()
    .with_fields(ForecastPercentileField.P50, ForecastPercentileField.P90)
    .with_aggregation(TimeAggregation.FIFTEEN_MINUTES)
    .with_forecast_horizon_hours(168)
)

result = client.execute_dataset_query(name="Volatile Load PFM-1", parameters=params)
rows = result.parsed_rows()
```

IDC DAS example (lean parameter set):

```python
from volatile_forecast_sdk import CommonTimeZone, EuropeanBiddingZone, ForecastQueryParams

params = (
    ForecastQueryParams.for_dataset("Volatile IDC DAS-1")
    .with_zone(EuropeanBiddingZone.DE_LU)
    .with_time_zone(CommonTimeZone.EUROPE_BERLIN)
    .with_forecast_issue_latest()
)
result = client.execute_dataset_query(name="Volatile IDC DAS-1", parameters=params)
```

**European bidding zones** are exposed as `EuropeanBiddingZone` and `EUROPEAN_BIDDING_ZONE_CODES` (ENTSO-E-style strings). **Time zones** use `CommonTimeZone` presets plus `validate_time_zone("Europe/Stockholm")` against the full IANA set (`zoneinfo.available_timezones()`).

## Refresh token policy

`VolatileForecastClient` applies a default `RefreshTokenPolicy`:

- **Proactive**: if the access token looks like a JWT, refresh when wall-clock time is within `leeway_seconds` of `exp` (requires a refresh token from `login`).
- **Reactive**: on HTTP **401** or GraphQL errors that look like auth failures, refresh once (configurable) and retry.

Disable automation (manual refresh only):

```python
from volatile_forecast_sdk import VolatileForecastClient, RefreshTokenPolicy

client = VolatileForecastClient(refresh_token_policy=RefreshTokenPolicy(enabled=False))
```

Tune behavior:

```python
RefreshTokenPolicy(
    enabled=True,
    leeway_seconds=300,
    max_reactive_refreshes=2,
    proactive_refresh=True,
    reactive_refresh_on_graphql_auth_error=True,
)
```

Helpers: `client.access_token_expires_at_unix()`, `decode_jwt_exp_unix(token)` (signature **not** verified—scheduling only).

## Classic dict parameters

You can still pass a plain mapping (no validation beyond the API):

```python
from volatile_forecast_sdk import VolatileForecastClient, normalize_bidding_zone, validate_time_zone

client = VolatileForecastClient()
client.login("user", "pass")

params = {
    "forecast_issue_time": "latest",
    "zone_code": normalize_bidding_zone("NO_2"),
    "time_zone": validate_time_zone("Europe/Berlin"),
}
result = client.execute_dataset_query(name="Volatile Spot PFM-1", parameters=params)
```

## API reference (high level)

| Symbol | Role |
|--------|------|
| `VolatileForecastClient` | GraphQL HTTP client, token policy, `execute_dataset_query` |
| `DEFAULT_BASE_URL` | Production API root (`https://api.volatile.de`); pass a different `base_url` to override |
| `ForecastQueryParams` | Validated fluent builder for query parameters |
| `EuropeanBiddingZone`, `EUROPEAN_BIDDING_ZONE_CODES`, `normalize_bidding_zone` | Curated bidding-zone codes |
| `CommonTimeZone`, `validate_time_zone`, `list_common_european_time_zones` | IANA time zones |
| `ForecastPercentileField`, `IDCField`, `TimeAggregation` | Allowed `fields` / `aggregation` values |
| `ForecastStatus` | Per-slot forecast-quality enum (0..7) returned by IDC datasets — see *Forecast quality* below |
| `format_parameter_hints`, `parameter_hints_for_dataset`, `STANDARD_FORECAST_DATASETS`, `IDC_DAS_DATASETS`, `STANDARD_PFM_DATASETS` | Documentation-oriented catalog |
| `RefreshTokenPolicy`, `decode_jwt_exp_unix` | Token lifecycle |
| `DatasetQueryResult.parsed_rows()` | Normalize row JSON strings vs objects |

The SDK mirrors the public GraphQL schema exposed by `https://api.volatile.de/graphql/`.

## Forecast quality (IDC datasets)

IDC DAS datasets attach a per-slot quality label so consumers can filter or
weight the forecast.  Two columns are returned alongside the quantile / MC
fields:

* `forecast_status` (int 0..7) — see table below.
* `in_scope` (bool) — `true` for **ID1 slots in the 1–6 h horizon** (the MC
  layer's active scope). `false` for ID3 slots or ID1 slots outside that
  window; MC columns (`signal`, `expected_edge`, `p_buy`, `p_sell`, …) will
  be `null` by design — **not a degradation**. The LightGBM quantile
  columns (`id1_p10/50/90`, `id3_p10/50/90`) are populated regardless.

| `forecast_status` | Name | Meaning |
|---|---|---|
| 0 | `OK` | All inputs fresh; VWAP-S0 (≤6 h) or DA-S0 (>6 h by design) |
| 1 | `OK_DA_FALLBACK` | ≤6 h slot, no VWAP, crawler is fresh → liquidity gap, DA-S0 used |
| 2 | `OK_IDA_FALLBACK` | Same as 1 but intraday auction is newer than DA → IDA-S0 used |
| 3 | `WATCH` | Non-critical input source stale OR borderline VWAP age (15–30 min) |
| 4 | `VWAP_STALE` | VWAP older than 30 min — likely crawler lag |
| 5 | `IMPUTED` | LightGBM substituted defaults for one or more missing features; data feed is fresh, model output is mildly biased |
| 6 | `STALE_CRITICAL` | Data feed itself is stale: EPEX continuous-trades crawler down within MC scope, or day-ahead price missing for the delivery date |
| 7 | `FAILED` | LightGBM produced NaN at this slot (model load / feature build failure) |

Statuses are **monotonically worse** — higher is more degraded.  Suggested
thresholds:

* LightGBM-only traders: skip slots with `forecast_status >= 5`.
* MC-signal traders: skip slots with `forecast_status >= 4`.
* Conservative consumers: only trade `forecast_status <= 1`.

### Difference between status 5 and status 6

These are the two most commonly confused values, and they imply different
operational responses:

* **5 `IMPUTED`** — the *model* improvised. Input features had one or more
  NaNs at inference time and the wrapper substituted a default (categorical
  missing-path, Ridge median, or `analytical_cf=0`). The data feed is
  fresh; only some engineered features were unavailable. Forecast is in
  the right ballpark; reduce position sizing.
* **6 `STALE_CRITICAL`** — the *data feed* stopped. The EPEX continuous-
  trades crawler is stale within the MC horizon, or the day-ahead price
  is missing entirely. The model may have produced a number, but it's
  reflecting old or absent market reality. Skip the slot.

```python
from volatile_forecast_sdk import (
    ForecastQueryParams, ForecastStatus, IDCField, VolatileForecastClient,
)

params = (
    ForecastQueryParams.for_dataset("Volatile IDC DAS-1")
    .with_zone("DE_LU")
    .with_forecast_issue_latest()
    .with_fields(
        IDCField.ID1_P50, IDCField.ID3_P50,
        IDCField.SIGNAL, IDCField.EXPECTED_EDGE,
        IDCField.FORECAST_STATUS, IDCField.IN_SCOPE,
    )
)
result = client.execute_dataset_query(
    name="Volatile IDC DAS-1", parameters=params,
)
rows = [
    r for r in result.parsed_rows()
    if r.get("in_scope") and int(r.get("forecast_status", 7)) < ForecastStatus.VWAP_STALE
]
```

