14 Commits

Author SHA1 Message Date
claude 41313d8188 latest_prices: add include_expired passthrough for historical data
CI / test (pull_request) Successful in 16s
CI / build-push (pull_request) Has been skipped
ag-monitor now drops expired delivery months from /api/data/latest by
default (current + future only). Expose the opt-in through the MCP so the
advisor can fetch past months when a farmer explicitly asks for history,
while the default stays current + future.

- client.latest(include_expired=False): forwards include_expired=true only
  when set (None is dropped), so the default query string is unchanged.
- latest_prices tool: new include_expired arg with a description steering
  the model to set it only on an explicit historical request.

Tests: client passthrough (default omits the param; opt-in forwards it).
43 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:44:55 -04:00
claude bb4219da87 feat: nutrient_cost tool — cheapest fertilizer per lb of N/P/K (#1)
CI / test (push) Successful in 18s
CI / build-push (push) Successful in 5s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-04 15:58:59 -04:00
justin fb50c103d3 Location params on best_local_bid / latest_prices (zip / GPS + radius)
CI / test (push) Successful in 17s
CI / build-push (push) Successful in 6s
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>
2026-05-31 07:51:54 -04:00
justin ae15aa5c3c docs: add Ag Advisor grain-marketing prompt library
CI / test (push) Successful in 17s
CI / build-push (push) Successful in 5s
Example end-user prompts (planning + monitoring) for the downstream Drawbar Ag
Advisor, each mapped to the ag-bids MCP/API tools that answer it, plus
cross-cutting synthesis examples. Linked from the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:00:57 -04:00
justin 03c6c540ef Add fertilizer to input-cost tools (regional $/ton, USDA AgTransport)
CI / test (push) Successful in 17s
CI / build-push (push) Successful in 30s
input_cost_trend/input_cost_series now accept six fertilizers (urea, uan,
anhydrous, dap, map, potash) alongside diesel, with an optional `geo` region
(default Cornbelt). Real $/ton + MoM/YoY change + seasonal context.

- client: pass geo through; add input_cost_geographies
- server: expand VALID_INPUTS; geo param + docstrings
- format already unit-aware ($/ton) and geo-aware
- README tools table now lists the reference/trend + input-cost tools
- CHANGELOG: regional fertilizer input-cost release notes
- tests: fertilizer $/ton + region formatting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:59:33 -04:00
justin a0e36996c4 fmt: 'periods on file' (series may be weekly, not just monthly)
CI / test (push) Failing after 1s
CI / build-push (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:41:20 -04:00
justin 457cdad2fb Add input_cost_trend / input_cost_series MCP tools (EIA diesel)
CI / test (push) Successful in 17s
CI / build-push (push) Successful in 6s
Real input price + WoW/YoY change + seasonal for diesel ($/gal). Formatters now
handle the item/label payload shape. Changelog updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:35:32 -04:00
justin 47cac9b521 Add price_trend / price_series MCP tools (USDA NASS grain)
CI / test (push) Failing after 0s
CI / build-push (push) Has been skipped
Real $/bu price + MoM/YoY change + seasonal percentile context for corn/soy/
wheat, US + states, deep history. Wraps the new /api/data/price-trend and
/api/data/price-series endpoints. Changelog updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:02:13 -04:00
justin 7b6661e3d9 Changelog: source geo + many more elevator locations
CI / test (push) Failing after 1s
CI / build-push (push) Has been skipped
Document the per-source geo on /api/data/sources + list_sources, and the new
Heritage Cooperative (23 locations), expanded Mercer Landmark (16, with two
source renames), and Bambauer Pemberton sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:15:59 -04:00
justin ffc705f485 Show source location (city/state/zip) in list_sources output
CI / test (push) Successful in 17s
CI / build-push (push) Successful in 5s
/api/data/sources now returns per-source geo; surface it as a Location column
in the sources table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:07:34 -04:00
justin a3b77414d8 CI: use REGISTRY_TOKEN secret for registry login
CI / test (push) Successful in 16s
CI / build-push (push) Successful in 24s
The auto Actions token can't push packages in this Gitea; use a PAT secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:00:29 -04:00
justin 0d7e61f65f CI: install into a venv (runner Python is PEP 668 managed)
CI / test (push) Successful in 17s
CI / build-push (push) Failing after 3s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:57:34 -04:00
justin dfbc77160e Add Gitea Actions CI: test + build/push image on main
CI / test (push) Failing after 4s
CI / build-push (push) Has been skipped
Tests on every push/PR; on main, builds and pushes
git.jpaul.io/justin/ag-bids-mcp:{latest,sha} to the Gitea registry. The .0.2
act_runner has the host docker.sock mounted, so plain docker build/push work.
Watchtower on .0.2 then auto-deploys :latest within ~5 min.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:55:38 -04:00
justin 2e67a0fce2 Document new tools in README
Add futures_quote, basis_movement, basis_detail to the tools table, mark
price_history/latest_prices filters optional, list the /futures endpoint, and
point to CHANGELOG.md for per-release detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:49:19 -04:00
9 changed files with 1105 additions and 36 deletions
+45
View File
@@ -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
View File
@@ -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
+24 -7
View File
@@ -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
View File
@@ -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:
+210 -17
View File
@@ -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"{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
View File
@@ -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."""
+231
View File
@@ -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.
+24
View File
@@ -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 = {}
+168
View File
@@ -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": []})