Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65509a27d6 | |||
| fb50c103d3 | |||
| ae15aa5c3c | |||
| 03c6c540ef | |||
| a0e36996c4 | |||
| 457cdad2fb | |||
| 47cac9b521 | |||
| 7b6661e3d9 | |||
| ffc705f485 | |||
| a3b77414d8 | |||
| 0d7e61f65f | |||
| dfbc77160e | |||
| 2e67a0fce2 | |||
| d0fada5985 | |||
| 0c9bc3b328 | |||
| 61c1736539 | |||
| 3340747600 |
@@ -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
@@ -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` (1–730, 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")` |
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Ag Advisor — Example Prompts (Grain Marketing)
|
||||
|
||||
Example end-user prompts for the **Drawbar Ag Advisor**, the AI agent that
|
||||
consumes the `ag-bids` MCP tools (and the underlying `ag-monitor` `/api/data/*`
|
||||
HTTP API). They're grouped into **Planning** (build a marketing strategy) and
|
||||
**Monitoring** (track prices and act day-to-day), with the tools each prompt
|
||||
leans on so the advisor knows which calls answer the question.
|
||||
|
||||
Copy this into the Drawbar repo (e.g. `prompts/grain-marketing.md`) as a prompt
|
||||
library / eval set. Phrasing is intentionally farmer-natural — the advisor is
|
||||
expected to translate it into tool calls.
|
||||
|
||||
## What the advisor can see
|
||||
|
||||
| Capability | Tool(s) |
|
||||
|---|---|
|
||||
| Live local cash bids (per elevator, crop, delivery) | `latest_prices`, `best_local_bid` |
|
||||
| Cash price history + basis trend per elevator | `price_history` |
|
||||
| Basis movement (overview, then drill-down) | `basis_movement` → `basis_detail` |
|
||||
| CBOT futures price + change (since open, on day) + carry | `futures_quote`, `list_deliveries` |
|
||||
| National price *received by farmers* ($/bu) + MoM/YoY + seasonal | `price_trend`, `price_series` |
|
||||
| Input costs — diesel ($/gal) + fertilizer ($/ton) + change + seasonal | `input_cost_trend`, `input_cost_series` |
|
||||
| Local fertilizer/lime snapshot (DTN dealer feed) | `current_input_price`, `current_lime_price` |
|
||||
| What's covered / is the data fresh | `list_sources`, `list_commodities`, `list_deliveries`, `source_health` |
|
||||
| One-call morning brief | `todays_summary` |
|
||||
|
||||
Conventions the advisor should keep in mind:
|
||||
- **Basis is the lever.** Positive basis move = cash strengthened vs futures;
|
||||
negative = weakened. `basis_movement` aggregates and can net divergent
|
||||
elevators to "flat" — follow up with `basis_detail` when it looks surprising.
|
||||
- **Cash = futures + basis.** A sell decision is really two decisions (futures
|
||||
level *and* basis), and the carry (`list_deliveries` + `futures_quote` across
|
||||
months) tells you whether the market pays you to store.
|
||||
- `price_trend`/`input_cost_trend` are the **macro/seasonal benchmark** — "is
|
||||
today cheap or dear vs history" — not a local bid. Pair them with
|
||||
`best_local_bid`/`latest_prices` for the actionable number.
|
||||
- Grains: `corn`, `soy`, `wheat`. `geo` for `price_trend` is `US` or a 2-letter
|
||||
state (e.g. `OH`, `IA`). Fertilizer `geo` is an AgTransport region (default
|
||||
`Cornbelt`).
|
||||
|
||||
---
|
||||
|
||||
## Planning — build a marketing strategy
|
||||
|
||||
**1. Build a pre-harvest plan**
|
||||
> "I grow corn and soybeans in west-central Ohio. Help me build a pre-harvest
|
||||
> marketing plan for this year's corn crop."
|
||||
|
||||
Advisor pulls: `price_trend("corn", geo="OH", years=10)` (where price sits vs
|
||||
norm) · `futures_quote("corn", delivery="Dec 2026")` (new-crop level) ·
|
||||
`basis_movement("corn")` (is basis a reason to wait or sell) ·
|
||||
`best_local_bid("corn")` (today's actionable cash).
|
||||
|
||||
**2. Sell now or store?**
|
||||
> "I've got 20,000 bushels of soybeans in the bin. Should I sell now or hold into
|
||||
> winter?"
|
||||
|
||||
`price_trend("soy", geo="OH")` (seasonal percentile — is now historically high or
|
||||
low) · `list_deliveries("soy")` + `futures_quote("soy", delivery=…)` across months
|
||||
(does the carry pay me to store) · `basis_movement("soy")` (is basis likely to
|
||||
improve) · `best_local_bid("soy")`.
|
||||
|
||||
**3. Does the market pay me to store? (carry)**
|
||||
> "Does the futures carry justify storing corn until March instead of selling at
|
||||
> harvest?"
|
||||
|
||||
`list_deliveries("corn")` then `futures_quote("corn", delivery=…)` for the nearby
|
||||
vs deferred months → compare the spread against storage cost.
|
||||
|
||||
**4. Time a new-crop sale**
|
||||
> "Is now a good time to make a new-crop corn sale for fall delivery?"
|
||||
|
||||
`price_trend("corn", years=10)` (seasonal context) · `futures_quote("corn",
|
||||
delivery="Dec 2026")` (change on day / since open) · `basis_movement("corn",
|
||||
delivery="Oct 2026")` · `best_local_bid("corn")`.
|
||||
|
||||
**5. Seasonality of basis / price**
|
||||
> "Historically, what time of year is corn basis strongest around here, and when
|
||||
> does the cash price tend to peak?"
|
||||
|
||||
`price_series("corn", geo="OH")` over multiple years (seasonal shape) ·
|
||||
`price_trend("corn", geo="OH")` (this month's percentile vs the same month over
|
||||
the last N years) · `price_history("corn", days=…)` for the recent basis arc.
|
||||
|
||||
**6. Compare which crop to sell**
|
||||
> "Relative to their 10-year norms, is corn or soybeans the better one to be
|
||||
> selling right now?"
|
||||
|
||||
`price_trend("corn", years=10)` vs `price_trend("soy", years=10)` — compare
|
||||
percentile-vs-normal and YoY, then `best_local_bid` for each.
|
||||
|
||||
**7. Breakeven / cost side of the plan**
|
||||
> "How have my input costs moved this year — what does that do to my corn
|
||||
> breakeven?"
|
||||
|
||||
`input_cost_trend("diesel")` · `input_cost_trend("urea")`,
|
||||
`input_cost_trend("anhydrous")`, `input_cost_trend("dap")`,
|
||||
`input_cost_trend("potash")` (all $/ton, Cornbelt) — latest + YoY change feed the
|
||||
cost side; combine with `price_trend("corn")` for the margin picture.
|
||||
|
||||
**8. Prebuy fertilizer decision**
|
||||
> "Should I prebuy urea and anhydrous now or wait until spring?"
|
||||
|
||||
`input_cost_trend("urea")` and `input_cost_trend("anhydrous")` (seasonal
|
||||
percentile + YoY — are they historically cheap) · `input_cost_series(…)` for the
|
||||
trend shape · `current_input_price()` for the latest local dealer quote.
|
||||
|
||||
**9. Set price targets / a scale-up plan**
|
||||
> "Give me three corn price targets to scale out the rest of my old-crop bushels,
|
||||
> based on where price sits historically."
|
||||
|
||||
`price_trend("corn", geo="OH", years=10)` (normal, range, percentile) +
|
||||
`futures_quote("corn")` + `basis_movement("corn")` → advisor proposes laddered
|
||||
targets above the seasonal normal.
|
||||
|
||||
**10. Wheat pre-harvest plan**
|
||||
> "Build me a marketing plan for this year's wheat ahead of harvest."
|
||||
|
||||
`price_trend("wheat", geo="OH")` · `futures_quote("wheat", delivery="Jul 2026")` ·
|
||||
`basis_movement("wheat")` · `best_local_bid("wheat")`.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring — track prices and act
|
||||
|
||||
**11. Morning brief**
|
||||
> "Give me my grain marketing brief for this morning."
|
||||
|
||||
`todays_summary()` (one call) — then drill in where it flags something.
|
||||
|
||||
**12. Best bid right now (location-aware)**
|
||||
> "Where's the best corn bid within 40 miles of 45810?"
|
||||
|
||||
`best_local_bid("corn", zip="45810", radius_miles=40)` — restricts to nearby
|
||||
elevators and shows the distance. From GPS instead:
|
||||
`best_local_bid("corn", lat=40.79, lng=-83.81)`. `latest_prices("corn",
|
||||
zip="45810", radius_miles=40)` lists every nearby elevator nearest-first. Without
|
||||
a location these consider every scraped elevator.
|
||||
|
||||
> Coverage note for the advisor: scraped cash bids are concentrated in **Ohio**
|
||||
> today. If the farmer's zip/GPS is far from Ohio, `best_local_bid` returns "none
|
||||
> in range" plus the nearest elevator's distance — say that plainly (it's a
|
||||
> coverage gap, not a price of zero), and fall back to `price_trend(commodity,
|
||||
> geo=<their state>)` for the national/state benchmark.
|
||||
|
||||
**13. Did futures move today?**
|
||||
> "How far has corn moved today — off the open and on the day?"
|
||||
|
||||
`futures_quote("corn")` → reports change since open and change on the day.
|
||||
|
||||
**14. Basis check**
|
||||
> "How has corn basis moved over the last two weeks, and which elevator improved
|
||||
> the most?"
|
||||
|
||||
`basis_movement("corn", days=14)` for the headline, then `basis_detail("corn",
|
||||
days=14)` for the per-elevator split.
|
||||
|
||||
**15. Best home for a specific delivery**
|
||||
> "Which elevator is paying the most for soybeans for January delivery?"
|
||||
|
||||
`latest_prices("soy", delivery="Jan 2026")` / `best_local_bid("soy")`.
|
||||
|
||||
**16. Is cash near a recent high?**
|
||||
> "Is the local corn cash price near its high for the last month?"
|
||||
|
||||
`price_history("corn", days=30)` (recent arc + basis) · `price_trend("corn",
|
||||
geo="OH")` (seasonal percentile for context).
|
||||
|
||||
**17. Spread between cash and CBOT**
|
||||
> "Has my local basis widened or narrowed this month?"
|
||||
|
||||
`basis_movement("corn", days=30)` → if it nets flat or odd, `basis_detail(…)`.
|
||||
|
||||
**18. All elevators at a glance**
|
||||
> "Show me today's corn bids across all my elevators."
|
||||
|
||||
`latest_prices("corn")` (snapshot table, all sources).
|
||||
|
||||
**19. What can I price?**
|
||||
> "What delivery months can I price corn for right now?"
|
||||
|
||||
`list_deliveries("corn")`.
|
||||
|
||||
**20. Data freshness / trust check**
|
||||
> "Are any of my price feeds stale or failing?"
|
||||
|
||||
`source_health()` (and `list_sources()` for the full roster + last-success
|
||||
times).
|
||||
|
||||
**21. Fertilizer price watch**
|
||||
> "Are fertilizer prices climbing — should I be worried about next year's input
|
||||
> bill?"
|
||||
|
||||
`input_cost_trend("urea")`, `input_cost_trend("dap")`,
|
||||
`input_cost_trend("potash")`, `input_cost_trend("anhydrous")` — latest $/ton +
|
||||
MoM/YoY + seasonal; `input_cost_series(…)` for the trend.
|
||||
|
||||
**22. Diesel watch for fieldwork**
|
||||
> "What's diesel doing — anything I should factor into fall fieldwork costs?"
|
||||
|
||||
`input_cost_trend("diesel")` (U.S. retail $/gal, week-over-week + YoY).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting — how the advisor synthesizes a recommendation
|
||||
|
||||
These show the *reasoning*, combining several tools into one answer.
|
||||
|
||||
**A "should I sell today" call:**
|
||||
> "Should I sell corn today?"
|
||||
|
||||
1. `best_local_bid("corn")` — the actual number on the table.
|
||||
2. `price_trend("corn", geo="OH", years=10)` — is that number historically high
|
||||
or low for this month (percentile vs normal)?
|
||||
3. `futures_quote("corn")` — is the board strong/weak today, and which way is it
|
||||
moving (since open / on day)?
|
||||
4. `basis_movement("corn")` — is basis strengthening (a reason to wait) or
|
||||
weakening (a reason to capture now)?
|
||||
5. Synthesis: "Cash is at the 78th percentile vs the last 10 Mays, board is up
|
||||
6¢ on the day, and basis has firmed 4¢ over two weeks but looks toppy — taking
|
||||
a portion here and leaving the rest for the carry is reasonable."
|
||||
|
||||
**A "store vs sell" call** combines `list_deliveries` + `futures_quote` (carry) +
|
||||
`basis_movement` (expected basis gain) + `input_cost_trend("diesel")` and storage
|
||||
assumptions to weigh the cost of carry against the expected pickup.
|
||||
|
||||
> Guidance for the advisor: always anchor a recommendation to a **live**
|
||||
> number (`best_local_bid`/`latest_prices`/`futures_quote`) and use
|
||||
> `price_trend`/`input_cost_trend` only for *context*. State the basis and
|
||||
> futures pieces separately so the user can act on whichever is actually driving
|
||||
> the call.
|
||||
@@ -71,6 +71,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)
|
||||
|
||||
|
||||
@@ -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": []})
|
||||
|
||||
Reference in New Issue
Block a user