From 0c9bc3b32842366cba738ed2393371c1e5e0cf38 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 29 May 2026 15:36:56 -0400 Subject: [PATCH] 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) --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ ag_bids_mcp/client.py | 4 ++++ ag_bids_mcp/format.py | 35 +++++++++++++++++++++++++++++++++++ ag_bids_mcp/server.py | 25 +++++++++++++++++++++++++ tests/test_client.py | 23 +++++++++++++++++++++++ tests/test_format.py | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ba1a7..f724d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=&delivery=`** — 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 diff --git a/ag_bids_mcp/client.py b/ag_bids_mcp/client.py index 6e56275..e446cf7 100644 --- a/ag_bids_mcp/client.py +++ b/ag_bids_mcp/client.py @@ -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) diff --git a/ag_bids_mcp/format.py b/ag_bids_mcp/format.py index 45a9720..f857ab5 100644 --- a/ag_bids_mcp/format.py +++ b/ag_bids_mcp/format.py @@ -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 ---------- diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py index 218b591..c68d05e 100644 --- a/ag_bids_mcp/server.py +++ b/ag_bids_mcp/server.py @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index 56e88f1..3c6a0fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 = {} diff --git a/tests/test_format.py b/tests/test_format.py index 5e69592..7ae45bc 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -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,