diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b120bd..ff1dc84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ Notes for clients/agents that consume the ag-bids MCP tools and the underlying `ag-monitor` `/api/data/*` HTTP API. Newest first. +## 2026-05-31 — Location-aware bid lookup (zip / GPS + radius) + +`best_local_bid` and `latest_prices` can now be scoped to a farmer's location, so +"where should I haul today" means *near me*, not "across every elevator we +scrape." Filtering is offline (vendored US Census ZIP centroids — no geocoding +API). + +### Changed MCP tools + +- **`best_local_bid(commodity, zip?, lat?, lng?, radius_miles?)`** — pass a + **`zip`** OR **`lat`+`lng`** (not both) with optional **`radius_miles`** + (default **50**) to restrict to nearby elevators; the result shows the + **distance** to the winner. With no location it behaves as before (all + sources). If nothing is in range it returns no winner **and reports the + nearest elevator + its distance**, so a coverage gap reads as a gap, not an + error. +- **`latest_prices(..., zip?, lat?, lng?, radius_miles?)`** — same location + filter; rows are limited to the radius, annotated with **`distance_miles`**, + and sorted **nearest-first** (adds a Distance column). + +GPS input is reverse-labeled to its nearest ZIP for display; only `zip → coord` +is used for the actual distance math. + +> **Coverage note:** scraped **cash-bid** sources are currently concentrated in +> **Ohio**. A zip/GPS far from Ohio (e.g. Iowa) will correctly return "nothing in +> range" with the nearest-source hint until more regions are added. (Reference +> data — `price_trend`, `input_cost_trend` — is already national.) + +### API + +- `GET /api/data/best?commodity=&zip=&lat=&lng=&radius_miles=` — new location + params. Response adds `center` (`{lat,lng,zip,source}`), `radius_miles`, and + (when out of range) `nearest`. `best` now carries `distance_miles`, `city`, + `state`. +- `GET /api/data/latest?...&zip=&lat=&lng=&radius_miles=` — same params; rows + carry `distance_miles` and source `city`/`state`/`latitude`/`longitude`; + response adds `center` + `radius_miles` when a location is given. +- Errors (HTTP 400): zip *and* GPS together, partial GPS, `radius_miles` with no + location, or an unknown zip. + +### Example questions → tool calls + +| Ask | Call | +|---|---| +| Best corn bid within 40 mi of my zip | `best_local_bid(commodity="corn", zip="45810", radius_miles=40)` | +| Best soybean bid near my coordinates | `best_local_bid(commodity="soy", lat=40.79, lng=-83.81)` | +| Elevators near me, nearest first | `latest_prices(commodity="corn", zip="45810", radius_miles=60)` | + ## 2026-05-30 — Regional fertilizer input costs (real $/ton + change) Real U.S. **regional retail fertilizer prices** now feed the input-cost tools, diff --git a/README.md b/README.md index 86b37ba..87a5569 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ auto-updates it every 5 minutes. | Tool | Returns | |---|---| -| `best_local_bid(commodity)` | Where to sell `corn`, `soy`, or `wheat` for this month's delivery — markdown one-liner + table | +| `best_local_bid(commodity, zip?, lat?, lng?, radius_miles?)` | Where to sell `corn`/`soy`/`wheat` for this month's delivery. Optional location (zip **or** lat/lng + `radius_miles`, default 50) restricts to nearby elevators and shows distance; out-of-range returns the nearest-source hint | | `futures_quote(commodity, delivery?)` | CBOT futures price + change since open + change on the day. With a delivery month it resolves the listed contract; without it, the continuous nearby | | `current_lime_price()` | Latest lime quotes across all manual-entry sources | | `current_input_price(product?)` | MAP / Potash / Lime — all three or one (local DTN dealer feed) | @@ -83,7 +83,7 @@ auto-updates it every 5 minutes. | `price_series(commodity, geo?, start_year?, end_year?)` | Raw monthly price-received series for charting | | `input_cost_trend(item, geo?, years?)` | Real input cost + change. `item` = `diesel` (U.S. `$/gal`, weekly, EIA) or a fertilizer `$/ton` (`urea`/`uan`/`anhydrous`/`dap`/`map`/`potash`, monthly, USDA AgTransport); `geo` selects the fertilizer region (default `Cornbelt`) | | `input_cost_series(item, geo?)` | Raw historical series for an input cost (diesel `$/gal` or fertilizer `$/ton`, region-selectable) | -| `latest_prices(commodity?, source?, delivery?, kind?)` | Live snapshot table; every filter optional (`kind` = `grain`/`fertilizer`) | +| `latest_prices(commodity?, source?, delivery?, kind?, zip?, lat?, lng?, radius_miles?)` | Live snapshot table; every filter optional (`kind` = `grain`/`fertilizer`). Location filter (zip **or** lat/lng + `radius_miles`) keeps nearby sources, nearest-first, with distance | | `price_history(commodity?, source?, delivery?, days?)` | Time series per (elevator, crop, delivery); **every filter optional** — omit `commodity` to span all crops. Shows bid + basis trend + futures | | `basis_movement(commodity?, source?, delivery?, days?)` | Aggregated basis trend, one headline line per crop (the cheap overview) | | `basis_detail(commodity?, source?, delivery?, days?)` | Per-(elevator, crop, delivery) basis first→last drill-down | diff --git a/ag_bids_mcp/client.py b/ag_bids_mcp/client.py index b57dfb6..35955ae 100644 --- a/ag_bids_mcp/client.py +++ b/ag_bids_mcp/client.py @@ -54,9 +54,12 @@ def _get(path: str, **params: Any) -> dict | list: def latest(commodity: str | None = None, source: str | None = None, - delivery: str | None = None, kind: str | None = None) -> dict: + delivery: str | None = None, kind: str | None = None, + zip: str | None = None, lat: float | None = None, + lng: float | None = None, radius_miles: float | None = None) -> dict: return _get("/api/data/latest", - commodity=commodity, source=source, delivery=delivery, kind=kind) + commodity=commodity, source=source, delivery=delivery, kind=kind, + zip=zip, lat=lat, lng=lng, radius_miles=radius_miles) def history(commodity: str | None = None, source_id: int | None = None, @@ -66,8 +69,10 @@ def history(commodity: str | None = None, source_id: int | None = None, delivery=delivery, days=days) -def best(commodity: str) -> dict: - return _get("/api/data/best", commodity=commodity) +def best(commodity: str, zip: str | None = None, lat: float | None = None, + lng: float | None = None, radius_miles: float | None = None) -> dict: + return _get("/api/data/best", commodity=commodity, + zip=zip, lat=lat, lng=lng, radius_miles=radius_miles) def price_trend(commodity: str, geo: str = "US", years: int = 10) -> dict: diff --git a/ag_bids_mcp/format.py b/ag_bids_mcp/format.py index bb396ef..89dc503 100644 --- a/ag_bids_mcp/format.py +++ b/ag_bids_mcp/format.py @@ -73,21 +73,56 @@ def _first_last(points: list[dict], field: str): # ---------- best_local_bid ---------- +def _loc_suffix(d: dict) -> str: + """' (City, ST)' when a row/result carries city/state, else ''.""" + city, state = d.get("city"), d.get("state") + if city and state: + return f" ({city}, {state})" + return f" ({state})" if state else "" + + +def _mi(d) -> str: + return "—" if d is None else f"{d:.1f} mi" + + +def _where(center: dict) -> str: + """Human label for a resolved center point.""" + if center.get("source") == "zip": + return f"ZIP {center.get('zip')}" + near = f" (near {center.get('zip')})" if center.get("zip") else "" + return f"{center['lat']:.4f}, {center['lng']:.4f}{near}" + + def fmt_best(commodity: str, payload: dict) -> str: best = payload.get("best") today = payload.get("today") + center = payload.get("center") + radius = payload.get("radius_miles") + scope = f" within {radius:.0f} mi of {_where(center)}" if center else "" + head = f"### Best place to sell {commodity} today ({today})\n\n" if not best: - return ( - f"### Best place to sell {commodity} today ({today})\n\n" - f"No current-month {commodity} bids posted across the tracked sources.\n" - ) - return ( - f"### Best place to sell {commodity} today ({today})\n\n" - f"**{best['source_name']}** — delivery **{best['delivery']}** — " - f"bid **{_bu(best['bid_cents'])}/bu** (basis {_basis(best.get('basis_cents'))}, " - f"futures {best.get('futures_contract') or '—'})\n\n" - f"_Fetched {best.get('fetched_at') or '?'}_\n" + if center: + out = head + f"No current-month {commodity} bids{scope}.\n" + near = payload.get("nearest") + if near: + out += (f"\nNearest tracked elevator: **{near['source_name']}**" + f"{_loc_suffix(near)} — about **{_mi(near.get('distance_miles'))}** " + f"away, outside the {radius:.0f} mi radius.\n") + return out + return head + f"No current-month {commodity} bids posted across the tracked sources.\n" + dist = best.get("distance_miles") + dist_txt = f" — **{_mi(dist)}** away" if dist is not None else "" + out = ( + head + + f"**{best['source_name']}**{_loc_suffix(best)}{dist_txt} — delivery " + f"**{best['delivery']}** — bid **{_bu(best['bid_cents'])}/bu** " + f"(basis {_basis(best.get('basis_cents'))}, " + f"futures {best.get('futures_contract') or '—'})\n" ) + if center: + out += f"\n_Searched{scope}._\n" + out += f"\n_Fetched {best.get('fetched_at') or '?'}_\n" + return out # ---------- reference price trends (USDA NASS / EIA) ---------- @@ -235,20 +270,31 @@ def fmt_inputs(payload: dict) -> str: def fmt_latest(payload: dict) -> str: rows = payload.get("rows") or [] + center = payload.get("center") + radius = payload.get("radius_miles") if not rows: - return "### Latest prices\n\nNo rows match those filters.\n" + head = "### Latest prices\n\n" + if center: + return head + f"No sources within {radius:.0f} mi of {_where(center)}.\n" + return head + "No rows match those filters.\n" + title = "### Latest prices" + if center: + title += f" — within {radius:.0f} mi of {_where(center)}" + dist_col = " Distance |" if center else "" + dist_sep = " ---: |" if center else "" lines = [ - "### Latest prices", "", - "| Source | Commodity | Delivery | Bid | Basis | Futures | Fetched |", - "|---|---|---|---:|---:|---|---|", + title, "", + f"| Source | Commodity | Delivery | Bid | Basis | Futures |{dist_col} Fetched |", + f"|---|---|---|---:|---:|---|{dist_sep}---|", ] for r in rows: unit_fmt = _ton if r.get("commodity_kind") == "fertilizer" else _bu + dist_cell = f" {_mi(r.get('distance_miles'))} |" if center else "" lines.append( f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | " f"{r['delivery']} | {unit_fmt(r.get('bid_cents'))} | " - f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or '—'} | " - f"{r.get('fetched_at') or '?'} |" + f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or '—'} |" + f"{dist_cell} {r.get('fetched_at') or '?'} |" ) return "\n".join(lines) + "\n" diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py index f37eeb4..05f29e5 100644 --- a/ag_bids_mcp/server.py +++ b/ag_bids_mcp/server.py @@ -48,19 +48,50 @@ VALID_INPUT = {"lime", "map", "potash"} # ============================================================================ +def _location_error(zip: str | None, lat: float | None, lng: float | None) -> str | None: + """Friendly guard for the (zip XOR gps) rule before hitting the API.""" + if zip and (lat is not None or lng is not None): + return "Pass either `zip` or `lat`+`lng`, not both." + if (lat is None) != (lng is None): + return "GPS needs both `lat` and `lng`." + return None + + @mcp.tool() def best_local_bid( commodity: Annotated[ str, Field(description="Grain to look up: 'corn', 'soy' (soybeans), or 'wheat'.") ], + zip: Annotated[ + str | None, + Field(description="Your 5-digit ZIP — limits the search to nearby elevators."), + ] = None, + lat: Annotated[ + float | None, Field(description="Your latitude (use with `lng` instead of a ZIP).") + ] = None, + lng: Annotated[float | None, Field(description="Your longitude (use with `lat`).")] = None, + radius_miles: Annotated[ + float | None, + Field(description="Search radius in miles around the location (default 50). " + "Ignored unless a ZIP or lat/lng is given."), + ] = None, ) -> str: """Return the highest local bid for *this calendar month's* delivery for - the given grain. This is the "where should I haul today" answer.""" + the given grain — the "where should I haul today" answer. + + Pass a `zip` (or `lat`+`lng`) with optional `radius_miles` to restrict to + elevators near the farmer; results then show the distance to each. Without a + location it considers every scraped elevator. If nothing is in range, it + reports the nearest source and its distance (a coverage gap, not an error). + Note: cash-bid coverage is currently concentrated in Ohio.""" commodity = commodity.strip().lower() - with track("best_local_bid", commodity=commodity): + with track("best_local_bid", commodity=commodity, zip=zip or "", radius=radius_miles or 0): if commodity not in VALID_GRAIN: return f"`commodity` must be one of: {sorted(VALID_GRAIN)}" - payload = client.best(commodity) + err = _location_error(zip, lat, lng) + if err: + return err + payload = client.best(commodity, zip=zip, lat=lat, lng=lng, radius_miles=radius_miles) return fmt.fmt_best(commodity, payload) @@ -132,14 +163,34 @@ def latest_prices( str | None, Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."), ] = None, + zip: Annotated[ + str | None, + Field(description="Your 5-digit ZIP — keep only nearby sources, nearest first."), + ] = None, + lat: Annotated[ + float | None, Field(description="Your latitude (use with `lng` instead of a ZIP).") + ] = None, + lng: Annotated[float | None, Field(description="Your longitude (use with `lat`).")] = None, + radius_miles: Annotated[ + float | None, + Field(description="Search radius in miles around the location (default 50). " + "Ignored unless a ZIP or lat/lng is given."), + ] = None, ) -> str: """Snapshot of the latest scraped bid per (source, commodity, delivery). Every filter is optional and AND'd together — pivot by elevator, crop, - delivery, or kind in any combination.""" + delivery, or kind in any combination. Pass a `zip` (or `lat`+`lng`) with + optional `radius_miles` to keep only elevators near the farmer, sorted + nearest-first with the distance shown.""" cm = commodity.strip().lower() if commodity else None - with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind): - payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind) + with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind, + zip=zip or "", radius=radius_miles or 0): + err = _location_error(zip, lat, lng) + if err: + return err + payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind, + zip=zip, lat=lat, lng=lng, radius_miles=radius_miles) return fmt.fmt_latest(payload) diff --git a/examples/ag-advisor-prompts.md b/examples/ag-advisor-prompts.md index 3fd18c6..12a1213 100644 --- a/examples/ag-advisor-prompts.md +++ b/examples/ag-advisor-prompts.md @@ -128,10 +128,20 @@ targets above the seasonal normal. `todays_summary()` (one call) — then drill in where it flags something. -**12. Best bid right now** -> "Where's the best corn bid near me today?" +**12. Best bid right now (location-aware)** +> "Where's the best corn bid within 40 miles of 45810?" -`best_local_bid("corn")` (or `latest_prices("corn")` to see every elevator). +`best_local_bid("corn", zip="45810", radius_miles=40)` — restricts to nearby +elevators and shows the distance. From GPS instead: +`best_local_bid("corn", lat=40.79, lng=-83.81)`. `latest_prices("corn", +zip="45810", radius_miles=40)` lists every nearby elevator nearest-first. Without +a location these consider every scraped elevator. + +> Coverage note for the advisor: scraped cash bids are concentrated in **Ohio** +> today. If the farmer's zip/GPS is far from Ohio, `best_local_bid` returns "none +> in range" plus the nearest elevator's distance — say that plainly (it's a +> coverage gap, not a price of zero), and fall back to `price_trend(commodity, +> geo=)` for the national/state benchmark. **13. Did futures move today?** > "How far has corn moved today — off the open and on the day?" diff --git a/tests/test_format.py b/tests/test_format.py index 0a8b570..9846117 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -35,6 +35,60 @@ def test_fmt_best_no_winner(): assert "wheat" in out +def test_fmt_best_with_location_shows_distance(): + payload = { + "commodity": "corn", "today": "2026-05-20", + "center": {"lat": 40.78, "lng": -83.81, "zip": "45810", "source": "zip"}, + "radius_miles": 50.0, + "best": { + "source_name": "Heritage Cooperative — Ada", "city": "Ada", "state": "OH", + "delivery": "May 2026", "bid_cents": 486, "basis_cents": 20, + "futures_contract": "ZCN26", "distance_miles": 2.3, + "fetched_at": "2026-05-20T15:00:00+00:00", + }, + } + out = fmt.fmt_best("corn", payload) + assert "(Ada, OH)" in out + assert "2.3 mi" in out + assert "within 50 mi of ZIP 45810" in out + + +def test_fmt_best_out_of_range_reports_nearest(): + payload = { + "commodity": "corn", "today": "2026-05-20", "best": None, + "center": {"lat": 42.0, "lng": -93.6, "zip": "50010", "source": "zip"}, + "radius_miles": 50.0, + "nearest": {"source_name": "Heritage Cooperative — Ada", "city": "Ada", + "state": "OH", "distance_miles": 513.5}, + } + out = fmt.fmt_best("corn", payload) + assert "No current-month corn bids within 50 mi of ZIP 50010" in out + assert "Nearest tracked elevator" in out + assert "513.5 mi" in out + + +def test_fmt_latest_with_location_adds_distance_column(): + payload = { + "center": {"lat": 40.78, "lng": -83.81, "zip": "45810", "source": "zip"}, + "radius_miles": 50.0, + "rows": [ + {"source_name": "Heritage Cooperative — Ada", "commodity": "corn", + "display_name": "Corn", "commodity_kind": "grain", "delivery": "May 2026", + "bid_cents": 486, "basis_cents": 20, "futures_contract": "ZCN26", + "distance_miles": 2.3, "fetched_at": "2026-05-20T15:00:00+00:00"}, + ], + } + out = fmt.fmt_latest(payload) + assert "Distance" in out and "2.3 mi" in out + assert "within 50 mi of ZIP 45810" in out + + +def test_fmt_latest_location_no_rows(): + payload = {"center": {"zip": "50010", "source": "zip"}, "radius_miles": 50.0, "rows": []} + out = fmt.fmt_latest(payload) + assert "No sources within 50 mi of ZIP 50010" in out + + def test_fmt_futures_with_changes(): payload = { "commodity": "corn", "delivery": "Jul 2026", "contract": "ZCN26",