Skip to main content

forecast-store

A forecast store is a tri-temporal store for forecasts — and for the data they are made from and scored against. Producing a forecast has become nearly free; operating forecasts — keeping every vintage, guaranteeing point-in-time correctness, reproducing backtests, tracking accuracy, catching drift — is hand-rolled glue code on every team that ships one. This repo is an open schema convention for that layer on Postgres/TimescaleDB, plus its reference implementation: a schema generator, a small SDK, and adapters.

Two ideas carry the whole design:

  • Forecasts are tri-temporal. Every row answers three questions with three clocks: when is the value true (target_time), when did its source know it (available_at), and when did your database learn it (recorded_at, stamped by the database, never by a client). Cut available_at at a simulated decision moment and backtests cannot leak — the query cannot return what wasn't knowable. Pin recorded_at and evaluations reproduce exactly, forever.
  • A forecast store is a log of beliefs, not a table of values. Measurements and predictions are different kinds of belief and live in different append-only tables; a revision or a new vintage is a new row, never an update.
table holds a newer row about the same target
actuals measurements, possibly revised corrects it
predictors external forecast vintages (weather, prices) re-predicts it
forecasts your models' output, with run provenance (runs) re-predicts it
evaluation_* accuracy results is a new evaluation

The operational questions, answered

you ask the store answers with
How good is the model? a backtest that cuts available_at <= S — it cannot leak, because the query cannot return what wasn't knowable
How good is the model as operated? pin recorded_at <= S as well — scoring against what your system had actually ingested, delays included
Can I reproduce last quarter's evaluation? freeze recorded_at at experiment time; the same rows come back forever, whatever arrived since
What did we know when that bid went wrong? one as-of read at bid time — separating the model was wrong from the inputs were late or later revised
Is the model drifting? accuracy lives as rows in evaluation_*; drift is a WHERE clause and a dashboard, not a notebook
Which vendor forecasts best at a 12-hour lead? every superseded vintage stays scoreable — vendor skill by lead time is a query
Did the feed deliver on time? recorded_at − available_at is the measured delivery lag, per row
Can I backfill years of history honestly? state genuine per-row availability and backtests behave as if you'd ingested live; a claimless load fails loud, never quietly optimistic

Quickstart

pip install forecast-store        # v0 in preparation; see status below
forecast-store ddl                # print the generated schema
forecast-store provision --dsn postgres://...
forecast-store provision --dsn postgres://... --config store.yaml   # any set of tables, from a YAML declaration
forecast-store register-series site42/load --interval "15 minutes" --dsn postgres://...
forecast-store describe --dsn postgres://...                        # the store's declaration, as a YAML file
forecast-store describe --dsn postgres://... --config store.yaml    # drift check: exit 1 if the store differs

Write a forecast with honest knowledge time, then read it back as-of any moment:

from forecast_store import Store

with Store.connect(DSN) as store:            # a DSN or a pool; declaration read from the store
    store.register_series("site42/load", "15 minutes")   # get-or-create
    store.write_forecast(
        series="site42/load",                 # a registered name, or its series_id
        model="my-model", run_name="prod/site42",
        available_at=now,                     # the knowledge time, recorded
        points=[(ts, {"q05": 1.1, "q50": 2.0, "q95": 3.2}), ...],
    )
    # Same point shape for measurements and vendor feeds; knowledge time follows the table's role:
    store.write_actuals("site42/load", [(ts, 2.1), ...])                  # arrival measured
    store.write_predictors("site42/wx/temp", [(ts, 9.8), ...], available_at=published)
# the block is the unit of work: committed on exit, rolled back on exception

Store is sugar over the module functions — write_forecast(conn, config, ...) and friends take a connection and a declaration you manage, and never commit. Bind a connection you already hold (or a pool checkout) with Store(conn, config); StoreConfig.from_store(conn) reads the declaration a store was provisioned with. The Python guide walks the whole lifecycle and states the rules — knowledge time per table role, aware timestamps, the block as the unit of work.

-- The workhorse query: the latest belief per target, as of a knowledge cutoff.
SELECT DISTINCT ON (target_time) target_time, available_at, q50
FROM forecast.forecasts
WHERE series_id = forecast.get_series_id('site42/load')
  AND target_time BETWEEN %(t0)s AND %(t1)s
  AND available_at <= %(asof)s
ORDER BY target_time, available_at DESC;

OpenSTEF integration

Adapters for every integration seam OpenSTEF 4.x exposes (forecast_store.integrations):

OpenSTEF seam object role
ForecastingCallback ForecastStoreCallback persists every predict() with its real available_at — the knowledge time OpenSTEF itself never records; run + points in one transaction
context assembly StoreReader (with Observed / ForecastFeed bindings) model-ready datasets: measured history to the decision moment, covariates as-of it — leakage-free by construction, read provenance riding along as store_context
TargetProvider TimescaleTargetProvider serves versioned measurements and predictor vintages to OpenSTEF's backtest engine, which applies its own knowledge cutoffs
BenchmarkStorage TimescaleBenchmarkStorage backtest vintages (simulated available_at) and evaluation reports into the store; label-scoped overwrite, indexed resume checks, per-instance target tables

The production loop in miniature — context in as-of the decision moment, forecasts out with honest knowledge time:

from forecast_store.integrations.openstef import ForecastStoreCallback, StoreReader
from openstef_models.workflows.custom_forecasting_workflow import CustomForecastingWorkflow

dataset = StoreReader(DSN).context(
    target_series="site42/load",
    covariates={"wind_speed_80m": "site42/wx/wind_speed_80m"},  # plain name = vendor feed
    history_start=asof - timedelta(days=30), asof=asof, horizon_end=asof + timedelta(days=3),
)
workflow = CustomForecastingWorkflow(
    model=model, model_id="site42",
    callbacks=[ForecastStoreCallback(DSN, "site42/load")],
)
workflow.fit(dataset)
workflow.predict(dataset)  # persisted: run + points, real available_at, one transaction

Every adapter takes a connection source — a DSN, or a pool with a .connection() context manager — and works in one connection per call. The store's declaration is read from its own store_tables on first use when store_config is omitted; pass schema= (or store_schema= on the provider) for a store outside the default forecast schema.

Details, mappings, and packaging notes: docs/integrations/openstef.md.

Validated against a real pipeline

The convention is validated end-to-end against OpenSTEF (LF Energy's short-term energy forecasting pipeline), with the store as the only data source and result sink for its public liander2024 benchmark — real grid measurements with their real ~48-hour publication lags, weather as versioned vintage histories. Full official window, 5 wind parks × 306 days, identical store-served data:

The whole suite runs on two of the benchmark's groups — its hardest (wind: weather-driven, no daily rhythm) and its most anchored (solar: nights are exactly zero). rCRPS, lower is better; each column's leader in bold:

model weather covariates wind solar
TimesFM 3.0 (zero-shot)*† all 11, native 0.0657 0.0433
TimesFM 3.0 (zero-shot)*† 3, native 0.0708 0.0437
Chronos-2 (zero-shot) all 11 0.0711 0.0413
Chronos-2 (zero-shot) 3 (official example) 0.0726 0.0424
Chronos-2 small (zero-shot) 3 0.0739 —
XGBoost + conformal calibration all 11 + engineered 0.0834 0.0539
TimesFM 2.5 (zero-shot)* all 11, via XReg 0.0848 0.0590
TimesFM 2.5 (zero-shot)* 3, via XReg 0.0894 —
XGBoost (weekly retrain) all 11 + engineered 0.0946 0.0552
GBLinear (weekly retrain) all 11 + engineered 0.0947 0.0571
TimesFM 3.0 (zero-shot)*† none (univariate) 0.1301 0.0553
Moirai 2.0 R small (zero-shot)* all 11 0.1416 —
Moirai 2.0 R small (zero-shot)* 3 0.1421 —
TimesFM 2.5 (zero-shot)* none (univariate) 0.1437 0.0598

The two domains read together are the finding: on wind, covariate access is everything (TimesFM 3.0 univariate → all-11 improves 49%) and TimesFM 3.0 leads; on solar, the day/night anchor makes univariate TSFMs competitive with trained models, the ladder compresses to 21% and saturates at three covariates, a linear covariate side-channel collapses to worthless while native attention keeps working — and Chronos-2 takes the lead. Neither frontier model dominates both.

Beneath the domain split, three levers separate the rows: covariate access, covariate mechanism (native attention vs. a linear side-channel), and band calibration (split-conformal alone closes half the classical gap on wind, median untouched) — not raw model class. The store is what makes the comparison honest, serving every model identical versioned vintages.

* Decile-native models: scored over their own 5-level band (a separate forecast-log instance whose declared band is true), so their rCRPS spans a narrower band than the others'; rankings hold on band-independent rMAE (recorded per run in the benchmark log). † TimesFM 3.0 weights are non-commercial-licensed — benchmark use only; the best production-licensed model here is Chronos-2. Every number above is a row in the store's evaluation tables; the full record — per-park results, run labels, and the planned runs — is docs/benchmark_log.md.

Documentation

Requirements and status

Built for TimescaleDB (hypertables, columnstore, and the generated data-quality sweep); the core schema and read/write paths also run on plain Postgres 14+. Python ≥ 3.12.

Status: pre-release. The convention is draft v0.4 (spike-validated; the spec's §11 keeps an honest ledger of open and closed design questions). The PyPI name currently holds a placeholder; the v0 package release is in preparation, and APIs may still change.

License

Apache-2.0

Metadata

Release files for forecast-store 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for forecast-store 0.1.0
File Size Uploaded Provenance Actions
forecast_store-0.1.0.tar.gz 796.9 kB Without provenance Details for forecast_store-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for forecast-store 0.1.0
File Interpreter ABI Platform Actions
forecast_store-0.1.0-py3-none-any.whl Python 3 none any Details for forecast_store-0.1.0-py3-none-any.whl

Total release size: 858.4 kB

Release files / forecast_store-0.1.0.tar.gz

Download URL forecast_store-0.1.0.tar.gz
Size 796.9 kB
Tags Source
SHA-256 checksum
How to use checksums
5ad92da493611235712743d58596590d9374185c7ac0d554449bdf817a0ef3d7
BLAKE2b-256 checksum
How to use checksums
8311b4da1180c8e47079a56ee8274a3ad02a18810f7c042d9a1f2ff2ec6c0258
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.14

Release files / forecast_store-0.1.0-py3-none-any.whl

Download URL forecast_store-0.1.0-py3-none-any.whl
Size 61.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
617533399f21f2b49da6d97fca9d62ae94a5584538e2ef07eabf792f688f7857
BLAKE2b-256 checksum
How to use checksums
634bceed9d0cbbfbf8b6091342e45c21cf47261d0866c22cd5e36eac122de79b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.14

Security for forecast-store 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page