From 47cac9b5217f4b08f8481c10c5cb53e99a1909a3 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 30 May 2026 12:02:13 -0400 Subject: [PATCH] Add price_trend / price_series MCP tools (USDA NASS grain) Real $/bu price + MoM/YoY change + seasonal percentile context for corn/soy/ wheat, US + states, deep history. Wraps the new /api/data/price-trend and /api/data/price-series endpoints. Changelog updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++++ ag_bids_mcp/client.py | 10 ++++++ ag_bids_mcp/format.py | 79 +++++++++++++++++++++++++++++++++++++++++++ ag_bids_mcp/server.py | 57 +++++++++++++++++++++++++++++++ tests/test_format.py | 43 +++++++++++++++++++++++ 5 files changed, 215 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43dfcc8..0f63a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ Notes for clients/agents that consume the ag-bids MCP tools and the underlying `ag-monitor` `/api/data/*` HTTP API. Newest first. +## 2026-05-30 — Grain price-received trends (real $ + change + seasonal) + +New national/seasonal reference layer (USDA NASS, corn back to 1908) — the +macro benchmark to compare local cash bids against. Real prices and the change, +not an index. + +### New MCP tools + +- **`price_trend(commodity, geo="US", years=10)`** — monthly price *received by + farmers* ($/bu) with the move: latest real price, **month-over-month and + year-over-year change ($ and %)**, and seasonal context (percentile vs the + same month over the last N years, normal, and range). `geo` is `US` or any + 2-letter state. Conclusions, not rows. +- **`price_series(commodity, geo="US", start_year?, end_year?)`** — raw monthly + series ($/bu) for charting/drill-down (defaults to the recent window). + +### API + +- `GET /api/data/price-trend?commodity=&geo=&years=` — computed trend (cents + + `change_cents`/`change_pct`/`yoy_*` + `seasonal`). +- `GET /api/data/price-series?commodity=&geo=&start_year=&end_year=` — raw series. +- `GET /api/data/price-geographies?commodity=` — which geos (US + states) exist. + +All grain: corn/soy/wheat, all 50 states + US. (Input-cost trends — diesel, +fertilizer — coming next.) + ## 2026-05-30 — Source geo + many more locations ### Per-source geo (API + MCP) diff --git a/ag_bids_mcp/client.py b/ag_bids_mcp/client.py index e446cf7..c405a6d 100644 --- a/ag_bids_mcp/client.py +++ b/ag_bids_mcp/client.py @@ -70,6 +70,16 @@ def best(commodity: str) -> dict: return _get("/api/data/best", commodity=commodity) +def price_trend(commodity: str, geo: str = "US", years: int = 10) -> dict: + return _get("/api/data/price-trend", commodity=commodity, geo=geo, years=years) + + +def price_series(commodity: str, geo: str = "US", + start_year: int | None = None, end_year: int | None = None) -> dict: + return _get("/api/data/price-series", commodity=commodity, geo=geo, + start_year=start_year, end_year=end_year) + + def futures(commodity: str, delivery: str | None = None) -> dict: return _get("/api/data/futures", commodity=commodity, delivery=delivery) diff --git a/ag_bids_mcp/format.py b/ag_bids_mcp/format.py index 49f58a7..270cdfe 100644 --- a/ag_bids_mcp/format.py +++ b/ag_bids_mcp/format.py @@ -90,6 +90,85 @@ def fmt_best(commodity: str, payload: dict) -> str: ) +# ---------- reference price trends (USDA NASS / EIA) ---------- + + +def _unit_money(cents, unit: str) -> str: + if cents is None: + return "—" + dollars = cents / 100 + if unit == "$/gal": + return f"${dollars:.3f}/gal" + if unit == "$/bu": + return f"${dollars:.2f}/bu" + if unit == "$/ton": + return f"${dollars:.2f}/ton" + return f"${dollars:.2f}" + + +def _signed(cents) -> str: + if cents is None: + return "—" + return f"{'+' if cents >= 0 else '−'}${abs(cents)/100:.2f}" + + +def _pct(p) -> str: + return "—" if p is None else f"{'+' if p >= 0 else '−'}{abs(p):.1f}%" + + +def _ym(period: str) -> str: + # 2026-04-01 -> "Apr 2026"; weekly date -> as-is + try: + y, m, d = period.split("-") + return f"{_MONTH_NAMES_3[int(m)-1]} {y}" if d == "01" else period + except Exception: + return period + + +_MONTH_NAMES_3 = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + +def fmt_price_trend(payload: dict, label: str = "price received") -> str: + commodity = payload.get("commodity") + geo = payload.get("geo") + unit = payload.get("unit") or "$/bu" + t = payload.get("trend") + if not t: + return f"### {commodity} — {geo} — {label}\n\nNo data on file.\n" + when = _ym(t["period"]) + lines = [f"### {commodity} — {geo} — {label}, {when}: {_unit_money(t['value_cents'], unit)}", ""] + arrow = _delta_arrow(t.get("change_cents")) + lines.append(f"- **Change:** {arrow} {_signed(t.get('change_cents'))} ({_pct(t.get('change_pct'))}) vs prior period") + if t.get("yoy_cents") is not None: + lines.append(f"- **Year-over-year:** {_signed(t.get('yoy_change_cents'))} ({_pct(t.get('yoy_pct'))})") + s = t.get("seasonal") + if s: + lines.append( + f"- **Seasonal:** {s['percentile']}th pct vs last {s['sample_years']} same-months " + f"(normal {_unit_money(s['normal_cents'], unit)}, range " + f"{_unit_money(s['min_cents'], unit)}–{_unit_money(s['max_cents'], unit)}) " + f"· {_pct(s.get('vs_normal_pct'))} vs normal") + lines.append(f"- **Recent direction:** {_delta_arrow({'up':1,'down':-1,'flat':0}[t['recent_direction']])} {t['recent_direction']}") + lines.append(f"\n_Source: {payload.get('source') or 'USDA NASS'} · {t['points']} months on file_") + return "\n".join(lines) + "\n" + + +def fmt_price_series(payload: dict, max_points: int = 60) -> str: + commodity = payload.get("commodity") + geo = payload.get("geo") + unit = payload.get("unit") or "$/bu" + series = payload.get("series") or [] + if not series: + return f"### {commodity} — {geo} series\n\nNo data on file.\n" + shown = series[-max_points:] + head = [f"### {commodity} — {geo} — {payload.get('count', len(series))} periods " + f"(showing last {len(shown)})", "", + "| Period | Price |", "|---|---:|"] + body = [f"| {_ym(p['period'])} | {_unit_money(p['value_cents'], unit)} |" for p in shown] + return "\n".join(head + body) + "\n" + + # ---------- futures quote + change ---------- diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py index c68d05e..a54f5c4 100644 --- a/ag_bids_mcp/server.py +++ b/ag_bids_mcp/server.py @@ -246,6 +246,63 @@ def basis_detail( return fmt.fmt_basis_detail(_filter_source(payload, source)) +@mcp.tool() +def price_trend( + commodity: Annotated[ + str, Field(description="Grain: 'corn', 'soy', or 'wheat'.") + ], + geo: Annotated[ + str, + Field(description="'US' or a 2-letter state (e.g. 'OH', 'IA'). Default US."), + ] = "US", + years: Annotated[ + int, Field(ge=1, le=120, description="Baseline window for the seasonal normal/percentile."), + ] = 10, +) -> str: + """USDA NASS monthly price *received by farmers* ($/bu) with the change. + + Returns the latest real price plus month-over-month and year-over-year moves + (dollars + percent) and where it sits seasonally (percentile vs the same + month over the last N years, normal, and range). This is the macro/seasonal + benchmark to compare local cash bids against — corn history goes back to 1908.""" + cm = commodity.strip().lower() + g = geo.strip().upper() + with track("price_trend", commodity=cm, geo=g, years=years): + if cm not in VALID_GRAIN: + return f"`commodity` must be one of: {sorted(VALID_GRAIN)}" + return fmt.fmt_price_trend(client.price_trend(commodity=cm, geo=g, years=years)) + + +@mcp.tool() +def price_series( + commodity: Annotated[ + str, Field(description="Grain: 'corn', 'soy', or 'wheat'.") + ], + geo: Annotated[ + str, Field(description="'US' or a 2-letter state. Default US."), + ] = "US", + start_year: Annotated[ + int | None, Field(description="Optional first year (default: ~last 5 yrs of the full series).") + ] = None, + end_year: Annotated[int | None, Field(description="Optional last year.")] = None, +) -> str: + """Raw monthly price-received series ($/bu) for charting / drill-down. + + Use price_trend for the headline; use this when you need the actual monthly + numbers. Defaults to the recent window unless you pass a start_year.""" + cm = commodity.strip().lower() + g = geo.strip().upper() + with track("price_series", commodity=cm, geo=g): + if cm not in VALID_GRAIN: + return f"`commodity` must be one of: {sorted(VALID_GRAIN)}" + # Default to a readable recent window if no range given. + if start_year is None and end_year is None: + from datetime import datetime + start_year = datetime.utcnow().year - 5 + payload = client.price_series(commodity=cm, geo=g, start_year=start_year, end_year=end_year) + return fmt.fmt_price_series(payload) + + @mcp.tool() def list_sources() -> str: """All active scrapers + their last-success timestamps and any pending failures.""" diff --git a/tests/test_format.py b/tests/test_format.py index 942d9f1..7f2f508 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -75,6 +75,49 @@ def test_fmt_futures_no_quote(): assert "No futures quote on file" in out +def test_fmt_price_trend(): + payload = {"commodity": "corn", "geo": "OH", "unit": "$/bu", + "source": "USDA NASS Quick Stats", + "trend": {"period": "2026-04-01", "value_cents": 468, + "prev_cents": 459, "change_cents": 9, "change_pct": 2.0, + "yoy_cents": 480, "yoy_change_cents": -12, "yoy_pct": -2.5, + "seasonal": {"normal_cents": 435, "median_cents": 430, + "min_cents": 400, "max_cents": 480, + "percentile": 75, "vs_normal_pct": 7.6, + "sample_years": 4}, + "recent_direction": "up", "baseline_years": 10, "points": 120}} + out = fmt.fmt_price_trend(payload) + assert "corn — OH" in out + assert "$4.68/bu" in out + assert "+$0.09" in out and "+2.0%" in out # MoM change + assert "−$0.12" in out and "−2.5%" in out # YoY + assert "75th pct" in out + + +def test_fmt_price_trend_empty(): + out = fmt.fmt_price_trend({"commodity": "wheat", "geo": "US", "unit": "$/bu", "trend": None}) + assert "No data on file" in out + + +def test_fmt_price_series(): + payload = {"commodity": "corn", "geo": "US", "unit": "$/bu", "count": 2, + "series": [{"period": "2026-03-01", "value_cents": 459}, + {"period": "2026-04-01", "value_cents": 468}]} + out = fmt.fmt_price_series(payload) + assert "Mar 2026" in out and "$4.59/bu" in out + assert "Apr 2026" in out and "$4.68/bu" in out + + +def test_fmt_price_trend_diesel_units(): + payload = {"commodity": "diesel", "geo": "US", "unit": "$/gal", "source": "EIA", + "trend": {"period": "2026-05-25", "value_cents": 552, "prev_cents": 560, + "change_cents": -8, "change_pct": -1.4, "yoy_cents": None, + "yoy_change_cents": None, "yoy_pct": None, "seasonal": None, + "recent_direction": "down", "baseline_years": 10, "points": 200}} + out = fmt.fmt_price_trend(payload, label="retail diesel") + assert "$5.520/gal" in out # 3-decimal $/gal formatting + + def test_fmt_inputs_lime_table(): payload = { "product": "lime", "count": 2,