Add basis_movement/basis_detail tools; make history + latest fully filterable

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 15:25:39 -04:00
parent e78733d55e
commit 3340747600
5 changed files with 313 additions and 31 deletions
+1 -1
View File
@@ -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,
+136 -16
View File
@@ -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 ----------
+95 -14
View File
@@ -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()