17 Commits

Author SHA1 Message Date
claude 65509a27d6 feat: nutrient_cost tool — cheapest fertilizer per lb of N/P/K
CI / test (pull_request) Successful in 18s
CI / build-push (pull_request) Has been skipped
New agbids__nutrient_cost(geo) tool: wraps ag-monitor's /api/data/nutrient-cost
and formats a per-nutrient value comparison for the advisor — cheapest source of
N / P2O5 / K2O + a ranked $/lb table. This is what answers "which fertilizer is
the best nitrogen value per dollar?" (input_cost_trend is $/ton only). UAN grade
shown as assumed 32-0-0.

client.nutrient_cost + fmt.fmt_nutrient_cost (+ _per_lb). 42 tests pass.

For planning #90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:52:33 -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
justin d0fada5985 Label image with org.opencontainers.image.source for Gitea repo linking
Gitea attaches a container package to its repo via this OCI label; without it
the pushed image shows up unlinked under the user's packages instead of the
ag-bids-mcp repo's Packages tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:46:32 -04:00
justin 0c9bc3b328 Add futures_quote tool: CBOT price + change since open + on day
New futures_quote(commodity, delivery?) tool wraps the new /api/data/futures
endpoint: reports latest price, today's session open, prior settle, and both
moves (since open and on the day). With a delivery month it resolves the listed
contract; without it, the continuous nearby. Adds client.futures(), fmt_futures(),
tests, and a CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:36:56 -04:00
justin 61c1736539 Add CHANGELOG for upstream tool consumers
Document the new basis_movement/basis_detail tools, the flexible price_history
and latest_prices signatures, and the /api/data/history commodity-optional
change, with example question→call mappings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:26:18 -04:00
justin 3340747600 Add basis_movement/basis_detail tools; make history + latest fully filterable
New MCP tools:
- basis_movement: aggregated basis trend, one headline line per crop. The cheap
  "how is basis moving overall" view; optional commodity/source/delivery/days.
- basis_detail: per-(elevator, crop, delivery) basis first→last drill-down.

Both do the aggregation MCP-side and return compact markdown to keep token
burn low, so a client can call the cheap aggregate first and drill in only when
needed.

Flexibility/parity changes:
- price_history: commodity is now optional (spans all crops); groups by
  (source, commodity, delivery); surfaces basis first→last in the summary and
  adds a futures column to the raw table.
- latest_prices: expose the `kind` filter (grain/fertilizer) that the API and
  client already supported.
- client.history(): commodity optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:25:39 -04:00
10 changed files with 1617 additions and 64 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 }}"
+266
View File
@@ -0,0 +1,266 @@
# Changelog
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
- **`futures_quote(commodity, delivery?)`** — CBOT futures price and change for a
grain. Reports the latest price, today's **session open**, the prior day's
close, and **both** moves: **change since open** and **change on the day**
(vs the previous settle). With `delivery` (e.g. `"Jul 2026"`) it resolves the
listed contract that month prices against (e.g. `ZCN26`); without it, the
continuous nearby.
### API changes (`ag-monitor`)
- **`GET /api/data/futures?commodity=<grain>&delivery=<label?>`** — new endpoint.
Returns `{ commodity, delivery, contract, symbol, quote }` where `quote` is
`{ settle_date, open_cents, last_cents, prev_close_cents,
change_since_open_cents, change_on_day_cents, fetched_at }` (or `null` if no
data yet for that contract). `commodity` must be `corn`/`soy`/`wheat`.
- **`futures_quotes` table** gained an `open_cents` column; the futures scraper
now stores the session **Open** alongside the close. Rows captured before this
change have `open_cents = NULL`, so `change_since_open_cents` is `null` for
those until the next session is scraped.
- Futures now also pull at **:30 during the CBOT day session** (on top of the
hourly :00), so `last` and the changes track the session every ~30 min.
### Example questions → tool calls
| Ask | Call |
|---|---|
| Corn Jul futures and how far it's moved today | `futures_quote(commodity="corn", delivery="Jul 2026")` |
| Soy nearby futures, change on the day | `futures_quote(commodity="soy")` |
## 2026-05-29 — Flexible history + basis movement
### New MCP tools
- **`basis_movement(commodity?, source?, delivery?, days=30)`** — aggregated
basis trend, **one headline line per crop**. Averages every matching
(elevator × delivery) series to `avg basis first → last` over the window and
reports how far it moved. This is the cheap "how is basis moving overall"
view. All filters optional; `commodity` (if given) must be `corn`/`soy`/`wheat`.
- Positive move = **cash strengthened** vs futures (basis up); negative = weaker.
- Aggregation is intentional: divergent elevators can net to "flat". When the
aggregate looks flat or surprising, call `basis_detail` to see per-elevator
splits.
- **`basis_detail(commodity?, source?, delivery?, days=30)`** — the drill-down
for `basis_movement`. **One row per (elevator, crop, delivery)** series with
basis `first → last` and the move. Same optional filters.
Both tools do the aggregation **server-side (in the MCP)** and return compact
markdown — they do not dump every raw sample — to keep token usage low. Pattern:
call `basis_movement` first, then `basis_detail` (optionally filtered) only when
you need the breakdown.
### Changed MCP tools
- **`price_history`** — `commodity` is now **optional** (was required). Omit it
to span every crop. Output now groups by **(elevator, crop, delivery)**,
surfaces **basis first → last** in each series summary, and adds a **Futures**
column to the raw points table. Every filter (`commodity`, `source`,
`delivery`, `days`) is optional and AND'd — pivot per elevator, per crop, per
delivery, or any combination.
- Raw per-row points are still only included when the window has ≤ ~60 samples;
wider queries return the per-series summaries only.
- **`latest_prices`** — added the **`kind`** filter (`grain` | `fertilizer`) for
parity with the API. `commodity`, `source`, `delivery`, `kind` all optional.
### API changes (`ag-monitor`)
- **`GET /api/data/history`** — `commodity` query param is now **optional**.
When omitted, returns history across all crops. `source_id`, `delivery`, and
`days` (1730, default 30) remain optional. Response shape is unchanged:
`{ "commodity": <str|null>, "days": <int>, "rows": [...] }`. Each row carries
`fetched_at, source_id, source_name, commodity, delivery, bid_cents,
basis_cents, futures_cents` so callers can pivot client-side.
- `GET /api/data/latest` already supported optional `commodity`, `source`,
`delivery`, `kind` — unchanged.
### Conventions / gotchas
- All money fields are **integer cents**. `bid_cents`/`futures_cents` render as
`$/bu` (4 dp) for grain and `$/ton` (2 dp) for fertilizer; `basis_cents`
renders as a signed dollar value (2 dp), e.g. `-0.14`.
- Basis is only meaningful for grain — the basis tools skip non-grain rows and
any series with no basis on file.
- `source` filtering is by **exact** elevator display name (e.g.
`"Mercer Landmark — Minster"`). Use `list_sources` to get exact names.
### Example questions → tool calls
| Ask | Call |
|---|---|
| Basis movement overall, last 7 days | `basis_movement(days=7)` |
| Corn basis movement across all elevators | `basis_movement(commodity="corn")` |
| Basis movement at one elevator | `basis_movement(source="Mercer Landmark — Minster")` |
| Per-elevator basis breakdown for corn | `basis_detail(commodity="corn")` |
| Last 7 days of history from elevator X for corn | `price_history(commodity="corn", source="…", days=7)` |
| All-crop price history at one elevator | `price_history(source="…")` |
| Latest grain bids only | `latest_prices(kind="grain")` |
+7
View File
@@ -1,5 +1,12 @@
FROM python:3.12-slim
# Links this container package to its Gitea repo (Gitea reads
# org.opencontainers.image.source to attach the package to the repo's
# Packages tab). Without it the image shows up unlinked under the user.
LABEL org.opencontainers.image.source="https://git.jpaul.io/justin/ag-bids-mcp" \
org.opencontainers.image.title="ag-bids-mcp" \
org.opencontainers.image.description="MCP server exposing ag-monitor commodity price data"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
+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).
+40 -5
View File
@@ -54,20 +54,55 @@ def _get(path: str, **params: Any) -> dict | list:
def latest(commodity: str | None = None, source: str | None = None,
delivery: str | None = None, kind: str | None = None) -> dict:
delivery: str | None = None, kind: str | None = None,
zip: str | None = None, lat: float | None = None,
lng: float | None = None, radius_miles: float | None = None) -> dict:
return _get("/api/data/latest",
commodity=commodity, source=source, delivery=delivery, kind=kind)
commodity=commodity, source=source, delivery=delivery, kind=kind,
zip=zip, lat=lat, lng=lng, radius_miles=radius_miles)
def history(commodity: str, source_id: int | None = None,
def history(commodity: str | None = None, source_id: int | None = None,
delivery: str | None = None, days: int = 30) -> dict:
return _get("/api/data/history",
commodity=commodity, source_id=source_id,
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:
return _get("/api/data/futures", commodity=commodity, delivery=delivery)
def inputs(product: str | None = None) -> dict:
+382 -34
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 ""
@@ -35,24 +41,262 @@ def _delta_arrow(cents: Optional[int]) -> str:
return "" if cents > 0 else ""
GRAIN = ("corn", "soy", "wheat")
def _basis_move(delta_cents: Optional[int]) -> str:
"""Describe a basis change: positive = cash strengthened vs futures."""
if delta_cents is None:
return ""
if delta_cents == 0:
return "→ flat"
arrow = "" if delta_cents > 0 else ""
word = "stronger" if delta_cents > 0 else "weaker"
return f"{arrow} {_basis(delta_cents)} ({word})"
def _build_series(rows: list[dict], default_commodity: Optional[str] = None) -> dict:
"""Group history rows into ordered per-(source, commodity, delivery) lists.
Rows arrive ordered by fetched_at ASC, so each series list stays in time
order. `default_commodity` backfills rows that don't carry their own (older
payload shape / single-commodity queries)."""
series: dict[tuple, list[dict]] = {}
for r in rows:
com = r.get("commodity") or default_commodity or "?"
series.setdefault((r.get("source_name"), com, r.get("delivery")), []).append(r)
return series
def _first_last(points: list[dict], field: str):
"""First and last non-null values of `field` across an ordered point list."""
vals = [p.get(field) for p in points if p.get(field) is not None]
if not vals:
return None, None
return vals[0], vals[-1]
# ---------- 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 ----------
def fmt_futures(payload: dict) -> str:
commodity = payload.get("commodity")
delivery = payload.get("delivery")
contract = payload.get("contract")
q = payload.get("quote")
scope = f"{commodity} {contract}" + (f" ({delivery})" if delivery else " (continuous nearby)")
if not q:
return f"### CBOT futures — {scope}\n\nNo futures quote on file yet for this contract.\n"
last = q.get("last_cents")
open_c = q.get("open_cents")
prev = q.get("prev_close_cents")
d_open = q.get("change_since_open_cents")
d_day = q.get("change_on_day_cents")
lines = [f"### CBOT futures — {scope}", ""]
lines.append(f"- **Last**: {_bu(last)} _(settle/last for {q.get('settle_date') or '?'})_")
if open_c is not None:
lines.append(f"- **Open**: {_bu(open_c)}")
if prev is not None:
lines.append(f"- **Prev close**: {_bu(prev)}")
if d_open is not None:
lines.append(f"- **Change since open**: {_delta_arrow(d_open)} {_basis(d_open)}")
else:
lines.append("- **Change since open**: — (no open captured yet)")
if d_day is not None:
lines.append(f"- **Change on day**: {_delta_arrow(d_day)} {_basis(d_day)}")
else:
lines.append("- **Change on day**: — (no prior settle yet)")
return "\n".join(lines) + "\n"
# ---------- inputs / fertilizer ----------
@@ -83,20 +327,31 @@ def fmt_inputs(payload: dict) -> str:
def fmt_latest(payload: dict) -> str:
rows = payload.get("rows") or []
center = payload.get("center")
radius = payload.get("radius_miles")
if not rows:
return "### Latest prices\n\nNo rows match those filters.\n"
head = "### Latest prices\n\n"
if center:
return head + f"No sources within {radius:.0f} mi of {_where(center)}.\n"
return head + "No rows match those filters.\n"
title = "### Latest prices"
if center:
title += f" — within {radius:.0f} mi of {_where(center)}"
dist_col = " Distance |" if center else ""
dist_sep = " ---: |" if center else ""
lines = [
"### Latest prices", "",
"| Source | Commodity | Delivery | Bid | Basis | Futures | Fetched |",
"|---|---|---|---:|---:|---|---|",
title, "",
f"| Source | Commodity | Delivery | Bid | Basis | Futures |{dist_col} Fetched |",
f"|---|---|---|---:|---:|---|{dist_sep}---|",
]
for r in rows:
unit_fmt = _ton if r.get("commodity_kind") == "fertilizer" else _bu
dist_cell = f" {_mi(r.get('distance_miles'))} |" if center else ""
lines.append(
f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | "
f"{r['delivery']} | {unit_fmt(r.get('bid_cents'))} | "
f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or ''} | "
f"{r.get('fetched_at') or '?'} |"
f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or ''} |"
f"{dist_cell} {r.get('fetched_at') or '?'} |"
)
return "\n".join(lines) + "\n"
@@ -108,57 +363,150 @@ def fmt_history(payload: dict, max_rows: int = 60) -> str:
rows = payload.get("rows") or []
commodity = payload.get("commodity")
days = payload.get("days")
scope = commodity or "all crops"
if not rows:
return f"### {commodity} history ({days}d)\n\nNo samples in the window.\n"
return f"### Price history — {scope} ({days}d)\n\nNo samples in the window.\n"
# Per (source, delivery) trend annotation: first vs last sample.
series: dict[tuple, list[dict]] = {}
for r in rows:
series.setdefault((r["source_name"], r["delivery"]), []).append(r)
# Per (source, commodity, delivery) trend annotation: first vs last sample.
series = _build_series(rows, default_commodity=commodity)
lines = [f"### {commodity} price history — last {days} days", ""]
for (src, dlv), pts in sorted(series.items()):
def _com(r: dict) -> str:
return r.get("commodity") or commodity or "?"
lines = [f"### Price history — {scope} — last {days} days", ""]
for (src, com, dlv), pts in sorted(series.items()):
if not pts:
continue
first = pts[0].get("bid_cents")
last = pts[-1].get("bid_cents")
delta = (last - first) if (first is not None and last is not None) else None
b_first, b_last = _first_last(pts, "bid_cents")
delta = (b_last - b_first) if (b_first is not None and b_last is not None) else None
arrow = _delta_arrow(delta)
bz_first, bz_last = _first_last(pts, "basis_cents")
basis_part = ""
if bz_first is not None:
basis_part = (f" · basis {_basis(bz_first)}{_basis(bz_last)} "
f"{_basis_move(bz_last - bz_first)}")
lines.append(
f"- **{src}** / {dlv}: {len(pts)} samples · "
f"{_bu(first)}{_bu(last)} {arrow} {_basis(delta) if delta is not None else ''}".rstrip()
f"- **{src}** / {com} / {dlv}: {len(pts)} samples · "
f"{_bu(b_first)}{_bu(b_last)} {arrow} "
f"{_basis(delta) if delta is not None else ''}{basis_part}".rstrip()
)
# If the history is shallow include the raw rows too (helpful for charts).
if sum(len(p) for p in series.values()) <= max_rows:
lines.extend([
"",
"| Time | Source | Delivery | Bid | Basis |",
"|---|---|---|---:|---:|",
"| Time | Source | Commodity | Delivery | Bid | Basis | Futures |",
"|---|---|---|---|---:|---:|---:|",
])
for r in rows[-max_rows:]:
lines.append(
f"| {r['fetched_at']} | {r['source_name']} | {r['delivery']} | "
f"{_bu(r.get('bid_cents'))} | {_basis(r.get('basis_cents'))} |"
f"| {r['fetched_at']} | {r['source_name']} | {_com(r)} | {r['delivery']} | "
f"{_bu(r.get('bid_cents'))} | {_basis(r.get('basis_cents'))} | "
f"{_bu(r.get('futures_cents'))} |"
)
return "\n".join(lines) + "\n"
# ---------- basis movement ----------
def fmt_basis_movement(payload: dict) -> str:
"""Aggregated basis trend per commodity (the cheap, headline view).
Rolls every matching (source, delivery) series up to one line per crop:
average basis first→last across the window and how far it moved. Skips
non-grain rows and series with no basis on file."""
rows = [r for r in (payload.get("rows") or []) if r.get("commodity") in GRAIN]
days = payload.get("days")
if not rows:
return f"### Basis movement — last {days} days\n\nNo grain basis samples in the window.\n"
series = _build_series(rows)
agg: dict[str, dict] = {}
for (src, com, _dlv), pts in series.items():
first, last = _first_last(pts, "basis_cents")
if first is None:
continue
a = agg.setdefault(com, {"firsts": [], "lasts": [], "elevators": set(), "series": 0})
a["firsts"].append(first)
a["lasts"].append(last)
a["elevators"].add(src)
a["series"] += 1
if not agg:
return f"### Basis movement — last {days} days\n\nNo basis data on the matching series.\n"
lines = [f"### Basis movement — last {days} days", ""]
for com in sorted(agg):
a = agg[com]
avg_first = round(sum(a["firsts"]) / len(a["firsts"]))
avg_last = round(sum(a["lasts"]) / len(a["lasts"]))
lines.append(
f"- **{com}**: avg basis {_basis(avg_first)}{_basis(avg_last)} "
f"{_basis_move(avg_last - avg_first)} · "
f"{len(a['elevators'])} elevators, {a['series']} series"
)
return "\n".join(lines) + "\n"
def fmt_basis_detail(payload: dict, max_rows: int = 80) -> str:
"""Per-(elevator, crop, delivery) basis trend — the drill-down view.
One row per series: basis first→last and how far it moved. Done MCP-side so
the caller gets a compact table instead of every raw sample."""
rows = [r for r in (payload.get("rows") or []) if r.get("commodity") in GRAIN]
days = payload.get("days")
if not rows:
return f"### Basis movement by elevator — last {days} days\n\nNo grain basis samples in the window.\n"
series = _build_series(rows)
body: list[str] = []
# Sort by commodity, then elevator, then delivery for stable readable output.
for (src, com, dlv), pts in sorted(series.items(), key=lambda kv: (kv[0][1], kv[0][0], kv[0][2])):
first, last = _first_last(pts, "basis_cents")
if first is None:
continue
body.append(
f"| {com} | {src} | {dlv} | {_basis(first)} | {_basis(last)} | "
f"{_basis_move(last - first)} | {len(pts)} |"
)
if len(body) >= max_rows:
break
if not body:
return f"### Basis movement by elevator — last {days} days\n\nNo basis data on the matching series.\n"
header = [
f"### Basis movement by elevator — last {days} days", "",
"| Commodity | Elevator | Delivery | First | Last | Move | Samples |",
"|---|---|---|---:|---:|---|---:|",
]
return "\n".join(header + body) + "\n"
# ---------- 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"
+309 -17
View File
@@ -48,22 +48,78 @@ 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)
@mcp.tool()
def futures_quote(
commodity: Annotated[
str, Field(description="Grain: 'corn', 'soy' (soybeans), or 'wheat'.")
],
delivery: Annotated[
str | None,
Field(description="Delivery month (e.g. 'Jul 2026') to resolve the listed "
"contract. Omit for the continuous nearby."),
] = None,
) -> str:
"""CBOT futures price and change for a commodity (optionally one delivery month).
Reports the latest price, today's session open, the prior day's close, and
both moves: change since the open and change on the day (vs the previous
settle). With `delivery` it resolves the listed contract that month prices
against; without it, the continuous nearby."""
cm = commodity.strip().lower()
with track("futures_quote", commodity=cm, delivery=delivery):
if cm not in VALID_GRAIN:
return f"`commodity` must be one of: {sorted(VALID_GRAIN)}"
payload = client.futures(commodity=cm, delivery=delivery)
return fmt.fmt_futures(payload)
@mcp.tool()
def current_lime_price() -> str:
"""Latest lime prices on file across all sources. Lime is rarely posted on
@@ -103,41 +159,277 @@ def latest_prices(
str | None,
Field(description="Filter to one delivery label (e.g. 'May 2026', 'Oct/Nov 2026')."),
] = None,
kind: Annotated[
str | None,
Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."),
] = None,
zip: Annotated[
str | None,
Field(description="Your 5-digit ZIP — keep only nearby sources, nearest first."),
] = None,
lat: Annotated[
float | None, Field(description="Your latitude (use with `lng` instead of a ZIP).")
] = None,
lng: Annotated[float | None, Field(description="Your longitude (use with `lat`).")] = None,
radius_miles: Annotated[
float | None,
Field(description="Search radius in miles around the location (default 50). "
"Ignored unless a ZIP or lat/lng is given."),
] = None,
) -> str:
"""Snapshot of the latest scraped bid per (source, commodity, delivery)."""
"""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. Pass a `zip` (or `lat`+`lng`) with
optional `radius_miles` to keep only elevators near the farmer, sorted
nearest-first with the distance shown."""
cm = commodity.strip().lower() if commodity else None
with track("latest_prices", commodity=cm, source=source, delivery=delivery):
payload = client.latest(commodity=cm, source=source, delivery=delivery)
with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind,
zip=zip or "", radius=radius_miles or 0):
err = _location_error(zip, lat, lng)
if err:
return err
payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind,
zip=zip, lat=lat, lng=lng, radius_miles=radius_miles)
return fmt.fmt_latest(payload)
def _filter_source(payload: dict, source: str | None) -> dict:
"""Narrow a history payload's rows to one source display name, in place."""
if source:
payload["rows"] = [
r for r in payload.get("rows") or [] if r.get("source_name") == source
]
return payload
@mcp.tool()
def price_history(
commodity: Annotated[
str, Field(description="One of corn / soy / wheat / map / potash / lime.")
],
str | None,
Field(description="Filter to one crop (corn / soy / wheat / map / potash / "
"lime). Omit to span every crop."),
] = None,
source: Annotated[
str | None,
Field(description="Optional source display name to narrow the chart."),
Field(description="Filter to one elevator by exact display name. Omit for all."),
] = None,
delivery: Annotated[
str | None,
Field(description="Optional delivery label to narrow the chart."),
Field(description="Filter to one delivery label (e.g. 'Jul 2026'). Omit for all."),
] = None,
days: Annotated[
int, Field(ge=1, le=365, description="Lookback window in days.")
] = 30,
) -> str:
"""Compact price history per (source, delivery) for the chosen commodity.
"""Price history per (elevator, crop, delivery), with every filter optional.
Returns per-series ▲/▼ trend annotations plus the raw points if the
window has fewer than ~60 samples."""
cm = commodity.strip().lower()
Pivot it however you like — one crop at one elevator, every elevator for one
crop, one delivery period across all crops, etc. Each series gets a ▲/▼ bid
trend plus its basis first→last; the raw bid/basis/futures points are
included when the window has fewer than ~60 samples."""
cm = commodity.strip().lower() if commodity else None
with track("price_history", commodity=cm, source=source, delivery=delivery, days=days):
payload = client.history(commodity=cm, delivery=delivery, days=days)
if source:
payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source]
return fmt.fmt_history(payload)
return fmt.fmt_history(_filter_source(payload, source))
@mcp.tool()
def basis_movement(
commodity: Annotated[
str | None,
Field(description="Filter to one grain (corn / soy / wheat). Omit to span all grains."),
] = None,
source: Annotated[
str | None,
Field(description="Filter to one elevator by exact display name. Omit for all."),
] = None,
delivery: Annotated[
str | None,
Field(description="Filter to one delivery label (e.g. 'Jul 2026'). Omit for all."),
] = None,
days: Annotated[
int, Field(ge=1, le=365, description="Lookback window in days.")
] = 30,
) -> str:
"""Aggregated basis trend — one headline line per crop (the cheap overview).
Rolls every matching elevator/delivery series up to a single average basis
first→last per crop, with how far it moved (positive = cash strengthened vs
futures). Use this for 'how is basis moving overall', optionally narrowed to
a crop and/or elevator. For the per-elevator breakdown use basis_detail."""
cm = commodity.strip().lower() if commodity else None
with track("basis_movement", commodity=cm, source=source, delivery=delivery, days=days):
if cm is not None and cm not in VALID_GRAIN:
return f"`commodity` must be one of {sorted(VALID_GRAIN)} (or omit for all grains)"
payload = client.history(commodity=cm, delivery=delivery, days=days)
return fmt.fmt_basis_movement(_filter_source(payload, source))
@mcp.tool()
def basis_detail(
commodity: Annotated[
str | None,
Field(description="Filter to one grain (corn / soy / wheat). Omit to span all grains."),
] = None,
source: Annotated[
str | None,
Field(description="Filter to one elevator by exact display name. Omit for all."),
] = None,
delivery: Annotated[
str | None,
Field(description="Filter to one delivery label (e.g. 'Jul 2026'). Omit for all."),
] = None,
days: Annotated[
int, Field(ge=1, le=365, description="Lookback window in days.")
] = 30,
) -> str:
"""Per-(elevator, crop, delivery) basis trend — the drill-down for basis_movement.
One compact row per series: basis first→last over the window and how far it
moved. All filters optional, so you can scope to one crop, one elevator, one
delivery, or any combination."""
cm = commodity.strip().lower() if commodity else None
with track("basis_detail", commodity=cm, source=source, delivery=delivery, days=days):
if cm is not None and cm not in VALID_GRAIN:
return f"`commodity` must be one of {sorted(VALID_GRAIN)} (or omit for all grains)"
payload = client.history(commodity=cm, delivery=delivery, days=days)
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()
+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.
+42
View File
@@ -71,6 +71,48 @@ def test_get_drops_none_params(monkeypatch):
assert captured["params"] == {"commodity": "corn"}
def test_futures_endpoint_params(monkeypatch):
client = _reload_client(monkeypatch)
captured = {}
class FakeResp:
status_code = 200
text = ""
def json(self): return {"quote": None}
def fake_get(url, params=None, timeout=None, headers=None):
captured["url"] = url
captured["params"] = dict(params or {})
return FakeResp()
monkeypatch.setattr(client.httpx, "get", fake_get)
client.futures(commodity="corn", delivery="Jul 2026")
assert captured["url"].endswith("/api/data/futures")
assert captured["params"] == {"commodity": "corn", "delivery": "Jul 2026"}
# delivery omitted → dropped
client.futures(commodity="corn")
assert captured["params"] == {"commodity": "corn"}
def test_history_without_commodity_drops_param(monkeypatch):
client = _reload_client(monkeypatch)
captured = {}
class FakeResp:
status_code = 200
text = ""
def json(self): return {"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)
client.history(days=14)
# commodity/source_id/delivery all None → only days survives
assert captured["params"] == {"days": 14}
def test_get_raises_on_non_200(monkeypatch):
client = _reload_client(monkeypatch)
+270
View File
@@ -35,6 +35,171 @@ 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",
"symbol": "ZC",
"quote": {
"settle_date": "2026-05-29", "open_cents": 461, "last_cents": 455,
"prev_close_cents": 460, "change_since_open_cents": -6,
"change_on_day_cents": -5, "fetched_at": "2026-05-29T18:00:00+00:00",
},
}
out = fmt.fmt_futures(payload)
assert "ZCN26" in out and "Jul 2026" in out
assert "$4.5500" in out # last
assert "$4.6100" in out # open
assert "▼ -0.06" in out # change since open
assert "▼ -0.05" in out # change on day
def test_fmt_futures_no_open_yet():
payload = {
"commodity": "soy", "delivery": None, "contract": "continuous",
"symbol": "ZS=F",
"quote": {
"settle_date": "2026-05-29", "open_cents": None, "last_cents": 1180,
"prev_close_cents": 1177, "change_since_open_cents": None,
"change_on_day_cents": 3, "fetched_at": "2026-05-29T18:00:00+00:00",
},
}
out = fmt.fmt_futures(payload)
assert "continuous nearby" in out
assert "no open captured yet" in out
assert "▲ +0.03" in out # change on day still shown
def test_fmt_futures_no_quote():
out = fmt.fmt_futures({"commodity": "wheat", "delivery": None,
"contract": "continuous", "quote": None})
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,
@@ -93,17 +258,81 @@ def test_fmt_history_with_trend_arrow():
assert "" in out
assert "$4.8000" in out
assert "$4.9600" in out
# Basis trend is now surfaced in the summary line too.
assert "basis +0.25 → +0.30" in out
def _history_multi():
return {
"commodity": None, "days": 7,
"rows": [
{"source_name": "Minster", "commodity": "corn", "delivery": "Jul 2026",
"bid_cents": 461, "basis_cents": 11, "futures_cents": 450,
"fetched_at": "2026-05-23T15:00:00+00:00"},
{"source_name": "Minster", "commodity": "corn", "delivery": "Jul 2026",
"bid_cents": 464, "basis_cents": 14, "futures_cents": 450,
"fetched_at": "2026-05-29T15:00:00+00:00"},
{"source_name": "Minster", "commodity": "soy", "delivery": "Nov 2026",
"bid_cents": 1145, "basis_cents": -50, "futures_cents": 1195,
"fetched_at": "2026-05-23T15:00:00+00:00"},
{"source_name": "Minster", "commodity": "soy", "delivery": "Nov 2026",
"bid_cents": 1148, "basis_cents": -45, "futures_cents": 1193,
"fetched_at": "2026-05-29T15:00:00+00:00"},
],
}
def test_fmt_history_spans_crops_and_shows_basis_futures():
out = fmt.fmt_history(_history_multi())
# No commodity filter → "all crops" scope, both crops present.
assert "all crops" in out
assert "corn" in out and "soy" in out
# Futures column populated from futures_cents.
assert "$4.5000" in out
# Basis movement annotated per series.
assert "+0.11 → +0.14" in out
assert "-0.50 → -0.45" in out
def test_fmt_basis_movement_aggregates_per_crop():
out = fmt.fmt_basis_movement(_history_multi())
assert "Basis movement" in out
# corn basis 0.11 → 0.14 (stronger), soy -0.50 → -0.45 (stronger)
assert "**corn**" in out and "**soy**" in out
assert "stronger" in out
assert "1 elevators, 1 series" in out
def test_fmt_basis_movement_skips_non_grain_and_nulls():
payload = {"commodity": None, "days": 7, "rows": [
{"source_name": "Coop", "commodity": "lime", "delivery": "spot",
"bid_cents": 42000, "basis_cents": None, "fetched_at": "2026-05-23T15:00:00+00:00"},
]}
out = fmt.fmt_basis_movement(payload)
assert "No grain basis samples" in out
def test_fmt_basis_detail_per_series():
out = fmt.fmt_basis_detail(_history_multi())
assert "Basis movement by elevator" in out
# One row per (crop, elevator, delivery)
assert "| corn | Minster | Jul 2026 |" in out
assert "| soy | Minster | Nov 2026 |" in out
assert "weaker" not in out # both strengthened in this fixture
assert "stronger" in out
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():
@@ -161,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": []})