From 26571cfac266a66bc134f865556a1d82bfebce8d Mon Sep 17 00:00:00 2001 From: Marc Shade Date: Sun, 8 Mar 2026 21:05:24 -0400 Subject: [PATCH] feat: add daily gas prices (AAA), electricity rates, natgas + dashboard deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace EIA weekly gasoline with AAA daily scraper (gasprices.aaa.com) - urllib-based fetch (Cloudflare blocks httpx), cached 30min - 5 grades: regular, mid-grade, premium, diesel, E85 - Day-over-day, week, month, year-over-year deltas - 51 state-level regular prices - Add EIA electricity retail rates (residential/commercial/industrial) - Add EIA residential natural gas prices (monthly) - Dashboard: all energy sections show change indicators (DoD/WoW/MoM) - Dashboard: vector panel moved to right:360px (was covering zoom controls) - Dashboard: fix pre-existing num() → fmtNum() bug in BTC technicals - 3 new tools: intel_gas_prices, intel_residential_natgas, intel_electricity_rates - CLI: gas-prices, natgas, electricity commands - Collector: 3 new source entries - Tests: 8 new tests (AAA parse, electricity, natgas, edge cases) --- src/world_intel_mcp/cli.py | 95 ++++ src/world_intel_mcp/collector.py | 13 +- src/world_intel_mcp/dashboard/app.py | 3 + src/world_intel_mcp/dashboard/index.html | 101 +++- src/world_intel_mcp/server.py | 36 +- src/world_intel_mcp/sources/economic.py | 392 ++++++++++++++- src/world_intel_mcp/tests/test_sources.py | 558 ++++++++++++++++------ 7 files changed, 1017 insertions(+), 181 deletions(-) diff --git a/src/world_intel_mcp/cli.py b/src/world_intel_mcp/cli.py index 4c9a16c..2638e9a 100644 --- a/src/world_intel_mcp/cli.py +++ b/src/world_intel_mcp/cli.py @@ -232,6 +232,101 @@ def energy(ctx: click.Context) -> None: console.print(table) +@main.command("gas-prices") +@click.pass_context +def gas_prices(ctx: click.Context) -> None: + """US retail gasoline & diesel prices (AAA, daily).""" + f = _get_fetcher() + data = _run(economic.fetch_gas_prices(f)) + + if ctx.obj.get("json") or "error" in data: + _print_json(data) + return + + prices = data.get("prices", {}) + table = Table(title="US Gas Prices — Today (AAA)", box=box.SIMPLE_HEAVY) + table.add_column("Grade", style="bold") + table.add_column("$/gallon", justify="right") + table.add_column("DoD", justify="right") + table.add_column("WoW", justify="right") + + grade_labels = { + "regular": "Regular", + "mid_grade": "Mid-Grade", + "premium": "Premium", + "diesel": "Diesel", + } + for grade, label in grade_labels.items(): + info = prices.get(grade) + if info and isinstance(info, dict): + price = info.get("price_per_gallon", 0) + dod = info.get("change_pct") + wow = info.get("week_ago_pct") + dod_str = ( + f"[{'green' if dod >= 0 else 'red'}]{dod:+.2f}%[/]" + if dod is not None + else "—" + ) + wow_str = ( + f"[{'green' if wow >= 0 else 'red'}]{wow:+.2f}%[/]" + if wow is not None + else "—" + ) + table.add_row(label, f"${price:.3f}", dod_str, wow_str) + console.print(table) + + +@main.command("natgas") +@click.pass_context +def natgas(ctx: click.Context) -> None: + """US residential natural gas prices (EIA).""" + f = _get_fetcher() + data = _run(economic.fetch_residential_natgas_prices(f)) + + if ctx.obj.get("json") or "error" in data: + _print_json(data) + return + + prices = data.get("prices", []) + table = Table(title="US Residential Natural Gas Prices", box=box.SIMPLE_HEAVY) + table.add_column("Period", style="bold") + table.add_column("$/MCF", justify="right") + + for entry in prices: + table.add_row(str(entry.get("period", "")), f"${entry.get('price', '?'):.2f}") + console.print(table) + + +@main.command("electricity") +@click.option("--state", "-s", default=None, help="2-letter state code (e.g., CA, TX)") +@click.pass_context +def electricity(ctx: click.Context, state: str | None) -> None: + """US electricity retail rates (EIA).""" + f = _get_fetcher() + data = _run(economic.fetch_electricity_rates(f, state=state)) + + if ctx.obj.get("json") or "error" in data: + _print_json(data) + return + + rates = data.get("rates", {}) + label = data.get("state", "US") + table = Table(title=f"Electricity Rates — {label}", box=box.SIMPLE_HEAVY) + table.add_column("Sector", style="bold") + table.add_column("cents/kWh", justify="right") + table.add_column("Period") + + for sector in ("residential", "commercial", "industrial", "all_sectors"): + info = rates.get(sector) + if info and isinstance(info, dict): + table.add_row( + sector.replace("_", " ").title(), + f"{info.get('price_cents_kwh', '?'):.2f}", + str(info.get("period", "")), + ) + console.print(table) + + @main.command() @click.argument("series_id") @click.option("--limit", "-n", default=30, help="Number of observations") diff --git a/src/world_intel_mcp/collector.py b/src/world_intel_mcp/collector.py index 1b36022..bf95940 100644 --- a/src/world_intel_mcp/collector.py +++ b/src/world_intel_mcp/collector.py @@ -46,8 +46,11 @@ SOURCES = [ ("etf_flows", "sources.markets", "fetch_etf_flows", {}), ("commodity_quotes", "sources.markets", "fetch_commodity_quotes", {}), ("btc_technicals", "sources.markets", "fetch_btc_technicals", {}), - # Economic (3) + # Economic (6) ("energy_prices", "sources.economic", "fetch_energy_prices", {}), + ("gas_prices", "sources.economic", "fetch_gas_prices", {}), + ("residential_natgas", "sources.economic", "fetch_residential_natgas_prices", {}), + ("electricity_rates", "sources.economic", "fetch_electricity_rates", {}), ("central_bank_rates", "sources.central_banks", "fetch_central_bank_rates", {}), # Natural Disasters (2) ("earthquakes", "sources.seismology", "fetch_earthquakes", {}), @@ -115,7 +118,13 @@ DOMAIN_GROUPS = { "commodity_quotes", "btc_technicals", ], - "economic": ["energy_prices", "central_bank_rates"], + "economic": [ + "energy_prices", + "gas_prices", + "residential_natgas", + "electricity_rates", + "central_bank_rates", + ], "natural": ["earthquakes", "wildfires"], "conflict": ["acled_events", "ucdp_events", "displacement"], "military": ["military_flights"], diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py index a9a2157..4361398 100644 --- a/src/world_intel_mcp/dashboard/app.py +++ b/src/world_intel_mcp/dashboard/app.py @@ -127,6 +127,9 @@ async def _fetch_overview() -> dict: "airport_delays": aviation.fetch_airport_delays(fetcher), "climate_anomalies": climate.fetch_climate_anomalies(fetcher), "energy_prices": economic.fetch_energy_prices(fetcher), + "gas_prices": economic.fetch_gas_prices(fetcher), + "residential_natgas": economic.fetch_residential_natgas_prices(fetcher), + "electricity_rates": economic.fetch_electricity_rates(fetcher), "stablecoin_status": markets.fetch_stablecoin_status(fetcher), "etf_flows": markets.fetch_etf_flows(fetcher), "acled_events": conflict.fetch_acled_events(fetcher), diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index d718312..34a363c 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -327,14 +327,19 @@ a { color: var(--accent); text-decoration: none; } /* ═══════════════ VECTOR INTELLIGENCE PANEL ═══════════════ */ #vectorPanel { - position: fixed; bottom: 44px; left: 10px; z-index: 40; + position: fixed; bottom: 44px; right: 360px; z-index: 40; width: 310px; max-height: 420px; overflow-y: auto; overflow-x: hidden; padding: 12px 14px; transition: transform 0.4s var(--ease), opacity 0.3s; } #vectorPanel.hidden { transform: translateY(20px); opacity: 0; pointer-events: none; } -.vec-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +#vectorPanel.collapsed { max-height: 36px; overflow: hidden; } +#vectorPanel.collapsed .vec-body { display: none; } +.vec-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; cursor: pointer; user-select: none; } +.vec-header:hover .vec-title { color: var(--bright); } +.vec-toggle { font-size: 0.7rem; color: var(--dim); transition: transform 0.25s var(--ease); } +#vectorPanel.collapsed .vec-toggle { transform: rotate(-90deg); } .vec-title { font-size: 0.62rem; font-weight: 600; letter-spacing: 2.5px; color: var(--accent); text-transform: uppercase; } .vec-stat { font-family: var(--mono); font-size: 0.6rem; color: var(--dim); } .vec-stat .v { color: var(--bright); font-weight: 600; } @@ -783,16 +788,21 @@ a { color: var(--accent); text-decoration: none; }