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>
This commit is contained in:
2026-05-29 15:36:56 -04:00
parent 61c1736539
commit 0c9bc3b328
6 changed files with 159 additions and 0 deletions
+32
View File
@@ -3,6 +3,38 @@
Notes for clients/agents that consume the ag-bids MCP tools and the underlying
`ag-monitor` `/api/data/*` HTTP API. Newest first.
## 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
+4
View File
@@ -70,6 +70,10 @@ def best(commodity: str) -> dict:
return _get("/api/data/best", commodity=commodity)
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:
return _get("/api/data/inputs", product=product)
+35
View File
@@ -90,6 +90,41 @@ def fmt_best(commodity: str, payload: dict) -> str:
)
# ---------- 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 ----------
+25
View File
@@ -64,6 +64,31 @@ def best_local_bid(
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
+23
View File
@@ -71,6 +71,29 @@ 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 = {}
+40
View File
@@ -35,6 +35,46 @@ def test_fmt_best_no_winner():
assert "wheat" 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_inputs_lime_table():
payload = {
"product": "lime", "count": 2,