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)
@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")
+11 -2
View File
@@ -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"],
+3
View File
@@ -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),
+86 -15
View File
@@ -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; }
<!-- VECTOR INTELLIGENCE PANEL -->
<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-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 id="vecContent"><div class="vec-unavail">Connecting...</div></div>
<div class="vec-search-wrap">
<input type="text" class="vec-search-input" id="vecSearchInput" placeholder="Semantic search..." autocomplete="off">
<button class="vec-search-btn" id="vecSearchBtn">Search</button>
<div class="vec-body">
<div id="vecContent"><div class="vec-unavail">Connecting...</div></div>
<div class="vec-search-wrap">
<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 class="vec-results" id="vecResults"></div>
</div>
<script>
@@ -2248,10 +2258,10 @@ function updateDrawer(data) {
var ep = data.energy_prices;
var eRows = [];
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.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.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, 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) {
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) {
@@ -2260,6 +2270,59 @@ function updateDrawer(data) {
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) {
var etfs = data.etf_flows.etfs || data.etf_flows.data || [];
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 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-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 ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></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>';
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>';
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">$' + fmtNum(bt.sma_200) + '</span></div>';
if (bt.change_7d_pct != null) {
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>';
@@ -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);
$('#vecSearchInput').addEventListener('keydown', function(e) {
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.",
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(
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": {}},
),
Tool(
@@ -1769,6 +1792,15 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
return await markets.fetch_commodity_quotes(fetcher)
# 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":
return await economic.fetch_energy_prices(fetcher)
case "intel_fred_series":
+375 -17
View File
@@ -85,9 +85,7 @@ async def fetch_energy_prices(
# --- Parse oil prices ---------------------------------------------------
if oil_data:
try:
records = (
oil_data.get("response", {}).get("data", [])
)
records = oil_data.get("response", {}).get("data", [])
for rec in records:
product = rec.get("product")
value = rec.get("value")
@@ -105,9 +103,7 @@ async def fetch_energy_prices(
# --- Parse natural gas price --------------------------------------------
if gas_data:
try:
records = (
gas_data.get("response", {}).get("data", [])
)
records = gas_data.get("response", {}).get("data", [])
if records:
rec = records[0]
value = rec.get("value")
@@ -123,6 +119,366 @@ async def fetch_energy_prices(
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
# ---------------------------------------------------------------------------
@@ -213,9 +569,9 @@ async def fetch_fred_series(
_WB_BASE = "https://api.worldbank.org/v2/country"
_DEFAULT_INDICATORS = [
"NY.GDP.MKTP.CD", # GDP (current US$)
"FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %)
"SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force)
"NY.GDP.MKTP.CD", # GDP (current US$)
"FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %)
"SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force)
]
@@ -249,9 +605,7 @@ async def fetch_world_bank_indicators(
params=params,
)
responses = await asyncio.gather(
*[_fetch_one(ind) for ind in indicator_ids]
)
responses = await asyncio.gather(*[_fetch_one(ind) for ind in indicator_ids])
parsed_indicators: list[dict] = []
@@ -288,14 +642,18 @@ async def fetch_world_bank_indicators(
parsed_value = float(value)
except (ValueError, TypeError):
pass
entry["values"].append({
"year": year,
"value": parsed_value,
})
entry["values"].append(
{
"year": year,
"value": parsed_value,
}
)
except (KeyError, TypeError, IndexError) as exc:
logger.warning(
"Failed to parse World Bank indicator %s for %s: %s",
indicator_id, country, exc,
indicator_id,
country,
exc,
)
parsed_indicators.append(entry)
+413 -145
View File
@@ -1,8 +1,7 @@
"""Tests for source modules — uses respx to mock HTTP calls."""
import json
from pathlib import Path
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import httpx
import pytest
@@ -37,14 +36,16 @@ async def test_fetch_market_quotes(fetcher: Fetcher) -> None:
# Mock Yahoo Finance v8 chart response for ^GSPC
chart_response = {
"chart": {
"result": [{
"meta": {
"symbol": "^GSPC",
"regularMarketPrice": 5123.45,
"regularMarketChangePercent": 0.42,
"currency": "USD",
"result": [
{
"meta": {
"symbol": "^GSPC",
"regularMarketPrice": 5123.45,
"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):
# Remove the key if present
import os
os.environ.pop("NASA_FIRMS_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
import os
os.environ.pop("FRED_API_KEY", None)
result = await fetch_fred_series(fetcher, series_id="UNRATE", api_key=None)
assert "error" in result
@@ -202,8 +205,16 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
wb_response = [
{"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)
)
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 len(result["indicators"]) == 1
assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD"
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)
# ---------------------------------------------------------------------------
@@ -351,14 +468,16 @@ async def test_fetch_shipping_index(fetcher: Fetcher) -> None:
chart_response = {
"chart": {
"result": [{
"meta": {
"symbol": "BDRY",
"regularMarketPrice": 15.50,
"regularMarketChangePercent": 4.2,
"currency": "USD",
"result": [
{
"meta": {
"symbol": "BDRY",
"regularMarketPrice": 15.50,
"regularMarketChangePercent": 4.2,
"currency": "USD",
}
}
}]
]
}
}
@@ -504,6 +623,7 @@ async def test_fetch_internet_outages_ioda_fallback(fetcher: Fetcher) -> None:
)
import os
os.environ.pop("CLOUDFLARE_API_TOKEN", None)
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])
)
respx.get("https://hacker-news.firebaseio.com/v0/item/101.json").mock(
return_value=httpx.Response(200, json={
"id": 101, "title": "Show HN: AI Tool", "url": "https://example.com",
"score": 200, "by": "user1", "time": 1700000000, "descendants": 50,
})
return_value=httpx.Response(
200,
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(
return_value=httpx.Response(200, json={
"id": 102, "title": "Rust 2.0", "url": "https://example.com/rust",
"score": 150, "by": "user2", "time": 1700001000, "descendants": 30,
})
return_value=httpx.Response(
200,
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)
@@ -632,19 +768,24 @@ async def test_fetch_trending_repos(fetcher: Fetcher) -> None:
from world_intel_mcp.sources.github_trending import fetch_trending_repos
respx.get(url__regex=r".*api\.github\.com/search/repositories.*").mock(
return_value=httpx.Response(200, json={
"total_count": 1,
"items": [{
"full_name": "user/cool-repo",
"description": "A cool tool",
"html_url": "https://github.com/user/cool-repo",
"stargazers_count": 500,
"forks_count": 20,
"language": "Python",
"created_at": "2026-02-20T00:00:00Z",
"topics": ["ai", "ml"],
}],
})
return_value=httpx.Response(
200,
json={
"total_count": 1,
"items": [
{
"full_name": "user/cool-repo",
"description": "A cool tool",
"html_url": "https://github.com/user/cool-repo",
"stargazers_count": 500,
"forks_count": 20,
"language": "Python",
"created_at": "2026-02-20T00:00:00Z",
"topics": ["ai", "ml"],
}
],
},
)
)
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
respx.get(url__regex=r".*api\.usaspending\.gov.*").mock(
return_value=httpx.Response(200, json={
"results": [{
"agency_name": "Department of Defense",
"abbreviation": "DOD",
"current_total_budget_authority_amount": 850000000000,
"obligated_amount": 700000000000,
"outlay_amount": 650000000000,
"agency_id": 97,
}],
"page_metadata": {"total": 1},
})
return_value=httpx.Response(
200,
json={
"results": [
{
"agency_name": "Department of Defense",
"abbreviation": "DOD",
"current_total_budget_authority_amount": 850000000000,
"obligated_amount": 700000000000,
"outlay_amount": 650000000000,
"agency_id": 97,
}
],
"page_metadata": {"total": 1},
},
)
)
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
respx.get(url__regex=r".*eonet\.gsfc\.nasa\.gov.*").mock(
return_value=httpx.Response(200, json={
"events": [{
"id": "EONET_1234",
"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]}],
}],
})
return_value=httpx.Response(
200,
json={
"events": [
{
"id": "EONET_1234",
"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)
@@ -761,22 +919,24 @@ async def test_fetch_disaster_alerts(fetcher: Fetcher) -> None:
gdacs_geojson = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {
"eventtype": "EQ",
"eventname": "M6.5 Earthquake",
"alertlevel": "orange",
"alertscore": 2.5,
"severity": {"value": 6.5, "unit": "M"},
"country": "Turkey",
"fromdate": "2026-02-20T12:00:00Z",
"todate": "2026-02-20T12:05:00Z",
"url": {"report": "https://gdacs.example.com/report"},
"population": {"value": 500000},
},
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
}],
"features": [
{
"type": "Feature",
"properties": {
"eventtype": "EQ",
"eventname": "M6.5 Earthquake",
"alertlevel": "orange",
"alertscore": 2.5,
"severity": {"value": 6.5, "unit": "M"},
"country": "Turkey",
"fromdate": "2026-02-20T12:00:00Z",
"todate": "2026-02-20T12:05:00Z",
"url": {"report": "https://gdacs.example.com/report"},
"population": {"value": 500000},
},
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
}
],
}
respx.get(url__regex=r".*gdacs\.org.*").mock(
@@ -856,14 +1016,16 @@ async def test_fetch_country_stocks(fetcher: Fetcher) -> None:
chart_response = {
"chart": {
"result": [{
"meta": {
"symbol": "^GSPC",
"regularMarketPrice": 5200.0,
"regularMarketChangePercent": 0.75,
"currency": "USD",
"result": [
{
"meta": {
"symbol": "^GSPC",
"regularMarketPrice": 5200.0,
"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
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae1234.*").mock(
return_value=httpx.Response(200, json={
"Registration": "12-3456",
"Type": "C-17A",
"Operator": "USAF",
})
return_value=httpx.Response(
200,
json={
"Registration": "12-3456",
"Type": "C-17A",
"Operator": "USAF",
},
)
)
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae5678.*").mock(
return_value=httpx.Response(200, json={
"Registration": "78-9012",
"Type": "KC-135R",
"Operator": "USAF",
})
return_value=httpx.Response(
200,
json={
"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["count"] == 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
import os
os.environ.pop("FRED_API_KEY", None)
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
import os
os.environ["FRED_API_KEY"] = "test_key_123"
fred_response = {
"observations": [
{"date": "2026-02-25", "value": "4.33"}
]
}
fred_response = {"observations": [{"date": "2026-02-25", "value": "4.33"}]}
respx.get("https://api.stlouisfed.org/fred/series/observations").mock(
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)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
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
respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock(
return_value=httpx.Response(200, json=[
{"page": 1},
[{"date": "2024", "value": 28000000000000}],
])
return_value=httpx.Response(
200,
json=[
{"page": 1},
[{"date": "2024", "value": 28000000000000}],
],
)
)
# Mock World Bank inflation
respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock(
return_value=httpx.Response(200, json=[
{"page": 1},
[{"date": "2024", "value": 3.2}],
])
return_value=httpx.Response(
200,
json=[
{"page": 1},
[{"date": "2024", "value": 3.2}],
],
)
)
# Mock ACLED (no key = skip)
# Mock Yahoo Finance for country stocks
respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock(
return_value=httpx.Response(200, json={
"chart": {"result": [{"meta": {
"regularMarketPrice": 5800,
"chartPreviousClose": 5750,
"currency": "USD",
"exchangeName": "SNP",
}}]},
})
return_value=httpx.Response(
200,
json={
"chart": {
"result": [
{
"meta": {
"regularMarketPrice": 5800,
"chartPreviousClose": 5750,
"currency": "USD",
"exchangeName": "SNP",
}
}
]
},
},
)
)
# Mock OFAC sanctions
respx.get("https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML").mock(
return_value=httpx.Response(200, text="<sdnList></sdnList>")
)
respx.get(
"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
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>
<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>
</channel></rss>"""))
</channel></rss>""",
)
)
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)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
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."""
from world_intel_mcp.sources.traffic import fetch_traffic_flow
respx.route().mock(return_value=httpx.Response(200, json={
"flowSegmentData": {
"currentSpeed": 30.0,
"freeFlowSpeed": 60.0,
},
}))
respx.route().mock(
return_value=httpx.Response(
200,
json={
"flowSegmentData": {
"currentSpeed": 30.0,
"freeFlowSpeed": 60.0,
},
},
)
)
with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}):
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)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@respx.mock
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
# Simulate 3 airborne aircraft at known positions
respx.route().mock(return_value=httpx.Response(200, json={
"states": [
# [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],
],
}))
respx.route().mock(
return_value=httpx.Response(
200,
json={
"states": [
# [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)
@@ -1339,6 +1591,7 @@ async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
# Webcams (Phase 16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None:
"""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."""
from world_intel_mcp.sources.webcams import fetch_webcams
respx.route().mock(return_value=httpx.Response(200, json={
"webcams": [
{
"webcamId": "cam-1",
"title": "Times Square",
"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",
respx.route().mock(
return_value=httpx.Response(
200,
json={
"webcams": [
{
"webcamId": "cam-1",
"title": "Times Square",
"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"}):
result = await fetch_webcams(fetcher, category="traffic", limit=10)