Get a free API key

This page as Markdown, byte for byte: /backtest.md

Backtest a structure#

Forty years of history for a parametric structure, over HTTP, in one block. Read the quickstart first: it explains the two values you export (CEDE_BASE_URL, CEDE_API_KEY), how to get a key from POST /signup, and the job pattern every Craton verb answers with.

What you need#

The quickstart ends with a risk object carrying exposure and peril — which is everything Ingest promises and no more. To evaluate a parametric structure you need the rest: a limit, an attachment, and the index that decides the payout. This block writes one out, cuts a structure from it, backtests that structure against forty years of the pinned public catalogue, and prints the history year by year.

set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first — see 'What you need'}"

field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

# 1 — A complete parametric object: exposure, peril with the box, limit and
#     attachment, and the index that decides the payout. Ingest emits exposure
#     and peril; the rest is what a structure adds, so it is written out here.
cat > structure-object.json <<'JSON'
{
  "schema_version": "0.1.0",
  "status": "analysed",
  "exposure": {
    "kind": "location_schedule",
    "currency": "JPY",
    "locations": [
      { "ref": "LOC-0001",
        "address_as_given": "2-16-1 Konan, Minato-ku, Tokyo 108-0075",
        "latitude": 35.6284, "longitude": 139.7387,
        "geocode": { "resolution": "rooftop", "confidence": 0.96 },
        "occupancy": "warehouse", "construction": "reinforced_concrete",
        "year_built": 2011,
        "values": { "building": { "amount": 8400000000, "currency": "JPY" } } }
    ],
    "source_fidelity": { "unmapped_columns": [], "guessed_units": [], "ambiguous_rows": [] }
  },
  "peril": {
    "code": "earthquake",
    "region": {
      "description": "Kanto cat-in-a-box: 34.9-36.2N, 139.0-140.6E.",
      "bounding_geometry": {
        "type": "Polygon",
        "coordinates": [[[139.0, 34.9], [140.6, 34.9], [140.6, 36.2],
                         [139.0, 36.2], [139.0, 34.9]]]
      }
    }
  },
  "financial_structure": {
    "limit": { "amount": 2000000000, "currency": "JPY" },
    "attachment": { "value": 6.0, "unit": "M", "index_ref": "kanto-eq-box-magnitude" }
  },
  "trigger": {
    "type": "parametric_cat_in_a_box",
    "index": {
      "name": "kanto-eq-box-magnitude",
      "version": "1.0.0",
      "description": "Largest catalogue magnitude inside the Kanto box, shallower than the depth threshold.",
      "measurement": { "variable": "catalogue_preferred_magnitude", "unit": "M", "statistic": "max" },
      "aggregation_window": { "duration": "PT72H", "alignment": "event" },
      "thresholds": [
        { "label": "attachment", "level": 6.0, "unit": "M" },
        { "label": "exhaustion", "level": 7.0, "unit": "M" },
        { "label": "max_focal_depth", "level": 100, "unit": "km" }
      ],
      "payout_function": {
        "type": "step",
        "points": [ { "level": 6.0, "payout_ratio": 0.25 },
                    { "level": 6.5, "payout_ratio": 0.5 },
                    { "level": 7.0, "payout_ratio": 1 } ],
        "maximum_payout_ratio": 1
      }
    },
    "data_sources": [
      { "id": "usgs-eq-kanto",
        "name": "USGS ANSS Comprehensive Earthquake Catalog - Kanto extract",
        "kind": "quake_catalogue",
        "version": "2026-08-10", "vintage": "2026-08-10" }
    ]
  },
  "period": {
    "inception": "2026-04-01T00:00:00+09:00",
    "expiry": "2027-04-01T00:00:00+09:00",
    "timezone": "Asia/Tokyo"
  }
}
JSON

object_id=$(curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @structure-object.json \
  "$CEDE_BASE_URL/objects" | field id)
echo "object: $object_id"

# 2 — Cut a structure from it. No body: this structure is the object's own
#     terms. Send trigger, financial_structure or period to vary them.
structure=$(curl -sS --fail-with-body -X POST \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL/objects/$object_id/structures")
structure_id=$(printf '%s' "$structure" | field id)
backtest_path=$(printf '%s' "$structure" | field links.backtest)
echo "structure: $structure_id"

# 3 — Read it back at its own address.
curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL/structures/$structure_id" | field status

# 4 — Backtest it against the archive. A job, like every verb. Add
#     -d '{"window":{"from_year":1996,"to_year":2015}}' to narrow the window.
job_id=$(curl -sS --fail-with-body -X POST \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL$backtest_path" | field id)

state=unknown
for _ in $(seq 1 300); do
  job=$(curl -sS --fail-with-body \
    -H "Authorization: Bearer $CEDE_API_KEY" \
    "$CEDE_BASE_URL/jobs/$job_id")
  state=$(printf '%s' "$job" | field status)
  case "$state" in succeeded|failed) break ;; esac
  sleep 0.2
done
if [ "$state" != succeeded ]; then
  printf 'backtest did not succeed: %s\n' "$job" >&2
  exit 1
fi

# 5 — Read the artifact at its own address, and print the history.
artifact_path=$(printf '%s' "$job" | field result.backtest.links.self)
curl -sS --fail-with-body \
  -H "Authorization: Bearer $CEDE_API_KEY" \
  "$CEDE_BASE_URL$artifact_path" > backtest.json

python3 -c 'import json
artifact = json.load(open("backtest.json"))
window, statistics = artifact["window"], artifact["statistics"]
print("window:", window["from_year"], "-", window["to_year"], f"({window['"'"'years'"'"']} years)")
print("triggering years:", statistics["triggering_years"], "of", statistics["years"])
print("burning cost:", statistics["burning_cost"]["annual_payout"]["amount"],
      statistics["burning_cost"]["annual_payout"]["currency"], "a year")
print("thin years:", len(artifact["data_coverage"]["thin_years"]))
for year in artifact["years"]:
    if year["triggered"]:
        print(" ", year["year"], year["payout"]["amount"], year["payout"]["currency"],
              "on", year["occurrences"][0]["event_id"],
              "M" + str(year["occurrences"][0]["magnitude"]))
print(artifact["disclosure"])'
echo "done — the run id is reproducible: $(python3 -c 'import json;print(json.load(open("backtest.json"))["run_id"])')"

**POST /objects/{id}/structures** — a structure is a variant of an object: the trigger, limit, attachment and term a verb evaluates, addressable on its own so a backtest has something to run against. Send no body to take the object's own terms, or send trigger, financial_structure or period to vary them. The response carries the address of its own backtest, so no client builds a path from a template.

**POST /structures/{id}/backtest** — the Backtest verb (a job, like every verb). The default window is the archive's full period of record. Narrow it with {"window": {"from_year": 1996, "to_year": 2015}}; a window reaching outside what the archive holds is refused rather than quietly clipped, because a burning cost read over a period other than the one it was computed on is a wrong number nobody can see is wrong.

**GET /backtests/{id}** — the artifact, at the address the job gave you. Every calendar year in the window, with the occurrences behind each payout named by the publisher's own event ids; loss statistics and a burning-cost view; and a data_coverage block naming every year the pinned archive holds no record for — disclosed, never filled in. The run_id is content-addressed: run the same structure over the same window again and it is the same id, which is how you show a committee that a number reproduces.

It is history, and it says so on every copy. Nothing in it is a forecast.


Checking it yourself#

A burning cost you cannot rebuild is a number you have to trust, so the artifact carries the whole calculation and not just its ends. Everything below is in the document GET /backtests/{id} returns:

Field What it lets you check
assumptions.payout_function the function that was evaluated: every point, the ceiling, the attachment, and evaluation — the rule for reading them, stated in words. It is flat steps, never interpolated: a level of 6.7 against points at 6.5 and 7.0 pays the 6.5 point's ratio, not a blend of the two
assumptions.event_selection.rule what makes a catalogue record qualify — the period, the box, the magnitude threshold, and the fact that a record the publisher gave no depth for is excluded by a depth threshold rather than assumed shallow
assumptions.event_selection.qualifying_events the step between events_in_box and qualifying_occurrences, so the thresholds and the clustering can be checked separately
assumptions.event_selection.clauses.steps the same selection as a running count, clause by clause: what each one removed and what it left. events_in_box and qualifying_events are the two ends of it — 592 records in the Kanto box, 7 qualifying — and these are the thresholds in between. The clauses are a conjunction, so the qualifying set does not depend on their order and the intermediate counts do; the order stated is the order applied
assumptions.event_selection.depth_exclusions the records the depth threshold removed, one by one, each saying whether it was deeper than max_focal_depth_km or had no published depth at all. applied is false when the index sets no depth threshold — a threshold that removed nothing and a threshold that was never applied are different facts, and an empty list alone does not tell them apart
years[].occurrences[].events[] every catalogue record the aggregation window merged into that occurrence, with is_peak on the one whose magnitude the index read — not the peak alone
years[].occurrences[].window the bounds that cluster was formed in: from is its first qualifying event, to is from plus the index's aggregation window
years[].occurrences[].payout_point the point of the step function the level landed on, and capped_at_maximum when the ceiling bit. Not derivable from the ratio when two points share one
reproduction.steps the model's own steps in the order it runs them, each naming the fields it reads and the fields it wrote
reproduction.worked_example one payout done out loud — the first triggering year of the window, chosen by that fixed rule so it replays with everything else

The data source is pinned by sha256, so the bytes behind all of it are fixed: read the snapshot, apply the rules above, and the payouts come out the same. That walk is a merge gate here — a second implementation of the model, written from the artifact and importing none of the engine, rebuilds every payout and every loss statistic on each build. If the product ever does something it does not say, the gate goes red before the release does.


Which index shapes replay#

Two models sit behind Backtest, one per index shape, and a structure is replayed by the one that reads what it declares:

trigger.type What the index measures The model The archive it replays
parametric_cat_in_a_box the catalogue's preferred magnitude inside peril.region.bounding_geometry, peril earthquake cede/parametric-burn-eq-box a pinned quake catalogue vintage
parametric_index a station-measured daily series over a rolling window: daily_precipitation_mm on a rainfall_daily feed (peril weather_station), daily_maximum_wind_speed_mph on a wind_daily one (perils weather_station and tropical_cyclone) cede/parametric-burn-station-index a pinned daily-series vintage
wording nothing: an indemnity structure is a wording, not an index

Both models take a step payout function, both burn forty years, and both name themselves on every result in model. A structure carrying a wording trigger stores and reads back like any other — a structure is a draft — but there is no index to replay, so a backtest job for it finishes failed.

So does a structure whose declarations disagree with each other. This is the one to watch, and it is deliberate: the two index shapes look identical in JSON — a level, a payout ratio, an attachment — and nothing in that shape says whether 140 means millimetres of rain or moment magnitude. So each model checks the measurement and the data-source kind against what it actually computes before it reads a byte, and refuses by name rather than converting. An earthquake index relabelled parametric_index gets:

{
  "status": 422,
  "code": "unbacktestable_structure",
  "message": "Backtest v0 cannot backtest this structure: the v0 parametric model reads parametric_cat_in_a_box triggers only; this structure's trigger is parametric_index"
}

and a rainfall index whose attachment is stated in M rather than mm, or whose data_sources claim a quake catalogue, is refused in the same way with the disagreement named. A history computed by a model that does not fit the structure would be a plausible number with nothing downstream able to tell it was wrong.


A station-measured index, end to end#

The same walk as the block at the top of this page, on the other index shape: signup, an object whose trigger is a parametric_index over a pinned daily rainfall series, a structure cut from it, forty years of history, and a technical price for the same terms. Run it in an empty directory.

The index is five-day rainfall at one public grid point over Bangkok. The snapshot it reads — era5-rain-bangkok, vintage 2026-08-11 — is a pinned public reanalysis series, named on the result with its licence and the sha256 of the exact bytes the numbers came out of.

set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"

field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

await_job() {
  state=unknown
  for _ in $(seq 1 300); do
    curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/jobs/$1" > job.json
    state=$(field status < job.json)
    case "$state" in succeeded|failed) break ;; esac
    sleep 0.2
  done
  if [ "$state" != succeeded ]; then
    printf 'job %s did not succeed: %s\n' "$1" "$(cat job.json)" >&2
    exit 1
  fi
}

# 1 — An account of this block's own.
CEDE_API_KEY=$(curl -sS --fail-with-body \
  -H "Content-Type: application/json" \
  -d '{"label": "rainfall index walkthrough"}' \
  "$CEDE_BASE_URL/signup" | field api_key.secret)
export CEDE_API_KEY
auth="Authorization: Bearer $CEDE_API_KEY"
printf 'your key (shown once): %s\n' "$CEDE_API_KEY"

# 2 — The object. The index measures rainfall, so it says so in three places
#     that must agree: the peril code, the measurement, and the kind of feed
#     it reads. The attachment is in the index's own unit, mm.
cat > rain-object.json <<'JSON'
{
  "schema_version": "0.1.0",
  "status": "analysed",
  "exposure": {
    "kind": "revenue_stream",
    "currency": "USD",
    "values": { "business_interruption": { "amount": 12000000, "currency": "USD" } },
    "source_fidelity": { "unmapped_columns": [], "guessed_units": [], "ambiguous_rows": [] }
  },
  "peril": {
    "code": "weather_station",
    "region": { "description": "Public reanalysis grid point over central Bangkok." }
  },
  "financial_structure": {
    "limit": { "amount": 5000000, "currency": "USD" },
    "attachment": { "value": 140, "unit": "mm", "index_ref": "bangkok-rain-5day" }
  },
  "trigger": {
    "type": "parametric_index",
    "index": {
      "name": "bangkok-rain-5day",
      "version": "1.0.0",
      "description": "Largest rainfall total over any run of five consecutive days at the pinned grid point.",
      "measurement": { "variable": "daily_precipitation_mm", "unit": "mm", "statistic": "sum" },
      "aggregation_window": { "duration": "P5D", "alignment": "rolling" },
      "thresholds": [
        { "label": "attachment", "level": 140, "unit": "mm" },
        { "label": "exhaustion", "level": 200, "unit": "mm" }
      ],
      "payout_function": {
        "type": "step",
        "points": [ { "level": 140, "payout_ratio": 0.25 },
                    { "level": 160, "payout_ratio": 0.5 },
                    { "level": 200, "payout_ratio": 1 } ],
        "maximum_payout_ratio": 1
      }
    },
    "data_sources": [
      { "id": "era5-rain-bangkok",
        "name": "ERA5 daily precipitation - Bangkok grid point",
        "kind": "station_network",
        "version": "2026-08-11", "vintage": "2026-08-11" }
    ]
  },
  "period": {
    "inception": "2026-05-01T00:00:00+07:00",
    "expiry": "2027-05-01T00:00:00+07:00",
    "timezone": "Asia/Bangkok"
  }
}
JSON

object_id=$(curl -sS --fail-with-body -H "$auth" \
  -H "Content-Type: application/json" \
  --data-binary @rain-object.json \
  "$CEDE_BASE_URL/objects" | field id)
echo "object: $object_id"

# 3 — Cut a structure from it, and replay forty years.
structure=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/objects/$object_id/structures")
backtest_path=$(printf '%s' "$structure" | field links.backtest)
echo "structure: $(printf '%s' "$structure" | field id)"

backtest_job=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL$backtest_path" | field id)
await_job "$backtest_job"
cp job.json backtest-job.json

python3 -c '
import json
artifact = json.load(open("backtest-job.json"))["result"]["backtest"]
window, statistics = artifact["window"], artifact["statistics"]
selection = artifact["assumptions"]["event_selection"]
print("model:", artifact["model"]["name"], artifact["model"]["version"])
print("measured:", selection["measurement"]["variable"], "as",
      selection["transform"]["type"], "over",
      selection["transform"]["window_days"], "days")
print("window:", window["from_year"], "-", window["to_year"],
      "(" + str(window["years"]), "years)")
print("days reported:", selection["days_reported"],
      "- missing:", selection["days_missing"])
print("triggering years:", statistics["triggering_years"], "of", statistics["years"])
print("burning cost:", statistics["burning_cost"]["annual_payout"]["amount"],
      statistics["burning_cost"]["annual_payout"]["currency"], "a year")
for year in artifact["years"]:
    if year["triggered"]:
        print(" ", year["year"], year["payout"]["amount"],
              year["payout"]["currency"], "on",
              year["index_level"], year["index_unit"],
              "measured", year["measured_from"], "to", year["measured_to"])
print(artifact["disclosure"])'

# 4 — Price the same terms. Same model, same forty years, loadings on top.
price_job=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/objects/$object_id/price" | field id)
await_job "$price_job"

python3 -c '
import json
price = json.load(open("job.json"))["result"]["price"]
assumptions = price["assumptions"]
print("technical price:", price["technical_price"]["amount"],
      price["technical_price"]["currency"],
      "- rate on line", price["technical_price"]["rate_on_line"])
print("expected loss:  ", price["expected_loss"]["amount"],
      price["expected_loss"]["currency"])
print("burn rate:", assumptions["burn"]["burn_rate"], "-",
      assumptions["burn"]["triggering_years"], "of",
      assumptions["burn"]["years"], "years would have paid")
for source in assumptions["data_sources"]:
    print("  data:", source["id"], "vintage", source["vintage"],
          "kind", source["kind"], "sha256", source["sha256"][:16],
          "-", source["licence"])
for limitation in assumptions["limitations"]:
    print("  -", limitation)
print(price["technical_price"]["disclosure"])'
echo "done — one index, forty years of history and a technical price"

The burn rate under that price is the burn rate the backtest shows year by year: one model, asked two questions, not two numbers that happen to look alike.


The other way in: the schedule you were given#

The block above writes the whole object out by hand. The other way — the usual way, for anyone evaluating an index against a portfolio somebody sent them — is to ingest that schedule and write the structure onto the object the file became: same id, same locations, the same exposure.source_fidelity record of how the file was read, and the file itself in the object's provenance.

A location schedule carries buildings, coordinates and values. It carries no peril, no limit, no attachment and no index, because those are decisions you make after reading it and Ingest never invents them. PATCH /objects/{id} is where they go.

Run this in an empty directory. It signs up for its own account, ingests a two-row schedule, writes the four blocks a file cannot carry, cuts a structure and replays it — signup to forty years of history, in one block.

set -euo pipefail

: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"

field() {
  python3 -c 'import json, sys
document = json.load(sys.stdin)
for step in sys.argv[1].split("."):
    document = document[int(step)] if step.isdigit() else document[step]
print(document)' "$1"
}

await_job() {
  state=unknown
  for _ in $(seq 1 300); do
    curl -sS --fail-with-body -H "$auth" "$CEDE_BASE_URL/jobs/$1" > job.json
    state=$(field status < job.json)
    case "$state" in succeeded|failed) break ;; esac
    sleep 0.2
  done
  if [ "$state" != succeeded ]; then
    printf 'job %s did not succeed: %s\n' "$1" "$(cat job.json)" >&2
    exit 1
  fi
}

# 1 — An account of this block's own. No credential goes in.
CEDE_API_KEY=$(curl -sS --fail-with-body \
  -H "Content-Type: application/json" \
  -d '{"label": "schedule to backtest walkthrough"}' \
  "$CEDE_BASE_URL/signup" | field api_key.secret)
export CEDE_API_KEY
auth="Authorization: Bearer $CEDE_API_KEY"
printf 'your key (shown once): %s\n' "$CEDE_API_KEY"

# 2 — The schedule. Locations and values; nothing that says what the cover is
#     against or where it attaches.
cat > schedule.csv <<'CSV'
Location ID,Address,Latitude,Longitude,Occupancy,Building Value,Currency
LOC-0001,"2-16-1 Konan, Minato-ku, Tokyo",35.6284,139.7387,Warehouse,8400000000,JPY
LOC-0002,"1-1 Soga, Chuo-ku, Chiba",35.5687,140.1247,Light Industrial,5100000000,JPY
CSV

ingest_job=$(curl -sS --fail-with-body -H "$auth" \
  -F "[email protected];type=text/csv" "$CEDE_BASE_URL/ingest" | field id)
await_job "$ingest_job"
object_id=$(field result.objects.0.id < job.json)
echo "object out of the file: $object_id"

# 3 — The four blocks the file could not carry: the peril and the box the
#     index is read over, the limit and the index level it attaches at, the
#     index itself with the catalogue vintage it reads, and the term.
cat > completion.json <<'JSON'
{
  "peril": {
    "code": "earthquake",
    "region": {
      "description": "Kanto cat-in-a-box: 34.9-36.2N, 139.0-140.6E.",
      "countries": ["JP-13", "JP-12"],
      "bounding_geometry": {
        "type": "Polygon",
        "coordinates": [[[139.0, 34.9], [140.6, 34.9], [140.6, 36.2],
                         [139.0, 36.2], [139.0, 34.9]]]
      }
    }
  },
  "financial_structure": {
    "limit": { "amount": 2000000000, "currency": "JPY" },
    "attachment": { "value": 6.0, "unit": "M", "index_ref": "kanto-eq-box-magnitude" }
  },
  "trigger": {
    "type": "parametric_cat_in_a_box",
    "index": {
      "name": "kanto-eq-box-magnitude",
      "version": "1.0.0",
      "description": "Largest catalogue magnitude inside the Kanto box, shallower than the depth threshold.",
      "measurement": { "variable": "catalogue_preferred_magnitude", "unit": "M", "statistic": "max" },
      "aggregation_window": { "duration": "PT72H", "alignment": "event" },
      "thresholds": [
        { "label": "attachment", "level": 6.0, "unit": "M" },
        { "label": "exhaustion", "level": 7.0, "unit": "M" },
        { "label": "max_focal_depth", "level": 100, "unit": "km" }
      ],
      "payout_function": {
        "type": "step",
        "points": [ { "level": 6.0, "payout_ratio": 0.25 },
                    { "level": 6.5, "payout_ratio": 0.5 },
                    { "level": 7.0, "payout_ratio": 1 } ],
        "maximum_payout_ratio": 1
      }
    },
    "data_sources": [
      { "id": "usgs-eq-kanto",
        "name": "USGS ANSS Comprehensive Earthquake Catalog - Kanto extract",
        "kind": "quake_catalogue",
        "version": "2026-08-10", "vintage": "2026-08-10" }
    ]
  },
  "period": {
    "inception": "2026-04-01T00:00:00+09:00",
    "expiry": "2027-04-01T00:00:00+09:00",
    "timezone": "Asia/Tokyo"
  }
}
JSON

curl -sS --fail-with-body -X PATCH -H "$auth" \
  -H "Content-Type: application/json" \
  --data-binary @completion.json \
  "$CEDE_BASE_URL/objects/$object_id" > completed.json
printf 'peril now: %s, attaching at M%s - and the exposure is still the file'"'"'s\n' \
  "$(field peril.code < completed.json)" \
  "$(field financial_structure.attachment.value < completed.json)"

# 4 — Cut the structure. No body: it is the object's own terms.
structure=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL/objects/$object_id/structures")
structure_id=$(printf '%s' "$structure" | field id)
backtest_path=$(printf '%s' "$structure" | field links.backtest)
echo "structure: $structure_id"

# 5 — Replay it against the archive, and read the artifact back.
backtest_job=$(curl -sS --fail-with-body -X POST -H "$auth" \
  "$CEDE_BASE_URL$backtest_path" | field id)
await_job "$backtest_job"
artifact_path=$(field result.backtest.links.self < job.json)
curl -sS --fail-with-body -H "$auth" \
  "$CEDE_BASE_URL$artifact_path" > backtest.json

python3 -c '
import json
artifact = json.load(open("backtest.json"))
window, statistics = artifact["window"], artifact["statistics"]
print("window:", window["from_year"], "-", window["to_year"],
      f"({window['"'"'years'"'"']} years)")
print("triggering years:", statistics["triggering_years"], "of", statistics["years"])
print("burning cost:", statistics["burning_cost"]["annual_payout"]["amount"],
      statistics["burning_cost"]["annual_payout"]["currency"], "a year")
print("structure:", artifact["subject"]["structure_id"],
      "- cut from the object the file became")
print(artifact["disclosure"])
'
echo "done — the schedule is now a structure with forty years behind it"

The object, the structure and the artifact all stay where they are: read the object back and the locations Ingest normalised sit beside the blocks you wrote, with the file that produced them in provenance. Change the limit with another PATCH, cut a second structure, and replay that — each structure keeps its own identity, so two candidate layers can be compared over the same forty years.