Compare commits
4 Commits
e78733d55e
...
d0fada5985
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fada5985 | |||
| 0c9bc3b328 | |||
| 61c1736539 | |||
| 3340747600 |
+106
@@ -0,0 +1,106 @@
|
|||||||
|
# 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-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
|
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 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def latest(commodity: str | None = None, source: str | None = None,
|
|||||||
commodity=commodity, source=source, delivery=delivery, kind=kind)
|
commodity=commodity, source=source, delivery=delivery, kind=kind)
|
||||||
|
|
||||||
|
|
||||||
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:
|
delivery: str | None = None, days: int = 30) -> dict:
|
||||||
return _get("/api/data/history",
|
return _get("/api/data/history",
|
||||||
commodity=commodity, source_id=source_id,
|
commodity=commodity, source_id=source_id,
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+171
-16
@@ -35,6 +35,41 @@ def _delta_arrow(cents: Optional[int]) -> str:
|
|||||||
return "▲" if cents > 0 else "▼"
|
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 ----------
|
# ---------- best_local_bid ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -55,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 ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -108,42 +178,127 @@ def fmt_history(payload: dict, max_rows: int = 60) -> str:
|
|||||||
rows = payload.get("rows") or []
|
rows = payload.get("rows") or []
|
||||||
commodity = payload.get("commodity")
|
commodity = payload.get("commodity")
|
||||||
days = payload.get("days")
|
days = payload.get("days")
|
||||||
|
scope = commodity or "all crops"
|
||||||
if not rows:
|
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.
|
# Per (source, commodity, delivery) trend annotation: first vs last sample.
|
||||||
series: dict[tuple, list[dict]] = {}
|
series = _build_series(rows, default_commodity=commodity)
|
||||||
for r in rows:
|
|
||||||
series.setdefault((r["source_name"], r["delivery"]), []).append(r)
|
|
||||||
|
|
||||||
lines = [f"### {commodity} price history — last {days} days", ""]
|
def _com(r: dict) -> str:
|
||||||
for (src, dlv), pts in sorted(series.items()):
|
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:
|
if not pts:
|
||||||
continue
|
continue
|
||||||
first = pts[0].get("bid_cents")
|
b_first, b_last = _first_last(pts, "bid_cents")
|
||||||
last = pts[-1].get("bid_cents")
|
delta = (b_last - b_first) if (b_first is not None and b_last is not None) else None
|
||||||
delta = (last - first) if (first is not None and last is not None) else None
|
|
||||||
arrow = _delta_arrow(delta)
|
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(
|
lines.append(
|
||||||
f"- **{src}** / {dlv}: {len(pts)} samples · "
|
f"- **{src}** / {com} / {dlv}: {len(pts)} samples · "
|
||||||
f"{_bu(first)} → {_bu(last)} {arrow} {_basis(delta) if delta is not None else ''}".rstrip()
|
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 the history is shallow include the raw rows too (helpful for charts).
|
||||||
if sum(len(p) for p in series.values()) <= max_rows:
|
if sum(len(p) for p in series.values()) <= max_rows:
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"",
|
"",
|
||||||
"| Time | Source | Delivery | Bid | Basis |",
|
"| Time | Source | Commodity | Delivery | Bid | Basis | Futures |",
|
||||||
"|---|---|---|---:|---:|",
|
"|---|---|---|---|---:|---:|---:|",
|
||||||
])
|
])
|
||||||
for r in rows[-max_rows:]:
|
for r in rows[-max_rows:]:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| {r['fetched_at']} | {r['source_name']} | {r['delivery']} | "
|
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('bid_cents'))} | {_basis(r.get('basis_cents'))} | "
|
||||||
|
f"{_bu(r.get('futures_cents'))} |"
|
||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n"
|
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 ----------
|
# ---------- sources / health ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+120
-14
@@ -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
|
||||||
@@ -103,41 +128,122 @@ def latest_prices(
|
|||||||
str | None,
|
str | None,
|
||||||
Field(description="Filter to one delivery label (e.g. 'May 2026', 'Oct/Nov 2026')."),
|
Field(description="Filter to one delivery label (e.g. 'May 2026', 'Oct/Nov 2026')."),
|
||||||
] = None,
|
] = None,
|
||||||
|
kind: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."),
|
||||||
|
] = None,
|
||||||
) -> str:
|
) -> 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."""
|
||||||
cm = commodity.strip().lower() if commodity else None
|
cm = commodity.strip().lower() if commodity else None
|
||||||
with track("latest_prices", commodity=cm, source=source, delivery=delivery):
|
with track("latest_prices", commodity=cm, source=source, delivery=delivery, kind=kind):
|
||||||
payload = client.latest(commodity=cm, source=source, delivery=delivery)
|
payload = client.latest(commodity=cm, source=source, delivery=delivery, kind=kind)
|
||||||
return fmt.fmt_latest(payload)
|
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()
|
@mcp.tool()
|
||||||
def price_history(
|
def price_history(
|
||||||
commodity: Annotated[
|
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[
|
source: Annotated[
|
||||||
str | None,
|
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,
|
] = None,
|
||||||
delivery: Annotated[
|
delivery: Annotated[
|
||||||
str | None,
|
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,
|
] = None,
|
||||||
days: Annotated[
|
days: Annotated[
|
||||||
int, Field(ge=1, le=365, description="Lookback window in days.")
|
int, Field(ge=1, le=365, description="Lookback window in days.")
|
||||||
] = 30,
|
] = 30,
|
||||||
) -> str:
|
) -> 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
|
Pivot it however you like — one crop at one elevator, every elevator for one
|
||||||
window has fewer than ~60 samples."""
|
crop, one delivery period across all crops, etc. Each series gets a ▲/▼ bid
|
||||||
cm = commodity.strip().lower()
|
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):
|
with track("price_history", commodity=cm, source=source, delivery=delivery, days=days):
|
||||||
payload = client.history(commodity=cm, delivery=delivery, days=days)
|
payload = client.history(commodity=cm, delivery=delivery, days=days)
|
||||||
if source:
|
return fmt.fmt_history(_filter_source(payload, source))
|
||||||
payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source]
|
|
||||||
return fmt.fmt_history(payload)
|
|
||||||
|
@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()
|
@mcp.tool()
|
||||||
|
|||||||
@@ -71,6 +71,48 @@ 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):
|
||||||
|
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):
|
def test_get_raises_on_non_200(monkeypatch):
|
||||||
client = _reload_client(monkeypatch)
|
client = _reload_client(monkeypatch)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -93,6 +133,68 @@ def test_fmt_history_with_trend_arrow():
|
|||||||
assert "▲" in out
|
assert "▲" in out
|
||||||
assert "$4.8000" in out
|
assert "$4.8000" in out
|
||||||
assert "$4.9600" 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():
|
def test_fmt_sources_table():
|
||||||
|
|||||||
Reference in New Issue
Block a user