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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,32 @@
|
|||||||
Notes for clients/agents that consume the ag-bids MCP tools and the underlying
|
Notes for clients/agents that consume the ag-bids MCP tools and the underlying
|
||||||
`ag-monitor` `/api/data/*` HTTP API. Newest first.
|
`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
|
## 2026-05-30 — Source geo + many more locations
|
||||||
|
|
||||||
### Per-source geo (API + MCP)
|
### Per-source geo (API + MCP)
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ def best(commodity: str) -> dict:
|
|||||||
return _get("/api/data/best", commodity=commodity)
|
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:
|
def futures(commodity: str, delivery: str | None = None) -> dict:
|
||||||
return _get("/api/data/futures", commodity=commodity, delivery=delivery)
|
return _get("/api/data/futures", commodity=commodity, delivery=delivery)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ----------
|
# ---------- futures quote + change ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,63 @@ def basis_detail(
|
|||||||
return fmt.fmt_basis_detail(_filter_source(payload, source))
|
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()
|
@mcp.tool()
|
||||||
def list_sources() -> str:
|
def list_sources() -> str:
|
||||||
"""All active scrapers + their last-success timestamps and any pending failures."""
|
"""All active scrapers + their last-success timestamps and any pending failures."""
|
||||||
|
|||||||
@@ -75,6 +75,49 @@ def test_fmt_futures_no_quote():
|
|||||||
assert "No futures quote on file" in out
|
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():
|
def test_fmt_inputs_lime_table():
|
||||||
payload = {
|
payload = {
|
||||||
"product": "lime", "count": 2,
|
"product": "lime", "count": 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user