ManticDocs
Read forecasts

Compare forecast runs over time

Measure how a forecast moved between runs, and detect when a new run completes.

Full script: forecast_history.py.

Fetch the forecast history

Fetch a forecast's runs with GET /questions/{question_id}/forecasts:

curl --get "https://api.mantic.com/questions/QUESTION_ID/forecasts" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode "show_forecasts=true"

Each item in forecasts is a run, newest first:

{
  "question": {
    "id": 123,
    "title": "Will UK CPI inflation be below 2.5% in December 2026?",
    "url": "https://app.mantic.com/question/123"
  },
  "forecasts": [
    {
      "id": 987,
      "date": "2026-09-21T08:30:00Z",
      "type": "event",
      "probability": 0.64,
      "by": "2026-12-31T23:59:59Z"
    },
    {
      "id": 963,
      "date": "2026-09-20T08:30:00Z",
      "type": "event",
      "probability": 0.57,
      "by": "2026-12-31T23:59:59Z"
    }
  ],
  "next": {
    "date": "2026-09-20T08:30:00Z",
    "id": 963
  }
}

The history is paginated. In Python, take the latest two runs from paginate():

history = paginate(
    client,
    f"/questions/{question_id}/forecasts",
    "forecasts",
    {"show_forecasts": "true"},
)
return list(islice(history, 2))

Measure the change

Here the change is 0.64 - 0.57, or +7 percentage points (pp), not a 7% relative increase. Each forecast type needs its own unit:

if latest["type"] != previous["type"]:
    return {"kind": "type_changed", "from": previous["type"], "to": latest["type"]}

match latest["type"]:
    case "event":
        points = 100 * (latest["probability"] - previous["probability"])
        return {"kind": "percentage_points", "value": points}
    case "numerical" | "date":
        return median_change(latest, previous)
    case "categorical":
        old = {item["label"]: item["probability"] for item in previous["categories"]}
        if old.keys() != {item["label"] for item in latest["categories"]}:
            return {"kind": "categories_changed"}
        return {
            "kind": "category_percentage_points",
            "value": {
                item["label"]: 100 * (item["probability"] - old[item["label"]])
                for item in latest["categories"]
            },
        }
    case "redacted" | "unknown":
        return {"kind": "unavailable"}
    case other:
        return {"kind": "unsupported", "type": other}

median_change, in the full script, returns the change in median: in the forecast's units, or in days for a date forecast.

See why it moved

Compare the two runs' reasoning.

Detect a new run

To watch forecasts for new runs, poll GET /questions. Each response has an ETag header (a fingerprint of the page) and an X-Poll-Interval header (seconds to wait before polling again). Send the ETag back in If-None-Match; an unchanged page returns 304 with no body:

curl --get --include "https://api.mantic.com/questions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'If-None-Match: W/"ETAG_FROM_PREVIOUS_RESPONSE"' \
  --data-urlencode 'question_ids=[123,456]' \
  --data-urlencode "show_forecasts=true"

The ETag covers one page of up to 25 forecasts, so watch at most 25 per request, even though question_ids accepts 100. GET /questions/{question_id} supports the same headers for a single forecast.

ManticClient raises on 304, so catch it:

headers = {"If-None-Match": etag} if etag else {}
params = {"question_ids": json.dumps(question_ids), "show_forecasts": "true"}
try:
    response = client.request("GET", "/questions", params=params, headers=headers)
except httpx.HTTPStatusError as error:
    if error.response.status_code != 304:
        raise
    return None, etag, int(error.response.headers["X-Poll-Interval"])
interval = int(response.headers["X-Poll-Interval"])
return response.json()["questions"], response.headers["ETag"], interval

A changed page doesn't always mean a new run, because other fields can change too. A new latest_forecast.id does:

changed = []
for question in questions:
    forecast = question["latest_forecast"]
    if forecast is None:
        continue
    if seen.get(question["id"]) != forecast["id"]:
        seen[question["id"]] = forecast["id"]
        changed.append(question)
return changed

Run the full script once, or keep watching with --watch:

python forecast_history.py 123 456
python forecast_history.py 123 456 --watch

On this page