← Writing

Product · August 18, 2026 · 7 min

Describe your data, don’t reshape it

Most forecasting APIs ask you to flatten your world (ours did too, until today!). You take whatever lives in your warehouse (stores, SKUs, regions, daily and weekly grains, gaps and all), collapse it into anonymous arrays, and re-upload the whole thing on every call. That’s the “before”; drag the divider for a taste of the “after”:

Before
{
  "series": [
    { "index": ["2022-01-01", ...], "values": [142, 137, ...] },
    { "index": ["2024-01-01", ...], "values": [90, 84, ...] }
  ],
  "horizon": 28
}
The pipeline to get to this query payloaddatabasefan out your timeseriesbucket transactions dailyimpute missing daysaggregate at (sku × store)encode a flat JSONsend the query
After
{
  "input": <the schema you write once>,
  "selector": { "daily": "any", "sku": "any", "store": "any" },
  "prediction_length": 28
}
The pipeline to get to this query payloadtimeseries schemadatabaseselector: daily · sku · storequery

In the “before”, each series is a bare list of numbers with dates attached. Position in the list is the only identity: nothing says which store, which product, which city, or how one series relates to another. Your data has structure. It’s the first thing this shape throws away, leaving you to rebuild it by hand, in glue code, before every request.

Our beta API addresses this issue. You describe your data; we do the reshaping. You hand us a schema that says what your columns mean, and a selector that says which slice you want to forecast. Everything else — aggregating or interpolating to the right grain, filling gaps, rolling up a hierarchy, running a backtest across dozens of dates or a bulk forecast — folds in one description, automatically handled by our timeseries engine.

The rest of this post runs the whole loop on a tiny retail example: a few SKUs, a few stores, a few cities.

Describe a family once

So what do you describe, exactly? Not just one timeseries: a real dataset is a family of them, meaning you have many related timeseries that live together. Your sales spans multiple stores, multiple SKUs, multiple categories, maybe is transactional… We call this bag of timeseries a timeseries family.

Here’s the kind of raw, long-format table that lands in your data lake:

View data table
dateskucolorproduct_familystorecitysales
2024-01-01SKU_1blackshoesS1Paris100
2024-01-01SKU_1blackshoesS2Lyon80
2024-01-01SKU_2redshirtsS1Paris60
2024-01-02SKU_1blackshoesS1Paris102
2024-01-02SKU_1blackshoesS2Lyon78
2024-01-02SKU_2redshirtsS1Paris55

With our API v1, instead of reshaping this dataset into flat timeseries, you just describe it. Assign each column a role, say how each signal behaves when it’s aggregated or imputed, and declare how your identifiers roll up:

{
  "input": {
"source": { "uri": "s3://acme/retail/sales/*.parquet" },
Thesourceyour data already lives somewhere — no need to serialize megabytes into a request body. Inline arrays, Parquet on object storage, a glob across many files; more connectors on the way.    "columns": {
      "date":           { "kind": "time", "frequency": "1d" },
      "sku":            "identifier",
      "color":          "identifier",
      "product_family": "identifier",
      "store":          "identifier",
      "city":           "identifier",
      "sales":          { "kind": "target",
                          "aggregate": "sum",
                          "impute": { "const": 0 } }
    },
    "hierarchies": {
      "product":   [["sku", "color"], ["sku", "product_family"]],
      "geography": [["store", "city"]]
    }
  },
  "selector": { "product_family": "any", "city": "any" },
  "prediction_length": "3w"
}

Read the hierarchies block as aggregation paths, grouped into dimensions you name. In this example, the most granular level you have in your product dimension is SKU. For the geographical dimension, that’s the level store.

The hierarchies ["sku", "color"] and ["sku", "product_family"] mean that you can either aggregate multiple SKUs over their color, or over their product_family. Also, individual stores aggregate into cities.

The selector is the dial: it picks which level you want to forecast. Click around and watch the request change:

Pick a level for each dimension, then refine its values. The request updates live.
With sku rolled up to product_family and store rolled up to city, the selector in the request above gives one forecast per (day, product_family, city).
time
range
product
·
product_family values
geography
·
city values
One forecast per (day, product_family, city)
{
  "input": {
    "source": "s3://acme/retail/sales/*.parquet",
    "columns": { ... },
    "hierarchies": {
      "product": [["sku", "color"], ["sku", "product_family"]],
      "geography": [["store", "city"]]
    }
  },
  "selector": {
    "product_family": "any",
    "city": "any"
  },
  "prediction_length": "3w"
}
the timeseries this selector materializes
shoes · Parisshoes · Lyonshirts · Parisshirts · Lyon2024-10-012024-11-152024-12-31

Aggregate SKUs up to families, stores into cities, days into weeks or months, and you never hand-roll that GROUP BY again. Missing days, mixed grains, ragged history: you declared how to handle them once, in the schema above.

One query to rule them all 💍: full backtest in one go

Because you described a family, a single query fans out into a forecast for every timeseries it contains: every family, every city, in one request.

Once the API understands your data’s shape, backtesting is another parameter, not another pipeline. A backtest is just that forecast re-run from many points in time, so say so:

{
  "input": { 
    "source": { "uri": "s3://acme/retail/sales/*.parquet" },
    "columns": { 
      "date": { "kind": "time", "frequency": "1d" },
      "sku":            "identifier",
      "color":          "identifier",
      "product_family": "identifier",
      "store":          "identifier",
      "city":           "identifier",
      "sales":          { "kind": "target",
                          "aggregate": "sum",
                          "impute": { "const": 0 } }
    }
  },
  "selector": { "product_family": "any", "city": "any" },
  "context": "90d",
  "prediction_length": "28d",
  "cutoff": [{ "every": "2w" }],
  "quantiles": [0.1, 0.5, 0.9],
  "compute_metrics": true
}

"cutoff": [{ "every": "2w" }] re-runs the forecast from a cutoff every two weeks, across every timeseries, with 90 days of context each time. You get back accuracy metrics and quantile forecasts for each cutoff date — a full rolling-origin evaluation from the request you were already going to send. Pass a list of explicit dates instead, and you control exactly when each forecast is made.

Each cutoff reuses the same request shape: historical cutoffs become backtests, and future-facing cutoffs become forecasts. Two more knobs shape each run: lead_time inserts a gap between the cutoff and the first predicted day (you place Friday’s order for a week that starts Monday), and span sets how much time each forecast value covers (daily history in, weekly values out). Expand the controls to see how cutoff, lead_time, span, and prediction_length carve up the timeline:

One query, re-run from many cutoffs. Historical cutoffs become backtests, scored against history; future-facing cutoffs project forecasts forward.
500k1.0M1.5Mcontextprediction_lengthspancutoffcutoffW1W2W3future →
actuals · per dayforecast · per spancutoffone of many series in the family

Hover a forecast to isolate it: each one sees only the context behind its own cutoff.

Forecasts you can keep

With our API v1, you get forecasts in a long-format table, shaped like what went in: one row for each predicted time of each series.

Each row answers three questions:

  1. Which timeseries is this? The row has one column per identifier. For instance city or sku.
  2. What is the period forecasted by this row? The row has one time column for the target datetime, and one span column. For instance a forecast for the first week of 2026 has time 2026-01-05 and span 1 week.
  3. What knowledge was used for this forecast? The row has one cutoff column that says the last instant the forecast saw, and a lead_time duration that says how far this row is past the first forecastable datetime.

This shape is a context-free format for storing forecasts. Any level of aggregation in the hierarchy, any information cutoff date, any horizon can fit in the same format with the same columns. One table with no metadata sidecar to tell them apart, and GROUP BY cutoff, span pulls each forecast back out.

The table below presents the output from two runs over the same data, interleaved (one daily, one weekly):

Two runs over the same family — one daily, one weekly — land in one table. No metadata column says which is which: cutoff, lead_time, and span make every row self-describing. Hover a row to isolate its run.
predicted periodlead_timecutoffwhich timeseriestarget: sales
timespanproduct_familycityq0.1q0.5q0.9
2024-01-031d0d2024-01-02shoesParis99.7100.9103.3
2024-01-031d0d2024-01-02shoesLyon76.678.581.5
2024-01-031d0d2024-01-02shirtsParis51.756.263.5
2024-01-081d5d2024-01-02shoesParis99.7100.8103.3
2024-01-081w0d2024-01-01shoesParis99.3100.0101.6
2024-01-081w0d2024-01-01shirtsParis59.360.061.6
2024-01-151w1w2024-01-01shoesLyon79.380.182.2
Two forecasts, one table · group by cutoff and span to pull them apart7 of the runs' 72 rows shown

Metrics arrive the same way — one row per series: CRPS, WAPE, bias — and it all lands in your lake next to the actuals. No glue code on the way out either.

Coming soon: attach covariates, forecast among peers

The two ideas below aren’t live yet, but you’ll get both for free: they only need the family description you already wrote.

Attach what the world knows

Your timeseries alone is often just one part of the story, the other parts live outside your sales table: promotions, prices, holidays, weather, events. Those external parts help explain why that SKU sold so well last March.

Turns out, a covariate is just a timeseries family like any other. All we need is to be able to attach timeseries families together. This can be done by declaring how their hierarchies match: joining a store-level timeseries with a city-level timeseries is possible because (1) we know which store is in which city, and (2) we know how to aggregate from the store level to the city level.

Your promotions calendar and your price list are families you already own: attach them and they stay yours, as private as the rest of your data. Public data is different: nobody should maintain a holiday calendar twice! So the world’s families will sit in a shared catalog, for you to pull:

"covariates": [
  "soon://timeseries-lake/holidays?country=FR",
  "soon://timeseries-lake/weather?city=Paris&frequency=weekly"
]

Surprisingly, soon:// doesn’t resolve yet, and probably needs a rename.

Forecast among peers

Much of the signal for forecasting one timeseries comes from its relatives (it’s a timeseries family, after all). A newly launched product has no history of its own, but its predecessors do. So instead of forecasting every timeseries in isolation, you can pair each target with the peers that should inform it: a small map from one selector to another.

Forecasting {"sku": ["jPhone10"]}? Let it borrow from {"sku": ["jPhone8", "jPhone9"]}. A fresh store leans on the established ones nearby. This season’s launch leans on the last two generations. Each target points at its own hand-picked peers, and the model sees them alongside the target at request time: no fine-tuning, no separate training job.

launchlaunchtodayjPhone8jPhone10forecastlearns from

Describe once, forecast anything

Describe your data where it lives, once and for all, and query forecasts or backtests at any aggregation level, soon with covariates and peers in context.

This API is in beta. Grab an API key, describe the data you already have, and forecast any slice of it you can name.

Questions or feedback? Reach us at support@theforecastingcompany.com.