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:
@@ -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")
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">▼</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(); }
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user