Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65509a27d6 | |||
| 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
|
Notes for clients/agents that consume the ag-bids MCP tools and the underlying
|
||||||
`ag-monitor` `/api/data/*` HTTP API. Newest first.
|
`ag-monitor` `/api/data/*` HTTP API. Newest first.
|
||||||
|
|
||||||
|
## 2026-05-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
|
## 2026-05-29 — Futures price + change tool
|
||||||
|
|
||||||
### New MCP tool
|
### New MCP tool
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ plain-English questions like:
|
|||||||
- "What's the best place to sell corn today?"
|
- "What's the best place to sell corn today?"
|
||||||
- "What's the current price of lime?"
|
- "What's the current price of lime?"
|
||||||
- "Show me the corn basis trend at Andersons Greenville over the last 30 days."
|
- "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?"
|
- "Which sources are stale or failing?"
|
||||||
|
|
||||||
## How it talks to data
|
## 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/*
|
ag-bids-mcp ── X-API-Key ─► https://agbids.paul.farm/api/data/*
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints used: `/api/data/latest`, `/history`, `/best`, `/inputs`, `/sources`,
|
Endpoints used: `/api/data/latest`, `/history`, `/futures`, `/best`, `/inputs`,
|
||||||
`/deliveries`. See the [ag-monitor source](https://git.jpaul.io/justin/ag-bids)
|
`/sources`, `/deliveries`, `/price-trend`, `/price-series`, `/price-geographies`,
|
||||||
for the contract.
|
`/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
|
## Authentication
|
||||||
|
|
||||||
@@ -72,13 +75,27 @@ auto-updates it every 5 minutes.
|
|||||||
|
|
||||||
| Tool | Returns |
|
| 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_lime_price()` | Latest lime quotes across all manual-entry sources |
|
||||||
| `current_input_price(product?)` | MAP / Potash / Lime — all three or one |
|
| `current_input_price(product?)` | MAP / Potash / Lime — all three or one (local DTN dealer feed) |
|
||||||
| `latest_prices(commodity?, source?, delivery?)` | Snapshot table, optionally filtered |
|
| `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_history(commodity, source?, delivery?, days?)` | Compact time series |
|
| `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_sources()` | Active scrapers + last-success timestamps |
|
||||||
| `list_commodities()` | corn, soy, wheat, map, potash, lime |
|
| `list_commodities()` | corn, soy, wheat, map, potash, lime |
|
||||||
| `list_deliveries(commodity)` | Posted delivery labels, chronological |
|
| `list_deliveries(commodity)` | Posted delivery labels, chronological |
|
||||||
| `source_health()` | Stale / failing / healthy summary |
|
| `source_health()` | Stale / failing / healthy summary |
|
||||||
| `todays_summary()` | Same shape as the morning brief snapshot |
|
| `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).
|
||||||
|
|||||||
+35
-4
@@ -54,9 +54,12 @@ def _get(path: str, **params: Any) -> dict | list:
|
|||||||
|
|
||||||
|
|
||||||
def latest(commodity: str | None = None, source: str | None = None,
|
def latest(commodity: str | None = None, source: str | None = None,
|
||||||
delivery: str | None = None, kind: str | None = None) -> dict:
|
delivery: str | None = None, kind: str | None = None,
|
||||||
|
zip: str | None = None, lat: float | None = None,
|
||||||
|
lng: float | None = None, radius_miles: float | None = None) -> dict:
|
||||||
return _get("/api/data/latest",
|
return _get("/api/data/latest",
|
||||||
commodity=commodity, source=source, delivery=delivery, kind=kind)
|
commodity=commodity, source=source, delivery=delivery, kind=kind,
|
||||||
|
zip=zip, lat=lat, lng=lng, radius_miles=radius_miles)
|
||||||
|
|
||||||
|
|
||||||
def history(commodity: str | None = None, source_id: int | None = None,
|
def history(commodity: str | None = None, source_id: int | None = None,
|
||||||
@@ -66,8 +69,36 @@ def history(commodity: str | None = None, source_id: int | None = None,
|
|||||||
delivery=delivery, days=days)
|
delivery=delivery, days=days)
|
||||||
|
|
||||||
|
|
||||||
def best(commodity: str) -> dict:
|
def best(commodity: str, zip: str | None = None, lat: float | None = None,
|
||||||
return _get("/api/data/best", commodity=commodity)
|
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:
|
def futures(commodity: str, delivery: str | None = None) -> dict:
|
||||||
|
|||||||
+212
-19
@@ -22,6 +22,12 @@ def _ton(cents: Optional[int]) -> str:
|
|||||||
return f"${cents / 100:.2f}"
|
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:
|
def _basis(cents: Optional[int]) -> str:
|
||||||
if cents is None:
|
if cents is None:
|
||||||
return "—"
|
return "—"
|
||||||
@@ -73,21 +79,189 @@ def _first_last(points: list[dict], field: str):
|
|||||||
# ---------- best_local_bid ----------
|
# ---------- 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:
|
def fmt_best(commodity: str, payload: dict) -> str:
|
||||||
best = payload.get("best")
|
best = payload.get("best")
|
||||||
today = payload.get("today")
|
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:
|
if not best:
|
||||||
return (
|
if center:
|
||||||
f"### Best place to sell {commodity} today ({today})\n\n"
|
out = head + f"No current-month {commodity} bids{scope}.\n"
|
||||||
f"No current-month {commodity} bids posted across the tracked sources.\n"
|
near = payload.get("nearest")
|
||||||
)
|
if near:
|
||||||
return (
|
out += (f"\nNearest tracked elevator: **{near['source_name']}**"
|
||||||
f"### Best place to sell {commodity} today ({today})\n\n"
|
f"{_loc_suffix(near)} — about **{_mi(near.get('distance_miles'))}** "
|
||||||
f"**{best['source_name']}** — delivery **{best['delivery']}** — "
|
f"away, outside the {radius:.0f} mi radius.\n")
|
||||||
f"bid **{_bu(best['bid_cents'])}/bu** (basis {_basis(best.get('basis_cents'))}, "
|
return out
|
||||||
f"futures {best.get('futures_contract') or '—'})\n\n"
|
return head + f"No current-month {commodity} bids posted across the tracked sources.\n"
|
||||||
f"_Fetched {best.get('fetched_at') or '?'}_\n"
|
dist = best.get("distance_miles")
|
||||||
|
dist_txt = f" — **{_mi(dist)}** away" if dist is not None else ""
|
||||||
|
out = (
|
||||||
|
head
|
||||||
|
+ f"**{best['source_name']}**{_loc_suffix(best)}{dist_txt} — delivery "
|
||||||
|
f"**{best['delivery']}** — bid **{_bu(best['bid_cents'])}/bu** "
|
||||||
|
f"(basis {_basis(best.get('basis_cents'))}, "
|
||||||
|
f"futures {best.get('futures_contract') or '—'})\n"
|
||||||
)
|
)
|
||||||
|
if center:
|
||||||
|
out += f"\n_Searched{scope}._\n"
|
||||||
|
out += f"\n_Fetched {best.get('fetched_at') or '?'}_\n"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- reference price trends (USDA NASS / EIA) ----------
|
||||||
|
|
||||||
|
|
||||||
|
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 ----------
|
# ---------- futures quote + change ----------
|
||||||
@@ -153,20 +327,31 @@ def fmt_inputs(payload: dict) -> str:
|
|||||||
|
|
||||||
def fmt_latest(payload: dict) -> str:
|
def fmt_latest(payload: dict) -> str:
|
||||||
rows = payload.get("rows") or []
|
rows = payload.get("rows") or []
|
||||||
|
center = payload.get("center")
|
||||||
|
radius = payload.get("radius_miles")
|
||||||
if not rows:
|
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 = [
|
lines = [
|
||||||
"### Latest prices", "",
|
title, "",
|
||||||
"| Source | Commodity | Delivery | Bid | Basis | Futures | Fetched |",
|
f"| Source | Commodity | Delivery | Bid | Basis | Futures |{dist_col} Fetched |",
|
||||||
"|---|---|---|---:|---:|---|---|",
|
f"|---|---|---|---:|---:|---|{dist_sep}---|",
|
||||||
]
|
]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
unit_fmt = _ton if r.get("commodity_kind") == "fertilizer" else _bu
|
unit_fmt = _ton if r.get("commodity_kind") == "fertilizer" else _bu
|
||||||
|
dist_cell = f" {_mi(r.get('distance_miles'))} |" if center else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | "
|
f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | "
|
||||||
f"{r['delivery']} | {unit_fmt(r.get('bid_cents'))} | "
|
f"{r['delivery']} | {unit_fmt(r.get('bid_cents'))} | "
|
||||||
f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or '—'} | "
|
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"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
@@ -302,18 +487,26 @@ def fmt_basis_detail(payload: dict, max_rows: int = 80) -> str:
|
|||||||
# ---------- sources / health ----------
|
# ---------- 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:
|
def fmt_sources(payload: dict) -> str:
|
||||||
src = payload.get("sources") or []
|
src = payload.get("sources") or []
|
||||||
if not src:
|
if not src:
|
||||||
return "### Sources\n\nNo active sources.\n"
|
return "### Sources\n\nNo active sources.\n"
|
||||||
lines = [
|
lines = [
|
||||||
"### Tracked sources", "",
|
"### Tracked sources", "",
|
||||||
"| Source | Kind | Last success | Consecutive failures | Last error |",
|
"| Source | Kind | Location | Last success | Consecutive failures | Last error |",
|
||||||
"|---|---|---|---:|---|",
|
"|---|---|---|---|---:|---|",
|
||||||
]
|
]
|
||||||
for s in src:
|
for s in src:
|
||||||
lines.append(
|
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 ''} |"
|
f"{s.get('consecutive_failures') or 0} | {s.get('last_error') or ''} |"
|
||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|||||||
+192
-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()
|
@mcp.tool()
|
||||||
def best_local_bid(
|
def best_local_bid(
|
||||||
commodity: Annotated[
|
commodity: Annotated[
|
||||||
str, Field(description="Grain to look up: 'corn', 'soy' (soybeans), or 'wheat'.")
|
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:
|
) -> str:
|
||||||
"""Return the highest local bid for *this calendar month's* delivery for
|
"""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()
|
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:
|
if commodity not in VALID_GRAIN:
|
||||||
return f"`commodity` must be one of: {sorted(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)
|
return fmt.fmt_best(commodity, payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,14 +163,34 @@ def latest_prices(
|
|||||||
str | None,
|
str | None,
|
||||||
Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."),
|
Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."),
|
||||||
] = None,
|
] = None,
|
||||||
|
zip: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Your 5-digit ZIP — keep only nearby sources, nearest first."),
|
||||||
|
] = None,
|
||||||
|
lat: Annotated[
|
||||||
|
float | None, Field(description="Your latitude (use with `lng` instead of a ZIP).")
|
||||||
|
] = None,
|
||||||
|
lng: Annotated[float | None, Field(description="Your longitude (use with `lat`).")] = None,
|
||||||
|
radius_miles: Annotated[
|
||||||
|
float | None,
|
||||||
|
Field(description="Search radius in miles around the location (default 50). "
|
||||||
|
"Ignored unless a ZIP or lat/lng is given."),
|
||||||
|
] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Snapshot of the latest scraped bid per (source, commodity, delivery).
|
"""Snapshot of the latest scraped bid per (source, commodity, delivery).
|
||||||
|
|
||||||
Every filter is optional and AND'd together — pivot by elevator, crop,
|
Every filter is optional and AND'd together — pivot by elevator, crop,
|
||||||
delivery, or kind in any combination."""
|
delivery, or kind in any combination. Pass a `zip` (or `lat`+`lng`) with
|
||||||
|
optional `radius_miles` to keep only elevators near the farmer, sorted
|
||||||
|
nearest-first with the distance shown."""
|
||||||
cm = commodity.strip().lower() if commodity else None
|
cm = commodity.strip().lower() if commodity else None
|
||||||
with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind):
|
with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind,
|
||||||
payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind)
|
zip=zip or "", radius=radius_miles or 0):
|
||||||
|
err = _location_error(zip, lat, lng)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind,
|
||||||
|
zip=zip, lat=lat, lng=lng, radius_miles=radius_miles)
|
||||||
return fmt.fmt_latest(payload)
|
return fmt.fmt_latest(payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -246,6 +297,141 @@ def basis_detail(
|
|||||||
return fmt.fmt_basis_detail(_filter_source(payload, source))
|
return fmt.fmt_basis_detail(_filter_source(payload, source))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def price_trend(
|
||||||
|
commodity: Annotated[
|
||||||
|
str, Field(description="Grain: 'corn', 'soy', or 'wheat'.")
|
||||||
|
],
|
||||||
|
geo: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="'US' or a 2-letter state (e.g. 'OH', 'IA'). Default US."),
|
||||||
|
] = "US",
|
||||||
|
years: Annotated[
|
||||||
|
int, Field(ge=1, le=120, description="Baseline window for the seasonal normal/percentile."),
|
||||||
|
] = 10,
|
||||||
|
) -> str:
|
||||||
|
"""USDA NASS monthly price *received by farmers* ($/bu) with the change.
|
||||||
|
|
||||||
|
Returns the latest real price plus month-over-month and year-over-year moves
|
||||||
|
(dollars + percent) and where it sits seasonally (percentile vs the same
|
||||||
|
month over the last N years, normal, and range). This is the macro/seasonal
|
||||||
|
benchmark to compare local cash bids against — corn history goes back to 1908."""
|
||||||
|
cm = commodity.strip().lower()
|
||||||
|
g = geo.strip().upper()
|
||||||
|
with track("price_trend", commodity=cm, geo=g, years=years):
|
||||||
|
if cm not in VALID_GRAIN:
|
||||||
|
return f"`commodity` must be one of: {sorted(VALID_GRAIN)}"
|
||||||
|
return fmt.fmt_price_trend(client.price_trend(commodity=cm, geo=g, years=years))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def price_series(
|
||||||
|
commodity: Annotated[
|
||||||
|
str, Field(description="Grain: 'corn', 'soy', or 'wheat'.")
|
||||||
|
],
|
||||||
|
geo: Annotated[
|
||||||
|
str, Field(description="'US' or a 2-letter state. Default US."),
|
||||||
|
] = "US",
|
||||||
|
start_year: Annotated[
|
||||||
|
int | None, Field(description="Optional first year (default: ~last 5 yrs of the full series).")
|
||||||
|
] = None,
|
||||||
|
end_year: Annotated[int | None, Field(description="Optional last year.")] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Raw monthly price-received series ($/bu) for charting / drill-down.
|
||||||
|
|
||||||
|
Use price_trend for the headline; use this when you need the actual monthly
|
||||||
|
numbers. Defaults to the recent window unless you pass a start_year."""
|
||||||
|
cm = commodity.strip().lower()
|
||||||
|
g = geo.strip().upper()
|
||||||
|
with track("price_series", commodity=cm, geo=g):
|
||||||
|
if cm not in VALID_GRAIN:
|
||||||
|
return f"`commodity` must be one of: {sorted(VALID_GRAIN)}"
|
||||||
|
# Default to a readable recent window if no range given.
|
||||||
|
if start_year is None and end_year is None:
|
||||||
|
from datetime import datetime
|
||||||
|
start_year = datetime.utcnow().year - 5
|
||||||
|
payload = client.price_series(commodity=cm, geo=g, start_year=start_year, end_year=end_year)
|
||||||
|
return fmt.fmt_price_series(payload)
|
||||||
|
|
||||||
|
|
||||||
|
# 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()
|
@mcp.tool()
|
||||||
def list_sources() -> str:
|
def list_sources() -> str:
|
||||||
"""All active scrapers + their last-success timestamps and any pending failures."""
|
"""All active scrapers + their last-success timestamps and any pending failures."""
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -35,6 +35,60 @@ def test_fmt_best_no_winner():
|
|||||||
assert "wheat" in out
|
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():
|
def test_fmt_futures_with_changes():
|
||||||
payload = {
|
payload = {
|
||||||
"commodity": "corn", "delivery": "Jul 2026", "contract": "ZCN26",
|
"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
|
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():
|
def test_fmt_inputs_lime_table():
|
||||||
payload = {
|
payload = {
|
||||||
"product": "lime", "count": 2,
|
"product": "lime", "count": 2,
|
||||||
@@ -200,12 +325,14 @@ def test_fmt_basis_detail_per_series():
|
|||||||
def test_fmt_sources_table():
|
def test_fmt_sources_table():
|
||||||
payload = {"sources": [
|
payload = {"sources": [
|
||||||
{"id": 1, "name": "Test Elev", "kind": "elevator",
|
{"id": 1, "name": "Test Elev", "kind": "elevator",
|
||||||
|
"city": "Ada", "state": "OH", "zip": "45810",
|
||||||
"last_success_at": "2026-05-20T14:55:00+00:00",
|
"last_success_at": "2026-05-20T14:55:00+00:00",
|
||||||
"consecutive_failures": 0, "last_error": None},
|
"consecutive_failures": 0, "last_error": None},
|
||||||
]}
|
]}
|
||||||
out = fmt.fmt_sources(payload)
|
out = fmt.fmt_sources(payload)
|
||||||
assert "Test Elev" in out
|
assert "Test Elev" in out
|
||||||
assert "elevator" in out
|
assert "elevator" in out
|
||||||
|
assert "Ada, OH 45810" in out # location column
|
||||||
|
|
||||||
|
|
||||||
def test_fmt_health_buckets():
|
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 "Mercer Landmark — St Henry" in out
|
||||||
assert "$4.5800" in out # corn last
|
assert "$4.5800" in out # corn last
|
||||||
assert "No current-month local bid posted" in out # soy fallback
|
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