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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user