From bca46a73315fe391d61e8497d9b053b2ca24997a Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:16:07 -0700 Subject: [PATCH 1/2] feat(fred): get_revisions and get_release_calendar (#34) Two tools for questions the raw API makes hard rather than merely verbose. get_revisions covers ALFRED. Asking FRED for vintages without a real-time window spanning the record fails with "No vintage dates exist for the specified real-time period", which reads like the series has no history rather than like a missing parameter. The tool sets the window itself; that one detail is most of its value. For a single observation it collapses the repeated vintages: Q3 2025 GDP has nine, of which eight are the same number, so it returns two entries and one revision of +1.877 rather than nine columns. Without a date it reports first-printed against current across recent observations. get_release_calendar splits into released and upcoming around today. It fetches the two halves as separate requests, which is not an optimization: one request across the whole window is truncated by limit before the split, and FRED returns dates ascending, so the truncation lands entirely on the future. The first version answered "50 released, 0 upcoming" for a window holding 385 scheduled releases, which is a confidently empty answer to half the question. Each half now carries its own limit and FRED's own total, so a limited page is visibly a page. --- plugins/fred/src/dates.py | 8 + plugins/fred/src/schemas.py | 65 ++++++ plugins/fred/src/shaping.py | 58 ++++++ plugins/fred/src/tools.py | 190 +++++++++++++++++- plugins/fred/tests/fixtures/fred_api.py | 95 ++++++++- .../test_revisions_and_calendar.py | 162 +++++++++++++++ .../fred/tests/unit/test_revision_shaping.py | 98 +++++++++ plugins/fred/tests/unit/test_server.py | 8 +- 8 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 plugins/fred/tests/integration/test_revisions_and_calendar.py create mode 100644 plugins/fred/tests/unit/test_revision_shaping.py diff --git a/plugins/fred/src/dates.py b/plugins/fred/src/dates.py index dd2bec5..6cb1469 100644 --- a/plugins/fred/src/dates.py +++ b/plugins/fred/src/dates.py @@ -24,6 +24,14 @@ def today() -> date: return date.today() +def days_before(day: date, count: int) -> date: + return date.fromordinal(day.toordinal() - count) + + +def days_after(day: date, count: int) -> date: + return date.fromordinal(day.toordinal() + count) + + def parse(value: str, *, field: str) -> str: """Return a YYYY-MM-DD string, or "" for an unset value. diff --git a/plugins/fred/src/schemas.py b/plugins/fred/src/schemas.py index e65062b..eca364c 100644 --- a/plugins/fred/src/schemas.py +++ b/plugins/fred/src/schemas.py @@ -341,3 +341,68 @@ def _range_is_the_right_way_round(self) -> "ObservationArgs": @property def units_meaning(self) -> str: return UNITS_MEANING[self.units] + + +class RevisionArgs(BaseModel): + """Arguments for get_revisions.""" + + series_id: str + observation_date: str = "" + limit: int = Field(default=10, ge=1, le=100) + + @model_validator(mode="before") + @classmethod + def _correct(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + data = dict(data) + if isinstance(data.get("series_id"), str): + ids = normalize_series_ids(data["series_id"]) + data["series_id"] = ids[0] if ids else "" + if data.get("observation_date") is not None: + data["observation_date"] = dates.parse(data["observation_date"], field="observation_date") + return data + + @field_validator("series_id") + @classmethod + def _not_blank(cls, value: str) -> str: + if not value: + raise ValueError("series_id is required; find one with search_series") + return value + + +class CalendarArgs(BaseModel): + """Arguments for get_release_calendar.""" + + start: str = "" + end: str = "" + release_id: int | None = None + limit: int = Field(default=50, ge=1, le=1000) + + @model_validator(mode="before") + @classmethod + def _correct(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + data = dict(data) + for field in ("start", "end"): + if data.get(field) is not None: + data[field] = dates.parse(data[field], field=field) + return data + + @model_validator(mode="after") + def _default_window(self) -> "CalendarArgs": + # "What just came out and what is next" is the question, so the default window + # straddles today rather than running from the beginning of the record. + today = dates.today() + if not self.start: + object.__setattr__(self, "start", dates.days_before(today, 7).isoformat()) + if not self.end: + object.__setattr__(self, "end", dates.days_after(today, 14).isoformat()) + if self.start > self.end: + raise ValueError(f"start ({self.start}) is after end ({self.end}); swap them") + return self + + @property + def reaches_into_the_future(self) -> bool: + return self.end >= dates.today().isoformat() diff --git a/plugins/fred/src/shaping.py b/plugins/fred/src/shaping.py index e525db3..c9eeeab 100644 --- a/plugins/fred/src/shaping.py +++ b/plugins/fred/src/shaping.py @@ -152,6 +152,64 @@ def summarize(pairs: list[tuple[str, Number]]) -> dict: return summary +# --- revisions --------------------------------------------------------------------- +# +# With output_type=2 FRED returns one column per vintage, named _: +# +# {"date": "2025-07-01", "GDPC1_20251223": "24024.957", "GDPC1_20260122": "24026.834", +# "GDPC1_20260220": "24026.834", ... nine in total} +# +# Seven of those nine are the same number. A vintage exists for every release of the +# series, not for every change to this observation, so most columns repeat the one +# before. Collapsing them turns nine columns into "first printed as X, revised once to +# Y", which is the actual answer. + + +def vintage_history(row: dict, series_id: str) -> list[dict]: + """Collapse a vintage row into the points where the value actually changed.""" + vintages: list[tuple[str, Number]] = [] + prefix = f"{series_id}_" + for key, raw in row.items(): + if not key.startswith(prefix): + continue + stamp = key[len(prefix) :] + if len(stamp) != 8 or not stamp.isdigit(): + continue + vintages.append((f"{stamp[:4]}-{stamp[4:6]}-{stamp[6:]}", parse_value(raw))) + vintages.sort() + + history: list[dict] = [] + for vintage, value in vintages: + if history and history[-1]["value"] == value: + continue + entry: dict[str, Any] = {"vintage": vintage, "value": value} + if history: + previous = history[-1]["value"] + if previous is not None and value is not None: + entry["change"] = round(value - previous, 6) + if previous: + entry["pct_change"] = round((value - previous) / abs(previous) * 100, 4) + history.append(entry) + return history + + +def revision_rows(initial: list[tuple[str, Number]], current: list[tuple[str, Number]]) -> list[dict]: + """Join first-printed against latest, one row per observation date.""" + latest = dict(current) + rows: list[dict] = [] + for date, first in sorted(initial): + if date not in latest: + continue + now = latest[date] + row: dict[str, Any] = {"date": date, "initial": first, "current": now} + if first is not None and now is not None: + row["revision"] = round(now - first, 6) + if first: + row["revision_pct"] = round((now - first) / abs(first) * 100, 4) + rows.append(row) + return rows + + def downsample(dates: list[str], columns: dict[str, list[Number]], max_points: int) -> tuple[list, dict, int]: """Thin to evenly spaced points, always keeping the first and the last. diff --git a/plugins/fred/src/tools.py b/plugins/fred/src/tools.py index 6d7ded7..eea46f9 100644 --- a/plugins/fred/src/tools.py +++ b/plugins/fred/src/tools.py @@ -11,10 +11,17 @@ import httpx from mcp.server import MCPServer -from . import shaping +from . import dates, shaping from .client import FredClient from .errors import guarded_tool -from .schemas import GetSeriesArgs, ObservationArgs, SearchArgs +from .schemas import CalendarArgs, GetSeriesArgs, ObservationArgs, RevisionArgs, SearchArgs + +# ALFRED's real-time window. Vintage requests need one that spans the whole record; +# with the default (today to today) FRED answers output_type=2 and 4 with +# "No vintage dates exist for the specified real-time period", which reads like the +# series has no revision history rather than like a missing parameter. Every vintage +# call here sets it, which is most of what get_revisions is for. +_ALL_TIME = {"realtime_start": "1776-07-04", "realtime_end": "9999-12-31"} _client: FredClient | None = None @@ -196,6 +203,129 @@ async def _get_observations(args: ObservationArgs) -> dict: return result +async def _revisions_for_one_date(args: RevisionArgs) -> dict: + """The full revision history of a single observation.""" + payload = await get_client().get( + "/series/observations", + series_id=args.series_id, + output_type=2, # one column per vintage + observation_start=args.observation_date, + observation_end=args.observation_date, + **_ALL_TIME, + ) + rows = payload.get("observations", []) + if not rows: + return { + "series_id": args.series_id, + "observation_date": args.observation_date, + "revisions": [], + "note": "No observation on that date. Check the date against the series' frequency " + "with get_series; quarterly dates are the first day of the quarter.", + } + + history = shaping.vintage_history(rows[0], args.series_id) + result: dict[str, object] = { + "series_id": args.series_id, + "observation_date": args.observation_date, + "revisions": history, + "revision_count": max(0, len(history) - 1), + } + if history: + result["initial"] = history[0] + result["current"] = history[-1] + return result + + +async def _revision_overview(args: RevisionArgs) -> dict: + """Initial print against current value, across the most recent observations.""" + client = get_client() + initial, current, vintages = await asyncio.gather( + client.get( + "/series/observations", + series_id=args.series_id, + output_type=4, # initial release only + limit=args.limit, + sort_order="desc", + **_ALL_TIME, + ), + client.get("/series/observations", series_id=args.series_id, limit=args.limit, sort_order="desc"), + client.get("/series/vintagedates", series_id=args.series_id, limit=1, sort_order="desc"), + ) + + rows = shaping.revision_rows(shaping.observation_pairs(initial), shaping.observation_pairs(current)) + vintage_dates = vintages.get("vintage_dates") or [] + return { + "series_id": args.series_id, + "observations": rows, + "revised": sum(1 for row in rows if row.get("revision")), + "vintages": {"count": vintages.get("count"), "latest": vintage_dates[0] if vintage_dates else None}, + "note": "initial is the number as first published; current is the number today. " + "Pass observation_date for the full revision history of one of these.", + } + + +async def _fetch_release_dates(args: CalendarArgs, start: str, end: str, future: bool) -> dict: + params: dict[str, object] = { + "realtime_start": start, + "realtime_end": end, + "sort_order": "asc", + "limit": args.limit, + } + # Without this, FRED returns only dates that have already produced data, so the + # whole "what is scheduled next" half of the question silently comes back empty. + if future: + params["include_release_dates_with_no_data"] = True + if args.release_id is not None: + return await get_client().get("/release/dates", release_id=args.release_id, **params) + return await get_client().get("/releases/dates", **params) + + +def _calendar_rows(payload: dict) -> list[dict]: + rows = [] + for entry in payload.get("release_dates", []): + row: dict = {"date": entry.get("date"), "release_id": entry.get("release_id")} + if entry.get("release_name"): + row["release_name"] = entry["release_name"] + rows.append(row) + return rows + + +async def _release_calendar(args: CalendarArgs) -> dict: + """Fetch the two halves of the window separately, then report them separately. + + One request for the whole window would be truncated by ``limit`` before the split, + and since FRED returns dates ascending, the truncation lands entirely on the + future. That produced "50 released, 0 upcoming" for a window with 150 scheduled + releases in it: a confidently empty answer to half the question being asked. + """ + today = dates.today().isoformat() + tomorrow = dates.days_after(dates.today(), 1).isoformat() + + past = _fetch_release_dates(args, args.start, min(args.end, today), future=False) if args.start <= today else None + ahead = ( + _fetch_release_dates(args, max(args.start, tomorrow), args.end, future=True) if args.end > today else None + ) + release = get_client().get("/release", release_id=args.release_id) if args.release_id is not None else None + + pending = [task for task in (past, ahead, release) if task is not None] + done = iter(await asyncio.gather(*pending)) + past_payload = next(done) if past is not None else {} + ahead_payload = next(done) if ahead is not None else {} + release_payload = next(done) if release is not None else None + + result: dict[str, object] = { + "window": {"start": args.start, "end": args.end}, + "today": today, + "released": _calendar_rows(past_payload), + "upcoming": _calendar_rows(ahead_payload), + # FRED's totals for each half, so a limited page is visibly a page. + "totals": {"released": past_payload.get("count", 0), "upcoming": ahead_payload.get("count", 0)}, + } + if release_payload is not None: + result["release"] = shaping.trim_release(shaping.first_or_empty(release_payload, "releases")) + return result + + def register_all(mcp: MCPServer) -> None: """Register every tool on the given MCP server.""" @@ -320,3 +450,59 @@ async def get_observations( } ) return fmt(await _get_observations(args)) + + @mcp.tool() + @guarded_tool + async def get_revisions(series_id: str, observation_date: str = "", limit: int = 10) -> str: + """What a number was first reported as, and how it has been revised since. + + FRED keeps every vintage of every series (this is ALFRED). Answers questions + like "what did Q3 GDP originally print at" and "does this series get revised + much", which the current values alone cannot. + + Two modes: + observation_date set the full revision history of that one data point, + with the repeated vintages collapsed so you see the + changes rather than one column per publication + observation_date omitted first-printed against current for the most recent + observations, which shows the revision pattern + + observation_date takes the same forms as elsewhere, and must be an observation + date rather than a publication date: quarterly series are dated to the first + day of the quarter, monthly to the first of the month. + + The real-time window this needs is set for you. Asking FRED for vintages + without it fails with a message about no vintage dates existing, which reads + like the series has no history when the parameter is simply missing. + """ + args = RevisionArgs.model_validate( + {"series_id": series_id, "observation_date": observation_date, "limit": limit} + ) + if args.observation_date: + return fmt(await _revisions_for_one_date(args)) + return fmt(await _revision_overview(args)) + + @mcp.tool() + @guarded_tool + async def get_release_calendar( + start: str = "", + end: str = "", + release_id: int | None = None, + limit: int = 50, + ) -> str: + """What economic data just came out, and what is scheduled next. + + Splits results into "released" and "upcoming" around today, because those are + two different questions and comparing dates to tell them apart is work the + caller should not have to do. + + Defaults to the last 7 days and the next 14. start and end take the same forms + as get_observations ("today", "5y", "2026-08", a full date). + + release_id narrows to one publication's schedule, e.g. 50 for the Employment + Situation. Release IDs come from this tool or from get_series(include= + ["release"]), and feed back into search_series(release_id=...) to list every + series a release publishes. + """ + args = CalendarArgs.model_validate({"start": start, "end": end, "release_id": release_id, "limit": limit}) + return fmt(await _release_calendar(args)) diff --git a/plugins/fred/tests/fixtures/fred_api.py b/plugins/fred/tests/fixtures/fred_api.py index 59fd21f..d56118c 100644 --- a/plugins/fred/tests/fixtures/fred_api.py +++ b/plugins/fred/tests/fixtures/fred_api.py @@ -113,6 +113,40 @@ def _daily_series() -> list[tuple[str, str]]: OBSERVATIONS = {"UNRATE": UNRATE_OBS, "GDPC1": GDPC1_OBS, "CPIAUCSL": UNRATE_OBS, "DGS10": DAILY_OBS} +# What FRED returns for output_type=4: the value as first published, with the realtime +# window showing when that print was current. GDPC1 gets revised, UNRATE does not. +INITIAL_OBS = { + # 2025-01-01 was revised (22900 -> 23000); 2025-04-01 stands as first published. + # One of each, so "revised" counting is tested against a mix rather than a + # uniformly-revised series where an off-by-one would pass. + "GDPC1": [("2025-01-01", "22900.0"), ("2025-04-01", "23150.5")], + "UNRATE": UNRATE_OBS, + "CPIAUCSL": UNRATE_OBS, +} + +# One column per vintage, the output_type=2 shape. Six vintages, one real revision: +# the collapse should reduce this to two entries, not six. +VINTAGE_ROW = { + "date": "2025-01-01", + "GDPC1_20250430": "22900.0", + "GDPC1_20250528": "22900.0", + "GDPC1_20250625": "22900.0", + "GDPC1_20250730": "23000.0", + "GDPC1_20250828": "23000.0", + "GDPC1_20250925": "23000.0", +} + +VINTAGE_DATES = ["2025-09-25", "2025-08-28", "2025-07-30", "2025-06-25", "2025-05-28", "2025-04-30"] + +# Dates straddle TODAY so the released/upcoming split has something on both sides. +TODAY = "2026-08-05" +RELEASE_DATES = [ + {"release_id": 50, "release_name": "Employment Situation", "date": "2026-08-01"}, + {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-08-05"}, + {"release_id": 50, "release_name": "Employment Situation", "date": "2026-08-07"}, + {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-08-12"}, +] + class MockFred: """Routes FRED paths to captured payloads and records every request.""" @@ -141,6 +175,20 @@ def _handle(self, request: httpx.Request) -> httpx.Response: return self._one_series(params.get("series_id", "")) if path.endswith("/series/observations"): return self._observations(params) + if path.endswith("/series/vintagedates"): + return _ok({"count": len(VINTAGE_DATES), "vintage_dates": VINTAGE_DATES[: int(params.get("limit", 100))]}) + if path.endswith("/releases/dates"): + return self._release_dates(params, RELEASE_DATES) + if path.endswith("/release/dates"): + # FRED omits release_name here, unlike /releases/dates. + rows = [ + {"release_id": r["release_id"], "date": r["date"]} + for r in RELEASE_DATES + if str(r["release_id"]) == params.get("release_id") + ] + return self._release_dates(params, rows) + if path.endswith("/fred/release"): + return _ok({"releases": [{"id": 50, "name": "Employment Situation", "link": "http://www.bls.gov/ces/"}]}) if path.endswith("/series/search"): return self._series_list(params, [UNRATE, UNRATENSA, CPIAUCSL, GDPC1]) if path.endswith("/release/series") or path.endswith("/category/series"): @@ -190,24 +238,41 @@ def _observations(self, params: dict[str, str]) -> httpx.Response: if series_id not in OBSERVATIONS: return _error(400, "Bad Request. The series does not exist.") + output_type = params.get("output_type", "1") + if output_type in ("2", "4"): + # FRED's own behaviour, and the reason get_revisions exists: without a + # real-time window spanning the record, a vintage request fails. + if params.get("realtime_start") != "1776-07-04": + return _error( + 400, + "Bad Request. No vintage dates exist for the specified real-time period: " + f"{TODAY} to {TODAY}.", + ) + if output_type == "2": + row = VINTAGE_ROW if params.get("observation_start") == VINTAGE_ROW["date"] else None + return _ok({"observations": [row] if row else []}) + rows = INITIAL_OBS.get(series_id, []) + return self._observation_payload(_limited(rows, params), params) + rows = OBSERVATIONS[series_id] start, end = params.get("observation_start"), params.get("observation_end") if start: rows = [r for r in rows if r[0] >= start] if end: rows = [r for r in rows if r[0] <= end] + return self._observation_payload(_limited(rows, params), params) + def _observation_payload(self, rows: list[tuple[str, str]], params: dict[str, str]) -> httpx.Response: # Real-time fields carry the same value on every row, which is the redundancy # the columnar shaping exists to remove; the fixture reproduces it faithfully. return _ok( { - "realtime_start": "2026-08-05", - "realtime_end": "2026-08-05", + "realtime_start": TODAY, + "realtime_end": TODAY, "units": params.get("units", "lin"), "count": len(rows), "observations": [ - {"realtime_start": "2026-08-05", "realtime_end": "2026-08-05", "date": d, "value": v} - for d, v in rows + {"realtime_start": TODAY, "realtime_end": TODAY, "date": d, "value": v} for d, v in rows ], } ) @@ -235,6 +300,28 @@ def _series_list(self, params: dict[str, str], pool: list[dict]) -> httpx.Respon return _ok({"count": total, "offset": 0, "limit": limit, "seriess": rows[:limit]}) + def _release_dates(self, params: dict[str, str], pool: list[dict]) -> httpx.Response: + rows = list(pool) + start, end = params.get("realtime_start"), params.get("realtime_end") + if start: + rows = [r for r in rows if r["date"] >= start] + if end: + rows = [r for r in rows if r["date"] <= end] + # FRED only returns scheduled dates that have not produced data yet when this + # flag is set, so the fixture withholds them without it. + if params.get("include_release_dates_with_no_data") != "true": + rows = [r for r in rows if r["date"] <= TODAY] + return _ok({"count": len(rows), "release_dates": rows[: int(params.get("limit", 1000))]}) + + +def _limited(rows: list[tuple[str, str]], params: dict[str, str]) -> list[tuple[str, str]]: + """FRED's limit and sort_order, applied the way the real API does.""" + if params.get("sort_order") == "desc": + rows = sorted(rows, reverse=True) + limit = int(params.get("limit", 100000)) + return rows[:limit] + + def _tags_for(series: dict) -> set[str]: """The freq and seas tags FRED would carry for a series, derived from its fields.""" return { diff --git a/plugins/fred/tests/integration/test_revisions_and_calendar.py b/plugins/fred/tests/integration/test_revisions_and_calendar.py new file mode 100644 index 0000000..a3c6486 --- /dev/null +++ b/plugins/fred/tests/integration/test_revisions_and_calendar.py @@ -0,0 +1,162 @@ +"""get_revisions and get_release_calendar, through the registered MCP server.""" + +from datetime import date + +import pytest + +from src import dates as dates_module + +from ..fixtures.fred_api import TODAY + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +def _pinned_clock(monkeypatch): + """Pin today to the fixture's, so the released/upcoming split is deterministic.""" + monkeypatch.setattr(dates_module, "today", lambda: date.fromisoformat(TODAY)) + + +class TestRevisionsForOneDate: + async def test_the_real_time_window_is_always_set(self, call, fred): + # The bug this tool exists to prevent. Without it FRED answers with "No vintage + # dates exist for the specified real-time period", which reads like the series + # has no revision history rather than like a missing parameter. + await call("get_revisions", series_id="GDPC1", observation_date="2025-01-01") + params = fred.query("/series/observations") + assert params["realtime_start"] == "1776-07-04" + assert params["realtime_end"] == "9999-12-31" + + async def test_output_type_2_is_requested(self, call, fred): + await call("get_revisions", series_id="GDPC1", observation_date="2025-01-01") + assert fred.query("/series/observations")["output_type"] == "2" + + async def test_six_vintages_become_two_entries(self, call): + out = await call("get_revisions", series_id="GDPC1", observation_date="2025-01-01") + assert len(out["revisions"]) == 2 + assert out["revision_count"] == 1 + assert out["initial"]["value"] == 22900.0 + assert out["current"]["value"] == 23000.0 + + async def test_the_date_is_normalized_first(self, call, fred): + await call("get_revisions", series_id="gdpc1", observation_date="2025-01") + params = fred.query("/series/observations") + assert params["series_id"] == "GDPC1" + assert params["observation_start"] == "2025-01-01" + assert params["observation_end"] == "2025-01-01" + + async def test_a_date_with_no_observation_explains_itself(self, call): + out = await call("get_revisions", series_id="GDPC1", observation_date="2025-02-15") + assert out["revisions"] == [] + assert "frequency" in out["note"] + + +class TestRevisionOverview: + async def test_initial_against_current(self, call): + out = await call("get_revisions", series_id="GDPC1") + by_date = {row["date"]: row for row in out["observations"]} + assert by_date["2025-01-01"]["initial"] == 22900.0 + assert by_date["2025-01-01"]["current"] == 23000.0 + assert by_date["2025-01-01"]["revision"] == pytest.approx(100.0) + + async def test_an_unrevised_observation_is_not_counted_as_revised(self, call): + out = await call("get_revisions", series_id="GDPC1") + assert out["observations"][1]["revision"] == 0.0 + assert out["revised"] == 1 + + async def test_the_initial_request_uses_output_type_4_and_the_window(self, call, fred): + await call("get_revisions", series_id="GDPC1") + initial = next( + params + for path, params in fred.requests + if path.endswith("/series/observations") and params.get("output_type") == "4" + ) + assert initial["realtime_start"] == "1776-07-04" + + async def test_vintage_context_comes_back(self, call): + out = await call("get_revisions", series_id="GDPC1") + assert out["vintages"] == {"count": 6, "latest": "2025-09-25"} + + async def test_a_series_that_is_never_revised(self, call): + out = await call("get_revisions", series_id="UNRATE") + assert out["revised"] == 0 + # UNRATE's fixture carries a missing observation, which has no revision to + # report at all. That is different from a revision of zero, and the absent + # key is the honest way to say so. + assert all(row.get("revision") in (0.0, None) for row in out["observations"]) + assert any("revision" not in row for row in out["observations"]) + + async def test_a_bad_series_is_guidance_not_a_traceback(self, call): + out = await call("get_revisions", series_id="NOPE") + assert "does not exist" in out["error"] + assert any("search_series" in s for s in out["suggestions"]) + + async def test_a_blank_series_id_never_reaches_the_api(self, call, fred): + out = await call("get_revisions", series_id="") + assert any("required" in s for s in out["suggestions"]) + assert not fred.requests + + +class TestReleaseCalendar: + async def test_splits_on_today(self, call): + out = await call("get_release_calendar") + assert out["today"] == TODAY + assert [r["date"] for r in out["released"]] == ["2026-08-01", "2026-08-05"] + assert [r["date"] for r in out["upcoming"]] == ["2026-08-07", "2026-08-12"] + + async def test_a_release_dated_today_counts_as_released(self, call): + # It has come out; a model asking "what came out today" should see it. + out = await call("get_release_calendar") + assert TODAY in [r["date"] for r in out["released"]] + + async def test_the_default_window_straddles_today(self, call): + out = await call("get_release_calendar") + assert out["window"] == {"start": "2026-07-29", "end": "2026-08-19"} + + async def test_future_dates_are_asked_for_when_the_window_reaches_forward(self, call, fred): + # Without the flag FRED returns only dates that already produced data, so the + # whole "what is next" half of the question comes back empty. + await call("get_release_calendar") + assert fred.query("/releases/dates")["include_release_dates_with_no_data"] == "true" + + async def test_a_purely_historical_window_does_not_set_the_flag(self, call, fred): + out = await call("get_release_calendar", start="2026-07-01", end="2026-08-01") + assert "include_release_dates_with_no_data" not in fred.query("/releases/dates") + assert out["upcoming"] == [] + # And it does not spend a request asking about a future it was not asked about. + assert len([p for p, _ in fred.requests if p.endswith("/releases/dates")]) == 1 + + async def test_each_half_is_fetched_with_its_own_limit(self, call, fred): + # One request across the whole window is truncated by limit before the split, + # and FRED returns dates ascending, so the truncation lands entirely on the + # future: "50 released, 0 upcoming" for a window full of scheduled releases. + await call("get_release_calendar", limit=1) + windows = [ + (params["realtime_start"], params["realtime_end"]) + for path, params in fred.requests + if path.endswith("/releases/dates") + ] + assert windows == [("2026-07-29", TODAY), ("2026-08-06", "2026-08-19")] + + async def test_a_limited_page_says_how_much_it_left_out(self, call): + out = await call("get_release_calendar", limit=1) + assert len(out["released"]) == 1 + assert len(out["upcoming"]) == 1 + assert out["totals"] == {"released": 2, "upcoming": 2} + + async def test_release_id_narrows_to_one_publication_and_names_it(self, call, fred): + out = await call("get_release_calendar", release_id=50) + assert fred.query("/release/dates")["release_id"] == "50" + assert out["release"]["name"] == "Employment Situation" + assert {r["release_id"] for r in out["released"] + out["upcoming"]} == {50} + + async def test_relative_dates_work_here_too(self, call, fred): + await call("get_release_calendar", start="30d", end="today") + params = fred.query("/releases/dates") + assert params["realtime_start"] == "2026-07-06" + assert params["realtime_end"] == TODAY + + async def test_a_backwards_window_is_caught_locally(self, call, fred): + out = await call("get_release_calendar", start="2026-08-01", end="2026-07-01") + assert any("swap them" in s for s in out["suggestions"]) + assert not fred.requests diff --git a/plugins/fred/tests/unit/test_revision_shaping.py b/plugins/fred/tests/unit/test_revision_shaping.py new file mode 100644 index 0000000..ff0bf50 --- /dev/null +++ b/plugins/fred/tests/unit/test_revision_shaping.py @@ -0,0 +1,98 @@ +"""Collapsing vintage columns, and joining first-printed against current.""" + +import pytest + +from src import shaping + +from ..fixtures.fred_api import VINTAGE_ROW + +pytestmark = pytest.mark.unit + + +class TestVintageHistory: + def test_repeated_vintages_collapse_to_the_actual_changes(self): + # Six vintages, one real revision. The point of the tool: a vintage exists for + # every publication of the series, not for every change to this observation. + history = shaping.vintage_history(VINTAGE_ROW, "GDPC1") + assert len(history) == 2 + assert history[0] == {"vintage": "2025-04-30", "value": 22900.0} + assert history[1]["vintage"] == "2025-07-30" + assert history[1]["value"] == 23000.0 + + def test_a_real_revision_carries_its_size(self): + history = shaping.vintage_history(VINTAGE_ROW, "GDPC1") + assert history[1]["change"] == pytest.approx(100.0) + assert history[1]["pct_change"] == pytest.approx(0.4367, abs=1e-3) + + def test_vintages_are_ordered_oldest_first_whatever_the_key_order(self): + row = {"date": "2025-01-01", "X_20260101": "3", "X_20240101": "1", "X_20250101": "2"} + assert [h["vintage"] for h in shaping.vintage_history(row, "X")] == [ + "2024-01-01", + "2025-01-01", + "2026-01-01", + ] + + def test_a_never_revised_observation_has_one_entry(self): + row = {"date": "2025-01-01", "X_20250201": "4.0", "X_20250301": "4.0"} + history = shaping.vintage_history(row, "X") + assert len(history) == 1 + assert "change" not in history[0] + + def test_the_date_column_is_not_mistaken_for_a_vintage(self): + assert all(h["vintage"] != "date" for h in shaping.vintage_history(VINTAGE_ROW, "GDPC1")) + + def test_columns_for_another_series_are_ignored(self): + row = {"date": "2025-01-01", "X_20250201": "1", "Y_20250201": "999"} + assert [h["value"] for h in shaping.vintage_history(row, "X")] == [1.0] + + def test_a_malformed_column_name_is_skipped(self): + row = {"date": "2025-01-01", "X_20250201": "1", "X_notadate": "999"} + assert len(shaping.vintage_history(row, "X")) == 1 + + def test_a_missing_value_does_not_produce_a_change(self): + row = {"date": "2025-01-01", "X_20250201": ".", "X_20250301": "4.0"} + history = shaping.vintage_history(row, "X") + assert history[0]["value"] is None + assert "change" not in history[1] + + def test_an_empty_row(self): + assert shaping.vintage_history({"date": "2025-01-01"}, "X") == [] + + +class TestRevisionRows: + def test_joins_first_printed_against_current(self): + rows = shaping.revision_rows( + [("2025-01-01", 22900.0), ("2025-04-01", 23100.0)], + [("2025-01-01", 23000.0), ("2025-04-01", 23100.0)], + ) + assert rows[0] == { + "date": "2025-01-01", + "initial": 22900.0, + "current": 23000.0, + "revision": pytest.approx(100.0), + "revision_pct": pytest.approx(0.4367, abs=1e-3), + } + + def test_an_unrevised_observation_shows_a_zero_revision(self): + rows = shaping.revision_rows([("2025-04-01", 23100.0)], [("2025-04-01", 23100.0)]) + assert rows[0]["revision"] == 0.0 + + def test_rows_come_back_oldest_first(self): + rows = shaping.revision_rows( + [("2025-04-01", 1.0), ("2025-01-01", 2.0)], + [("2025-04-01", 1.0), ("2025-01-01", 2.0)], + ) + assert [r["date"] for r in rows] == ["2025-01-01", "2025-04-01"] + + def test_a_date_with_no_current_value_is_dropped(self): + assert shaping.revision_rows([("2025-01-01", 1.0)], []) == [] + + def test_a_downward_revision_is_negative(self): + rows = shaping.revision_rows([("2025-01-01", 100.0)], [("2025-01-01", 90.0)]) + assert rows[0]["revision"] == pytest.approx(-10.0) + assert rows[0]["revision_pct"] == pytest.approx(-10.0) + + def test_a_zero_initial_does_not_divide_by_zero(self): + rows = shaping.revision_rows([("2025-01-01", 0.0)], [("2025-01-01", 5.0)]) + assert rows[0]["revision"] == 5.0 + assert "revision_pct" not in rows[0] diff --git a/plugins/fred/tests/unit/test_server.py b/plugins/fred/tests/unit/test_server.py index 97ee13b..3e77d03 100644 --- a/plugins/fred/tests/unit/test_server.py +++ b/plugins/fred/tests/unit/test_server.py @@ -20,7 +20,13 @@ async def test_server_builds_and_exposes_its_tools(): # Grows with the stack in #30. Asserting the exact set is what catches a tool that was # written but never registered, which is otherwise invisible until a user asks for it. -EXPECTED_TOOLS: set[str] = {"search_series", "get_series", "get_observations"} +EXPECTED_TOOLS: set[str] = { + "search_series", + "get_series", + "get_observations", + "get_revisions", + "get_release_calendar", +} def test_instructions_name_the_entry_point(): From 997c505be9ba2803063df8e505de32a8d2153cdd Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:19:10 -0700 Subject: [PATCH 2/2] chore(fred): bump to 0.4.0 for get_revisions and get_release_calendar (#34) --- plugins/fred/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/fred/.claude-plugin/plugin.json b/plugins/fred/.claude-plugin/plugin.json index 54d8847..5a6f1d4 100644 --- a/plugins/fred/.claude-plugin/plugin.json +++ b/plugins/fred/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fred", - "version": "0.3.0", + "version": "0.4.0", "description": "MCP server for the FRED API: economic time series from the St. Louis Fed, with search, aligned multi-series observations, revision history, and the release calendar.", "author": { "name": "Walker Hughes"