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
|
Notes for clients/agents that consume the ag-bids MCP tools and the underlying
|
||||||
`ag-monitor` `/api/data/*` HTTP API. Newest first.
|
`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
|
## 2026-05-29 — Flexible history + basis movement
|
||||||
|
|
||||||
### New MCP tools
|
### New MCP tools
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ def best(commodity: str) -> dict:
|
|||||||
return _get("/api/data/best", commodity=commodity)
|
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:
|
def inputs(product: str | None = None) -> dict:
|
||||||
return _get("/api/data/inputs", product=product)
|
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 ----------
|
# ---------- inputs / fertilizer ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,31 @@ def best_local_bid(
|
|||||||
return fmt.fmt_best(commodity, payload)
|
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()
|
@mcp.tool()
|
||||||
def current_lime_price() -> str:
|
def current_lime_price() -> str:
|
||||||
"""Latest lime prices on file across all sources. Lime is rarely posted on
|
"""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"}
|
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):
|
def test_history_without_commodity_drops_param(monkeypatch):
|
||||||
client = _reload_client(monkeypatch)
|
client = _reload_client(monkeypatch)
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|||||||
@@ -35,6 +35,46 @@ def test_fmt_best_no_winner():
|
|||||||
assert "wheat" in out
|
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():
|
def test_fmt_inputs_lime_table():
|
||||||
payload = {
|
payload = {
|
||||||
"product": "lime", "count": 2,
|
"product": "lime", "count": 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user