Location params on best_local_bid / latest_prices (zip / GPS + radius)
Thread zip/lat/lng/radius_miles through the client and both tools; friendly guard for the zip-XOR-gps rule. Formatters surface distance, the searched center, and the nearest-source hint when nothing is in range. - client: best()/latest() take zip/lat/lng/radius_miles - server: location params + docstrings (note Ohio-concentrated coverage) - format: distance column + center/nearest rendering - README + CHANGELOG + advisor prompt library updated - tests: location formatting cases Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
+61
-15
@@ -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"{dist_cell} {r.get('fetched_at') or '?'} |"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
+57
-6
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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=<their state>)` for the national/state benchmark.
|
||||
|
||||
**13. Did futures move today?**
|
||||
> "How far has corn moved today — off the open and on the day?"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user