Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41313d8188 | |||
| bb4219da87 | |||
| fb50c103d3 | |||
| ae15aa5c3c | |||
| 03c6c540ef | |||
| a0e36996c4 | |||
| 457cdad2fb | |||
| 47cac9b521 | |||
| 7b6661e3d9 | |||
| ffc705f485 | |||
| a3b77414d8 | |||
| 0d7e61f65f | |||
| dfbc77160e | |||
| 2e67a0fce2 |
@@ -0,0 +1,45 @@
|
||||
name: CI
|
||||
|
||||
# Test on every push/PR; build + push the image only on main.
|
||||
# The act_runner on .0.2 has the host docker.sock mounted (DOCKER_HOST is
|
||||
# preset), so plain `docker build`/`docker push` work with no DinD setup.
|
||||
# Watchtower on .0.2 auto-pulls git.jpaul.io/justin/ag-bids-mcp:latest within
|
||||
# ~5 min, so a successful push here is the whole deploy.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
IMAGE: git.jpaul.io/justin/ag-bids-mcp
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install + run tests
|
||||
run: |
|
||||
# Runner image's system Python is PEP 668 externally-managed, so use a venv.
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet -r requirements.txt pytest
|
||||
pytest -q
|
||||
|
||||
build-push:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The auto-provisioned Actions token cannot push packages in this Gitea,
|
||||
# so use a PAT (write:package) stored as the REGISTRY_TOKEN repo secret.
|
||||
- name: Log in to registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.jpaul.io -u "${{ github.actor }}" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
docker build -t "$IMAGE:latest" -t "$IMAGE:${{ github.sha }}" .
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:${{ github.sha }}"
|
||||
+160
@@ -3,6 +3,166 @@
|
||||
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,
|
||||
so the advisor can reason about fertilizer the same way it does diesel and grain
|
||||
— actual dollars and the move, not an index. Source: **USDA AgTransport**
|
||||
(monthly, `$/ton`, history back to 2023).
|
||||
|
||||
### Changed MCP tools (fertilizer added to the existing input-cost tools)
|
||||
|
||||
- **`input_cost_trend(item, geo?, years=10)`** — `item` now accepts six
|
||||
fertilizers in addition to `diesel`: **`urea`, `uan`, `anhydrous`** (anhydrous
|
||||
ammonia), **`dap`, `map`, `potash`**. Returns the latest real `$/ton`,
|
||||
month-over-month and year-over-year change ($ and %), and seasonal context.
|
||||
- New optional **`geo`** = AgTransport region; **defaults to `Cornbelt`**.
|
||||
Other regions: `U.S. Gulf NOLA`, `Northern Plains`, `Southern Plains`,
|
||||
`Southeast`, `Northeast`, `California`, `Pacific Northwest`, `South Central`,
|
||||
`Central Florida`, `Tampa` (not every product is published for every region).
|
||||
- `diesel` is unchanged — national U.S. `$/gal`, weekly; `geo` is ignored for it.
|
||||
- **`input_cost_series(item, geo?)`** — raw monthly `$/ton` series for any
|
||||
fertilizer (or the diesel `$/gal` weekly series), region-selectable.
|
||||
|
||||
Seasonal percentile for fertilizer is computed over a short history (2023+), so
|
||||
it deepens over time; the price and YoY/MoM change are solid now.
|
||||
|
||||
### API
|
||||
|
||||
- `GET /api/data/input-cost-trend?item=&geo=&years=` — `geo` added.
|
||||
- `GET /api/data/input-cost-series?item=&geo=` — `geo` added.
|
||||
- `GET /api/data/input-cost-geographies?item=` — **new**: lists the regions a
|
||||
given input has data for (plus its `default_geo`).
|
||||
|
||||
### Example questions → tool calls
|
||||
|
||||
| Ask | Call |
|
||||
|---|---|
|
||||
| Cornbelt urea price and how it's moved | `input_cost_trend(item="urea")` |
|
||||
| Anhydrous ammonia in the Southern Plains | `input_cost_trend(item="anhydrous", geo="Southern Plains")` |
|
||||
| Potash $/ton history | `input_cost_series(item="potash")` |
|
||||
| Which regions have DAP prices | (API) `GET /api/data/input-cost-geographies?item=dap` |
|
||||
|
||||
## 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 tools (real $ + change)
|
||||
|
||||
- **`input_cost_trend(item, years=10)`** — real input price with the move.
|
||||
Currently `item="diesel"` (EIA U.S. retail $/gal, weekly, back to 1994):
|
||||
latest price + week-over-week and year-over-year change + seasonal
|
||||
percentile/range. (For current fertilizer $/ton, `current_input_price` (DTN)
|
||||
still applies; more inputs extend this same tool.)
|
||||
- **`input_cost_series(item)`** — raw historical series for an input.
|
||||
- API: `GET /api/data/input-cost-trend?item=&years=`,
|
||||
`GET /api/data/input-cost-series?item=`.
|
||||
|
||||
USDA stopped publishing dollar input prices in 2014, so these use real-dollar
|
||||
sources (EIA) rather than an index.
|
||||
|
||||
## 2026-05-30 — Source geo + many more locations
|
||||
|
||||
### Per-source geo (API + MCP)
|
||||
|
||||
- **`GET /api/data/sources`** now returns location geo on every source:
|
||||
`city, state, zip, county, latitude, longitude` (any may be `null`). The
|
||||
AgriCharts-fed elevators carry exact coordinates; smaller sites carry
|
||||
city/state/zip.
|
||||
- **MCP `list_sources`** gained a **Location** column (`City, ST ZIP`).
|
||||
`source_health` is unchanged in shape.
|
||||
- Sources that are national or non-physical (CBOT futures, USDA AMS, DTN
|
||||
fertilizer, hedge-to-arrive / "Direct" bid buckets) legitimately have `null`
|
||||
geo. ~43 of ~51 sources are geo-tagged.
|
||||
|
||||
### Many more elevator locations
|
||||
|
||||
These flow through every data tool/endpoint automatically (`latest`, `history`,
|
||||
`best`, `basis_movement`/`basis_detail`, `sources`, `deliveries`). No tool
|
||||
signatures changed — just far more sources, all named `"<Co-op> — <Location>"`:
|
||||
|
||||
- **Heritage Cooperative** — new co-op, **23 central/eastern-Ohio locations**
|
||||
(corn/soy/wheat).
|
||||
- **Mercer Landmark** — expanded from 2 to **all ~16 locations**. Note: the two
|
||||
old source names were renamed for continuity — `"Mercer Landmark — St Henry"`
|
||||
→ `"Mercer Landmark — MPS St Henry"`, `"Mercer Landmark — Minster"` →
|
||||
`"Mercer Landmark — Heartland Minster"`.
|
||||
- **Bambauer** — now both locations incl. **Pemberton**
|
||||
(`"Bambauer — Jackson Center / New Knoxville"`, `"Bambauer — Pemberton"`);
|
||||
the old `"Bambauer Jackson Center / New Knoxville, OH"` source was renamed to
|
||||
the first of those.
|
||||
|
||||
If you cache source names, refresh them — several changed and many were added.
|
||||
|
||||
## 2026-05-29 — Futures price + change tool
|
||||
|
||||
### New MCP tool
|
||||
|
||||
@@ -12,6 +12,8 @@ plain-English questions like:
|
||||
- "What's the best place to sell corn today?"
|
||||
- "What's the current price of lime?"
|
||||
- "Show me the corn basis trend at Andersons Greenville over the last 30 days."
|
||||
- "How is basis moving overall this week?"
|
||||
- "What's the July corn futures price and how far has it moved since the open?"
|
||||
- "Which sources are stale or failing?"
|
||||
|
||||
## How it talks to data
|
||||
@@ -22,9 +24,10 @@ All data is read from ag-monitor over HTTPS:
|
||||
ag-bids-mcp ── X-API-Key ─► https://agbids.paul.farm/api/data/*
|
||||
```
|
||||
|
||||
Endpoints used: `/api/data/latest`, `/history`, `/best`, `/inputs`, `/sources`,
|
||||
`/deliveries`. See the [ag-monitor source](https://git.jpaul.io/justin/ag-bids)
|
||||
for the contract.
|
||||
Endpoints used: `/api/data/latest`, `/history`, `/futures`, `/best`, `/inputs`,
|
||||
`/sources`, `/deliveries`, `/price-trend`, `/price-series`, `/price-geographies`,
|
||||
`/input-cost-trend`, `/input-cost-series`, `/input-cost-geographies`. See the
|
||||
[ag-monitor source](https://git.jpaul.io/justin/ag-bids) for the contract.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -72,13 +75,27 @@ 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 |
|
||||
| `latest_prices(commodity?, source?, delivery?)` | Snapshot table, optionally filtered |
|
||||
| `price_history(commodity, source?, delivery?, days?)` | Compact time series |
|
||||
| `current_input_price(product?)` | MAP / Potash / Lime — all three or one (local DTN dealer feed) |
|
||||
| `price_trend(commodity, geo?, years?)` | USDA NASS monthly price *received* ($/bu) + MoM/YoY change + seasonal context. `geo` = `US` or a state (corn back to 1908) |
|
||||
| `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?, 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 |
|
||||
| `list_sources()` | Active scrapers + last-success timestamps |
|
||||
| `list_commodities()` | corn, soy, wheat, map, potash, lime |
|
||||
| `list_deliveries(commodity)` | Posted delivery labels, chronological |
|
||||
| `source_health()` | Stale / failing / healthy summary |
|
||||
| `todays_summary()` | Same shape as the morning brief snapshot |
|
||||
|
||||
For the full per-release detail (signatures, conventions, example question → call
|
||||
mappings), see [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
Grain-marketing prompt library for the downstream Ag Advisor (planning +
|
||||
monitoring, with the tools each prompt uses):
|
||||
[examples/ag-advisor-prompts.md](examples/ag-advisor-prompts.md).
|
||||
|
||||
+39
-4
@@ -54,9 +54,16 @@ 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,
|
||||
include_expired: bool = False) -> dict:
|
||||
# Only send include_expired when True — _get drops None, so the default
|
||||
# call keeps its existing query string (current + future months only).
|
||||
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,
|
||||
include_expired=True if include_expired else None)
|
||||
|
||||
|
||||
def history(commodity: str | None = None, source_id: int | None = None,
|
||||
@@ -66,8 +73,36 @@ 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:
|
||||
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 input_cost_trend(item: str, years: int = 10, geo: str | None = None) -> dict:
|
||||
return _get("/api/data/input-cost-trend", item=item, years=years, geo=geo)
|
||||
|
||||
|
||||
def input_cost_series(item: str, geo: str | None = None) -> dict:
|
||||
return _get("/api/data/input-cost-series", item=item, geo=geo)
|
||||
|
||||
|
||||
def input_cost_geographies(item: str) -> dict:
|
||||
return _get("/api/data/input-cost-geographies", item=item)
|
||||
|
||||
|
||||
def nutrient_cost(geo: str | None = None) -> dict:
|
||||
return _get("/api/data/nutrient-cost", geo=geo)
|
||||
|
||||
|
||||
def futures(commodity: str, delivery: str | None = None) -> dict:
|
||||
|
||||
+211
-18
@@ -22,6 +22,12 @@ def _ton(cents: Optional[int]) -> str:
|
||||
return f"${cents / 100:.2f}"
|
||||
|
||||
|
||||
def _per_lb(cents: Optional[int]) -> str:
|
||||
if cents is None:
|
||||
return "—"
|
||||
return f"${cents / 100:.2f}"
|
||||
|
||||
|
||||
def _basis(cents: Optional[int]) -> str:
|
||||
if cents is None:
|
||||
return "—"
|
||||
@@ -73,21 +79,189 @@ 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"
|
||||
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"
|
||||
)
|
||||
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 += f"\n_Searched{scope}._\n"
|
||||
out += f"\n_Fetched {best.get('fetched_at') or '?'}_\n"
|
||||
return out
|
||||
|
||||
|
||||
# ---------- 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:
|
||||
name = payload.get("commodity") or payload.get("item")
|
||||
geo = payload.get("geo")
|
||||
label = payload.get("label") or label
|
||||
unit = payload.get("unit") or "$/bu"
|
||||
t = payload.get("trend")
|
||||
loc = f" — {geo}" if geo else ""
|
||||
if not t:
|
||||
return f"### {name}{loc} — {label}\n\nNo data on file.\n"
|
||||
when = _ym(t["period"])
|
||||
lines = [f"### {name}{loc} — {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']} periods on file_")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def fmt_price_series(payload: dict, max_points: int = 60) -> str:
|
||||
name = payload.get("commodity") or payload.get("item")
|
||||
geo = payload.get("geo")
|
||||
unit = payload.get("unit") or "$/bu"
|
||||
series = payload.get("series") or []
|
||||
loc = f" — {geo}" if geo else ""
|
||||
if not series:
|
||||
return f"### {name}{loc} series\n\nNo data on file.\n"
|
||||
shown = series[-max_points:]
|
||||
head = [f"### {name}{loc} — {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"
|
||||
|
||||
|
||||
_NUTRIENT_LABEL = {"n": "Nitrogen (N)", "p2o5": "Phosphate (P₂O₅)", "k2o": "Potash (K₂O)"}
|
||||
|
||||
|
||||
def fmt_nutrient_cost(payload: dict) -> str:
|
||||
"""Cheapest fertilizer per pound of N / P2O5 / K2O for a region."""
|
||||
geo = payload.get("geo") or "Cornbelt"
|
||||
src = payload.get("source") or "USDA AgTransport"
|
||||
products = payload.get("products") or []
|
||||
cheapest = payload.get("cheapest") or {}
|
||||
if not products:
|
||||
return f"### Fertilizer value per nutrient — {geo}\n\nNo data on file.\n"
|
||||
by_item = {p["item"]: p for p in products}
|
||||
|
||||
lines = [f"### Fertilizer value per pound of nutrient — {geo}", ""]
|
||||
for nut in ("n", "p2o5", "k2o"):
|
||||
it = cheapest.get(nut)
|
||||
prod = by_item.get(it or "")
|
||||
if prod is not None:
|
||||
c = (prod.get("cost_per_lb") or {}).get(nut)
|
||||
lines.append(
|
||||
f"- **Cheapest {_NUTRIENT_LABEL[nut]}:** "
|
||||
f"{prod.get('label') or it} at {_per_lb(c)}/lb"
|
||||
)
|
||||
lines += ["", "| Product | Grade | $/ton | $/lb N | $/lb P₂O₅ | $/lb K₂O |",
|
||||
"|---|---|---:|---:|---:|---:|"]
|
||||
|
||||
def _nkey(p: dict):
|
||||
v = (p.get("cost_per_lb") or {}).get("n")
|
||||
return (v is None, v if v is not None else 0)
|
||||
|
||||
for p in sorted(products, key=_nkey): # cheapest N first, blanks last
|
||||
a = p.get("analysis") or {}
|
||||
cpl = p.get("cost_per_lb") or {}
|
||||
grade = (a.get("grade") or "") + ("*" if a.get("grade_assumed") else "")
|
||||
lines.append(
|
||||
f"| {p.get('label') or p['item']} | {grade} | {_ton(p.get('price_cents_per_ton'))} | "
|
||||
f"{_per_lb(cpl.get('n'))} | {_per_lb(cpl.get('p2o5'))} | {_per_lb(cpl.get('k2o'))} |"
|
||||
)
|
||||
|
||||
period = next((p.get("period") for p in products if p.get("period")), None)
|
||||
assumed = any((p.get("analysis") or {}).get("grade_assumed") for p in products)
|
||||
foot = f"_Source: {src}"
|
||||
if period:
|
||||
foot += f" · as of {_ym(period)}"
|
||||
foot += " · $/lb = $/ton ÷ (analysis% × 2000 lb)"
|
||||
if assumed:
|
||||
foot += " · *UAN grade assumed 32-0-0"
|
||||
lines.append("\n" + foot + "_")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ---------- futures quote + change ----------
|
||||
@@ -153,20 +327,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"
|
||||
|
||||
@@ -302,18 +487,26 @@ def fmt_basis_detail(payload: dict, max_rows: int = 80) -> str:
|
||||
# ---------- sources / health ----------
|
||||
|
||||
|
||||
def _location(s: dict) -> str:
|
||||
city, state = s.get("city"), s.get("state")
|
||||
loc = ", ".join(p for p in (city, state) if p)
|
||||
if s.get("zip"):
|
||||
loc = f"{loc} {s['zip']}".strip()
|
||||
return loc or "—"
|
||||
|
||||
|
||||
def fmt_sources(payload: dict) -> str:
|
||||
src = payload.get("sources") or []
|
||||
if not src:
|
||||
return "### Sources\n\nNo active sources.\n"
|
||||
lines = [
|
||||
"### Tracked sources", "",
|
||||
"| Source | Kind | Last success | Consecutive failures | Last error |",
|
||||
"|---|---|---|---:|---|",
|
||||
"| Source | Kind | Location | Last success | Consecutive failures | Last error |",
|
||||
"|---|---|---|---|---:|---|",
|
||||
]
|
||||
for s in src:
|
||||
lines.append(
|
||||
f"| {s['name']} | {s['kind']} | {s.get('last_success_at') or '—'} | "
|
||||
f"| {s['name']} | {s['kind']} | {_location(s)} | {s.get('last_success_at') or '—'} | "
|
||||
f"{s.get('consecutive_failures') or 0} | {s.get('last_error') or ''} |"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
+202
-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,44 @@ 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,
|
||||
include_expired: Annotated[
|
||||
bool,
|
||||
Field(description="Include past/expired delivery months. Default off — only "
|
||||
"the current and future months are shown. Set true only when "
|
||||
"the farmer explicitly asks for historical/past delivery data."),
|
||||
] = False,
|
||||
) -> 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.
|
||||
|
||||
By default this shows current + future delivery months only; expired
|
||||
months are rolled off. Set `include_expired=true` for a historical view."""
|
||||
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,
|
||||
include_expired=include_expired)
|
||||
return fmt.fmt_latest(payload)
|
||||
|
||||
|
||||
@@ -246,6 +307,141 @@ 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)
|
||||
|
||||
|
||||
# Fuel is national (US); fertilizer is regional ($/ton, USDA AgTransport).
|
||||
VALID_INPUTS = {"diesel", "urea", "uan", "anhydrous", "dap", "map", "potash"}
|
||||
_FERT_INPUTS = {"urea", "uan", "anhydrous", "dap", "map", "potash"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def input_cost_trend(
|
||||
item: Annotated[
|
||||
str,
|
||||
Field(description="Input to price: 'diesel' (U.S. retail $/gal) or a fertilizer "
|
||||
"($/ton) — 'urea', 'uan', 'anhydrous', 'dap', 'map', 'potash'."),
|
||||
],
|
||||
geo: Annotated[
|
||||
str | None,
|
||||
Field(description="Fertilizer region (default 'Cornbelt'). Other regions: "
|
||||
"'U.S. Gulf NOLA', 'Northern Plains', 'Southern Plains', "
|
||||
"'Southeast', 'Northeast', 'California', 'Pacific Northwest', "
|
||||
"'South Central', 'Central Florida', 'Tampa'. Ignored for diesel (US)."),
|
||||
] = None,
|
||||
years: Annotated[
|
||||
int, Field(ge=1, le=120, description="Baseline window for the seasonal normal/percentile."),
|
||||
] = 10,
|
||||
) -> str:
|
||||
"""Real input cost with the change — retail diesel ($/gal) or fertilizer ($/ton).
|
||||
|
||||
Latest real price + period-over-period and year-over-year moves + seasonal
|
||||
percentile/range. Diesel is U.S. weekly (back to 1994); fertilizer is monthly
|
||||
regional (USDA AgTransport, Cornbelt default, back to 2023). This is the
|
||||
input-cost side for the advisor. For a one-off current fertilizer quote from a
|
||||
local dealer feed, current_input_price (DTN) still applies."""
|
||||
it = item.strip().lower()
|
||||
g = geo.strip() if geo else None
|
||||
with track("input_cost_trend", item=it, geo=g or "", years=years):
|
||||
if it not in VALID_INPUTS:
|
||||
return f"`item` must be one of: {sorted(VALID_INPUTS)}"
|
||||
return fmt.fmt_price_trend(client.input_cost_trend(item=it, years=years, geo=g))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def input_cost_series(
|
||||
item: Annotated[
|
||||
str, Field(description="Input: 'diesel' or a fertilizer ('urea', 'uan', "
|
||||
"'anhydrous', 'dap', 'map', 'potash').")],
|
||||
geo: Annotated[
|
||||
str | None,
|
||||
Field(description="Fertilizer region (default 'Cornbelt'). Ignored for diesel."),
|
||||
] = None,
|
||||
) -> str:
|
||||
"""Raw historical series for a tracked input cost (diesel $/gal or fertilizer $/ton)."""
|
||||
it = item.strip().lower()
|
||||
g = geo.strip() if geo else None
|
||||
with track("input_cost_series", item=it, geo=g or ""):
|
||||
if it not in VALID_INPUTS:
|
||||
return f"`item` must be one of: {sorted(VALID_INPUTS)}"
|
||||
return fmt.fmt_price_series(client.input_cost_series(item=it, geo=g))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nutrient_cost(
|
||||
geo: Annotated[
|
||||
str | None,
|
||||
Field(description="Fertilizer region (default 'Cornbelt'). Same regions as "
|
||||
"input_cost_trend (e.g. 'U.S. Gulf NOLA', 'Northern Plains')."),
|
||||
] = None,
|
||||
) -> str:
|
||||
"""Cheapest fertilizer per POUND of nutrient — "what's the best value for N (or P/K)?".
|
||||
|
||||
Converts each fertilizer's regional $/ton (USDA AgTransport, latest month) into
|
||||
$/lb of N, P2O5, and K2O from its grade, ranks them, and names the cheapest
|
||||
source of each nutrient (anhydrous usually wins on N; DAP/MAP are phosphate
|
||||
buys; potash is the K source). Use THIS — not input_cost_trend ($/ton only) —
|
||||
whenever the grower asks which fertilizer is the best buy / best nitrogen value.
|
||||
UAN grade is assumed 32-0-0."""
|
||||
g = geo.strip() if geo else None
|
||||
with track("nutrient_cost", geo=g or ""):
|
||||
return fmt.fmt_nutrient_cost(client.nutrient_cost(geo=g))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_sources() -> str:
|
||||
"""All active scrapers + their last-success timestamps and any pending failures."""
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Ag Advisor — Example Prompts (Grain Marketing)
|
||||
|
||||
Example end-user prompts for the **Drawbar Ag Advisor**, the AI agent that
|
||||
consumes the `ag-bids` MCP tools (and the underlying `ag-monitor` `/api/data/*`
|
||||
HTTP API). They're grouped into **Planning** (build a marketing strategy) and
|
||||
**Monitoring** (track prices and act day-to-day), with the tools each prompt
|
||||
leans on so the advisor knows which calls answer the question.
|
||||
|
||||
Copy this into the Drawbar repo (e.g. `prompts/grain-marketing.md`) as a prompt
|
||||
library / eval set. Phrasing is intentionally farmer-natural — the advisor is
|
||||
expected to translate it into tool calls.
|
||||
|
||||
## What the advisor can see
|
||||
|
||||
| Capability | Tool(s) |
|
||||
|---|---|
|
||||
| Live local cash bids (per elevator, crop, delivery) | `latest_prices`, `best_local_bid` |
|
||||
| Cash price history + basis trend per elevator | `price_history` |
|
||||
| Basis movement (overview, then drill-down) | `basis_movement` → `basis_detail` |
|
||||
| CBOT futures price + change (since open, on day) + carry | `futures_quote`, `list_deliveries` |
|
||||
| National price *received by farmers* ($/bu) + MoM/YoY + seasonal | `price_trend`, `price_series` |
|
||||
| Input costs — diesel ($/gal) + fertilizer ($/ton) + change + seasonal | `input_cost_trend`, `input_cost_series` |
|
||||
| Local fertilizer/lime snapshot (DTN dealer feed) | `current_input_price`, `current_lime_price` |
|
||||
| What's covered / is the data fresh | `list_sources`, `list_commodities`, `list_deliveries`, `source_health` |
|
||||
| One-call morning brief | `todays_summary` |
|
||||
|
||||
Conventions the advisor should keep in mind:
|
||||
- **Basis is the lever.** Positive basis move = cash strengthened vs futures;
|
||||
negative = weakened. `basis_movement` aggregates and can net divergent
|
||||
elevators to "flat" — follow up with `basis_detail` when it looks surprising.
|
||||
- **Cash = futures + basis.** A sell decision is really two decisions (futures
|
||||
level *and* basis), and the carry (`list_deliveries` + `futures_quote` across
|
||||
months) tells you whether the market pays you to store.
|
||||
- `price_trend`/`input_cost_trend` are the **macro/seasonal benchmark** — "is
|
||||
today cheap or dear vs history" — not a local bid. Pair them with
|
||||
`best_local_bid`/`latest_prices` for the actionable number.
|
||||
- Grains: `corn`, `soy`, `wheat`. `geo` for `price_trend` is `US` or a 2-letter
|
||||
state (e.g. `OH`, `IA`). Fertilizer `geo` is an AgTransport region (default
|
||||
`Cornbelt`).
|
||||
|
||||
---
|
||||
|
||||
## Planning — build a marketing strategy
|
||||
|
||||
**1. Build a pre-harvest plan**
|
||||
> "I grow corn and soybeans in west-central Ohio. Help me build a pre-harvest
|
||||
> marketing plan for this year's corn crop."
|
||||
|
||||
Advisor pulls: `price_trend("corn", geo="OH", years=10)` (where price sits vs
|
||||
norm) · `futures_quote("corn", delivery="Dec 2026")` (new-crop level) ·
|
||||
`basis_movement("corn")` (is basis a reason to wait or sell) ·
|
||||
`best_local_bid("corn")` (today's actionable cash).
|
||||
|
||||
**2. Sell now or store?**
|
||||
> "I've got 20,000 bushels of soybeans in the bin. Should I sell now or hold into
|
||||
> winter?"
|
||||
|
||||
`price_trend("soy", geo="OH")` (seasonal percentile — is now historically high or
|
||||
low) · `list_deliveries("soy")` + `futures_quote("soy", delivery=…)` across months
|
||||
(does the carry pay me to store) · `basis_movement("soy")` (is basis likely to
|
||||
improve) · `best_local_bid("soy")`.
|
||||
|
||||
**3. Does the market pay me to store? (carry)**
|
||||
> "Does the futures carry justify storing corn until March instead of selling at
|
||||
> harvest?"
|
||||
|
||||
`list_deliveries("corn")` then `futures_quote("corn", delivery=…)` for the nearby
|
||||
vs deferred months → compare the spread against storage cost.
|
||||
|
||||
**4. Time a new-crop sale**
|
||||
> "Is now a good time to make a new-crop corn sale for fall delivery?"
|
||||
|
||||
`price_trend("corn", years=10)` (seasonal context) · `futures_quote("corn",
|
||||
delivery="Dec 2026")` (change on day / since open) · `basis_movement("corn",
|
||||
delivery="Oct 2026")` · `best_local_bid("corn")`.
|
||||
|
||||
**5. Seasonality of basis / price**
|
||||
> "Historically, what time of year is corn basis strongest around here, and when
|
||||
> does the cash price tend to peak?"
|
||||
|
||||
`price_series("corn", geo="OH")` over multiple years (seasonal shape) ·
|
||||
`price_trend("corn", geo="OH")` (this month's percentile vs the same month over
|
||||
the last N years) · `price_history("corn", days=…)` for the recent basis arc.
|
||||
|
||||
**6. Compare which crop to sell**
|
||||
> "Relative to their 10-year norms, is corn or soybeans the better one to be
|
||||
> selling right now?"
|
||||
|
||||
`price_trend("corn", years=10)` vs `price_trend("soy", years=10)` — compare
|
||||
percentile-vs-normal and YoY, then `best_local_bid` for each.
|
||||
|
||||
**7. Breakeven / cost side of the plan**
|
||||
> "How have my input costs moved this year — what does that do to my corn
|
||||
> breakeven?"
|
||||
|
||||
`input_cost_trend("diesel")` · `input_cost_trend("urea")`,
|
||||
`input_cost_trend("anhydrous")`, `input_cost_trend("dap")`,
|
||||
`input_cost_trend("potash")` (all $/ton, Cornbelt) — latest + YoY change feed the
|
||||
cost side; combine with `price_trend("corn")` for the margin picture.
|
||||
|
||||
**8. Prebuy fertilizer decision**
|
||||
> "Should I prebuy urea and anhydrous now or wait until spring?"
|
||||
|
||||
`input_cost_trend("urea")` and `input_cost_trend("anhydrous")` (seasonal
|
||||
percentile + YoY — are they historically cheap) · `input_cost_series(…)` for the
|
||||
trend shape · `current_input_price()` for the latest local dealer quote.
|
||||
|
||||
**9. Set price targets / a scale-up plan**
|
||||
> "Give me three corn price targets to scale out the rest of my old-crop bushels,
|
||||
> based on where price sits historically."
|
||||
|
||||
`price_trend("corn", geo="OH", years=10)` (normal, range, percentile) +
|
||||
`futures_quote("corn")` + `basis_movement("corn")` → advisor proposes laddered
|
||||
targets above the seasonal normal.
|
||||
|
||||
**10. Wheat pre-harvest plan**
|
||||
> "Build me a marketing plan for this year's wheat ahead of harvest."
|
||||
|
||||
`price_trend("wheat", geo="OH")` · `futures_quote("wheat", delivery="Jul 2026")` ·
|
||||
`basis_movement("wheat")` · `best_local_bid("wheat")`.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring — track prices and act
|
||||
|
||||
**11. Morning brief**
|
||||
> "Give me my grain marketing brief for this morning."
|
||||
|
||||
`todays_summary()` (one call) — then drill in where it flags something.
|
||||
|
||||
**12. Best bid right now (location-aware)**
|
||||
> "Where's the best corn bid within 40 miles of 45810?"
|
||||
|
||||
`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?"
|
||||
|
||||
`futures_quote("corn")` → reports change since open and change on the day.
|
||||
|
||||
**14. Basis check**
|
||||
> "How has corn basis moved over the last two weeks, and which elevator improved
|
||||
> the most?"
|
||||
|
||||
`basis_movement("corn", days=14)` for the headline, then `basis_detail("corn",
|
||||
days=14)` for the per-elevator split.
|
||||
|
||||
**15. Best home for a specific delivery**
|
||||
> "Which elevator is paying the most for soybeans for January delivery?"
|
||||
|
||||
`latest_prices("soy", delivery="Jan 2026")` / `best_local_bid("soy")`.
|
||||
|
||||
**16. Is cash near a recent high?**
|
||||
> "Is the local corn cash price near its high for the last month?"
|
||||
|
||||
`price_history("corn", days=30)` (recent arc + basis) · `price_trend("corn",
|
||||
geo="OH")` (seasonal percentile for context).
|
||||
|
||||
**17. Spread between cash and CBOT**
|
||||
> "Has my local basis widened or narrowed this month?"
|
||||
|
||||
`basis_movement("corn", days=30)` → if it nets flat or odd, `basis_detail(…)`.
|
||||
|
||||
**18. All elevators at a glance**
|
||||
> "Show me today's corn bids across all my elevators."
|
||||
|
||||
`latest_prices("corn")` (snapshot table, all sources).
|
||||
|
||||
**19. What can I price?**
|
||||
> "What delivery months can I price corn for right now?"
|
||||
|
||||
`list_deliveries("corn")`.
|
||||
|
||||
**20. Data freshness / trust check**
|
||||
> "Are any of my price feeds stale or failing?"
|
||||
|
||||
`source_health()` (and `list_sources()` for the full roster + last-success
|
||||
times).
|
||||
|
||||
**21. Fertilizer price watch**
|
||||
> "Are fertilizer prices climbing — should I be worried about next year's input
|
||||
> bill?"
|
||||
|
||||
`input_cost_trend("urea")`, `input_cost_trend("dap")`,
|
||||
`input_cost_trend("potash")`, `input_cost_trend("anhydrous")` — latest $/ton +
|
||||
MoM/YoY + seasonal; `input_cost_series(…)` for the trend.
|
||||
|
||||
**22. Diesel watch for fieldwork**
|
||||
> "What's diesel doing — anything I should factor into fall fieldwork costs?"
|
||||
|
||||
`input_cost_trend("diesel")` (U.S. retail $/gal, week-over-week + YoY).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting — how the advisor synthesizes a recommendation
|
||||
|
||||
These show the *reasoning*, combining several tools into one answer.
|
||||
|
||||
**A "should I sell today" call:**
|
||||
> "Should I sell corn today?"
|
||||
|
||||
1. `best_local_bid("corn")` — the actual number on the table.
|
||||
2. `price_trend("corn", geo="OH", years=10)` — is that number historically high
|
||||
or low for this month (percentile vs normal)?
|
||||
3. `futures_quote("corn")` — is the board strong/weak today, and which way is it
|
||||
moving (since open / on day)?
|
||||
4. `basis_movement("corn")` — is basis strengthening (a reason to wait) or
|
||||
weakening (a reason to capture now)?
|
||||
5. Synthesis: "Cash is at the 78th percentile vs the last 10 Mays, board is up
|
||||
6¢ on the day, and basis has firmed 4¢ over two weeks but looks toppy — taking
|
||||
a portion here and leaving the rest for the carry is reasonable."
|
||||
|
||||
**A "store vs sell" call** combines `list_deliveries` + `futures_quote` (carry) +
|
||||
`basis_movement` (expected basis gain) + `input_cost_trend("diesel")` and storage
|
||||
assumptions to weigh the cost of carry against the expected pickup.
|
||||
|
||||
> Guidance for the advisor: always anchor a recommendation to a **live**
|
||||
> number (`best_local_bid`/`latest_prices`/`futures_quote`) and use
|
||||
> `price_trend`/`input_cost_trend` only for *context*. State the basis and
|
||||
> futures pieces separately so the user can act on whichever is actually driving
|
||||
> the call.
|
||||
@@ -71,6 +71,30 @@ def test_get_drops_none_params(monkeypatch):
|
||||
assert captured["params"] == {"commodity": "corn"}
|
||||
|
||||
|
||||
def test_latest_include_expired_passthrough(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
def json(self): return {"count": 0, "rows": []}
|
||||
|
||||
def fake_get(url, params=None, timeout=None, headers=None):
|
||||
captured["params"] = dict(params or {})
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", fake_get)
|
||||
|
||||
# Default: not sent, so the API applies its current+future filter.
|
||||
client.latest(commodity="corn")
|
||||
assert "include_expired" not in captured["params"]
|
||||
|
||||
# Opt in: forwarded so the API returns expired months too.
|
||||
client.latest(commodity="corn", include_expired=True)
|
||||
assert captured["params"] == {"commodity": "corn", "include_expired": True}
|
||||
|
||||
|
||||
def test_futures_endpoint_params(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
@@ -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",
|
||||
@@ -75,6 +129,77 @@ 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_input_cost_payload_shape():
|
||||
# Input-cost payload uses item/label and has no geo.
|
||||
payload = {"item": "diesel", "label": "retail diesel", "unit": "$/gal", "source": "EIA",
|
||||
"trend": {"period": "2026-05-25", "value_cents": 552, "prev_cents": 560,
|
||||
"change_cents": -8, "change_pct": -1.4, "yoy_cents": 349,
|
||||
"yoy_change_cents": 203, "yoy_pct": 58.2, "seasonal": None,
|
||||
"recent_direction": "down", "baseline_years": 10, "points": 1680}}
|
||||
out = fmt.fmt_price_trend(payload)
|
||||
assert "diesel — retail diesel" in out # item/label header, no geo segment
|
||||
assert "$5.520/gal" in out
|
||||
assert "+58.2%" in out # YoY surfaced
|
||||
|
||||
|
||||
def test_fmt_input_cost_fertilizer_ton_and_region():
|
||||
# Fertilizer payload carries item + geo (region) + $/ton.
|
||||
payload = {"item": "urea", "geo": "Cornbelt", "label": "urea", "unit": "$/ton",
|
||||
"source": "USDA AgTransport",
|
||||
"trend": {"period": "2026-03-01", "value_cents": 69812, "prev_cents": 51812,
|
||||
"change_cents": 18000, "change_pct": 34.7, "yoy_cents": 45500,
|
||||
"yoy_change_cents": 24312, "yoy_pct": 53.4, "seasonal": None,
|
||||
"recent_direction": "up", "baseline_years": 10, "points": 39}}
|
||||
out = fmt.fmt_price_trend(payload)
|
||||
assert "urea — Cornbelt — urea" in out # item, region, label all present
|
||||
assert "$698.12/ton" in out
|
||||
assert "USDA AgTransport" in out
|
||||
assert "Mar 2026" in out # monthly period rendered as month
|
||||
|
||||
|
||||
def test_fmt_inputs_lime_table():
|
||||
payload = {
|
||||
"product": "lime", "count": 2,
|
||||
@@ -200,12 +325,14 @@ def test_fmt_basis_detail_per_series():
|
||||
def test_fmt_sources_table():
|
||||
payload = {"sources": [
|
||||
{"id": 1, "name": "Test Elev", "kind": "elevator",
|
||||
"city": "Ada", "state": "OH", "zip": "45810",
|
||||
"last_success_at": "2026-05-20T14:55:00+00:00",
|
||||
"consecutive_failures": 0, "last_error": None},
|
||||
]}
|
||||
out = fmt.fmt_sources(payload)
|
||||
assert "Test Elev" in out
|
||||
assert "elevator" in out
|
||||
assert "Ada, OH 45810" in out # location column
|
||||
|
||||
|
||||
def test_fmt_health_buckets():
|
||||
@@ -263,3 +390,44 @@ def test_fmt_summary_includes_best_for_today():
|
||||
assert "Mercer Landmark — St Henry" in out
|
||||
assert "$4.5800" in out # corn last
|
||||
assert "No current-month local bid posted" in out # soy fallback
|
||||
|
||||
|
||||
def test_fmt_nutrient_cost():
|
||||
payload = {
|
||||
"geo": "Cornbelt",
|
||||
"source": "USDA AgTransport",
|
||||
"products": [
|
||||
{"item": "anhydrous", "label": "anhydrous ammonia", "unit": "$/ton",
|
||||
"geo": "Cornbelt", "period": "2026-03-01", "price_cents_per_ton": 88062,
|
||||
"analysis": {"grade": "82-0-0", "grade_assumed": False},
|
||||
"cost_per_lb": {"n": 54, "p2o5": None, "k2o": None}},
|
||||
{"item": "urea", "label": "urea", "unit": "$/ton", "geo": "Cornbelt",
|
||||
"period": "2026-03-01", "price_cents_per_ton": 69812,
|
||||
"analysis": {"grade": "46-0-0", "grade_assumed": False},
|
||||
"cost_per_lb": {"n": 76, "p2o5": None, "k2o": None}},
|
||||
{"item": "uan", "label": "UAN (28-32%)", "unit": "$/ton", "geo": "Cornbelt",
|
||||
"period": "2026-03-01", "price_cents_per_ton": 47612,
|
||||
"analysis": {"grade": "32-0-0", "grade_assumed": True},
|
||||
"cost_per_lb": {"n": 74, "p2o5": None, "k2o": None}},
|
||||
{"item": "potash", "label": "potash (0-0-60)", "unit": "$/ton",
|
||||
"geo": "Cornbelt", "period": "2026-03-01", "price_cents_per_ton": 35938,
|
||||
"analysis": {"grade": "0-0-60", "grade_assumed": False},
|
||||
"cost_per_lb": {"n": None, "p2o5": None, "k2o": 30}},
|
||||
],
|
||||
"cheapest": {"n": "anhydrous", "p2o5": None, "k2o": "potash"},
|
||||
}
|
||||
out = fmt.fmt_nutrient_cost(payload)
|
||||
assert "Cornbelt" in out
|
||||
assert "Cheapest Nitrogen (N):** anhydrous ammonia at $0.54/lb" in out
|
||||
assert "Cheapest Potash (K₂O):** potash (0-0-60) at $0.30/lb" in out
|
||||
# anhydrous (cheapest N) sorts above urea in the table
|
||||
assert out.index("anhydrous ammonia |") < out.index("| urea |")
|
||||
# UAN grade-assumed marker + footnote
|
||||
assert "32-0-0*" in out
|
||||
assert "UAN grade assumed 32-0-0" in out
|
||||
# potash supplies no N -> em-dash in the N column
|
||||
assert "—" in out
|
||||
|
||||
|
||||
def test_fmt_nutrient_cost_empty():
|
||||
assert "No data on file" in fmt.fmt_nutrient_cost({"geo": "Cornbelt", "products": []})
|
||||
|
||||
Reference in New Issue
Block a user