The claims ledger

Every quantitative sentence on this site is an instance of one of the claim types below. Each entry states the exact rule that produces the sentence and the steps to recompute it from public data — same inputs, same sentence, by any reader.

Two rules govern all of them. Claims enumerate and juxtapose: they count, rank and date, and never attribute one series' move to another's — whether an event explains a move is a judgement the site deliberately leaves to the reader. And every claim is recomputable: if a sentence cannot be reproduced from published data by the stated rule, it does not belong on the site.

Example sentences below show the form; live values are on the linked pages. The ledger is also served machine-readable at /api/v1/claims.

How unusual a move is

Chinese Yuan to USD fell 0.7% over the past month — a larger move than 72% of month-long moves since 1981.

Appears on What's moving · Homepage strip

The rule

The window is fixed calendar time, chosen by the series' own cadence: 21 trading days for daily series, 4 observations for weekly, 1 for monthly, 1 for quarterly — so every series is measured over roughly one month regardless of how often it publishes.

Series quoted in percent units (rates, spreads, unemployment) are differenced in percentage points; all others are percent-changed. A 0.5pp rate move and a 5% index move are then each compared only against their own series' history, never against each other.

The percentile is the midrank of the latest move's absolute size within all same-length moves in the series' full published history: the share strictly smaller, plus half the share exactly equal. Direction is ranked separately from size — the verb carries direction, the percentile carries rarity.

A series whose latest observation is older than its cadence allows (10 days for daily, 21 for weekly, 100 for monthly, 200 for quarterly — one period, one publication lag, one missed release) is excluded from the ranking rather than silently compared on stale data.

To recompute it

  1. Fetch the full series: /api/v1/series/{id}?format=csv for redistributable series, or the same series id from FRED for display-only ones.
  2. Compute the move at every date in the history using the same window length and the same differencing rule (pp for percent-unit series, % otherwise).
  3. Take the absolute value of every move and compute the midrank percentile of the latest one.
  4. The page's percentage should match to rounding; the count of historical moves used appears in the API's own /whats-moving data.

Code: apps/web/lib/whats-moving.ts (pure functions, 19 tests)

Where today's reading sits in the whole record

Lower than all but 11 of 811 readings since 1959-01-01. Last at or below this level on 2020-04-01.

Appears on Series pages

The rule

The claim counts, it does not rank by percentile. "11 of 811" is checkable against the CSV by anyone; "the 8th percentile" invites an argument about which percentile definition and how ties were handled.

Observations without a finite value or a date are dropped, never counted as zero. A missing observation counted as zero makes every low reading look less rare than it is.

The rare side is whichever side is smaller: readings at or below today's value, against readings at or above it. Both counts include today. When the two are equal the reading is mid-record and no sentence is rendered.

Below 60 usable observations no claim is made at all, and none is made for a flat record where every reading is identical — a series with no variation has no rare end.

Above a fifth of the record on the rare side, no sentence is rendered: "rare" is not a word that applies to 300 of 811.

The second sentence names the most recent EARLIER date at or beyond today's level on the same side. When today is the first such reading it is omitted, which is the strongest form of the claim.

To recompute it

  1. Fetch the full series: /api/v1/series/{id}?format=csv for redistributable series, or the same series id from FRED for display-only ones.
  2. Drop rows with no value. Count the rows whose value is at or below the latest value, and the rows at or above it; the smaller count is the one reported, and it includes the latest row.
  3. The total is the number of usable rows, and the date is the first of them.
  4. Scan backwards from the second-to-last row for the first row at or beyond the latest value on the reported side; that date is the one named.

Code: apps/web/lib/series-rarity.ts (pure functions, 12 tests)

How long after a recession starts anyone officially knows

The NBER has dated 6 business-cycle peaks since 1980 and announced each between 4 and 12 complete months after the peak month; the middle of the 6 was 6. The longest was the 2007-12 peak, announced 2008-12-01.

Appears on Recession probability

The rule

Both dates come from the NBER's Business Cycle Dating Committee: the month it decided the expansion peaked, and the date it announced that decision. Source: nber.org/research/business-cycle-dating.

The lag is COMPLETE months from the first of the peak month to the announcement date. A peak in January announced on 3 June is four complete months, not five — the fifth had not finished. Rounding would let the same record produce two different middles depending on who counted.

The middle of an even-length record is the LOWER of the two middle values, never their average: averaging 6 and 8 would publish 7, a lag no announcement ever had.

The record starts in 1980, where the committee's published announcement record begins. Earlier peaks were dated retrospectively and have no comparable announcement date.

Below two announcements no sentence is rendered: a range and a middle both need more than one reading.

NOTHING HERE IS ABOUT A SEVENTH ANNOUNCEMENT. The claim counts, ranks and dates six past ones, and carries no field that could hold an expectation about the next.

To recompute it

  1. Open nber.org/research/business-cycle-dating and read the peak months and their announcement dates.
  2. For each pair, count whole months from the first of the peak month to the announcement date, subtracting one where the announcement's day-of-month is earlier than the peak's.
  3. Sort the lags. The reported range is the smallest and largest; the reported middle is the lower of the two central values.

Code: apps/web/lib/recession-dating.ts (pure functions, 10 tests) · scripts/check-nber-dates.py (asserts the TypeScript and Python tables of the same dates agree)

How often this has happened

This has happened 12 times since 1976 in 10-Year Minus 2-Year Treasury Spread data.

Appears on Indicator pages (episode history table)

The rule

Episodes are mechanical span rules applied to the full published series — no fitting, no judgement calls per episode. Curve inversion: a maximal run of consecutive closes below zero. Volatility stress: a maximal run of closes at or above the threshold. Bear market: from the day a close sits 20% or more below the running peak until a new peak is set. Hiking and cutting cycles: monotone runs of the policy rate, ignoring counter-moves smaller than 0.25pp and discarding cycles smaller than 0.75pp in total.

The sentence restates the stored spans: the count is the number of spans, the year is the first span's start, and each span's extreme (deepest inversion, peak close, maximum drawdown, total change) is computed inside that span only.

The chart shades the same spans. When the plotted window starts after the first episode, the sentence says so instead of implying all spans are visible.

To recompute it

  1. Fetch the series' full history (same sources as above).
  2. Apply the span rule for the episode kind with the stated thresholds.
  3. Count the spans and compare each span's dates and extreme against the table row by row.

Code: packages/data-infra/src/macroradar_data/episodes.py (span rules, 13 tests) · apps/web/components/episode-history.tsx (rendering only)

How long an open episode has run

Ongoing since Jul 2022 — 412 days so far, longer than 8 of 11 completed curve inversions in this series.

Appears on The Situation

The rule

An open episode is a span (defined by the same mechanical rules as the episode counts above) whose end has not occurred as of the latest observation. Its running duration is the whole days from its start date to today.

The rank counts the completed episodes of the same kind in the same series whose full duration was strictly shorter than the running duration. The comparison pool is only that series' own record — an inversion is never ranked against another curve's inversions, and an open episode with no completed predecessors is stated as the first of its kind rather than ranked against nothing.

The sentence says how long the span has run and where that sits historically. It says nothing about when, or whether, the episode ends.

To recompute it

  1. Derive the series' episode spans by the rules in the episode-count entry.
  2. Take the span with no end date; count days from its start to today.
  3. Count completed spans of the same kind with a strictly shorter duration.

Code: apps/web/lib/situation.ts (ranking and prose, 7 tests) · packages/data-infra/src/macroradar_data/episodes.py (span derivation)

How long a policy regime has been open

Russia — Ukraine sovereignty. Open since Feb 2022 — 1,646 days. 3 actions in the record since Aug 2026, most recent Jul 2026.

Appears on The Situation

The rule

A policy regime is a sanctions, tariff or export-control programme, dated from the instrument that opened it. Entry requires a verified Federal Register URL for that instrument, and a regime is shown closed only when the instrument that closed it can also be cited.

There are three end-states, not two, and the third is the honest one. A regime is OPEN when no closing instrument exists and none is expected to be missing. It is CLOSED on a cited instrument. It is UNRESOLVED when the record holds an opening but establishes no end — in which case the page says exactly that, cites the latest document proving the programme was still open, and stops the day count there rather than running a counter that would imply the programme is live. An earlier version had only two states and dropped such regimes entirely, which is how a macro record ended up with no COVID entry.

The DURATION is exact: whole days from the opening instrument's signing date to today, or to the closing instrument's date. Both ends sit on cited documents, so any reader can settle it.

The ACTION COUNT is deliberately weaker and always states the record it counts. It counts only documents this site has ingested and attributed to the regime by a stated title rule, and the Federal Register ingest is a rolling window rather than a complete backfill — so the sentence names the date the held record begins. An unqualified count off a partial record would be exactly the confident, uncheckable number this site does not produce.

Attribution is by plain case-insensitive title match within the regime's span: a stated, reproducible rule rather than a clever one. The regime definitions are curated and change rarely; every duration, count and latest-action date derives from the ingested documents and updates itself.

To recompute it

  1. Open the regime's cited opening instrument and read its signing date.
  2. Count whole days from that date to today (or to the cited closing instrument).
  3. For the action count, search the Federal Register for documents in the span whose titles carry the regime's match terms — noting that the site's own count covers only the period its record states.

Code: packages/shared/src/policy-regimes.ts (curated definitions, citation required) · apps/web/lib/policy-regimes.ts (spans and prose, 14 tests) · packages/data-infra/src/macroradar_data/federal_register.py (document ingest, 12 tests)

The decision-day move and its rank

2-Year Treasury Yield fell 0.12pp on the decision day — the 5th-largest decision-day move of 38 since 2020.

Appears on Economic calendar (FOMC panel)

The rule

For each scheduled FOMC decision and each tracked market series, the window runs from the last close strictly before the decision date to the first close on or after it — statements land at 2pm ET, so the decision day's own close is the first that can reflect it.

Percent-unit series change in percentage points; index and price series in percent. A window whose pre-close is more than 7 days before the decision, or whose post-close is more than 5 days after it, is discarded rather than measured across a data gap.

The rank is 1 plus the number of that same series' other decision-day windows with strictly larger absolute change; ties share the better ordinal. Each series is ranked only within its own record — never across series.

The panel shows only series whose latest window belongs to the latest decision; a series whose source publishes on a lag is omitted rather than shown under the wrong decision.

To recompute it

  1. Take the FOMC decision dates from the Federal Reserve's published meeting calendar (the last day of each scheduled meeting).
  2. Fetch each market series and read off the pre/post closes by the rule above.
  3. Compute every historical window the same way, then count the strictly larger absolute changes.

Code: packages/data-infra/src/macroradar_data/event_windows.py (window construction, 12 tests) · apps/web/lib/event-windows.ts (ranking and prose, 17 tests)

The daily log

March 13, 2020 — Volatility stress spell opened in CBOE Volatility Index; COVID-19 national emergency declared (cited).

Appears on The macro log

The rule

Each day's page is the union of the durable records that carry that date: tracked releases and communications from the events feed, episode spans whose start or end falls on the day, decision-day windows measured for a decision on the day, trade actions, and cited annotations.

Episode spans and decision windows derive from each series' full published history, so the log reaches decades back — while the release record begins with the events feed. A day's decision-day ranks count only the windows measured up to that date, so a later, larger move cannot demote a past day's rank.

The log is compiled from today's published data: where a source has revised its figures since that day, the revised values are what the derivations see, and the page says so. A day with no entries is stated as quiet, not padded.

To recompute it

  1. For episode transitions: derive the series' spans by the episode-count rules and read off the spans starting or ending on the date.
  2. For decision windows: build every window up to the date by the event-window rules and rank the date's windows within them.
  3. For the official record: the linked source documents carry their own dates.

Code: apps/web/lib/situation.ts (transitions, tested) · apps/web/lib/event-windows.ts (as-of ranking, tested)

Where a value sits in its own decade

10Y Percentile: 94% — well above its 10-year average.

Appears on Indicator pages (stats row and answer box)

The rule

The percentile is the share of the series' own observations in the trailing 10 years that are strictly below the current value, with the current observation excluded from its own pool.

The words map from the number mechanically: below 20% reads 'well below', below 40% 'below', above 80% 'well above', above 60% 'above', otherwise 'near' its 10-year average.

To recompute it

  1. Fetch the series and keep the trailing 10 years of observations before the latest date.
  2. Count the share strictly below the latest value.

Code: packages/data-infra/src/macroradar_data/processing/percentiles.py (SQL over the warehouse)

The next scheduled release

Next scheduled release: Thu, Sep 11 (Consumer Price Index).

Appears on Indicator pages (under the chart) · Economic calendar

The rule

Release dates come from the source institutions' own published schedules: the FRED release calendar for statistical releases, and the central banks' own feeds for meetings and communications. The site republishes the schedule; it does not estimate dates.

A date always refers to the release, not the period it covers — the CPI published in September covers August. An event dated today counts as upcoming everywhere, since a UTC day begins the prior evening in New York.

Scheduled dates can move; the caption says so.

To recompute it

  1. Check the same schedule at the source: FRED's release calendar for the release in question, or the institution's own calendar page linked from each event.

Code: packages/data-infra/src/macroradar_data/events.py (feeds, calendar parse, filters)

Named world events

Reciprocal tariffs proclaimed (Proclamation of April 2, 2025) — linked to the Federal Register.

Appears on Indicator pages (episode tables and chart markers) · Economic calendar (trade actions)

The rule

Every named event carries a citation to the official record — a Federal Register document URL, verified against the Federal Register API. An event that is not itself a federal instrument (an invasion is not an executive order) names what its cited document attests instead of implying the document is the event.

Annotations attach to episodes by date: an annotation joins a span when its date falls inside it, with a 5-day lead-in. That is a join on dates, not a causal statement — the market dated the span, the official record dates the event, and the page places them side by side.

The set is deliberately small, curated in a reviewed source file, and every entry requires a citation to enter it.

To recompute it

  1. Open the citation link on any annotation — the claim is the existence and date of the official document, and the document is the proof.
  2. For the join: compare the annotation's date against the episode span's dates printed in the same table.

Code: packages/shared/src/annotations.ts (the curated entries, citation required) · apps/web/lib/annotations.ts (the date join, 7 tests)

Recession shading

Red shading indicates NBER recession periods.

Appears on Every indicator chart · Chart composer

The rule

Shaded bands are the National Bureau of Economic Research's official US business-cycle dates, taken from the USREC series on FRED: months where USREC = 1, stored as start/end bands. The site adds no dating of its own.

NBER dates recessions in arrears — announcements can come many months after the fact — so the most recent unshaded span is a statement about announcements to date, not a claim that no recession is underway.

To recompute it

  1. Fetch USREC from FRED and read off the runs of 1s.

Code: packages/data-infra/src/macroradar_data/api/routes/recessions.py (band extraction)

When the record learned something

1 of these 6 entries was added to the record after the day it describes — the latest by 4 days.

Appears on Daily log

The rule

Every event carries four timestamps, and they answer four different questions: event_date is when the thing happened in the world; published_at is when the source published it; ingested_at is when THIS record first held it; revised_at is when a material field last changed.

ingested_at is set on insert and never updated. That is what makes an as-of question answerable: the record as of date D is the rows whose ingested_at is on or before D, never the rows whose event_date is.

A page describing a past day discloses the gap rather than hiding it. Entries the record picked up after the day they describe are counted, and the worst lag is named. Entries whose rows predate the timestamp are counted separately and described as unknown rather than assumed same-day.

revised_at is stamped only when the event date, title or URL actually changed. Every ingest run re-upserts its whole window, so stamping unconditionally would mark every row revised every day and the column would carry no information.

Federal Register instruments carry a signing date and a publication date, and event_date uses the signing date where the register gives one. The gap between them is a fact about the source, not an artefact of our ingest, and both are stored so a reader can see it.

To recompute it

  1. Fetch a day's record: /api/v1/log?date=YYYY-MM-DD. Each event carries event_date, published_at, ingested_at and revised_at.
  2. Count the events whose ingested_at falls after the date the page describes; that is the late count. The largest such gap, in whole days, is the worst lag.
  3. To reconstruct the record as it stood on any past date, keep only the events whose ingested_at is on or before it.

Code: apps/web/lib/event-time.ts (pure functions, 14 tests) · packages/db/sql/2026-08-25-event-timestamps.sql (the four columns and the never-update rule)

What a number read on a past date

On 15 September 2008, US Inflation Rate (CPI) for June 2008 read 218.815, published 16 July 2008. It now reads 217.463.

Appears on Claims ledger

The rule

Economic statistics are revised. FRED publishes every version it has ever released, and the warehouse keeps all of them: a row is keyed on (series, period, vintage), where the vintage date is the day that value was published. Nothing is overwritten, so the archive can say what was on the screen on any past date rather than only what is true now.

The value as of date D, for a given period, is the value from the LATEST vintage whose vintage date is on or before D. The boundary is inclusive: a number published that morning was on the screen that day.

When no vintage exists at or before D, the answer is that the number had not been published yet. It is never the earliest vintage we happen to hold. This is the one place the published rule deliberately differs from the point-in-time selection used by the recession model, which does fall back to the earliest vintage — correctly, because a model may take the best obtainable estimate. A page saying 'this is what was knowable then' may not: showing a figure first published in 2011 under that sentence would be false.

A period we hold no vintage for at all is reported as a gap in the archive, never as 'not yet published'. The first is a fact about our collection; the second is a fact about history, and only one of them is a claim about the world.

A revision count is the number of publications after the first. A number published once has been revised zero times, which is itself worth stating — market series like yields and spreads are never revised, and that is a property of the series rather than a hole in the record.

Some series have no vintage archive at FRED at all (SP500, DJIA, GOLDPRICE return an error on the vintage endpoint). Their vintage is anchored to the observation date on ingest, so they carry exactly one publication per period. That is a real record and not a revision history, and it is marked rather than presented as one.

To recompute it

  1. Every value here comes from FRED's ALFRED vintage archive, which is public: https://alfred.stlouisfed.org.
  2. For one series and period, request all releases: https://api.stlouisfed.org/fred/series/observations?series_id=CPIAUCSL&realtime_start=1776-07-04&realtime_end=9999-12-31 — every row is one publication, and realtime_start is its vintage date.
  3. Keep the rows for the period you want. Discard those whose realtime_start is after your as-of date. The value is the one from the latest remaining row; if none remain, the number was not published yet.
  4. The revision count is the number of rows for that period minus one. The net revision is the last value minus the first.

Code: apps/web/lib/as-of.ts (the rule, pure, 21 tests) · packages/data-infra/src/macroradar_data/ingest/fred_client.py (get_series_all_releases — how every vintage is collected) · packages/data-infra/src/macroradar_data/retention/policies.py (why the archive cannot be pruned)

What would invalidate this

Written down in advance: the observable events that would make this claim wrong or force it to be recomputed — not a confidence score.

  • A series flagged revisions: "published-once" is found by series-metadata-probe.yml, or a direct ALFRED query, to carry more than one vintage for any period. The single-vintage-per-period assumption breaks for that series, and every as-of answer already computed for it must be recomputed against the full vintage archive rather than the current record.
  • GS10 — the only published-once series measured over a long window, 479 monthly observations across 40 years with zero restatements — is found to have a restated vintage for any observation. The one long-window proof this claim's design rests on is gone, and published-once needs re-measuring across the remaining series before it is trusted again. (GS10)
  • FRED begins serving an ALFRED vintage history for SP500 — currently the only series in the shared registry with none, anchored to the observation date on ingest instead (DJIA and GOLDPRICE carry the same anchor but live only in data-infra's extra-series fixture, not the shared registry this check resolves against). The anchored-vintage answer must be retired in favor of the standard vintage-selection rule, and every as-of sentence already rendered under the anchor becomes a historical approximation of the earlier rule rather than the current one's output. (SP500)
  • A series this claim answers is flipped discontinued in the registry. Its vintage archive stops advancing at the discontinuation date, and an as-of request for a date after that must say the series had stopped rather than repeat the last vintage as if it were still current.

How many series moved unusually together

7 of 41 tracked series with a current print moved into their own top 5% — 5 up, 2 down.

Appears on The Situation · What's moving

The rule

A series is 'in its tail' when its latest move sits at or above the 95th percentile of same-length moves in its own history — the same threshold the word 'rare' uses elsewhere on the site, so the count and the word cannot disagree.

Breadth is the count of tracked series in their tails at once, over the count of tracked series with a current print. Series without a current print are excluded from BOTH sides: a series that has not published cannot be in its tail, and counting it in the denominator would make every day look narrower than it was.

The residual is the isolated case: a series in its tail while no other tracked series is. That sentence reports that this record holds no companion move. It is a statement about what we track, never about what caused the move.

No factor model, regression or beta is computed anywhere in this claim. Two series in their tails on the same day coincide, in the literal sense; what that means is the reader's to decide.

To recompute it

  1. Fetch /api/v1/whats-moving. Each entry carries its own percentile and a freshness flag.
  2. Keep the fresh entries; that count is the denominator. Count those at or above the 95th percentile; that is the numerator.
  3. For one series' residual, take the tail set and remove that series. An empty remainder is the isolated case.

Code: apps/web/lib/cross-market.ts (pure functions, 10 tests)

The market-pricing leg of the Consensus Gap — no narrative leg exists

As of 2026-08-28, the 10-Year breakeven inflation rate priced average annual inflation at 2.3%. The latest published trailing twelve-month CPI reading (July 2026) was 3.3%. Gap: -1.0 percentage points. Two different spans, two different dates, both stated.

Appears on The Situation

The rule

Two already-published headline readings, juxtaposed — reuses `latestReading` (the `headline-reading` claim's own function) on T10YIE and CPIAUCSL rather than computing either number again.

T10YIE's headline is its published level (Percent) — the market's own priced average annual inflation rate over the next ten years. CPIAUCSL's headline is its year-over-year change (Percent Change from Year Ago) — the realized trailing twelve-month rate. The gap is marketReading.value − modelReading.value, in percentage points.

Each reading keeps its own asOf date. A ten-year forward average and a trailing twelve-month realized rate are different spans; the claim states both spans and both dates rather than implying one date answers for both numbers.

Either reading missing or stale (per latestReading's own MAX_AGE_DAYS rule) refuses the whole claim, naming which side.

This is the market-pricing leg only. No narrative-consensus term (an economist survey reading) exists in this claim, by design — see attribution-assessment.md §5. There is no field reserved for one.

To recompute it

  1. Fetch /api/v1/series/T10YIE?format=csv and /api/v1/series/CPIAUCSL?format=csv — both redistributable, both ingested.
  2. Apply the headline-reading claim's own rule to each (level for T10YIE, year-over-year for CPIAUCSL) to get two dated values.
  3. Subtract: T10YIE's value minus CPIAUCSL's YoY value, in percentage points.

Code: apps/web/lib/reading.ts (reused unchanged, not reimplemented) · apps/web/lib/consensus-gap.ts (pure market-minus-realized subtraction with named refusal paths)

What would invalidate this

Written down in advance: the observable events that would make this claim wrong or force it to be recomputed — not a confidence score.

  • T10YIE's revisions flag is measured and flips from published-once to revised (the same ALFRED sweep already flipped DEXCAUS, BAA10Y and NASDAQCOM on the same reasoning) — the leg would no longer be point-in-time stable and the claim's as-of behavior would need re-stating. (T10YIE)
  • T5YIE or T5YIFR gains ingested:true and redistributable:true — the engine should extend to the 5-year and 5-year-5-year-forward horizons rather than staying 10-year-only. (T5YIE, T5YIFR)

How many economies moved into the same tail together

4 of 5 economies with a current policy-rate reading moved into their own top 20% together; Japan was the lone economy in the bottom 20%.

Appears on Cross-country breadth

The rule

A concept is a candidate here only when the registry holds a live (non-discontinued) series for it in more than one economy — the same 'economy, not a series, not a scope' distinction docs/coverage.md and concept-coverage.ts use everywhere else. A concept held for exactly one economy is still run, but any tail reading it produces is suppressed rather than counted: one economy is a reading, not a pattern.

A concept routinely holds more than one live series for the same economy — consumer-price-level carries seven US variants (headline, core, sticky, trimmed and three CPI components), output carries GDP and GDPC1, both US. Exactly one series per geography is selected before any reading is computed. Candidates the serving layer actually holds observations for (`ingested: true`) are preferred over ones it does not, whenever at least one ingested candidate exists for that economy — an un-ingested series has no served observations and no rendered page, so a recompute link to it would 404; a breadth reading must be verifiable, not just theoretically the longest record. Within that preferred pool (or the full pool, if none of a geography's candidates are ingested yet), the tie-break is the earliest observationStart — the longest record behind the reading — broken by the lexicographically smallest series id. This is a written-down enumeration rule, not an editorial pick, and it runs upstream of everything below — feeding more than one series for one economy into the same snapshot is what a duplicate-reading refusal exists to catch.

Each held series' reading is its latest observation's percentile within its own trailing 10-year window — the reading itself excluded from its own comparison pool, the exact rule cross-asset-rank (entry cross-asset-rank) uses for /ranked, applied per country instead of per asset. A series with data but too little history to clear that window's minimums still counts toward the concept's coverage, marked unrankable rather than dropped — the difference between 'we hold nothing for this economy' and 'we hold something we cannot yet rank'.

The calendar-month bucket a concept's snapshot runs against is the one most of its held readings share, chosen after every series' percentile is computed; ties go to the later month. A reading in another month stays part of coverage but reports unrankable rather than being forced into a period it did not report in. Month boundaries are coarse by construction: adjacent dates across a month end do not align, while dates farther apart within one month do.

Every covered economy's own exact observation date, computed calendar-month bucket, series id, and status are published as member_readings, including middle, suppressed, and unrankable members rather than only economies in a tail.

A reading at or above the 80th percentile is a top-tail reading; at or below the 20th, a bottom-tail reading — the same thresholds cross-country.ts defaults to. A concept needs at least 4 economies with a rankable reading on the chosen date before breadth is published; short of that, the concept states the count and the minimum rather than a number that would look like a pattern it is not.

No factor model, regression or causal claim is computed anywhere in this claim. Economies landing in the same tail in the same calendar month coincide, in the literal sense; the reader decides what that means.

To recompute it

  1. Fetch /api/v1/breadth. Each concept lists the economies the registry holds it for, every member's exact date, calendar-month bucket and status, the tail sets in the bucket used, and whether the minimum was cleared.
  2. To reproduce one economy's reading: identify the representative series for that economy and concept (among the registry's live, ingested series for that concept and geography if any exist, else among all its live series, the earliest observationStart, ties broken by the smallest series id), fetch it (redistributable ones via /api/v1/series/{id}?format=csv, licensed ones from the publisher named in the registry), take the trailing 10 years ending at its latest observation, and compute the share of the OTHER readings in that window strictly below the latest value.
  3. A concept whose economies-observed count is below the stated minimum is reported as suppressed, never rendered as if it were breadth.

Code: apps/web/lib/cross-country.ts (pure functions, 26 tests) · apps/web/lib/breadth-select.ts (pure functions, 22 tests: pickRepresentativeDate, selectRepresentativeSeries's ingested-preference and tie-break rules, both the suppressed-below-minimum and published-above states, and a regression test composing every multi-economy concept in the real registry against crossCountryBreadth) · apps/web/lib/breadth-run.ts (fetches Supabase observations and composes the two modules above — orchestration, not computation, so not unit tested directly (same reason getMoves in api.ts is not))

How often the record holds nothing

Of 48 moves in the top 5% between 2018-03-08 and 2026-08-25, the record holds no dated event within 1 day for 19 of them — an empty-record rate of 40%. A covered move is one with a document nearby in time; that a document is nearby is not a claim that it explains the move.

Appears on Claims ledger

The rule

This is the site's evaluation benchmark, and it scores the RECORD rather than any interpretation of it. For every move at or above the 95th percentile, one mechanical question is asked: does the record hold any dated event within the window, by default one day either side?

The headline number is the empty-record rate — the share of extreme moves for which we hold nothing at all — because a score is only useful when the failing half is what gets read.

The scored window is bounded by the record's own earliest and latest event. Scoring 2008 against a feed that begins in 2018 would report a total miss and call it a finding, when it only states when collection started. Extreme moves outside that window are reported as unscored, never counted as failures.

A covered move is one with a document nearby in time. That a document is nearby is not a claim that it explains the move, and the benchmark makes no such claim.

There is no human acceptance step anywhere in this score, and there will not be one: a number that depends on which of us approved a label is not a number a reader can recompute. The reasoning is written up in docs/strategy/attribution-assessment.md §4.

To recompute it

  1. Fetch /api/v1/whats-moving for the percentiles and /api/v1/calendar for the held record, or the daily log endpoint across a date range for a longer history.
  2. Take the earliest and latest event dates as the window. Keep the moves at or above the 95th percentile whose dates fall inside it.
  3. For each, count events within one day. The share with zero is the empty-record rate.

Code: apps/web/lib/record-coverage.ts (pure functions, 12 tests)

What a series measures, as distinct from what it is called

INDPRO measures production volume; PCEPI measures the consumer price level. Both are published as Index 2017=100.

Appears on Series catalog · Claims ledger

The rule

Every series carries a concept, a geography, a publisher, a seasonal-adjustment state, the publisher's first observation, and whether the publisher ever restates a figure. All six were measured against FRED on 2026-08-27 rather than typed from memory.

A concept is what the number measures. A unit is how it is quoted, and the two are not the same: INDPRO and PCEPI are both quoted as Index 2017=100, and a shared base year is a normalisation rather than a shared dimension. Two levels of different concepts cannot be combined, which is the rule this exists for.

Seasonal adjustment has three states, not the two FRED reports. FRED marks a Treasury yield and an unadjusted house-price index alike as Not Seasonally Adjusted; the first has no seasonal component to remove and the second does. Only the genuinely unadjusted ones refuse a month-over-month change, and their year-over-year is unaffected because comparing like months cancels the season.

Whether a series is ever restated is measured against ALFRED. Eleven return no vintage history at all, and each is a daily market close — a constant-maturity yield is not an estimate that gets refined. For those, and only those, an as-of question is answered from the current record truncated at the date, because the two are the same numbers.

The publisher's first observation bounds every study over that series: a window starting before it is asking about a period the record does not cover.

To recompute it

  1. Fetch /api/v1/catalog: every series carries concept, geography, publisher, seasonality, observation_start and revisions, and the response groups the series by concept.
  2. Check any of them against FRED's own /fred/series metadata for that id. scripts/series-metadata-probe.py does exactly that for all of them at once and reports every disagreement.

Code: packages/shared/src/series-registry.ts (the ontology itself) · scripts/check-series-ontology.py (CI ratchet: no series without it)

What a quoted number actually is

Europe Brent spot price, FOB, published by the US Energy Information Administration. This is not the ICE Brent front-month futures settlement, the number most 'Brent' quotes mean.

Appears on Catalog API

The rule

'Brent' is not one number, and neither are most of the others. A constant-maturity Treasury yield is interpolated from a curve rather than the yield of any bond a reader can buy; VIX is implied volatility from option prices, not realised volatility; DEXUSEU and DEXJPUS are quoted in opposite directions, so 'the currency went up' means opposite things in the two series.

Each ambiguous series declares three things: the publisher's own name for the construction, who publishes it, and — the load-bearing field — the near neighbour a reader is most likely to mistake it for.

Nothing in this claim is a judgement. Every line restates what the publisher already documents; the contribution is putting it where a reader or an agent will actually see it, next to the number.

To recompute it

  1. Fetch /api/v1/catalog. Series whose identity is ambiguous carry a price_basis object with kind, instrument, publisher and not_this.
  2. Check any entry against the publisher's own series notes on FRED, which is where each description comes from.

Code: packages/shared/src/price-basis.ts (curated from publisher notes)

The grammar an analysis compiles into

Real Fed Funds Rate = FEDFUNDS − yoy(CPILFESL), monthly, current record, 1990 to date.

Appears on Claims ledger · Any expression, computed

The rule

This ledger is a catalogue: every kind of sentence the site renders, each with its rule. The grammar is the same idea generalised — an expression language in which an analysis that cannot be recomputed cannot be written down.

Vintage is a required field with no default. An analysis must state whether it uses the current record, including later revisions, or only what was knowable on a given date. A default would mean every generated analysis silently inherited one answer, and the difference between those two matters most exactly where it would disappear.

Units are checked, not assumed. A series quoted in percent is a rate, and a rate that moves from 4.0 to 4.3 rose 0.3 percentage points, not 7.5%. Year-over-year on a rate therefore compiles to a percentage-point difference and on an index to a percentage change, and adding the two together is refused rather than rendered.

Rankings are refused as-of unless the ranking table itself carries a vintage. Ranking a past date against a percentile table that knows only today uses information from after that date; the same refusal exists in the Python extractor, because it is a property of the data rather than of either implementation.

Two index levels cannot be subtracted: different bases mean the difference is not a quantity. Series of different frequencies cannot be combined without an explicit alignment rule. An as-of analysis cannot request dates after the moment it stands at.

Errors accumulate rather than stopping at the first, so a caller — a person or a model — receives every reason at once instead of converging one round trip at a time.

To recompute it

  1. Take the compiled plan: it lists the vintage, each series with its units and frequency, each transformation in order, and the arithmetic applied.
  2. Fetch each named series at the stated vintage, apply the listed transformations in order, and perform the arithmetic.
  3. Any step you cannot reproduce is a defect in the plan, not in your reading — the plan is generated from the same object that produces the number.

Code: apps/web/lib/macro-ir.ts (pure grammar and compiler, 41 tests) · apps/web/lib/macro-eval.ts (the evaluator that executes it, 29 tests) · apps/web/lib/compute-url.ts (expression as address, 22 tests)

Any expression, at its own address

/compute/FEDFUNDS~minus~CPIAUCSL.yoy — the real policy rate, with the plan attached.

Appears on The real policy rate · API

The rule

Every expression the grammar accepts has a stable address, and that address renders the compiled plan beside the numbers. The address is canonical: parsing it and printing it again yields the same string, so one question has one URL rather than several spellings of it.

The address needs no percent-encoding. Series ids are alphanumeric plus underscore — measured across the registry rather than assumed — so a dot separates a series from its transforms, an underscore separates a transform from its argument, and a tilde carries the operator. Operators are words (plus, minus, times, over) because a slash is a path separator and a plus is a space in a query string.

Year-over-year and month-over-month are calendar spans rather than observation counts. Twelve observations is a year on a monthly series and a fortnight on a daily one; anchoring to the calendar means the same address means the same thing whatever the series' frequency. A lookback landing in a gap wider than 45 days yields no value rather than silently pairing today against a figure from years earlier.

The transforms that name observation counts — diff, lag, movingAverage, zscore, percentile — keep counting observations, because there the count is the caller's stated unit rather than an inference about the calendar.

An as-of address is refused, not answered. The vintage archive is not backfilled, and serving today's revised numbers under a point-in-time address would be wrong in exactly the way that question exists to avoid. Per-series point-in-time reads are served at /api/v1/series/{id}/as-of.

An expression naming a series MacroRadar may display but not redistribute is refused. A study publishes a distribution over windows, which is a derivation; a full computed observation series is close enough to its inputs that publishing one would redistribute the other.

Every one of these pages is noindex. The address space is unbounded, and an unbounded space of generated pages earns 'crawled — currently not indexed' at scale while spending crawl budget on pages nobody asked a question to reach. An expression that deserves indexing becomes a study in the catalogue, which is a written decision with a measured sample size behind it.

To recompute it

  1. Read the plan on the page: it names each series with its units and frequency, then each transformation in order.
  2. Fetch each input from /api/v1/series/{id}, apply the steps in order, and compare.
  3. The same answer as JSON, plan included, is at /api/v1/compute?expr={the address}.

Code: apps/web/lib/compute-url.ts (the address format, round-trip tested) · apps/web/lib/compute-run.ts (the refusals and the run)

What followed, every time these conditions held

10 windows since 1976 had the 10-year below the 2-year. Unemployment was higher 12 months later in 5 of them and lower in 4.

Appears on Macro studies

The rule

A study states conditions precisely enough that the answer is a count. Every date in the record where all conditions held is found, and the measured series' change over a fixed horizon after each is reported as a distribution and a sample size.

Conditions come in three forms. A THRESHOLD compares against a literal in the series' own units, and is refused on an index level — 'CPI above 5' is five index points against an arbitrary 1982-84 base, which computes and answers nothing. A PERCENTILE compares against the series' own trailing history, which is how to ask 'unusually high' without hard-coding a number that means different things in different decades. A DIRECTION compares against the value a stated number of periods earlier.

Conditions are put onto the measured series' dates before evaluation, taking the last observation on or before each — the same rule the vintage archive uses. Without this a daily condition and a monthly one would never share a date, and a study asking about both would return nothing while appearing to work. It also fixes what a window means: spans and windows count in the measured series' own periods.

Consecutive matches inside a stated span collapse into one episode. Twelve consecutive months of an inverted curve is one inversion; counting it as twelve observations turns a single episode into a body of evidence, and the collapse span is named in every study's method.

A window whose horizon runs past the end of the record is counted and named as still running, never dropped. Dropping it would bias the distribution toward whatever the older windows happened to do.

NO EXPECTED VALUE IS PUBLISHED, and none can be: the object these pages render from carries matched windows, a distribution and N, and has no field capable of holding a probability or a central estimate. The distribution and the count are the whole answer. Samples below five completed windows are labelled thin beside the result.

A single named study also answers `?as_of=YYYY-MM-DD`: every loaded series is truncated to observations on or before that date before anything is evaluated, so the count, the distribution and the window list are exactly what the record held on that day — a later match, or a window whose horizon runs past as_of, is absent or reported still-running rather than answered from today's revised figures, and the reported `range` is truncated the same way so it never claims to have seen past as_of. This is the same refusal `POST /api/v1/studies` already uses (`vintage/not-yet-served`): if any of the study's series is revised after publication and its vintage archive is not backfilled, the request is refused and the series is named, not silently answered from the current record. The catalogue LISTING (no `?study=`) has no as-of meaning and refuses the param instead.

HONESTLY, THIS REFUSES MOST OF THE CATALOGUE TODAY: `?as_of=` only succeeds for a study whose ENTIRE input set — every condition and the measure — is `revisions: "published-once"`. Measured against the current registry, that is 2 of the 19 published studies (`curve-un-inversion-ten-year-yield`, `ten-year-yield-spike-mean-reversion` — both Treasury-yield-only questions); the other 17 name at least one revised series (UNRATE, CPIAUCSL, FEDFUNDS and similar) and refuse by the same rule every time, naming the series. That is the correct behavior, not a bug to route around — an as-of answer over a revised, unbackfilled input would be wrong in exactly the way an as-of question exists to avoid — but it is a real limit on how much of the catalogue this capability actually reaches today, stated here rather than left to be discovered.

To recompute it

  1. List the studies: /api/v1/studies. One study: /api/v1/studies?study=inverted-curve-unemployment. The same study as of a past date works only when its whole input set is published-once — that study's own UNRATE measure is not, so it refuses; a study that DOES qualify: /api/v1/studies?study=curve-un-inversion-ten-year-yield&as_of=2015-06-01.
  2. The response's `method` array is the compiled plan — the same object that produced the numbers, so the description cannot drift from the computation.
  3. Fetch each named series from /api/v1/series/{id} (or, for an as_of request, /api/v1/series/{id}/as-of), apply every condition on the same dates, collapse consecutive matches, and measure the change over the horizon. The window list gives every date to check against.
  4. Some series are display-only and this API withholds their observations; the response's `recompute.licensed_series` names them, and they are fetchable from FRED directly.

Code: apps/web/lib/macro-study.ts (the grammar and evaluator, 46 tests) · apps/web/lib/study-catalogue.ts (the published questions)

A basket's outcome, every time these conditions held

A 2-member basket (60% Gold, 40% Oil) matched 8 windows since 2000 where the yield curve was inverted; the basket was higher 12 observations later in 5 of them and lower in 3.

Appears on Exposure API

The rule

POST /api/v1/exposure takes a long-only basket (up to 10 members, weights summing to 1 within 1e-6) and a condition set identical in form to the conditional-history claim above. Every date where all conditions hold is found by the SAME episode-matching machinery — one matcher, not a second implementation for baskets.

There is no single measured series to serve as the spine, so the FIRST basket member's own dates do: every condition and every other member is put onto those dates, taking the last observation on or before each — the identical alignment rule conditional-history uses against a study's measured series, and it fixes what a window and a percentile or direction span COUNT: that member's own periods, not calendar time. The response's `method` array states which series that is for the basket actually submitted, because a recompute plan that leaves this out would answer a different question on a different calendar than the one that produced the published N.

A basket's outcome in a matched window is the weighted sum of each member's own percent change over that window: Σ weight_i × (P_i,end / P_i,start − 1). This is a basket rebalanced to its stated weights at the start of every window — no drift within the window, no compounding across windows, no path dependency inside it.

Basket members must be level-class series — a percent-quoted series (a yield, a spread) refuses by name, because a basket of yields is not a portfolio. Weights must be finite, strictly positive, and sum to 1 within 1e-6; a wrong sum refuses with the sum stated. Short legs are refused entirely in this version. Every member must share one observation calendar — a mixed-frequency basket refuses, naming each member's own frequency — and a member cannot appear twice.

A window is completed only when EVERY member has a finite value at both the window's start and its horizon date. One member's unmeasurable outcome makes the whole window's outcome unmeasurable — the same NaN-outcome discipline conditional-history's own engine already applies to a single measured series.

A member this API may display but not redistribute (42 of 148 registry series, including SP500, NASDAQCOM, VIXCLS and PCOPPUSDM) is never refused outright — refusing would gut the surface. Instead its own start/end value is withheld (null) in the response; `basket_change`, a derived weighted number rather than a raw observation, is published either way. This is the same withholding the conditional-history GET route already states, applied here for the first time on a POST response — see `recompute.licensed_series`.

This is API-only and never archived, exactly like the ad-hoc conditional-history surface it is gated on: below THIN_SAMPLE, an unregistered series, or a basket that fails validation refuses with a name rather than computing quietly.

NO SENTENCE HERE SAYS HOW TO CHANGE, HEDGE OR REDUCE ANYTHING. The response counts, dates and distributes a basket's past outcomes; nothing in its type can hold a recommendation.

To recompute it

  1. POST the same basket and conditions to /api/v1/exposure with vintage stated. The response's `method` array is the compiled plan, and `windows` lists every matched date with each member's own start/end value — null for a member named in `recompute.licensed_series`.
  2. Fetch each member and condition series from /api/v1/series/{id} for redistributable ones, or from FRED directly for the series `recompute.licensed_series` names, find the same matched dates by the conditional-history rule, and compute each window's weighted percent-change sum by hand against the response's `basket_change`.

Code: apps/web/lib/macro-study.ts (pure functions, 46 tests)

Which past months most resemble this one

The five historical periods closest to today in percentile space, each dated.

Appears on Regime detail · Macro regime

The rule

Written down late. This claim has been rendered on four pages since before the ledger existed and had no entry until 2026-08-26, which made it the one number on the site a reader could not check the rule for. That was a gap in our discipline rather than a property of the method.

Each month since 1980 is represented as a vector of ten-year percentiles, one per tracked series, excluding recession-indicator series. A month's value is the latest observation within the prior three months, and months carrying fewer than eight indicators are dropped.

Distance from today is root-mean-square difference across the dimensions BOTH months have — so a month missing a series is compared on what it has rather than penalised for the gap. Fewer than five shared dimensions means no comparison is made.

The most recent eighteen months are excluded from the candidate set, because the nearest neighbour of any month is almost always the month before it and that is not an analog.

Adjacent candidate months within four months of each other are clustered and represented by the closest one, so a single resembling period appears once rather than five times.

THE HONEST LIMIT: unlike a study, this depends on choices nobody outside can vary — which series are in the vector, that percentiles are ten-year, that distance is unweighted RMS. Two defensible metrics give different neighbours. It is a similarity ranking, not a count, and it is the weakest claim on this site by the standard the rest are held to. Where a conditional-history study can answer the same question, prefer the study: its conditions are stated and its answer is a list of dates.

To recompute it

  1. Rebuild the matrix: for each month, take each series' percentile_10y from /api/v1/series/{id}?derived=true, using the latest observation within the prior three months.
  2. Drop months with fewer than eight indicators, exclude the last eighteen months, and compute root-mean-square distance from the current month over shared dimensions only.
  3. Cluster candidates within four months and keep the nearest per cluster. The result is /api/v1/regime's analog list.

Code: packages/data-infra/src/macroradar_data/processing/context/analogs.py (the matrix, the distance and the clustering)

Model outputs (regime, recession probability, sentiment)

US recession probability (6-month): 12%.

Appears on Recession probability · Macro regime · Sentiment

The rule

Model numbers are the one place the site goes beyond restating published data, and they carry their own discipline: inputs are public series stored with vintage dates, models are validated walk-forward — refit at each historical step on data available at that date — and published histories are the actual sequence of historical outputs, not a backfilled curve.

The full construction is documented on the methodology page; the ledger entry exists so the boundary is explicit — everything else on this page is arithmetic on published data, and these three are models.

To recompute it

  1. Read the construction at /methodology, fetch the inputs it names, and compare against the published history endpoints: /api/v1/regime/history, /api/v1/recession, /api/v1/sentiment.

Code: packages/data-infra/src/macroradar_data/processing/ (regime, recession, sentiment pipelines)

The headline reading a series page publishes

Consumer Price Index: +3.3% y/y (Jul 2026) — published as 3.3 Percent Change from Year Ago beside 332.81 Index 1982-84=100.

Appears on Inflation rate · Core PCE · Oil price

The rule

Six series publish an index against an arbitrary base, and the question their page answers is the twelve-month change rather than the level: CPIAUCSL, CPILFESL, PCEPI, PCEPILFE, INDPRO, CSUSHPINSA. They are named individually, not derived from the units string — consumer sentiment is also an index and its LEVEL is the headline every newspaper prints. For every other series the published level IS the answer, formatted the way a reader writes it.

The year-over-year change is (latest − comparable) / |comparable| × 100, where the comparable is the observation nearest twelve calendar months before the latest, within 45 days. No comparable in that window, or a zero base, and there is no reading at all.

The number is rounded to the one decimal the sentence shows BEFORE it is published, so the figure a parser reads and the figure a person reads are the same figure. Publishing 3.2999999 beside "+3.3% y/y" would be two answers to one question.

A reading older than its frequency allows produces no headline: 12 days daily, 24 weekly, 95 monthly, 220 quarterly, with two per-series exceptions (CSUSHPINSA 130, GFDEGDQ188S 290) whose publication lag was measured. A stopped ingest is exactly when a page must not keep saying "today".

WHERE IT IS PUBLISHED, and why both: until 2026-08-28 the change reached the <title> only. The Dataset block carried the LEVEL — /inflation-rate published value 332.81, unitText "Index 1982-84=100" — so an assistant that parsed the page correctly answered that inflation was 332.81. Both are now in variableMeasured with the headline FIRST, each carrying its own unitText: a parser taking the first reading takes the one the question asked for, and one reading all of them still gets the level. Where the headline IS the level there is one entry, because a duplicate invites a parser to think it found two readings.

To recompute it

  1. Fetch the series: /api/v1/series/{id}?format=csv, or the same id from FRED for display-only series.
  2. Take the newest observation and the one nearest twelve calendar months earlier; the change is (latest − comparable) / |comparable| × 100, rounded to one decimal.
  3. Compare against the page's <title> and against the first entry of variableMeasured in its application/ld+json block. The three must agree.

Code: apps/web/lib/reading.ts (the rule and its refusals, 22 tests) · apps/web/lib/dataset-jsonld.ts (headline first, level second, 13 tests)

Where an asset sits within its own history, right now

10-Year Treasury Yield's latest reading (2026-08-27, 4.31 Percent) sits at the 87th percentile of its own last 10 years — above its median.

Appears on Ranked · API

The rule

docs/strategy/scaffold-reconciliation.md names the exact boundary this claim sits on: a table ranked by distance from each asset's own ten-year median, rule stated, recompute button attached, is enumeration and passes the decision test. The same table with a column headed 'opportunity' would not, and nothing here is that column — no field or word in the output is a judgement about what to do with the reading.

The candidates are a written list — DGS10, DGS2, T10Y2Y, SP500, NASDAQCOM, VIXCLS, DCOILWTICO, DTWEXBGS, BAMLH0A0HYM2 — verified against the series registry (exists, ingested, not discontinued) rather than generated. Whether each one clears the requirements below is a property of the data, checked at run time, never assumed.

For each series, the latest observation with a finite value is the reading being ranked. Its window is every observation with a date strictly after (latest date minus 10 calendar years), so the latest reading sits inside its own window.

A window needs at least 8 complete calendar years of span, from its earliest reading to the latest, and at least 100 observations. A series short of either is excluded, and the reason — which threshold, and by how much — is reported rather than the series being silently dropped.

The percentile is the share of the window's OTHER readings — the latest excluded from its own comparison pool, the same rule the percentile-10y claim uses — that sit strictly below the latest value.

Distance is the percentile minus 50. Rows are ordered by the size of that distance, largest first, ties broken by series id ascending — a reading near either end of its own record ranks ahead of one sitting near its middle, regardless of which direction it moved to get there.

The side (above/below) is which half of the series' own window the reading sits in. It is not shown by colour alone on the page: a symbol and the word both carry it.

This reads the current record, not a point-in-time reconstruction: a percentile computed here for a past date would use that date's value as it reads today, revisions included. The page states this rather than implying otherwise.

To recompute it

  1. The ranked table itself: /api/v1/ranked. Percentile, side and rank are published for every candidate, licensed series included; the licensed candidates' raw latest value is withheld the same way /api/v1/whats-moving withholds one, and the response names which ones (BAMLH0A0HYM2, NASDAQCOM, SP500, VIXCLS here) and points to their own publisher for that value.
  2. Fetch each candidate's full history: /api/v1/series/{id}?format=csv for redistributable series, or the same id from FRED for display-only ones (BAMLH0A0HYM2, NASDAQCOM, SP500, VIXCLS here).
  3. Drop non-finite values. The latest remaining observation is the reading; keep every observation dated strictly after (that date minus 10 years) as the window.
  4. Exclude the series if the window holds fewer than 100 observations or spans fewer than 8 complete calendar years from its earliest reading to the latest.
  5. Otherwise, compute the share of the window's other readings strictly below the latest value; that is the percentile. Subtract 50 for distance, and sort by |distance| descending, series id ascending on ties.

Code: apps/web/lib/cross-asset-rank.ts (pure functions, 11 tests)

How this week's gas-storage change compares with EIA's own 5-year average

EIA reported a 15 bcf build in total Lower 48 natural gas storage for the week of 2026-08-14 — 5 bcf more than the change in EIA's own published 5-year-average storage level for this week.

Appears on Calendar

The rule

The actual is calculated.net_change from the current total-Lower-48 WNGSR release. EIA publishes calculated['5yr-avg'] as a storage level, not a weekly change, so those two quantities are never subtracted from each other.

The five-year-average weekly change is derived from MacroRadar's own accrued econ_events rows: the current week's five_yr_avg_bcf level minus the immediately prior consecutive week's five_yr_avg_bcf level. Both rows originate from the same keyless EIA WNGSR source and the total-Lower-48 series (png.nw2_epg0_swo_r48_bcf.w); no external input is used.

The reported difference is current net_change_bcf minus that two-row five-year-average weekly change. It is a signed bcf count, never a percentage, and the sentence names EIA's published seasonal reference rather than framing it as market expectations.

The record must contain two releases exactly seven calendar days apart. The first accrued week and any gap refuse by name; neither is estimated or backfilled because the current WNGSR response cannot supply the prior week's five-year-average level.

If revision_flag_net_change or revision_flag_five_yr_avg is true or missing on either contributing week's row, the claim is suppressed until two consecutive, unflagged captured rows support it.

To recompute it

  1. Read two consecutive total-Lower-48 EIA WNGSR rows from econ_events, ordered by event_date, and verify that both rows' net-change and five-year-average revision flags are false.
  2. Subtract the earlier row's five_yr_avg_bcf storage level from the later row's five_yr_avg_bcf storage level. This is five_yr_avg_net_change_bcf for the later week.
  3. Subtract five_yr_avg_net_change_bcf from the later row's net_change_bcf. The resulting signed bcf count and direction must match the rendered sentence.

Code: apps/web/lib/release-surprise.ts (pure two-row derivation with named refusals, 7 tests)

What would invalidate this

Written down in advance: the observable events that would make this claim wrong or force it to be recomputed — not a confidence score.

  • EIA's WNGSR JSON drops calculated.net_change or calculated['5yr-avg'], changes either field's meaning, or stops publishing the total-Lower-48 series
  • Either contributing captured week's revision flag is not false

The ledger is versioned with the code that computes the claims, and the validation suite holds its prose to the same language rules it documents. Data sources and licensing are on data sources; model construction is on methodology. Everything here is a historical indicator, not advice — see limitations.