feat: add daily gas prices (AAA), electricity rates, natgas + dashboard deltas

- 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)
This commit is contained in:
Marc Shade
2026-03-08 21:05:24 -04:00
parent 4b08dda62a
commit 26571cfac2
7 changed files with 1017 additions and 181 deletions
+95
View File
@@ -232,6 +232,101 @@ def energy(ctx: click.Context) -> None:
console.print(table) 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() @main.command()
@click.argument("series_id") @click.argument("series_id")
@click.option("--limit", "-n", default=30, help="Number of observations") @click.option("--limit", "-n", default=30, help="Number of observations")
+11 -2
View File
@@ -46,8 +46,11 @@ SOURCES = [
("etf_flows", "sources.markets", "fetch_etf_flows", {}), ("etf_flows", "sources.markets", "fetch_etf_flows", {}),
("commodity_quotes", "sources.markets", "fetch_commodity_quotes", {}), ("commodity_quotes", "sources.markets", "fetch_commodity_quotes", {}),
("btc_technicals", "sources.markets", "fetch_btc_technicals", {}), ("btc_technicals", "sources.markets", "fetch_btc_technicals", {}),
# Economic (3) # Economic (6)
("energy_prices", "sources.economic", "fetch_energy_prices", {}), ("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", {}), ("central_bank_rates", "sources.central_banks", "fetch_central_bank_rates", {}),
# Natural Disasters (2) # Natural Disasters (2)
("earthquakes", "sources.seismology", "fetch_earthquakes", {}), ("earthquakes", "sources.seismology", "fetch_earthquakes", {}),
@@ -115,7 +118,13 @@ DOMAIN_GROUPS = {
"commodity_quotes", "commodity_quotes",
"btc_technicals", "btc_technicals",
], ],
"economic": ["energy_prices", "central_bank_rates"], "economic": [
"energy_prices",
"gas_prices",
"residential_natgas",
"electricity_rates",
"central_bank_rates",
],
"natural": ["earthquakes", "wildfires"], "natural": ["earthquakes", "wildfires"],
"conflict": ["acled_events", "ucdp_events", "displacement"], "conflict": ["acled_events", "ucdp_events", "displacement"],
"military": ["military_flights"], "military": ["military_flights"],
+3
View File
@@ -127,6 +127,9 @@ async def _fetch_overview() -> dict:
"airport_delays": aviation.fetch_airport_delays(fetcher), "airport_delays": aviation.fetch_airport_delays(fetcher),
"climate_anomalies": climate.fetch_climate_anomalies(fetcher), "climate_anomalies": climate.fetch_climate_anomalies(fetcher),
"energy_prices": economic.fetch_energy_prices(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), "stablecoin_status": markets.fetch_stablecoin_status(fetcher),
"etf_flows": markets.fetch_etf_flows(fetcher), "etf_flows": markets.fetch_etf_flows(fetcher),
"acled_events": conflict.fetch_acled_events(fetcher), "acled_events": conflict.fetch_acled_events(fetcher),
+86 -15
View File
@@ -327,14 +327,19 @@ a { color: var(--accent); text-decoration: none; }
/* ═══════════════ VECTOR INTELLIGENCE PANEL ═══════════════ */ /* ═══════════════ VECTOR INTELLIGENCE PANEL ═══════════════ */
#vectorPanel { #vectorPanel {
position: fixed; bottom: 44px; left: 10px; z-index: 40; position: fixed; bottom: 44px; right: 360px; z-index: 40;
width: 310px; max-height: 420px; width: 310px; max-height: 420px;
overflow-y: auto; overflow-x: hidden; overflow-y: auto; overflow-x: hidden;
padding: 12px 14px; padding: 12px 14px;
transition: transform 0.4s var(--ease), opacity 0.3s; transition: transform 0.4s var(--ease), opacity 0.3s;
} }
#vectorPanel.hidden { transform: translateY(20px); opacity: 0; pointer-events: none; } #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-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 { font-family: var(--mono); font-size: 0.6rem; color: var(--dim); }
.vec-stat .v { color: var(--bright); font-weight: 600; } .vec-stat .v { color: var(--bright); font-weight: 600; }
@@ -783,16 +788,21 @@ a { color: var(--accent); text-decoration: none; }
<!-- VECTOR INTELLIGENCE PANEL --> <!-- VECTOR INTELLIGENCE PANEL -->
<div id="vectorPanel" class="glass hidden"> <div id="vectorPanel" class="glass hidden">
<div class="vec-header"> <div class="vec-header" id="vecHeaderToggle">
<span class="vec-title">Vector Intelligence</span> <span class="vec-title">Vector Intelligence</span>
<span class="vec-stat" id="vecPointCount"></span> <span style="display:flex;align-items:center;gap:6px">
<span class="vec-stat" id="vecPointCount"></span>
<span class="vec-toggle">&#x25BC;</span>
</span>
</div> </div>
<div id="vecContent"><div class="vec-unavail">Connecting...</div></div> <div class="vec-body">
<div class="vec-search-wrap"> <div id="vecContent"><div class="vec-unavail">Connecting...</div></div>
<input type="text" class="vec-search-input" id="vecSearchInput" placeholder="Semantic search..." autocomplete="off"> <div class="vec-search-wrap">
<button class="vec-search-btn" id="vecSearchBtn">Search</button> <input type="text" class="vec-search-input" id="vecSearchInput" placeholder="Semantic search..." autocomplete="off">
<button class="vec-search-btn" id="vecSearchBtn">Search</button>
</div>
<div class="vec-results" id="vecResults"></div>
</div> </div>
<div class="vec-results" id="vecResults"></div>
</div> </div>
<script> <script>
@@ -2248,10 +2258,10 @@ function updateDrawer(data) {
var ep = data.energy_prices; var ep = data.energy_prices;
var eRows = []; var eRows = [];
if (ep.oil) { if (ep.oil) {
if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date}); if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date, unit: '$/bbl'});
if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date}); if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date, unit: '$/bbl'});
} }
if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Natural Gas', price: ep.natural_gas.price, date: ep.natural_gas.date}); if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Nat Gas Futures', price: ep.natural_gas.price, date: ep.natural_gas.date, unit: '$/MMBtu'});
if (eRows.length) { if (eRows.length) {
h += '<div class="sub">Energy</div><table class="dtable"><thead><tr><th>Commodity</th><th>Price</th><th>Date</th></tr></thead><tbody>'; h += '<div class="sub">Energy</div><table class="dtable"><thead><tr><th>Commodity</th><th>Price</th><th>Date</th></tr></thead><tbody>';
eRows.forEach(function(item) { eRows.forEach(function(item) {
@@ -2260,6 +2270,59 @@ function updateDrawer(data) {
h += '</tbody></table>'; h += '</tbody></table>';
} }
} }
// US Retail Gas Prices (AAA daily)
if (data.gas_prices && !data.gas_prices.error) {
var gp = data.gas_prices;
var prices = gp.prices || {};
var grades = ['regular', 'mid_grade', 'premium', 'diesel'];
var gradeLabels = {regular: 'Regular', mid_grade: 'Mid-Grade', premium: 'Premium', diesel: 'Diesel'};
var gRows = [];
grades.forEach(function(g) {
var p = prices[g];
if (p && p.price_per_gallon != null) gRows.push({name: gradeLabels[g] || g, price: p.price_per_gallon, change: p.change, change_pct: p.change_pct, week_ago_pct: p.week_ago_pct, month_ago_pct: p.month_ago_pct});
});
if (gRows.length) {
h += '<div class="sub">US Gas Prices <span class="dim" style="font-size:0.55rem">today via AAA</span></div>';
h += '<table class="dtable"><thead><tr><th>Grade</th><th>$/gal</th><th>DoD</th><th>WoW</th></tr></thead><tbody>';
gRows.forEach(function(item) {
var dod = item.change_pct != null ? '<span class="' + cls(item.change_pct) + '">' + fmtPct(item.change_pct) + '%</span>' : '<span class="dim">\u2014</span>';
var wow = item.week_ago_pct != null ? '<span class="' + cls(item.week_ago_pct) + '">' + fmtPct(item.week_ago_pct) + '%</span>' : '<span class="dim">\u2014</span>';
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">$' + fmtNum(item.price, 3) + '</td><td>' + dod + '</td><td>' + wow + '</td></tr>';
});
h += '</tbody></table>';
}
}
// Residential Natural Gas
if (data.residential_natgas && !data.residential_natgas.error) {
var rng = data.residential_natgas;
var ngPrices = rng.prices || [];
if (ngPrices.length) {
var ngFirst = ngPrices[0];
var ngChg = ngFirst.change_pct != null ? ' <span class="' + cls(ngFirst.change_pct) + '" style="font-size:0.6rem">' + fmtPct(ngFirst.change_pct) + '% MoM</span>' : '';
h += '<div class="sub">Residential Nat Gas <span class="dim" style="font-size:0.55rem">' + esc(ngFirst.period || '') + '</span></div>';
h += '<div class="mini-row"><div class="mini-box"><div class="v bright">$' + fmtNum(ngFirst.price) + '</div><div class="l">$/MCF' + ngChg + '</div></div></div>';
}
}
// Electricity Rates
if (data.electricity_rates && !data.electricity_rates.error) {
var er = data.electricity_rates;
var rates = er.rates || {};
var sectors = ['residential', 'commercial', 'industrial'];
var eRateRows = [];
sectors.forEach(function(s) {
var r = rates[s];
if (r && r.price_cents_kwh != null) eRateRows.push({name: s.charAt(0).toUpperCase() + s.slice(1), price: r.price_cents_kwh, period: r.period, change: r.change, change_pct: r.change_pct});
});
if (eRateRows.length) {
h += '<div class="sub">Electricity (\u00A2/kWh) — ' + esc(er.state || 'US') + ' <span class="dim" style="font-size:0.55rem">' + esc(eRateRows[0].period || '') + '</span></div>';
h += '<table class="dtable"><thead><tr><th>Sector</th><th>\u00A2/kWh</th><th>MoM</th></tr></thead><tbody>';
eRateRows.forEach(function(item) {
var chg = item.change_pct != null ? '<span class="' + cls(item.change_pct) + '">' + fmtPct(item.change_pct) + '%</span>' : '<span class="dim">\u2014</span>';
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">' + fmtNum(item.price) + '\u00A2</td><td>' + chg + '</td></tr>';
});
h += '</tbody></table>';
}
}
if (data.etf_flows && !data.etf_flows.error) { if (data.etf_flows && !data.etf_flows.error) {
var etfs = data.etf_flows.etfs || data.etf_flows.data || []; var etfs = data.etf_flows.etfs || data.etf_flows.data || [];
if (etfs.length) { if (etfs.length) {
@@ -2803,12 +2866,12 @@ function updateDrawer(data) {
var crossCls = bt.cross_signal === 'golden_cross' ? 'good' : bt.cross_signal === 'death_cross' ? 'crit' : 'dim'; var crossCls = bt.cross_signal === 'golden_cross' ? 'good' : bt.cross_signal === 'death_cross' ? 'crit' : 'dim';
var crossLabel = bt.cross_signal === 'golden_cross' ? 'GOLDEN CROSS' : bt.cross_signal === 'death_cross' ? 'DEATH CROSS' : 'NEUTRAL'; var crossLabel = bt.cross_signal === 'golden_cross' ? 'GOLDEN CROSS' : bt.cross_signal === 'death_cross' ? 'DEATH CROSS' : 'NEUTRAL';
h += '<div class="mini-boxes" style="margin:6px 0">'; h += '<div class="mini-boxes" style="margin:6px 0">';
h += '<div class="mini-box"><div class="v">$' + num(bt.price) + '</div><div class="l">BTC Price</div></div>'; h += '<div class="mini-box"><div class="v">$' + fmtNum(bt.price) + '</div><div class="l">BTC Price</div></div>';
h += '<div class="mini-box"><div class="v">' + (bt.mayer_multiple || '—') + '</div><div class="l">Mayer Multiple</div></div>'; h += '<div class="mini-box"><div class="v">' + (bt.mayer_multiple || '—') + '</div><div class="l">Mayer Multiple</div></div>';
h += '<div class="mini-box"><div class="v ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></div>'; h += '<div class="mini-box"><div class="v ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></div>';
h += '</div>'; h += '</div>';
h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + num(bt.sma_50) + '</span></div>'; h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + fmtNum(bt.sma_50) + '</span></div>';
if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + num(bt.sma_200) + '</span></div>'; if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + fmtNum(bt.sma_200) + '</span></div>';
if (bt.change_7d_pct != null) { if (bt.change_7d_pct != null) {
var c7 = bt.change_7d_pct >= 0 ? 'good' : 'crit'; var c7 = bt.change_7d_pct >= 0 ? 'good' : 'crit';
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">7d</span> <span class="' + c7 + '">' + (bt.change_7d_pct > 0 ? '+' : '') + bt.change_7d_pct + '%</span>'; h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">7d</span> <span class="' + c7 + '">' + (bt.change_7d_pct > 0 ? '+' : '') + bt.change_7d_pct + '%</span>';
@@ -3235,6 +3298,14 @@ function doVecSearch() {
}); });
} }
$('#vecHeaderToggle').addEventListener('click', function() {
var panel = $('#vectorPanel');
panel.classList.toggle('collapsed');
try { localStorage.setItem('phoenix-vec-collapsed', panel.classList.contains('collapsed') ? '1' : ''); } catch(e) {}
});
// Restore collapsed state
try { if (localStorage.getItem('phoenix-vec-collapsed') === '1') { $('#vectorPanel').classList.add('collapsed'); } } catch(e) {}
$('#vecSearchBtn').addEventListener('click', doVecSearch); $('#vecSearchBtn').addEventListener('click', doVecSearch);
$('#vecSearchInput').addEventListener('keydown', function(e) { $('#vecSearchInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doVecSearch(); } if (e.key === 'Enter') { e.preventDefault(); doVecSearch(); }
+34 -2
View File
@@ -167,10 +167,33 @@ TOOLS: list[Tool] = [
description="Get commodity futures quotes: gold, silver, crude oil (WTI & Brent), natural gas, corn, wheat, soybeans from Yahoo Finance.", description="Get commodity futures quotes: gold, silver, crude oil (WTI & Brent), natural gas, corn, wheat, soybeans from Yahoo Finance.",
inputSchema={"type": "object", "properties": {}}, inputSchema={"type": "object", "properties": {}},
), ),
# --- Economic (3 tools) --- # --- Economic (6 tools) ---
Tool(
name="intel_gas_prices",
description="Get today's US retail gasoline and diesel prices ($/gallon) from AAA — daily national averages for regular, mid-grade, premium, diesel, E85. Includes day-over-day, week, month, year deltas plus per-state prices. No API key required.",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="intel_residential_natgas",
description="Get US residential natural gas prices ($/thousand cubic feet) — monthly average. Requires EIA_API_KEY.",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="intel_electricity_rates",
description="Get US electricity retail rates (cents/kWh) by sector (residential, commercial, industrial). Optionally filter by state. Requires EIA_API_KEY.",
inputSchema={
"type": "object",
"properties": {
"state": {
"type": "string",
"description": "2-letter US state code (e.g., 'CA', 'TX'). Defaults to nationwide.",
},
},
},
),
Tool( Tool(
name="intel_energy_prices", name="intel_energy_prices",
description="Get crude oil (Brent, WTI) and natural gas prices from EIA. Requires EIA_API_KEY.", description="Get crude oil (Brent, WTI) and natural gas futures prices from EIA. Requires EIA_API_KEY.",
inputSchema={"type": "object", "properties": {}}, inputSchema={"type": "object", "properties": {}},
), ),
Tool( Tool(
@@ -1769,6 +1792,15 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
return await markets.fetch_commodity_quotes(fetcher) return await markets.fetch_commodity_quotes(fetcher)
# Economic # Economic
case "intel_gas_prices":
return await economic.fetch_gas_prices(fetcher)
case "intel_residential_natgas":
return await economic.fetch_residential_natgas_prices(fetcher)
case "intel_electricity_rates":
return await economic.fetch_electricity_rates(
fetcher,
state=arguments.get("state"),
)
case "intel_energy_prices": case "intel_energy_prices":
return await economic.fetch_energy_prices(fetcher) return await economic.fetch_energy_prices(fetcher)
case "intel_fred_series": case "intel_fred_series":
+375 -17
View File
@@ -85,9 +85,7 @@ async def fetch_energy_prices(
# --- Parse oil prices --------------------------------------------------- # --- Parse oil prices ---------------------------------------------------
if oil_data: if oil_data:
try: try:
records = ( records = oil_data.get("response", {}).get("data", [])
oil_data.get("response", {}).get("data", [])
)
for rec in records: for rec in records:
product = rec.get("product") product = rec.get("product")
value = rec.get("value") value = rec.get("value")
@@ -105,9 +103,7 @@ async def fetch_energy_prices(
# --- Parse natural gas price -------------------------------------------- # --- Parse natural gas price --------------------------------------------
if gas_data: if gas_data:
try: try:
records = ( records = gas_data.get("response", {}).get("data", [])
gas_data.get("response", {}).get("data", [])
)
if records: if records:
rec = records[0] rec = records[0]
value = rec.get("value") value = rec.get("value")
@@ -123,6 +119,366 @@ async def fetch_energy_prices(
return result return result
# ---------------------------------------------------------------------------
# AAA: US retail gasoline & diesel prices (daily, via gasprices.aaa.com)
# ---------------------------------------------------------------------------
_AAA_URL = "https://gasprices.aaa.com/"
# Row labels in AAA's price table → our key names
_AAA_ROW_LABELS = {
"Current Avg.": "today",
"Yesterday Avg.": "yesterday",
"Week Ago Avg.": "week_ago",
"Month Ago Avg.": "month_ago",
"Year Ago Avg.": "year_ago",
}
# Column order in the AAA table (indices 1..5 after the row label)
_AAA_GRADES = ["regular", "mid_grade", "premium", "diesel", "e85"]
def _parse_aaa_html(html: str) -> dict:
"""Extract national gas prices from AAA's HTML price table.
Returns dict with per-grade prices, yesterday delta, and week/month/year
comparisons. Also extracts per-state prices from the ``iwmparam`` JS var.
"""
import re
result: dict = {"prices": {}, "state_prices": []}
# --- National price table ------------------------------------------------
# Table has rows: Current Avg., Yesterday Avg., Week Ago Avg., ...
# Each row: label, Regular, Mid-Grade, Premium, Diesel, E85
table_match = re.search(
r"<table[^>]*>.*?Regular.*?</table>", html, re.DOTALL | re.IGNORECASE
)
if table_match:
table_html = table_match.group(0)
rows = re.findall(
r"<tr[^>]*>\s*<td[^>]*>([^<]+)</td>\s*"
r"<td[^>]*>\$?([\d.]+)</td>\s*"
r"<td[^>]*>\$?([\d.]+)</td>\s*"
r"<td[^>]*>\$?([\d.]+)</td>\s*"
r"<td[^>]*>\$?([\d.]+)</td>\s*"
r"<td[^>]*>\$?([\d.]+)</td>",
table_html,
re.DOTALL,
)
time_rows: dict[str, dict[str, float]] = {}
for row in rows:
label = row[0].strip()
key = _AAA_ROW_LABELS.get(label)
if key is None:
continue
time_rows[key] = {
grade: float(row[i + 1]) for i, grade in enumerate(_AAA_GRADES)
}
today = time_rows.get("today", {})
yesterday = time_rows.get("yesterday", {})
for grade in _AAA_GRADES:
cur = today.get(grade)
if cur is None:
continue
entry: dict = {
"price_per_gallon": cur,
"unit": "$/gallon",
}
prev = yesterday.get(grade)
if prev is not None and prev != 0:
delta = cur - prev
entry["change"] = round(delta, 3)
entry["change_pct"] = round(delta / prev * 100, 2)
# Week/month/year comparisons
for period_key, period_label in [
("week_ago", "week_ago"),
("month_ago", "month_ago"),
("year_ago", "year_ago"),
]:
comp = time_rows.get(period_key, {}).get(grade)
if comp is not None and comp != 0:
entry[period_label] = comp
entry[f"{period_label}_pct"] = round((cur - comp) / comp * 100, 2)
result["prices"][grade] = entry
# --- State prices from iwmparam ------------------------------------------
iwm = re.search(r'iwmparam\[0\]\.placestxt\s*=\s*"([^"]+)"', html)
if iwm:
parts = iwm.group(1).split(",")
# Format: STATE,Name,$price,link,color;STATE,Name,...
i = 0
while i + 2 < len(parts):
state_code = parts[i].split(";")[-1] if ";" in parts[i] else parts[i]
state_code = state_code.strip()
price_str = parts[i + 2].strip().lstrip("$")
try:
price = float(price_str)
result["state_prices"].append({"state": state_code, "price": price})
except (ValueError, TypeError):
pass
i += 4 # skip link and color-tagged next state
return result
def _fetch_aaa_html() -> str | None:
"""Fetch AAA gas prices page using urllib (bypasses Cloudflare)."""
import urllib.request
req = urllib.request.Request(
_AAA_URL,
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
},
)
try:
resp = urllib.request.urlopen(req, timeout=20)
return resp.read().decode("utf-8", errors="replace")
except Exception:
return None
async def fetch_gas_prices(
fetcher: Fetcher,
region: str | None = None,
api_key: str | None = None,
) -> dict:
"""Fetch US retail gasoline & diesel prices from AAA (daily).
Scrapes gasprices.aaa.com for today's national averages across
regular, mid-grade, premium, diesel, and E85. Includes day-over-day,
week, month, and year-over-year deltas. Also includes per-state
regular prices.
No API key required public page. The *region* and *api_key*
parameters are accepted for interface compatibility but ignored.
"""
# AAA is behind Cloudflare — httpx gets 403. Use urllib which
# has a simpler TLS fingerprint and passes through. Cache via
# the fetcher's cache to avoid redundant requests.
cached = fetcher.cache.get("economic:gas_prices:aaa")
if cached is not None:
html = cached
else:
try:
html = await asyncio.to_thread(_fetch_aaa_html)
if html:
fetcher.cache.set("economic:gas_prices:aaa", html, ttl_seconds=1800)
except Exception as exc:
logger.warning("Failed to fetch AAA gas prices: %s", exc)
html = fetcher.cache.get_stale("economic:gas_prices:aaa")
result: dict = {
"region": "US",
"prices": {},
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "aaa",
"update_frequency": "daily",
}
if html is None:
return result
try:
parsed = _parse_aaa_html(html)
result["prices"] = parsed["prices"]
result["state_prices"] = parsed.get("state_prices", [])
except Exception as exc:
logger.warning("Failed to parse AAA gas prices: %s", exc)
return result
# ---------------------------------------------------------------------------
# EIA: US residential natural gas prices
# ---------------------------------------------------------------------------
_EIA_NATGAS_RESIDENTIAL_URL = "https://api.eia.gov/v2/natural-gas/pri/sum/data/"
# State codes (subset) — EIA uses 2-letter postal codes
_US_STATES = {
"US": "NUS", # nationwide
}
async def fetch_residential_natgas_prices(
fetcher: Fetcher,
api_key: str | None = None,
) -> dict:
"""Fetch US residential natural gas prices from EIA.
Returns the most recent monthly residential natural gas price
($/thousand cubic feet) nationwide.
Requires ``EIA_API_KEY``.
"""
key = api_key or os.environ.get("EIA_API_KEY")
if not key:
return {"error": "EIA_API_KEY not configured"}
params = {
"api_key": key,
"frequency": "monthly",
"data[0]": "value",
"sort[0][column]": "period",
"sort[0][direction]": "desc",
"length": 6,
"facets[duoarea][]": "NUS",
"facets[process][]": "PRS", # residential
}
data = await fetcher.get_json(
_EIA_NATGAS_RESIDENTIAL_URL,
source="eia",
cache_key="economic:natgas_residential",
cache_ttl=3600,
params=params,
)
result: dict = {
"prices": [],
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "eia",
"unit": "$/thousand cubic feet",
}
if data is None:
return result
try:
records = data.get("response", {}).get("data", [])
for rec in records:
value = rec.get("value")
period = rec.get("period")
if value is not None and period is not None:
result["prices"].append(
{
"price": float(value),
"period": period,
}
)
except (KeyError, TypeError, ValueError) as exc:
logger.warning("Failed to parse EIA residential natgas data: %s", exc)
# Add change from previous period on the most recent entry
if len(result["prices"]) >= 2:
cur = result["prices"][0]["price"]
prev = result["prices"][1]["price"]
delta = cur - prev
result["prices"][0]["change"] = round(delta, 2)
result["prices"][0]["change_pct"] = (
round(delta / prev * 100, 2) if prev else 0.0
)
return result
# ---------------------------------------------------------------------------
# EIA: US electricity retail rates
# ---------------------------------------------------------------------------
_EIA_ELECTRICITY_URL = "https://api.eia.gov/v2/electricity/retail-sales/data/"
async def fetch_electricity_rates(
fetcher: Fetcher,
state: str | None = None,
api_key: str | None = None,
) -> dict:
"""Fetch US electricity retail rates from EIA.
Returns average retail electricity price (cents/kWh) by sector
(residential, commercial, industrial). Optionally filter by
2-letter *state* code (e.g., 'CA', 'TX'). Defaults to nationwide.
Requires ``EIA_API_KEY``.
"""
key = api_key or os.environ.get("EIA_API_KEY")
if not key:
return {"error": "EIA_API_KEY not configured"}
area = state.upper() if state else "US"
params = {
"api_key": key,
"frequency": "monthly",
"data[0]": "price",
"sort[0][column]": "period",
"sort[0][direction]": "desc",
"length": 12,
"facets[stateid][]": area,
"facets[sectorid][]": ["RES", "COM", "IND", "ALL"],
}
data = await fetcher.get_json(
_EIA_ELECTRICITY_URL,
source="eia",
cache_key=f"economic:electricity:{area}",
cache_ttl=3600,
params=params,
)
sector_names = {
"RES": "residential",
"COM": "commercial",
"IND": "industrial",
"ALL": "all_sectors",
}
result: dict = {
"state": area,
"rates": {},
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "eia",
"unit": "cents/kWh",
}
if data is None:
return result
# Collect up to 2 most recent values per sector (for delta)
by_sector: dict[str, list[dict]] = {}
try:
records = data.get("response", {}).get("data", [])
for rec in records:
sector_code = rec.get("sectorid")
price = rec.get("price")
period = rec.get("period")
if sector_code is None or price is None or period is None:
continue
sector = sector_names.get(sector_code, sector_code)
lst = by_sector.setdefault(sector, [])
if len(lst) < 2:
lst.append({"price": float(price), "period": period})
for sector, entries in by_sector.items():
current = entries[0]
entry: dict = {
"price_cents_kwh": current["price"],
"period": current["period"],
}
if len(entries) >= 2:
prev = entries[1]["price"]
delta = current["price"] - prev
entry["change"] = round(delta, 2)
entry["change_pct"] = round(delta / prev * 100, 2) if prev else 0.0
entry["prev_period"] = entries[1]["period"]
result["rates"][sector] = entry
except (KeyError, TypeError, ValueError) as exc:
logger.warning("Failed to parse EIA electricity data: %s", exc)
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FRED: Federal Reserve Economic Data # FRED: Federal Reserve Economic Data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -213,9 +569,9 @@ async def fetch_fred_series(
_WB_BASE = "https://api.worldbank.org/v2/country" _WB_BASE = "https://api.worldbank.org/v2/country"
_DEFAULT_INDICATORS = [ _DEFAULT_INDICATORS = [
"NY.GDP.MKTP.CD", # GDP (current US$) "NY.GDP.MKTP.CD", # GDP (current US$)
"FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %) "FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %)
"SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force) "SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force)
] ]
@@ -249,9 +605,7 @@ async def fetch_world_bank_indicators(
params=params, params=params,
) )
responses = await asyncio.gather( responses = await asyncio.gather(*[_fetch_one(ind) for ind in indicator_ids])
*[_fetch_one(ind) for ind in indicator_ids]
)
parsed_indicators: list[dict] = [] parsed_indicators: list[dict] = []
@@ -288,14 +642,18 @@ async def fetch_world_bank_indicators(
parsed_value = float(value) parsed_value = float(value)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
entry["values"].append({ entry["values"].append(
"year": year, {
"value": parsed_value, "year": year,
}) "value": parsed_value,
}
)
except (KeyError, TypeError, IndexError) as exc: except (KeyError, TypeError, IndexError) as exc:
logger.warning( logger.warning(
"Failed to parse World Bank indicator %s for %s: %s", "Failed to parse World Bank indicator %s for %s: %s",
indicator_id, country, exc, indicator_id,
country,
exc,
) )
parsed_indicators.append(entry) parsed_indicators.append(entry)
+413 -145
View File
@@ -1,8 +1,7 @@
"""Tests for source modules — uses respx to mock HTTP calls.""" """Tests for source modules — uses respx to mock HTTP calls."""
import json
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import patch
import httpx import httpx
import pytest import pytest
@@ -37,14 +36,16 @@ async def test_fetch_market_quotes(fetcher: Fetcher) -> None:
# Mock Yahoo Finance v8 chart response for ^GSPC # Mock Yahoo Finance v8 chart response for ^GSPC
chart_response = { chart_response = {
"chart": { "chart": {
"result": [{ "result": [
"meta": { {
"symbol": "^GSPC", "meta": {
"regularMarketPrice": 5123.45, "symbol": "^GSPC",
"regularMarketChangePercent": 0.42, "regularMarketPrice": 5123.45,
"currency": "USD", "regularMarketChangePercent": 0.42,
"currency": "USD",
}
} }
}] ]
} }
} }
@@ -149,6 +150,7 @@ async def test_fetch_wildfires_no_api_key(fetcher: Fetcher) -> None:
with patch.dict("os.environ", {}, clear=False): with patch.dict("os.environ", {}, clear=False):
# Remove the key if present # Remove the key if present
import os import os
os.environ.pop("NASA_FIRMS_API_KEY", None) os.environ.pop("NASA_FIRMS_API_KEY", None)
result = await fetch_wildfires(fetcher, api_key=None) result = await fetch_wildfires(fetcher, api_key=None)
@@ -189,6 +191,7 @@ async def test_fetch_fred_series_no_key(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.economic import fetch_fred_series from world_intel_mcp.sources.economic import fetch_fred_series
import os import os
os.environ.pop("FRED_API_KEY", None) os.environ.pop("FRED_API_KEY", None)
result = await fetch_fred_series(fetcher, series_id="UNRATE", api_key=None) result = await fetch_fred_series(fetcher, series_id="UNRATE", api_key=None)
assert "error" in result assert "error" in result
@@ -202,8 +205,16 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
wb_response = [ wb_response = [
{"page": 1, "pages": 1, "per_page": 5, "total": 2}, {"page": 1, "pages": 1, "per_page": 5, "total": 2},
[ [
{"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"}, "date": "2023", "value": 25000000000000}, {
{"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"}, "date": "2022", "value": 24000000000000}, "indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"},
"date": "2023",
"value": 25000000000000,
},
{
"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"},
"date": "2022",
"value": 24000000000000,
},
], ],
] ]
@@ -211,13 +222,119 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
return_value=httpx.Response(200, json=wb_response) return_value=httpx.Response(200, json=wb_response)
) )
result = await fetch_world_bank_indicators(fetcher, country="USA", indicators=["NY.GDP.MKTP.CD"]) result = await fetch_world_bank_indicators(
fetcher, country="USA", indicators=["NY.GDP.MKTP.CD"]
)
assert "indicators" in result assert "indicators" in result
assert len(result["indicators"]) == 1 assert len(result["indicators"]) == 1
assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD" assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD"
assert result["source"] == "world-bank" assert result["source"] == "world-bank"
# ---------------------------------------------------------------------------
# Gas prices, natural gas, electricity
# ---------------------------------------------------------------------------
_AAA_HTML = """<html><body>
<table><thead><tr><th></th><th>Regular</th><th>Mid-Grade</th><th>Premium</th>
<th>Diesel</th><th>E85</th></tr></thead><tbody>
<tr><td>Current Avg.</td><td>$3.450</td><td>$3.942</td><td>$4.306</td><td>$4.595</td><td>$2.762</td></tr>
<tr><td>Yesterday Avg.</td><td>$3.413</td><td>$3.897</td><td>$4.260</td><td>$4.510</td><td>$2.717</td></tr>
<tr><td>Week Ago Avg.</td><td>$2.984</td><td>$3.482</td><td>$3.851</td><td>$3.761</td><td>$2.319</td></tr>
<tr><td>Month Ago Avg.</td><td>$2.897</td><td>$3.402</td><td>$3.765</td><td>$3.644</td><td>$2.306</td></tr>
<tr><td>Year Ago Avg.</td><td>$3.095</td><td>$3.572</td><td>$3.925</td><td>$3.640</td><td>$2.523</td></tr>
</tbody></table></body></html>"""
@pytest.mark.asyncio
async def test_fetch_gas_prices(
fetcher: Fetcher, monkeypatch: pytest.MonkeyPatch
) -> None:
from world_intel_mcp.sources import economic
monkeypatch.setattr(economic, "_fetch_aaa_html", lambda: _AAA_HTML)
result = await economic.fetch_gas_prices(fetcher)
assert result["source"] == "aaa"
assert result["region"] == "US"
assert "regular" in result["prices"]
assert result["prices"]["regular"]["price_per_gallon"] == 3.450
assert "diesel" in result["prices"]
assert result["prices"]["diesel"]["price_per_gallon"] == 4.595
# Day-over-day delta: 3.450 - 3.413 = 0.037
assert result["prices"]["regular"]["change"] == 0.037
assert result["prices"]["regular"]["change_pct"] is not None
# Week-over-week comparison
assert result["prices"]["regular"]["week_ago_pct"] is not None
@pytest.mark.asyncio
async def test_fetch_gas_prices_no_data(
fetcher: Fetcher, monkeypatch: pytest.MonkeyPatch
) -> None:
from world_intel_mcp.sources import economic
monkeypatch.setattr(economic, "_fetch_aaa_html", lambda: "<html>maintenance</html>")
result = await economic.fetch_gas_prices(fetcher)
assert result["source"] == "aaa"
assert result["prices"] == {}
@respx.mock
@pytest.mark.asyncio
async def test_fetch_residential_natgas(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.economic import fetch_residential_natgas_prices
eia_response = {
"response": {
"data": [
{"value": 15.42, "period": "2026-01"},
{"value": 14.87, "period": "2025-12"},
]
}
}
respx.get(url__regex=r".*api\.eia\.gov.*natural-gas/pri/sum.*").mock(
return_value=httpx.Response(200, json=eia_response)
)
result = await fetch_residential_natgas_prices(fetcher, api_key="test-key")
assert result["source"] == "eia"
assert len(result["prices"]) == 2
assert result["prices"][0]["price"] == 15.42
@respx.mock
@pytest.mark.asyncio
async def test_fetch_electricity_rates(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.economic import fetch_electricity_rates
eia_response = {
"response": {
"data": [
{"sectorid": "RES", "price": 16.21, "period": "2026-01"},
{"sectorid": "COM", "price": 13.45, "period": "2026-01"},
{"sectorid": "IND", "price": 8.76, "period": "2026-01"},
{"sectorid": "ALL", "price": 12.89, "period": "2026-01"},
]
}
}
respx.get(url__regex=r".*api\.eia\.gov.*electricity/retail-sales.*").mock(
return_value=httpx.Response(200, json=eia_response)
)
result = await fetch_electricity_rates(fetcher, api_key="test-key")
assert result["source"] == "eia"
assert result["state"] == "US"
assert "residential" in result["rates"]
assert result["rates"]["residential"]["price_cents_kwh"] == 16.21
assert "commercial" in result["rates"]
assert "industrial" in result["rates"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Health (disease outbreaks) # Health (disease outbreaks)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -351,14 +468,16 @@ async def test_fetch_shipping_index(fetcher: Fetcher) -> None:
chart_response = { chart_response = {
"chart": { "chart": {
"result": [{ "result": [
"meta": { {
"symbol": "BDRY", "meta": {
"regularMarketPrice": 15.50, "symbol": "BDRY",
"regularMarketChangePercent": 4.2, "regularMarketPrice": 15.50,
"currency": "USD", "regularMarketChangePercent": 4.2,
"currency": "USD",
}
} }
}] ]
} }
} }
@@ -504,6 +623,7 @@ async def test_fetch_internet_outages_ioda_fallback(fetcher: Fetcher) -> None:
) )
import os import os
os.environ.pop("CLOUDFLARE_API_TOKEN", None) os.environ.pop("CLOUDFLARE_API_TOKEN", None)
result = await fetch_internet_outages(fetcher) result = await fetch_internet_outages(fetcher)
@@ -603,16 +723,32 @@ async def test_fetch_hacker_news(fetcher: Fetcher) -> None:
return_value=httpx.Response(200, json=[101, 102]) return_value=httpx.Response(200, json=[101, 102])
) )
respx.get("https://hacker-news.firebaseio.com/v0/item/101.json").mock( respx.get("https://hacker-news.firebaseio.com/v0/item/101.json").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"id": 101, "title": "Show HN: AI Tool", "url": "https://example.com", 200,
"score": 200, "by": "user1", "time": 1700000000, "descendants": 50, json={
}) "id": 101,
"title": "Show HN: AI Tool",
"url": "https://example.com",
"score": 200,
"by": "user1",
"time": 1700000000,
"descendants": 50,
},
)
) )
respx.get("https://hacker-news.firebaseio.com/v0/item/102.json").mock( respx.get("https://hacker-news.firebaseio.com/v0/item/102.json").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"id": 102, "title": "Rust 2.0", "url": "https://example.com/rust", 200,
"score": 150, "by": "user2", "time": 1700001000, "descendants": 30, json={
}) "id": 102,
"title": "Rust 2.0",
"url": "https://example.com/rust",
"score": 150,
"by": "user2",
"time": 1700001000,
"descendants": 30,
},
)
) )
result = await fetch_hacker_news(fetcher, limit=2) result = await fetch_hacker_news(fetcher, limit=2)
@@ -632,19 +768,24 @@ async def test_fetch_trending_repos(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.github_trending import fetch_trending_repos from world_intel_mcp.sources.github_trending import fetch_trending_repos
respx.get(url__regex=r".*api\.github\.com/search/repositories.*").mock( respx.get(url__regex=r".*api\.github\.com/search/repositories.*").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"total_count": 1, 200,
"items": [{ json={
"full_name": "user/cool-repo", "total_count": 1,
"description": "A cool tool", "items": [
"html_url": "https://github.com/user/cool-repo", {
"stargazers_count": 500, "full_name": "user/cool-repo",
"forks_count": 20, "description": "A cool tool",
"language": "Python", "html_url": "https://github.com/user/cool-repo",
"created_at": "2026-02-20T00:00:00Z", "stargazers_count": 500,
"topics": ["ai", "ml"], "forks_count": 20,
}], "language": "Python",
}) "created_at": "2026-02-20T00:00:00Z",
"topics": ["ai", "ml"],
}
],
},
)
) )
result = await fetch_trending_repos(fetcher, limit=5) result = await fetch_trending_repos(fetcher, limit=5)
@@ -702,17 +843,22 @@ async def test_fetch_usa_spending(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.usa_spending import fetch_usa_spending from world_intel_mcp.sources.usa_spending import fetch_usa_spending
respx.get(url__regex=r".*api\.usaspending\.gov.*").mock( respx.get(url__regex=r".*api\.usaspending\.gov.*").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"results": [{ 200,
"agency_name": "Department of Defense", json={
"abbreviation": "DOD", "results": [
"current_total_budget_authority_amount": 850000000000, {
"obligated_amount": 700000000000, "agency_name": "Department of Defense",
"outlay_amount": 650000000000, "abbreviation": "DOD",
"agency_id": 97, "current_total_budget_authority_amount": 850000000000,
}], "obligated_amount": 700000000000,
"page_metadata": {"total": 1}, "outlay_amount": 650000000000,
}) "agency_id": 97,
}
],
"page_metadata": {"total": 1},
},
)
) )
result = await fetch_usa_spending(fetcher, limit=5) result = await fetch_usa_spending(fetcher, limit=5)
@@ -732,15 +878,27 @@ async def test_fetch_environmental_events(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.environmental import fetch_environmental_events from world_intel_mcp.sources.environmental import fetch_environmental_events
respx.get(url__regex=r".*eonet\.gsfc\.nasa\.gov.*").mock( respx.get(url__regex=r".*eonet\.gsfc\.nasa\.gov.*").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"events": [{ 200,
"id": "EONET_1234", json={
"title": "Wildfire in California", "events": [
"categories": [{"id": "wildfires", "title": "Wildfires"}], {
"sources": [{"id": "InciWeb", "url": "https://inciweb.example.com"}], "id": "EONET_1234",
"geometry": [{"date": "2026-02-20T00:00:00Z", "coordinates": [-119.5, 34.5]}], "title": "Wildfire in California",
}], "categories": [{"id": "wildfires", "title": "Wildfires"}],
}) "sources": [
{"id": "InciWeb", "url": "https://inciweb.example.com"}
],
"geometry": [
{
"date": "2026-02-20T00:00:00Z",
"coordinates": [-119.5, 34.5],
}
],
}
],
},
)
) )
result = await fetch_environmental_events(fetcher, days=7) result = await fetch_environmental_events(fetcher, days=7)
@@ -761,22 +919,24 @@ async def test_fetch_disaster_alerts(fetcher: Fetcher) -> None:
gdacs_geojson = { gdacs_geojson = {
"type": "FeatureCollection", "type": "FeatureCollection",
"features": [{ "features": [
"type": "Feature", {
"properties": { "type": "Feature",
"eventtype": "EQ", "properties": {
"eventname": "M6.5 Earthquake", "eventtype": "EQ",
"alertlevel": "orange", "eventname": "M6.5 Earthquake",
"alertscore": 2.5, "alertlevel": "orange",
"severity": {"value": 6.5, "unit": "M"}, "alertscore": 2.5,
"country": "Turkey", "severity": {"value": 6.5, "unit": "M"},
"fromdate": "2026-02-20T12:00:00Z", "country": "Turkey",
"todate": "2026-02-20T12:05:00Z", "fromdate": "2026-02-20T12:00:00Z",
"url": {"report": "https://gdacs.example.com/report"}, "todate": "2026-02-20T12:05:00Z",
"population": {"value": 500000}, "url": {"report": "https://gdacs.example.com/report"},
}, "population": {"value": 500000},
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]}, },
}], "geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
}
],
} }
respx.get(url__regex=r".*gdacs\.org.*").mock( respx.get(url__regex=r".*gdacs\.org.*").mock(
@@ -856,14 +1016,16 @@ async def test_fetch_country_stocks(fetcher: Fetcher) -> None:
chart_response = { chart_response = {
"chart": { "chart": {
"result": [{ "result": [
"meta": { {
"symbol": "^GSPC", "meta": {
"regularMarketPrice": 5200.0, "symbol": "^GSPC",
"regularMarketChangePercent": 0.75, "regularMarketPrice": 5200.0,
"currency": "USD", "regularMarketChangePercent": 0.75,
"currency": "USD",
}
} }
}] ]
} }
} }
@@ -888,21 +1050,29 @@ async def test_fetch_aircraft_details_batch(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.military import fetch_aircraft_details_batch from world_intel_mcp.sources.military import fetch_aircraft_details_batch
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae1234.*").mock( respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae1234.*").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"Registration": "12-3456", 200,
"Type": "C-17A", json={
"Operator": "USAF", "Registration": "12-3456",
}) "Type": "C-17A",
"Operator": "USAF",
},
)
) )
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae5678.*").mock( respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae5678.*").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"Registration": "78-9012", 200,
"Type": "KC-135R", json={
"Operator": "USAF", "Registration": "78-9012",
}) "Type": "KC-135R",
"Operator": "USAF",
},
)
) )
result = await fetch_aircraft_details_batch(fetcher, icao24_list=["ae1234", "ae5678"]) result = await fetch_aircraft_details_batch(
fetcher, icao24_list=["ae1234", "ae5678"]
)
assert result["source"] == "hexdb" assert result["source"] == "hexdb"
assert result["count"] == 2 assert result["count"] == 2
assert result["requested"] == 2 assert result["requested"] == 2
@@ -964,6 +1134,7 @@ async def test_fetch_central_bank_rates_no_fred(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
import os import os
os.environ.pop("FRED_API_KEY", None) os.environ.pop("FRED_API_KEY", None)
result = await fetch_central_bank_rates(fetcher) result = await fetch_central_bank_rates(fetcher)
@@ -984,13 +1155,10 @@ async def test_fetch_central_bank_rates_with_fred(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
import os import os
os.environ["FRED_API_KEY"] = "test_key_123" os.environ["FRED_API_KEY"] = "test_key_123"
fred_response = { fred_response = {"observations": [{"date": "2026-02-25", "value": "4.33"}]}
"observations": [
{"date": "2026-02-25", "value": "4.33"}
]
}
respx.get("https://api.stlouisfed.org/fred/series/observations").mock( respx.get("https://api.stlouisfed.org/fred/series/observations").mock(
return_value=httpx.Response(200, json=fred_response) return_value=httpx.Response(200, json=fred_response)
@@ -1189,6 +1357,7 @@ async def test_fetch_financial_centers_filter_country() -> None:
# Country Dossier (Phase 16) # Country Dossier (Phase 16)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_fetch_country_dossier(fetcher: Fetcher) -> None: async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
@@ -1197,40 +1366,60 @@ async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
# Mock World Bank GDP # Mock World Bank GDP
respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock( respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock(
return_value=httpx.Response(200, json=[ return_value=httpx.Response(
{"page": 1}, 200,
[{"date": "2024", "value": 28000000000000}], json=[
]) {"page": 1},
[{"date": "2024", "value": 28000000000000}],
],
)
) )
# Mock World Bank inflation # Mock World Bank inflation
respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock( respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock(
return_value=httpx.Response(200, json=[ return_value=httpx.Response(
{"page": 1}, 200,
[{"date": "2024", "value": 3.2}], json=[
]) {"page": 1},
[{"date": "2024", "value": 3.2}],
],
)
) )
# Mock ACLED (no key = skip) # Mock ACLED (no key = skip)
# Mock Yahoo Finance for country stocks # Mock Yahoo Finance for country stocks
respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock( respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(
"chart": {"result": [{"meta": { 200,
"regularMarketPrice": 5800, json={
"chartPreviousClose": 5750, "chart": {
"currency": "USD", "result": [
"exchangeName": "SNP", {
}}]}, "meta": {
}) "regularMarketPrice": 5800,
"chartPreviousClose": 5750,
"currency": "USD",
"exchangeName": "SNP",
}
}
]
},
},
)
) )
# Mock OFAC sanctions # Mock OFAC sanctions
respx.get("https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML").mock( respx.get(
return_value=httpx.Response(200, text="<sdnList></sdnList>") "https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML"
) ).mock(return_value=httpx.Response(200, text="<sdnList></sdnList>"))
# Mock news feeds — just one category needed # Mock news feeds — just one category needed
respx.route().mock(return_value=httpx.Response(200, text="""<?xml version="1.0"?> respx.route().mock(
return_value=httpx.Response(
200,
text="""<?xml version="1.0"?>
<rss version="2.0"><channel><title>Test</title> <rss version="2.0"><channel><title>Test</title>
<item><title>US Economy Grows</title><link>https://example.com/1</link></item> <item><title>US Economy Grows</title><link>https://example.com/1</link></item>
<item><title>China Trade</title><link>https://example.com/2</link></item> <item><title>China Trade</title><link>https://example.com/2</link></item>
</channel></rss>""")) </channel></rss>""",
)
)
result = await fetch_country_dossier(fetcher, country="US") result = await fetch_country_dossier(fetcher, country="US")
@@ -1260,6 +1449,7 @@ async def test_fetch_country_dossier_invalid_code(fetcher: Fetcher) -> None:
# Traffic (Phase 16) # Traffic (Phase 16)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_fetch_traffic_flow_no_key(fetcher: Fetcher) -> None: async def test_fetch_traffic_flow_no_key(fetcher: Fetcher) -> None:
@@ -1279,12 +1469,17 @@ async def test_fetch_traffic_flow_with_key(fetcher: Fetcher) -> None:
"""Traffic flow fetches congestion data from TomTom.""" """Traffic flow fetches congestion data from TomTom."""
from world_intel_mcp.sources.traffic import fetch_traffic_flow from world_intel_mcp.sources.traffic import fetch_traffic_flow
respx.route().mock(return_value=httpx.Response(200, json={ respx.route().mock(
"flowSegmentData": { return_value=httpx.Response(
"currentSpeed": 30.0, 200,
"freeFlowSpeed": 60.0, json={
}, "flowSegmentData": {
})) "currentSpeed": 30.0,
"freeFlowSpeed": 60.0,
},
},
)
)
with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}): with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}):
result = await fetch_traffic_flow(fetcher) result = await fetch_traffic_flow(fetcher)
@@ -1311,6 +1506,7 @@ async def test_fetch_traffic_incidents_no_key(fetcher: Fetcher) -> None:
# Aviation Domestic (Phase 16) # Aviation Domestic (Phase 16)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_fetch_domestic_flights(fetcher: Fetcher) -> None: async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
@@ -1318,14 +1514,70 @@ async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.aviation import fetch_domestic_flights from world_intel_mcp.sources.aviation import fetch_domestic_flights
# Simulate 3 airborne aircraft at known positions # Simulate 3 airborne aircraft at known positions
respx.route().mock(return_value=httpx.Response(200, json={ respx.route().mock(
"states": [ return_value=httpx.Response(
# [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...] 200,
["abc123", "UAL123 ", "United States", None, None, -73.9, 40.7, 10000, False, None, None, None, None, None, None, None], json={
["def456", "BAW789 ", "United Kingdom", None, None, -0.1, 51.5, 11000, False, None, None, None, None, None, None, None], "states": [
["ghi789", "CCA100 ", "China", None, None, 116.4, 39.9, 12000, False, None, None, None, None, None, None, None], # [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...]
], [
})) "abc123",
"UAL123 ",
"United States",
None,
None,
-73.9,
40.7,
10000,
False,
None,
None,
None,
None,
None,
None,
None,
],
[
"def456",
"BAW789 ",
"United Kingdom",
None,
None,
-0.1,
51.5,
11000,
False,
None,
None,
None,
None,
None,
None,
None,
],
[
"ghi789",
"CCA100 ",
"China",
None,
None,
116.4,
39.9,
12000,
False,
None,
None,
None,
None,
None,
None,
None,
],
],
},
)
)
result = await fetch_domestic_flights(fetcher) result = await fetch_domestic_flights(fetcher)
@@ -1339,6 +1591,7 @@ async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
# Webcams (Phase 16) # Webcams (Phase 16)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None: async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None:
"""Webcams returns error when WINDY_API_KEY is not set.""" """Webcams returns error when WINDY_API_KEY is not set."""
@@ -1357,18 +1610,33 @@ async def test_fetch_webcams_with_key(fetcher: Fetcher) -> None:
"""Webcams fetches camera data from Windy API.""" """Webcams fetches camera data from Windy API."""
from world_intel_mcp.sources.webcams import fetch_webcams from world_intel_mcp.sources.webcams import fetch_webcams
respx.route().mock(return_value=httpx.Response(200, json={ respx.route().mock(
"webcams": [ return_value=httpx.Response(
{ 200,
"webcamId": "cam-1", json={
"title": "Times Square", "webcams": [
"location": {"latitude": 40.758, "longitude": -73.985, "city": "New York", "country": "US"}, {
"images": {"current": {"preview": "https://example.com/prev.jpg", "thumbnail": "https://example.com/thumb.jpg"}}, "webcamId": "cam-1",
"player": {"day": {"embed": "https://example.com/player"}}, "title": "Times Square",
"status": "active", "location": {
"latitude": 40.758,
"longitude": -73.985,
"city": "New York",
"country": "US",
},
"images": {
"current": {
"preview": "https://example.com/prev.jpg",
"thumbnail": "https://example.com/thumb.jpg",
}
},
"player": {"day": {"embed": "https://example.com/player"}},
"status": "active",
},
],
}, },
], )
})) )
with patch.dict("os.environ", {"WINDY_API_KEY": "test-key"}): with patch.dict("os.environ", {"WINDY_API_KEY": "test-key"}):
result = await fetch_webcams(fetcher, category="traffic", limit=10) result = await fetch_webcams(fetcher, category="traffic", limit=10)