Frontend Integration
Energex is source-available (PolyForm Noncommercial 1.0.0). This repository is the
power-markets data platform; the polished cross-device frontend is a separate, PRIVATE
commercial repository. The two never import each other — the only contract between them
is the S2 read API (energex.service.readapi:app) documented here.
The S2 read API is a thin, read-only FastAPI seam over energex.core.storage.
It is point-in-time first: every data endpoint takes an optional as_of (ISO datetime)
and defaults to the latest committed vintage when omitted. It opens ArcticDB read-only
using the same URI grammar and credentials as the orchestration write
side (MINIO_* / ARCTIC_BUCKET from the environment); the connection URI embeds the S3
secret and is never logged.
The open-core boundary
energex (THIS repo) energex-app (SEPARATE, PRIVATE repo)
PolyForm Noncommercial (source-avail) proprietary / commercial
power data platform + S2 read API the frontend experience (the paid offering)
│ │
└──────── S2 read API (JSON, as_of) ───────┘
- Decoupled evolution. The only contract is the S2 read API. This repo owns it; the private app consumes it. Neither repo imports the other.
- Single read seam. The app NEVER touches ArcticDB/MinIO directly. It only calls S2, where auth and CORS live (see below).
- Point-in-time everywhere. Every data endpoint accepts an optional
as_of, which is what drives the frontend's time-travel slider.
Where the API runs
The api service in compose runs
uvicorn energex.service.readapi:app --workers 1.
| Caller | Base URL |
|---|---|
| In-network (other compose services, e.g. the frontend container) | http://api:8001 |
| Host / LAN (published port) | http://<host>:8000 |
The compose frontend service is passed ENERGEX_API_URL=http://api:8001 and depends on
the api service. From a browser or a host process, use the published port :8000.
Endpoints
as_of is knowledge time (when Energex learned a value), not valid time. Omit it for
the latest committed vintage; set it to reconstruct the world as it was known at that
instant. An as_of earlier than the first committed vintage returns an empty series and
never leaks the future.
| Method & path | Query params | Returns |
|---|---|---|
GET /healthz | — | {status, libraries, latest_as_of, graph} (cheap probe; never auth-gated) |
GET /libraries | — | ["power.lmp", "power.demand", …] — the ArcticDB libraries present |
GET /symbols | library (required) | symbols in a library (the *__vintages sidecars are hidden) |
GET /series | library, symbol (required); as_of, start, end (optional) | read_as_of rows as JSON records |
GET /curve | commodity (required); as_of (optional) | the assembled forward curve as JSON records |
GET /graph/entities | label (optional) | entity-graph catalog nodes [{label, key, properties}, …] |
GET /graph/related | instrument_id (required); depth (optional, 1–3) | connected entities + sibling instruments for discovery |
The /graph/* endpoints serve the current-state entity graph
(no as_of — catalog facts carry first_seen/last_seen provenance instead) and are
optional: they return 503 when the full profile's Neo4j is not running, while
every series endpoint keeps working. healthz.graph reflects the cached graph
connection state. Like every other data endpoint, they are gated by the optional API
key (see Auth).
GET /healthz
Cheap liveness/readiness probe. latest_as_of is the most recent committed knowledge time
across all symbols, computed by reading only the small *__vintages sidecars (never the
heavy series data). This endpoint is never auth-gated.
{
"status": "ok",
"libraries": ["power.lmp", "power.demand", "power.demand_forecast"],
"latest_as_of": "2026-06-28T14:00:00"
}
GET /libraries
The ArcticDB libraries present, e.g. power.lmp, power.dalmp, power.load,
power.demand, power.demand_forecast, power.generation, power.interchange,
power.generation_by_fuel, prices.spot, fundamentals.eia, weather.
["power.lmp", "power.dalmp", "power.load", "power.demand"]
GET /symbols?library=power.lmp
Symbols in a library. Power symbols are the bare lowercased codes — balancing-authority
codes for EIA-930 (erco, ciso, …) and settlement-point codes for ERCOT. The internal
*__vintages sidecars are filtered out. Returns 404 for an unknown library.
["hb_hubavg", "hb_north", "hb_houston"]
GET /series?library=…&symbol=…
The read_as_of rows for one symbol as JSON records. Each record carries the Datetime
index (the valid time, ISO 8601), the value columns, and the provenance columns including
vintage_reconstructed (see the honesty boundary).
NaN serializes to null.
as_of(optional) — knowledge time; defaults to the latest committed vintage.start,end(optional) — bound the valid-time range. Supplying either makes the read a bounded read.
Returns 404 for an unknown library or symbol, 400 for an unparseable datetime.
[
{
"Datetime": "2026-06-28T13:00:00.000Z",
"instrument_id": "ERCOT.SPP.HB_HUBAVG",
"value": 42.17,
"vintage_reconstructed": false
}
]
The full-history row cap (413)
An unbounded /series read (no start/end) is capped so a single request cannot
serialize an arbitrarily large series into one response. If the result exceeds the cap the
endpoint returns 413 with a detail telling you to narrow with start/end. The cap
defaults to 500000 rows and is configurable via the ENERGEX_SERIES_MAX_ROWS
environment variable. Intentional bounded reads (with start and/or end) are exempt.
{ "detail": "series has 812345 rows (> 500000); narrow with start/end" }
GET /curve?commodity=…
The assembled forward curve for a commodity as JSON records, optionally as_of a past
knowledge time. Returns 404 for an unknown commodity.
The reconstructed-vintage flag
Every /series (and /curve) record carries vintage_reconstructed: bool. true marks
a reconstructed baseline (a best-effort backfill, not a true point-in-time forward
vintage); false marks a true vintage. The flag is per-row, so a series can mix a
reconstructed baseline with later true revisions. The frontend recolors/badges
reconstructed data so the honesty boundary is always visible — never present reconstructed
data as if it were a true forward vintage. See storage & point-in-time
for the full bitemporal model.
Auth (optional API key)
Auth is opt-in. When the ENERGEX_READ_API_KEY environment variable is set, every
data endpoint (/libraries, /symbols, /series, /curve, /graph/entities,
/graph/related) requires a matching
X-API-Key header, compared in constant time. /healthz stays open so probes keep
working. When the variable is unset the API is open and logs a startup warning. A
missing or invalid key returns 401.
curl -H "X-API-Key: $ENERGEX_READ_API_KEY" \
"http://localhost:8000/series?library=power.lmp&symbol=hb_hubavg&start=2026-06-01"
CORS (opt-in, origin-scoped)
Cross-origin access is opt-in and origin-scoped. ENERGEX_CORS_ORIGINS is a
comma-separated allow-list (e.g. the frontend's origin); only GET is allowed. Unset
means no cross-origin access (the safe default).
ENERGEX_CORS_ORIGINS=https://app.example.com,http://localhost:5173
Example fetches
# Liveness + latest knowledge time (no key required)
curl http://localhost:8000/healthz
# Discover libraries, then symbols in one
curl http://localhost:8000/libraries
curl "http://localhost:8000/symbols?library=power.lmp"
# A bounded series read (exempt from the 413 cap)
curl "http://localhost:8000/series?library=power.lmp&symbol=hb_hubavg&start=2026-06-01&end=2026-06-28"
# Time-travel: the same series as it was KNOWN at a past instant
curl "http://localhost:8000/series?library=power.demand_forecast&symbol=erco&as_of=2026-06-20T00:00:00"
# Forward curve, as known today
curl "http://localhost:8000/curve?commodity=henryhub"
// From the private frontend (in-network base URL, with the optional key)
const API = process.env.ENERGEX_API_URL ?? "http://api:8001";
async function series(library: string, symbol: string, asOf?: string) {
const u = new URL(`${API}/series`);
u.searchParams.set("library", library);
u.searchParams.set("symbol", symbol);
if (asOf) u.searchParams.set("as_of", asOf);
const res = await fetch(u, { headers: { "X-API-Key": process.env.ENERGEX_READ_API_KEY ?? "" } });
if (res.status === 413) throw new Error("unbounded read too large — pass start/end");
if (!res.ok) throw new Error(`S2 ${res.status}`);
return res.json();
}
Running the read-path stack
The frontend lives in a separate private repo. Check it out into ./frontend (gitignored
here — the compose frontend service builds from it and is inert until the repo is
present), then bring up the stack:
docker compose --profile full up -d
This starts MinIO, the Dagster write side, and the api (S2 read API) service. The
Dagster operator console (:3000) is the operator surface — out of scope for the product.
See deployment and operations for the full topology,
and data sources & connectors for what fills the libraries.