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" 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():