From 33407476000ee67f8946394fbcfe2eb2016ccf8c Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 29 May 2026 15:25:39 -0400 Subject: [PATCH] Add basis_movement/basis_detail tools; make history + latest fully filterable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tools: - basis_movement: aggregated basis trend, one headline line per crop. The cheap "how is basis moving overall" view; optional commodity/source/delivery/days. - basis_detail: per-(elevator, crop, delivery) basis first→last drill-down. Both do the aggregation MCP-side and return compact markdown to keep token burn low, so a client can call the cheap aggregate first and drill in only when needed. Flexibility/parity changes: - price_history: commodity is now optional (spans all crops); groups by (source, commodity, delivery); surfaces basis first→last in the summary and adds a futures column to the raw table. - latest_prices: expose the `kind` filter (grain/fertilizer) that the API and client already supported. - client.history(): commodity optional. Co-Authored-By: Claude Opus 4.8 (1M context) --- ag_bids_mcp/client.py | 2 +- ag_bids_mcp/format.py | 152 +++++++++++++++++++++++++++++++++++++----- ag_bids_mcp/server.py | 109 ++++++++++++++++++++++++++---- tests/test_client.py | 19 ++++++ tests/test_format.py | 62 +++++++++++++++++ 5 files changed, 313 insertions(+), 31 deletions(-) diff --git a/ag_bids_mcp/client.py b/ag_bids_mcp/client.py index 8642343..6e56275 100644 --- a/ag_bids_mcp/client.py +++ b/ag_bids_mcp/client.py @@ -59,7 +59,7 @@ def latest(commodity: str | None = None, source: str | None = None, 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: return _get("/api/data/history", commodity=commodity, source_id=source_id, diff --git a/ag_bids_mcp/format.py b/ag_bids_mcp/format.py index 16128b7..45a9720 100644 --- a/ag_bids_mcp/format.py +++ b/ag_bids_mcp/format.py @@ -35,6 +35,41 @@ def _delta_arrow(cents: Optional[int]) -> str: 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 ---------- @@ -108,42 +143,127 @@ def fmt_history(payload: dict, max_rows: int = 60) -> str: rows = payload.get("rows") or [] commodity = payload.get("commodity") days = payload.get("days") + scope = commodity or "all crops" 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. - series: dict[tuple, list[dict]] = {} - for r in rows: - series.setdefault((r["source_name"], r["delivery"]), []).append(r) + # Per (source, commodity, delivery) trend annotation: first vs last sample. + series = _build_series(rows, default_commodity=commodity) - lines = [f"### {commodity} price history — last {days} days", ""] - for (src, dlv), pts in sorted(series.items()): + def _com(r: dict) -> str: + 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: continue - first = pts[0].get("bid_cents") - last = pts[-1].get("bid_cents") - delta = (last - first) if (first is not None and last is not None) else None + b_first, b_last = _first_last(pts, "bid_cents") + delta = (b_last - b_first) if (b_first is not None and b_last is not None) else None 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( - f"- **{src}** / {dlv}: {len(pts)} samples · " - f"{_bu(first)} → {_bu(last)} {arrow} {_basis(delta) if delta is not None else ''}".rstrip() + f"- **{src}** / {com} / {dlv}: {len(pts)} samples · " + 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 sum(len(p) for p in series.values()) <= max_rows: lines.extend([ "", - "| Time | Source | Delivery | Bid | Basis |", - "|---|---|---|---:|---:|", + "| Time | Source | Commodity | Delivery | Bid | Basis | Futures |", + "|---|---|---|---|---:|---:|---:|", ]) for r in rows[-max_rows:]: lines.append( - f"| {r['fetched_at']} | {r['source_name']} | {r['delivery']} | " - f"{_bu(r.get('bid_cents'))} | {_basis(r.get('basis_cents'))} |" + 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('futures_cents'))} |" ) 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 ---------- diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py index 4f494f2..218b591 100644 --- a/ag_bids_mcp/server.py +++ b/ag_bids_mcp/server.py @@ -103,41 +103,122 @@ def latest_prices( str | None, Field(description="Filter to one delivery label (e.g. 'May 2026', 'Oct/Nov 2026')."), ] = None, + kind: Annotated[ + str | None, + Field(description="Filter to one commodity kind: 'grain' or 'fertilizer'. Omit for all."), + ] = None, ) -> 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 - with track("latest_prices", commodity=cm, source=source, delivery=delivery): - payload = client.latest(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, kind=kind) 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() def price_history( 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[ 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, delivery: Annotated[ 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, days: Annotated[ int, Field(ge=1, le=365, description="Lookback window in days.") ] = 30, ) -> 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 - window has fewer than ~60 samples.""" - cm = commodity.strip().lower() + Pivot it however you like — one crop at one elevator, every elevator for one + crop, one delivery period across all crops, etc. Each series gets a ▲/▼ bid + 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): payload = client.history(commodity=cm, delivery=delivery, days=days) - if source: - payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source] - return fmt.fmt_history(payload) + return fmt.fmt_history(_filter_source(payload, source)) + + +@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() diff --git a/tests/test_client.py b/tests/test_client.py index 755dca0..56e88f1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -71,6 +71,25 @@ def test_get_drops_none_params(monkeypatch): 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): client = _reload_client(monkeypatch) diff --git a/tests/test_format.py b/tests/test_format.py index d12813b..5e69592 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -93,6 +93,68 @@ def test_fmt_history_with_trend_arrow(): assert "▲" in out assert "$4.8000" 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():