From baba564b8405c9de7072f83ab1d0a14c3eb4144b Mon Sep 17 00:00:00 2001 From: Marc Shade Date: Sun, 8 Mar 2026 08:22:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(world-intel):=20Phase=2015=20=E2=80=94=20b?= =?UTF-8?q?usiness=20intelligence=20tools=20(101=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 12 new tools across 6 domains: - Forex: intel_forex_rates, intel_forex_timeseries, intel_major_crosses (ECB/Frankfurter) - Bonds: intel_yield_curve, intel_bond_indices (FRED + Yahoo Finance fallback) - Earnings: intel_earnings_calendar, intel_earnings_surprise (Yahoo Finance) - SEC: intel_sec_filings, intel_company_filings, intel_recent_8k (SEC EDGAR) - Company: intel_company_profile (composite: Yahoo + GDELT + SEC + GitHub) - Macro: intel_macro_composite (weighted score from 6 signals) New source modules: forex.py, bonds.py, earnings.py, sec_edgar.py New analysis modules: company.py, macro_composite.py 66 new tests (186 total, all passing) All free public APIs, no API keys required. --- pyproject.toml | 2 +- src/world_intel_mcp/analysis/company.py | 267 +++++ .../analysis/macro_composite.py | 263 +++++ src/world_intel_mcp/fetcher.py | 35 +- src/world_intel_mcp/server.py | 1033 ++++++++++++++--- src/world_intel_mcp/sources/bonds.py | 286 +++++ src/world_intel_mcp/sources/earnings.py | 312 +++++ src/world_intel_mcp/sources/forex.py | 238 ++++ src/world_intel_mcp/sources/sec_edgar.py | 363 ++++++ src/world_intel_mcp/tests/conftest.py | 30 +- src/world_intel_mcp/tests/test_bonds.py | 267 +++++ src/world_intel_mcp/tests/test_company.py | 235 ++++ src/world_intel_mcp/tests/test_earnings.py | 276 +++++ src/world_intel_mcp/tests/test_forex.py | 217 ++++ .../tests/test_macro_composite.py | 292 +++++ src/world_intel_mcp/tests/test_sec_edgar.py | 334 ++++++ 16 files changed, 4291 insertions(+), 159 deletions(-) create mode 100644 src/world_intel_mcp/analysis/company.py create mode 100644 src/world_intel_mcp/analysis/macro_composite.py create mode 100644 src/world_intel_mcp/sources/bonds.py create mode 100644 src/world_intel_mcp/sources/earnings.py create mode 100644 src/world_intel_mcp/sources/forex.py create mode 100644 src/world_intel_mcp/sources/sec_edgar.py create mode 100644 src/world_intel_mcp/tests/test_bonds.py create mode 100644 src/world_intel_mcp/tests/test_company.py create mode 100644 src/world_intel_mcp/tests/test_earnings.py create mode 100644 src/world_intel_mcp/tests/test_forex.py create mode 100644 src/world_intel_mcp/tests/test_macro_composite.py create mode 100644 src/world_intel_mcp/tests/test_sec_edgar.py diff --git a/pyproject.toml b/pyproject.toml index e9ae673..30eeb5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "world-intel-mcp" version = "0.1.0" -description = "World Intelligence MCP Server - real-time global intelligence across 27 domains with 68 MCP tools" +description = "World Intelligence MCP Server — real-time global intelligence across 30+ domains with 100+ MCP tools" readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} diff --git a/src/world_intel_mcp/analysis/company.py b/src/world_intel_mcp/analysis/company.py new file mode 100644 index 0000000..94a956e --- /dev/null +++ b/src/world_intel_mcp/analysis/company.py @@ -0,0 +1,267 @@ +"""Company enrichment — aggregate stock, financials, news, and metadata. + +Given a company name or ticker symbol, fetches data from Yahoo Finance +(quote + profile), GDELT news, SEC EDGAR filings (if available), and +GitHub (if tech company). All sources are queried in parallel. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.analysis.company") + +_YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" +_YAHOO_SUMMARY_URL = ( + "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{symbol}" +) +_GDELT_DOC_URL = "https://api.gdeltproject.org/api/v2/doc/doc" +_GITHUB_SEARCH_URL = "https://api.github.com/search/repositories" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def _safe(coro, label: str): + """Run a coroutine, swallowing exceptions.""" + try: + return await coro + except Exception as exc: + logger.warning("Company: %s failed: %s", label, exc) + return None + + +# --------------------------------------------------------------------------- +# Sub-fetchers +# --------------------------------------------------------------------------- + + +async def _fetch_stock_quote(fetcher: Fetcher, symbol: str) -> dict | None: + """Fetch price data from Yahoo Finance v8 chart API.""" + url = _YAHOO_CHART_URL.format(symbol=symbol) + data = await fetcher.get_json( + url, + source="yahoo-finance", + cache_key=f"company:quote:{symbol}", + cache_ttl=300, + params={"range": "5d", "interval": "1d"}, + yahoo_rate_limit=True, + ) + if not isinstance(data, dict): + return None + try: + meta = data["chart"]["result"][0]["meta"] + price = meta.get("regularMarketPrice") + prev = meta.get("previousClose") or meta.get("chartPreviousClose") + change_pct = None + if price is not None and prev and prev > 0: + change_pct = round(((price - prev) / prev) * 100, 4) + return { + "price": price, + "change_pct": change_pct, + "volume": meta.get("regularMarketVolume"), + "market_cap": meta.get("marketCap"), + } + except (KeyError, IndexError, TypeError): + logger.warning("Unexpected Yahoo chart structure for %s", symbol) + return None + + +async def _fetch_company_info(fetcher: Fetcher, symbol: str) -> dict | None: + """Fetch company profile + financials from Yahoo quoteSummary.""" + url = _YAHOO_SUMMARY_URL.format(symbol=symbol) + data = await fetcher.get_json( + url, + source="yahoo-finance", + cache_key=f"company:info:{symbol}", + cache_ttl=1800, + params={"modules": "assetProfile,financialData,defaultKeyStatistics"}, + yahoo_rate_limit=True, + ) + if not isinstance(data, dict): + return None + try: + result = data["quoteSummary"]["result"][0] + profile = result.get("assetProfile", {}) + fin = result.get("financialData", {}) + stats = result.get("defaultKeyStatistics", {}) + return { + "sector": profile.get("sector"), + "industry": profile.get("industry"), + "employees": profile.get("fullTimeEmployees"), + "website": profile.get("website"), + "description": profile.get("longBusinessSummary"), + "revenue": _raw_val(fin.get("totalRevenue")), + "profit_margin": _raw_val(fin.get("profitMargins")), + "pe_ratio": _raw_val(stats.get("forwardPE") or stats.get("trailingPE")), + "market_cap": _raw_val(stats.get("marketCap")), + } + except (KeyError, IndexError, TypeError): + logger.warning("Unexpected Yahoo quoteSummary structure for %s", symbol) + return None + + +def _raw_val(field) -> float | int | None: + """Extract raw value from Yahoo quoteSummary nested dicts.""" + if field is None: + return None + if isinstance(field, dict): + return field.get("raw") + return field + + +async def _fetch_company_news(fetcher: Fetcher, query: str) -> list[dict]: + """Fetch recent news about the company from GDELT.""" + data = await fetcher.get_json( + _GDELT_DOC_URL, + source="gdelt", + cache_key=f"company:news:{query}", + cache_ttl=1800, + params={ + "query": f'"{query}"', + "mode": "artlist", + "maxrecords": "5", + "format": "json", + }, + ) + if not isinstance(data, dict): + return [] + articles = data.get("articles", []) + results: list[dict] = [] + for art in articles[:5]: + results.append( + { + "title": art.get("title"), + "url": art.get("url"), + "date": art.get("seendate"), + } + ) + return results + + +async def _fetch_sec_filings(fetcher: Fetcher, ticker: str) -> list[dict] | None: + """Try to fetch SEC filings via the sec_edgar source module (lazy import).""" + try: + from ..sources.sec_edgar import fetch_company_filings + except ImportError: + return None + try: + result = await fetch_company_filings(fetcher, ticker, limit=5) + return result.get("filings", []) + except Exception as exc: + logger.warning("SEC filings fetch failed for %s: %s", ticker, exc) + return None + + +async def _fetch_github_repos(fetcher: Fetcher, query: str) -> list[dict]: + """Search GitHub for repositories related to the company.""" + data = await fetcher.get_json( + _GITHUB_SEARCH_URL, + source="github", + cache_key=f"company:github:{query}", + cache_ttl=1800, + params={"q": query, "sort": "stars", "per_page": "3"}, + ) + if not isinstance(data, dict): + return [] + items = data.get("items", []) + results: list[dict] = [] + query_lower = query.lower() + for repo in items[:3]: + owner = (repo.get("owner", {}).get("login") or "").lower() + name = (repo.get("full_name") or "").lower() + # Only include if the org/owner or repo name plausibly matches + if query_lower in owner or query_lower in name: + results.append( + { + "name": repo.get("full_name"), + "stars": repo.get("stargazers_count"), + "url": repo.get("html_url"), + } + ) + return results + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def fetch_company_profile(fetcher: Fetcher, query: str) -> dict: + """Build a composite company profile from multiple data sources. + + Args: + fetcher: Shared HTTP fetcher with caching and circuit breaking. + query: Ticker symbol (e.g. "AAPL") or company name. + + Returns: + Dict with stock, financials, news, SEC filings, and GitHub data. + """ + symbol = query.upper().strip() + + ( + stock_data, + info_data, + news_data, + sec_data, + github_data, + ) = await asyncio.gather( + _safe(_fetch_stock_quote(fetcher, symbol), "stock_quote"), + _safe(_fetch_company_info(fetcher, symbol), "company_info"), + _safe(_fetch_company_news(fetcher, query), "company_news"), + _safe(_fetch_sec_filings(fetcher, symbol), "sec_filings"), + _safe(_fetch_github_repos(fetcher, query), "github_repos"), + ) + + # Build stock section + stock = stock_data if stock_data else {} + + # Build financials section from company info + financials: dict = {} + company_name = symbol + sector = None + industry = None + if info_data: + company_name = ( + info_data.get("description", symbol)[:80] + if info_data.get("description") + else symbol + ) + sector = info_data.get("sector") + industry = info_data.get("industry") + financials = { + "revenue": info_data.get("revenue"), + "profit_margin": info_data.get("profit_margin"), + "pe_ratio": info_data.get("pe_ratio"), + "employees": info_data.get("employees"), + } + # Merge market cap from info if not in stock quote + if not stock.get("market_cap") and info_data.get("market_cap"): + stock["market_cap"] = info_data["market_cap"] + + result: dict = { + "query": query, + "ticker": symbol, + "company_name": company_name, + "sector": sector, + "industry": industry, + "stock": stock, + "financials": financials, + "recent_news": news_data if news_data else [], + } + + if sec_data is not None: + result["sec_filings"] = sec_data + + if github_data: + result["github"] = github_data + + result["fetched_at"] = _utc_now_iso() + result["source"] = "composite" + + return result diff --git a/src/world_intel_mcp/analysis/macro_composite.py b/src/world_intel_mcp/analysis/macro_composite.py new file mode 100644 index 0000000..baef5e0 --- /dev/null +++ b/src/world_intel_mcp/analysis/macro_composite.py @@ -0,0 +1,263 @@ +"""Macro signal composite — synthesize market signals into an actionable verdict. + +Aggregates Fear & Greed, VIX, sector breadth, DXY, BTC technicals, and +10Y yield into a single weighted score with a market stance verdict. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.analysis.macro_composite") + +# Signal weights (sum to 1.0) +SIGNAL_WEIGHTS: dict[str, float] = { + "fear_greed": 0.25, + "vix": 0.20, + "sector_breadth": 0.20, + "dxy": 0.15, + "btc": 0.10, + "yield_10y": 0.10, +} + +_VERDICT_BANDS: list[tuple[float, str]] = [ + (80, "RISK_ON"), + (60, "CONSTRUCTIVE"), + (40, "NEUTRAL"), + (20, "CAUTIOUS"), + (0, "STRONG_CAUTION"), +] + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _classify_vix(value: float | None) -> tuple[str, float]: + """Classify VIX into a label and a 0-100 score (inverted: low VIX = high score).""" + if value is None: + return "unavailable", 50.0 + if value < 15: + return "complacent", 90.0 + elif value < 20: + return "calm", 70.0 + elif value < 30: + return "cautious", 40.0 + else: + return "fear", 10.0 + + +def _classify_dxy(value: float | None) -> tuple[str, float]: + """Classify DXY and produce a 0-100 risk-on score. + + Weak dollar is generally risk-on for equities/crypto. + """ + if value is None: + return "unavailable", 50.0 + if value < 100: + return "weak dollar", 75.0 + elif value <= 105: + return "neutral", 50.0 + else: + return "strong dollar", 25.0 + + +def _classify_yield(value: float | None) -> tuple[str, float]: + """Classify 10Y yield (in percentage points from Yahoo ^TNX format).""" + if value is None: + return "unavailable", 50.0 + if value < 3.0: + return "accommodative", 80.0 + elif value < 4.0: + return "moderate", 60.0 + elif value < 5.0: + return "elevated", 35.0 + else: + return "restrictive", 15.0 + + +def _classify_btc(technicals: dict) -> tuple[str, float, float | None]: + """Classify BTC technicals into a signal and a 0-100 score.""" + cross = technicals.get("cross_signal", "neutral") + mayer = technicals.get("mayer_multiple") + + if cross == "golden_cross": + label = "bullish" + score = 75.0 + elif cross == "death_cross": + label = "bearish" + score = 25.0 + else: + label = "neutral" + score = 50.0 + + # Mayer multiple adjustment: >2.4 = overheated, <0.8 = undervalued + if mayer is not None: + if mayer > 2.4: + score = max(score - 20, 0) + label = "overheated" + elif mayer < 0.8: + score = min(score + 20, 100) + label = "undervalued" + + return label, score, mayer + + +def _compute_sector_breadth(heatmap: dict) -> tuple[int, int, float]: + """Count positive vs negative sectors and produce a 0-100 score.""" + sectors = heatmap.get("sectors", []) + positive = sum(1 for s in sectors if (s.get("change_pct") or 0) > 0) + negative = sum(1 for s in sectors if (s.get("change_pct") or 0) < 0) + total = positive + negative + if total == 0: + return 0, 0, 50.0 + score = (positive / total) * 100 + return positive, negative, score + + +def _verdict(score: float) -> str: + """Map composite score to verdict string.""" + for threshold, label in _VERDICT_BANDS: + if score >= threshold: + return label + return "STRONG_CAUTION" + + +async def _safe(coro, label: str): + """Run a coroutine, swallowing exceptions.""" + try: + return await coro + except Exception as exc: + logger.warning("MacroComposite: %s failed: %s", label, exc) + return {} + + +async def fetch_macro_composite(fetcher: Fetcher) -> dict: + """Compute a weighted macro market composite from existing signal sources. + + Fetches macro signals, sector heatmap, and BTC technicals in parallel, + then scores each dimension and produces an overall market verdict. + + Returns: + Dict with verdict, score, individual signals, top/bottom sectors. + """ + from ..sources.markets import ( + fetch_btc_technicals, + fetch_macro_signals, + fetch_sector_heatmap, + ) + + ( + macro_data, + heatmap_data, + btc_data, + ) = await asyncio.gather( + _safe(fetch_macro_signals(fetcher), "macro_signals"), + _safe(fetch_sector_heatmap(fetcher), "sector_heatmap"), + _safe(fetch_btc_technicals(fetcher), "btc_technicals"), + ) + + signals_raw = macro_data.get("signals", {}) if macro_data else {} + + # --- Fear & Greed --- + fg_data = signals_raw.get("fear_greed") or {} + fg_value = fg_data.get("value") + fg_label = fg_data.get("classification", "unavailable") + fg_score = float(fg_value) if fg_value is not None else 50.0 + + # --- VIX --- + vix_data = signals_raw.get("vix") or {} + vix_value = vix_data.get("price") + vix_label, vix_score = _classify_vix(vix_value) + + # --- DXY --- + dxy_data = signals_raw.get("dxy") or {} + dxy_value = dxy_data.get("price") + dxy_label, dxy_score = _classify_dxy(dxy_value) + + # --- 10Y Yield --- + yield_data = signals_raw.get("treasury_10y") or {} + yield_value = yield_data.get("price") + yield_label, yield_score = _classify_yield(yield_value) + + # --- Sector breadth --- + heatmap = heatmap_data if heatmap_data else {} + positive, negative, breadth_score = _compute_sector_breadth(heatmap) + + # --- BTC --- + btc = btc_data if btc_data else {} + btc_label, btc_score, btc_mayer = _classify_btc(btc) + + # --- Weighted composite --- + component_scores = { + "fear_greed": fg_score, + "vix": vix_score, + "sector_breadth": breadth_score, + "dxy": dxy_score, + "btc": btc_score, + "yield_10y": yield_score, + } + + composite = sum( + component_scores[name] * weight for name, weight in SIGNAL_WEIGHTS.items() + ) + composite = min(100.0, max(0.0, composite)) + + # --- Top / bottom sectors --- + sectors = heatmap.get("sectors", []) + sorted_sectors = sorted( + sectors, key=lambda s: s.get("change_pct") or 0, reverse=True + ) + top_sectors = [ + {"name": s.get("name"), "change_pct": s.get("change_pct")} + for s in sorted_sectors[:3] + ] + bottom_sectors = [ + {"name": s.get("name"), "change_pct": s.get("change_pct")} + for s in sorted_sectors[-3:] + ] + + return { + "verdict": _verdict(composite), + "score": round(composite, 1), + "signals": { + "fear_greed": { + "value": fg_value, + "label": fg_label, + "weight": SIGNAL_WEIGHTS["fear_greed"], + }, + "vix": { + "value": vix_value, + "label": vix_label, + "weight": SIGNAL_WEIGHTS["vix"], + }, + "sector_breadth": { + "positive": positive, + "negative": negative, + "weight": SIGNAL_WEIGHTS["sector_breadth"], + }, + "dxy": { + "value": dxy_value, + "label": dxy_label, + "weight": SIGNAL_WEIGHTS["dxy"], + }, + "btc": { + "signal": btc_label, + "mayer": btc_mayer, + "weight": SIGNAL_WEIGHTS["btc"], + }, + "yield_10y": { + "value": yield_value, + "label": yield_label, + "weight": SIGNAL_WEIGHTS["yield_10y"], + }, + }, + "top_sectors": top_sectors, + "bottom_sectors": bottom_sectors, + "fetched_at": _utc_now_iso(), + "source": "composite", + } diff --git a/src/world_intel_mcp/fetcher.py b/src/world_intel_mcp/fetcher.py index f535abc..732127c 100644 --- a/src/world_intel_mcp/fetcher.py +++ b/src/world_intel_mcp/fetcher.py @@ -25,18 +25,19 @@ _YAHOO_MIN_INTERVAL = 0.6 # seconds # Per-source rate limits (min seconds between calls). # Sources not listed here have no enforced limit. _SOURCE_RATE_LIMITS: dict[str, float] = { - "yahoo-finance": 0.6, # unofficial — ~100 req/min safe - "opensky": 6.0, # free tier: 10 req/min - "coingecko": 2.0, # free tier: 30 calls/min - "cloudflare-radar": 3.0, # 20 req/min - "reddit": 1.5, # ~60 req/min (be conservative) - "nasa-firms": 2.0, # API key: ~1000 req/day - "adsblol": 5.0, # community API — be very polite - "polymarket": 1.0, # be polite - "faa": 1.0, # govt API - "usgs": 1.0, # generous but be polite - "acled": 2.0, # API key based - "nga": 2.0, # govt API + "yahoo-finance": 0.6, # unofficial — ~100 req/min safe + "opensky": 6.0, # free tier: 10 req/min + "coingecko": 2.0, # free tier: 30 calls/min + "cloudflare-radar": 3.0, # 20 req/min + "reddit": 1.5, # ~60 req/min (be conservative) + "nasa-firms": 2.0, # API key: ~1000 req/day + "adsblol": 5.0, # community API — be very polite + "polymarket": 1.0, # be polite + "faa": 1.0, # govt API + "usgs": 1.0, # generous but be polite + "acled": 2.0, # API key based + "nga": 2.0, # govt API + "sec-edgar": 1.0, # SEC EDGAR — be polite } _source_locks: dict[str, asyncio.Lock] = {} _source_last_call: dict[str, float] = {} @@ -138,8 +139,14 @@ class Fetcher: last_error = exc if attempt < self.max_retries: wait = 1.0 * (attempt + 1) - logger.debug("Retry %d/%d for %s (%s), waiting %.1fs", - attempt + 1, self.max_retries, source, exc, wait) + logger.debug( + "Retry %d/%d for %s (%s), waiting %.1fs", + attempt + 1, + self.max_retries, + source, + exc, + wait, + ) await asyncio.sleep(wait) # All retries failed — try stale cache before giving up diff --git a/src/world_intel_mcp/server.py b/src/world_intel_mcp/server.py index fb6456f..24307e8 100644 --- a/src/world_intel_mcp/server.py +++ b/src/world_intel_mcp/server.py @@ -23,6 +23,8 @@ Phase 12: Extended geospatial (cables, datacenters, spaceports, minerals, exchan NASA EONET, GDACS disaster alerts (+14 = 82 tools). Phase 13: USNI fleet tracker, RSS expansion, report removal. Phase 14: BTC technicals, central bank rates, trade routes, cloud regions, financial centers (+5 = 87 tools). +Phase 15: Business intelligence — forex (3), bonds/yields (2), earnings (2), SEC filings (3), + company enrichment (1), macro composite (1) (+12 = 99 tools). """ import asyncio @@ -38,7 +40,40 @@ from mcp.types import Tool, TextContent from .cache import Cache from .circuit_breaker import CircuitBreaker from .fetcher import Fetcher -from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status, geospatial, hacker_news, github_trending, arxiv_papers, usa_spending, environmental, usni_fleet, central_banks +from .sources import ( + markets, + economic, + seismology, + wildfire, + conflict, + military, + infrastructure, + maritime, + climate, + news, + intelligence, + prediction, + displacement, + aviation, + cyber, + space_weather, + ai_watch, + health, + sanctions, + elections, + shipping, + social, + nuclear, + service_status, + geospatial, + hacker_news, + github_trending, + arxiv_papers, + usa_spending, + environmental, + usni_fleet, + central_banks, +) logging.basicConfig( level=os.environ.get("WORLD_INTEL_LOG_LEVEL", "INFO"), @@ -77,7 +112,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Number of coins (default 20)", "default": 20}, + "limit": { + "type": "integer", + "description": "Number of coins (default 20)", + "default": 20, + }, }, }, ), @@ -118,8 +157,15 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "series_id": {"type": "string", "description": "FRED series ID (e.g., 'UNRATE')"}, - "limit": {"type": "integer", "description": "Number of observations", "default": 30}, + "series_id": { + "type": "string", + "description": "FRED series ID (e.g., 'UNRATE')", + }, + "limit": { + "type": "integer", + "description": "Number of observations", + "default": 30, + }, }, "required": ["series_id"], }, @@ -130,7 +176,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "ISO country code (default: USA)", "default": "USA"}, + "country": { + "type": "string", + "description": "ISO country code (default: USA)", + "default": "USA", + }, "indicators": { "type": "array", "items": {"type": "string"}, @@ -146,9 +196,21 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "min_magnitude": {"type": "number", "description": "Minimum magnitude (default 4.5)", "default": 4.5}, - "hours": {"type": "integer", "description": "Lookback hours (default 24)", "default": 24}, - "limit": {"type": "integer", "description": "Max results (default 50)", "default": 50}, + "min_magnitude": { + "type": "number", + "description": "Minimum magnitude (default 4.5)", + "default": 4.5, + }, + "hours": { + "type": "integer", + "description": "Lookback hours (default 24)", + "default": 24, + }, + "limit": { + "type": "integer", + "description": "Max results (default 50)", + "default": 50, + }, }, }, ), @@ -162,8 +224,15 @@ TOOLS: list[Tool] = [ "type": "string", "description": "Specific region (north_america, europe, etc.) or omit for all 9", "enum": [ - "north_america", "south_america", "europe", "africa", - "middle_east", "south_asia", "east_asia", "southeast_asia", "oceania", + "north_america", + "south_america", + "europe", + "africa", + "middle_east", + "south_asia", + "east_asia", + "southeast_asia", + "oceania", ], }, }, @@ -177,8 +246,16 @@ TOOLS: list[Tool] = [ "type": "object", "properties": { "country": {"type": "string", "description": "Country name filter"}, - "days": {"type": "integer", "description": "Lookback days (default 7)", "default": 7}, - "limit": {"type": "integer", "description": "Max results (default 100)", "default": 100}, + "days": { + "type": "integer", + "description": "Lookback days (default 7)", + "default": 7, + }, + "limit": { + "type": "integer", + "description": "Max results (default 100)", + "default": 100, + }, }, }, ), @@ -188,8 +265,16 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "days": {"type": "integer", "description": "Lookback days (default 30)", "default": 30}, - "limit": {"type": "integer", "description": "Max results (default 100)", "default": 100}, + "days": { + "type": "integer", + "description": "Lookback days (default 30)", + "default": 30, + }, + "limit": { + "type": "integer", + "description": "Max results (default 100)", + "default": 100, + }, }, }, ), @@ -210,7 +295,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "bbox": {"type": "string", "description": "Bounding box: lamin,lomin,lamax,lomax"}, + "bbox": { + "type": "string", + "description": "Bounding box: lamin,lomin,lamax,lomax", + }, }, }, ), @@ -248,7 +336,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "navarea": {"type": "string", "description": "NAVAREA number (e.g., IV, XII)"}, + "navarea": { + "type": "string", + "description": "NAVAREA number (e.g., IV, XII)", + }, }, }, ), @@ -274,7 +365,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Number of markets (default 20)", "default": 20}, + "limit": { + "type": "integer", + "description": "Number of markets (default 20)", + "default": 20, + }, }, }, ), @@ -285,7 +380,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "year": {"type": "integer", "description": "Reporting year (default: last year)"}, + "year": { + "type": "integer", + "description": "Reporting year (default: last year)", + }, }, }, ), @@ -302,7 +400,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Max threats (default 50)", "default": 50}, + "limit": { + "type": "integer", + "description": "Max threats (default 50)", + "default": 50, + }, }, }, ), @@ -316,12 +418,38 @@ TOOLS: list[Tool] = [ "category": { "type": "string", "description": "Category filter (24 categories available)", - "enum": ["geopolitics", "security", "technology", "finance", "military", "science", - "think_tanks", "middle_east", "asia_pacific", "africa", "latin_america", - "multilingual", "energy", "government", "crisis", "europe", "south_asia", - "health", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"], + "enum": [ + "geopolitics", + "security", + "technology", + "finance", + "military", + "science", + "think_tanks", + "middle_east", + "asia_pacific", + "africa", + "latin_america", + "multilingual", + "energy", + "government", + "crisis", + "europe", + "south_asia", + "health", + "central_asia", + "arctic", + "maritime", + "space", + "nuclear", + "climate", + ], + }, + "limit": { + "type": "integer", + "description": "Max items (default 50)", + "default": 50, }, - "limit": {"type": "integer", "description": "Max items (default 50)", "default": 50}, }, }, ), @@ -331,7 +459,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "min_count": {"type": "integer", "description": "Minimum occurrences (default 3)", "default": 3}, + "min_count": { + "type": "integer", + "description": "Minimum occurrences (default 3)", + "default": 3, + }, }, }, ), @@ -341,14 +473,22 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "query": {"type": "string", "description": "Search query (default: 'conflict')", "default": "conflict"}, + "query": { + "type": "string", + "description": "Search query (default: 'conflict')", + "default": "conflict", + }, "mode": { "type": "string", "description": "artlist (articles) or timelinevol (volume timeline)", "enum": ["artlist", "timelinevol"], "default": "artlist", }, - "limit": {"type": "integer", "description": "Max records (default 50)", "default": 50}, + "limit": { + "type": "integer", + "description": "Max records (default 50)", + "default": 50, + }, }, }, ), @@ -359,7 +499,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country_code": {"type": "string", "description": "ISO country code (default: US)", "default": "US"}, + "country_code": { + "type": "string", + "description": "ISO country code (default: US)", + "default": "US", + }, }, }, ), @@ -369,7 +513,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "ISO-2 or ISO-3 country code (e.g. US, USA, UA, UKR)", "default": "US"}, + "country": { + "type": "string", + "description": "ISO-2 or ISO-3 country code (e.g. US, USA, UA, UKR)", + "default": "US", + }, }, }, ), @@ -379,7 +527,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Top N countries (default 20)", "default": 20}, + "limit": { + "type": "integer", + "description": "Top N countries (default 20)", + "default": 20, + }, }, }, ), @@ -389,7 +541,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country_code": {"type": "string", "description": "ISO alpha-3 code (e.g., UKR). Omit for top-10 focus countries."}, + "country_code": { + "type": "string", + "description": "ISO alpha-3 code (e.g., UKR). Omit for top-10 focus countries.", + }, }, }, ), @@ -399,9 +554,16 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "lat": {"type": "number", "description": "Center latitude (omit for 5 global hotspots)"}, + "lat": { + "type": "number", + "description": "Center latitude (omit for 5 global hotspots)", + }, "lon": {"type": "number", "description": "Center longitude"}, - "radius_deg": {"type": "number", "description": "Radius in degrees (default 5.0)", "default": 5.0}, + "radius_deg": { + "type": "number", + "description": "Radius in degrees (default 5.0)", + "default": 5.0, + }, }, }, ), @@ -416,7 +578,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "Country name filter (optional)"}, + "country": { + "type": "string", + "description": "Country name filter (optional)", + }, }, }, ), @@ -432,8 +597,16 @@ TOOLS: list[Tool] = [ "type": "object", "properties": { "country": {"type": "string", "description": "Country name filter"}, - "days": {"type": "integer", "description": "Lookback days (default 7)", "default": 7}, - "limit": {"type": "integer", "description": "Max results (default 100)", "default": 100}, + "days": { + "type": "integer", + "description": "Lookback days (default 7)", + "default": 7, + }, + "limit": { + "type": "integer", + "description": "Max results (default 100)", + "default": 100, + }, }, }, ), @@ -462,8 +635,12 @@ TOOLS: list[Tool] = [ "type": "string", "description": "Cable corridor to simulate (e.g., red_sea, transpacific, asia_europe)", "enum": [ - "transatlantic_north", "transatlantic_south", - "asia_europe", "red_sea", "transpacific", "mediterranean", + "transatlantic_north", + "transatlantic_south", + "asia_europe", + "red_sea", + "transpacific", + "mediterranean", ], }, }, @@ -482,7 +659,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Max items (default 50)", "default": 50}, + "limit": { + "type": "integer", + "description": "Max items (default 50)", + "default": 50, + }, }, }, ), @@ -493,7 +674,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Max items (default 50)", "default": 50}, + "limit": { + "type": "integer", + "description": "Max items (default 50)", + "default": 50, + }, }, }, ), @@ -506,8 +691,15 @@ TOOLS: list[Tool] = [ "properties": { "query": {"type": "string", "description": "Name substring to search"}, "country": {"type": "string", "description": "Country filter"}, - "program": {"type": "string", "description": "Sanctions program filter"}, - "limit": {"type": "integer", "description": "Max results (default 50)", "default": 50}, + "program": { + "type": "string", + "description": "Sanctions program filter", + }, + "limit": { + "type": "integer", + "description": "Max results (default 50)", + "default": 50, + }, }, }, ), @@ -518,7 +710,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "ISO-3 code or country name filter"}, + "country": { + "type": "string", + "description": "ISO-3 code or country name filter", + }, }, }, ), @@ -535,7 +730,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Max posts per subreddit (default 25)", "default": 25}, + "limit": { + "type": "integer", + "description": "Max posts per subreddit (default 25)", + "default": 25, + }, }, }, ), @@ -546,7 +745,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "hours": {"type": "integer", "description": "Lookback hours (default 72)", "default": 72}, + "hours": { + "type": "integer", + "description": "Lookback hours (default 72)", + "default": 72, + }, }, }, ), @@ -569,7 +772,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "provider": {"type": "string", "description": "Filter by provider (aws, azure, gcp, cloudflare, github)"}, + "provider": { + "type": "string", + "description": "Filter by provider (aws, azure, gcp, cloudflare, github)", + }, }, }, ), @@ -580,10 +786,22 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "operator": {"type": "string", "description": "Filter by operating country (USA, RUS, CHN, GBR, FRA, NATO, IND, TUR, ISR, IRN, ARE)"}, - "country": {"type": "string", "description": "Filter by host country name or ISO-3 code"}, - "base_type": {"type": "string", "description": "Filter by type: air_base, naval_base, army_base, marine_base, training, space_base, missile_defense, expeditionary"}, - "branch": {"type": "string", "description": "Filter by branch (USAF, US Navy, PLA Navy, RAF, etc.)"}, + "operator": { + "type": "string", + "description": "Filter by operating country (USA, RUS, CHN, GBR, FRA, NATO, IND, TUR, ISR, IRN, ARE)", + }, + "country": { + "type": "string", + "description": "Filter by host country name or ISO-3 code", + }, + "base_type": { + "type": "string", + "description": "Filter by type: air_base, naval_base, army_base, marine_base, training, space_base, missile_defense, expeditionary", + }, + "branch": { + "type": "string", + "description": "Filter by branch (USAF, US Navy, PLA Navy, RAF, etc.)", + }, }, }, ), @@ -593,8 +811,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "port_type": {"type": "string", "description": "Filter by type: container, oil, lng, naval, bulk, mixed"}, - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, + "port_type": { + "type": "string", + "description": "Filter by type: container, oil, lng, naval, bulk, mixed", + }, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, }, }, ), @@ -604,8 +828,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "pipeline_type": {"type": "string", "description": "Filter by type: oil, gas, hydrogen"}, - "status": {"type": "string", "description": "Filter by status: active, destroyed, proposed, stalled, reduced, cancelled, construction, intermittent, terminated"}, + "pipeline_type": { + "type": "string", + "description": "Filter by type: oil, gas, hydrogen", + }, + "status": { + "type": "string", + "description": "Filter by status: active, destroyed, proposed, stalled, reduced, cancelled, construction, intermittent, terminated", + }, }, }, ), @@ -615,9 +845,18 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "facility_type": {"type": "string", "description": "Filter by type: power, enrichment, research, reprocessing, decommissioned"}, - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, - "status": {"type": "string", "description": "Filter by status: operational, construction, shutdown, occupied, commissioning, decommissioning, exclusion_zone"}, + "facility_type": { + "type": "string", + "description": "Filter by type: power, enrichment, research, reprocessing, decommissioned", + }, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, + "status": { + "type": "string", + "description": "Filter by status: operational, construction, shutdown, occupied, commissioning, decommissioning, exclusion_zone", + }, }, }, ), @@ -628,7 +867,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "text": {"type": "string", "description": "Text to analyze. If omitted, analyzes recent news headlines."}, + "text": { + "type": "string", + "description": "Text to analyze. If omitted, analyzes recent news headlines.", + }, }, }, ), @@ -638,7 +880,10 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "text": {"type": "string", "description": "Event text or headline to classify."}, + "text": { + "type": "string", + "description": "Event text or headline to classify.", + }, }, "required": ["text"], }, @@ -649,9 +894,18 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "category": {"type": "string", "description": "RSS feed category filter (geopolitics, security, military, etc.)"}, - "limit": {"type": "integer", "description": "Max news items to cluster (default: 100)"}, - "threshold": {"type": "number", "description": "Similarity threshold 0.0-1.0 (default: 0.25)"}, + "category": { + "type": "string", + "description": "RSS feed category filter (geopolitics, security, military, etc.)", + }, + "limit": { + "type": "integer", + "description": "Max news items to cluster (default: 100)", + }, + "threshold": { + "type": "number", + "description": "Similarity threshold 0.0-1.0 (default: 0.25)", + }, }, }, ), @@ -661,8 +915,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "min_count": {"type": "integer", "description": "Minimum keyword frequency to consider (default: 3)"}, - "z_threshold": {"type": "number", "description": "Z-score threshold for spike detection (default: 2.0)"}, + "min_count": { + "type": "integer", + "description": "Minimum keyword frequency to consider (default: 3)", + }, + "z_threshold": { + "type": "number", + "description": "Z-score threshold for spike detection (default: 2.0)", + }, }, }, ), @@ -688,10 +948,17 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "radius_km": {"type": "number", "description": "Search radius in km (default: 200)", "default": 200}, + "radius_km": { + "type": "number", + "description": "Search radius in km (default: 200)", + "default": 200, + }, "event_types": { "type": "array", - "items": {"type": "string", "enum": ["earthquake", "wildfire", "conflict"]}, + "items": { + "type": "string", + "enum": ["earthquake", "wildfire", "conflict"], + }, "description": "Event types to include (default: all three)", }, }, @@ -704,10 +971,22 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "status": {"type": "string", "description": "Filter by status: active, planned, construction, decommissioned"}, - "country": {"type": "string", "description": "Filter by country in landing points"}, - "owner": {"type": "string", "description": "Filter by cable owner (Google, Meta, Microsoft, etc.)"}, - "min_capacity_tbps": {"type": "number", "description": "Minimum cable capacity in Tbps"}, + "status": { + "type": "string", + "description": "Filter by status: active, planned, construction, decommissioned", + }, + "country": { + "type": "string", + "description": "Filter by country in landing points", + }, + "owner": { + "type": "string", + "description": "Filter by cable owner (Google, Meta, Microsoft, etc.)", + }, + "min_capacity_tbps": { + "type": "number", + "description": "Minimum cable capacity in Tbps", + }, }, }, ), @@ -717,10 +996,22 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, - "operator": {"type": "string", "description": "Filter by operator (AWS, Google, Microsoft, Meta, etc.)"}, - "min_power_mw": {"type": "integer", "description": "Minimum power capacity in MW"}, - "region": {"type": "string", "description": "Filter by region (North America, Europe, Asia-Pacific, etc.)"}, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, + "operator": { + "type": "string", + "description": "Filter by operator (AWS, Google, Microsoft, Meta, etc.)", + }, + "min_power_mw": { + "type": "integer", + "description": "Minimum power capacity in MW", + }, + "region": { + "type": "string", + "description": "Filter by region (North America, Europe, Asia-Pacific, etc.)", + }, }, }, ), @@ -730,10 +1021,22 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, - "status": {"type": "string", "description": "Filter by status: active, limited, planned, decommissioned"}, - "spaceport_type": {"type": "string", "description": "Filter by type: orbital, suborbital"}, - "operator": {"type": "string", "description": "Filter by operator (SpaceX, NASA, CNSA, Roscosmos, etc.)"}, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, + "status": { + "type": "string", + "description": "Filter by status: active, limited, planned, decommissioned", + }, + "spaceport_type": { + "type": "string", + "description": "Filter by type: orbital, suborbital", + }, + "operator": { + "type": "string", + "description": "Filter by operator (SpaceX, NASA, CNSA, Roscosmos, etc.)", + }, }, }, ), @@ -743,9 +1046,18 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "mineral": {"type": "string", "description": "Filter by mineral (lithium, cobalt, rare_earths, nickel, copper, graphite, manganese, platinum_group, tungsten, uranium, tin, gallium, germanium)"}, - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, - "mineral_type": {"type": "string", "description": "Filter by type: battery, electronic, structural, energy, industrial, strategic"}, + "mineral": { + "type": "string", + "description": "Filter by mineral (lithium, cobalt, rare_earths, nickel, copper, graphite, manganese, platinum_group, tungsten, uranium, tin, gallium, germanium)", + }, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, + "mineral_type": { + "type": "string", + "description": "Filter by type: battery, electronic, structural, energy, industrial, strategic", + }, "operator": {"type": "string", "description": "Filter by operator"}, }, }, @@ -756,9 +1068,18 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "tier": {"type": "string", "description": "Filter by tier: mega, major, emerging, frontier"}, - "country": {"type": "string", "description": "Filter by country name or ISO-3 code"}, - "currency": {"type": "string", "description": "Filter by currency (USD, EUR, GBP, JPY, CNY, etc.)"}, + "tier": { + "type": "string", + "description": "Filter by tier: mega, major, emerging, frontier", + }, + "country": { + "type": "string", + "description": "Filter by country name or ISO-3 code", + }, + "currency": { + "type": "string", + "description": "Filter by currency (USD, EUR, GBP, JPY, CNY, etc.)", + }, }, }, ), @@ -769,7 +1090,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "ISO-3 country code (USA, GBR, JPN, CHN, DEU, etc.)", "default": "USA"}, + "country": { + "type": "string", + "description": "ISO-3 country code (USA, GBR, JPN, CHN, DEU, etc.)", + "default": "USA", + }, }, }, ), @@ -796,7 +1121,11 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "limit": {"type": "integer", "description": "Number of stories (default 30, max 100)", "default": 30}, + "limit": { + "type": "integer", + "description": "Number of stories (default 30, max 100)", + "default": 30, + }, }, }, ), @@ -806,9 +1135,20 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "language": {"type": "string", "description": "Programming language filter (python, rust, typescript, etc.)"}, - "since_days": {"type": "integer", "description": "Look back N days for new repos (default 7)", "default": 7}, - "limit": {"type": "integer", "description": "Number of repos (default 25)", "default": 25}, + "language": { + "type": "string", + "description": "Programming language filter (python, rust, typescript, etc.)", + }, + "since_days": { + "type": "integer", + "description": "Look back N days for new repos (default 7)", + "default": 7, + }, + "limit": { + "type": "integer", + "description": "Number of repos (default 25)", + "default": 25, + }, }, }, ), @@ -818,8 +1158,15 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "query": {"type": "string", "description": "arXiv search query (default: cs.AI OR cs.LG OR cs.CL)"}, - "limit": {"type": "integer", "description": "Number of papers (default 25)", "default": 25}, + "query": { + "type": "string", + "description": "arXiv search query (default: cs.AI OR cs.LG OR cs.CL)", + }, + "limit": { + "type": "integer", + "description": "Number of papers (default 25)", + "default": 25, + }, }, }, ), @@ -830,8 +1177,15 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "agency": {"type": "string", "description": "Filter by agency name substring"}, - "limit": {"type": "integer", "description": "Number of agencies (default 25)", "default": 25}, + "agency": { + "type": "string", + "description": "Filter by agency name substring", + }, + "limit": { + "type": "integer", + "description": "Number of agencies (default 25)", + "default": 25, + }, }, }, ), @@ -848,9 +1202,20 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "days": {"type": "integer", "description": "Look back N days (default 30)", "default": 30}, - "category": {"type": "string", "description": "Filter by category: wildfires, severeStorms, volcanoes, floods, earthquakes, drought, seaLakeIce"}, - "limit": {"type": "integer", "description": "Max events (default 50)", "default": 50}, + "days": { + "type": "integer", + "description": "Look back N days (default 30)", + "default": 30, + }, + "category": { + "type": "string", + "description": "Filter by category: wildfires, severeStorms, volcanoes, floods, earthquakes, drought, seaLakeIce", + }, + "limit": { + "type": "integer", + "description": "Max events (default 50)", + "default": 50, + }, }, }, ), @@ -860,9 +1225,19 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "alert_level": {"type": "string", "description": "Filter by level: green, orange, red"}, - "event_type": {"type": "string", "description": "Filter by type: EQ, FL, TC, DR, WF, VO"}, - "limit": {"type": "integer", "description": "Max alerts (default 30)", "default": 30}, + "alert_level": { + "type": "string", + "description": "Filter by level: green, orange, red", + }, + "event_type": { + "type": "string", + "description": "Filter by type: EQ, FL, TC, DR, WF, VO", + }, + "limit": { + "type": "integer", + "description": "Max alerts (default 30)", + "default": 30, + }, }, }, ), @@ -885,8 +1260,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "route_type": {"type": "string", "description": "Filter: chokepoint, canal, route"}, - "country": {"type": "string", "description": "Filter by ISO-3 country code"}, + "route_type": { + "type": "string", + "description": "Filter: chokepoint, canal, route", + }, + "country": { + "type": "string", + "description": "Filter by ISO-3 country code", + }, }, }, ), @@ -897,8 +1278,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "provider": {"type": "string", "description": "Filter: AWS, Azure, GCP"}, - "country": {"type": "string", "description": "Filter by region name substring"}, + "provider": { + "type": "string", + "description": "Filter: AWS, Azure, GCP", + }, + "country": { + "type": "string", + "description": "Filter by region name substring", + }, }, }, ), @@ -909,8 +1296,14 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "country": {"type": "string", "description": "Filter by ISO-3 country code"}, - "min_rank": {"type": "integer", "description": "Only include centers ranked this or better"}, + "country": { + "type": "string", + "description": "Filter by ISO-3 country code", + }, + "min_rank": { + "type": "integer", + "description": "Only include centers ranked this or better", + }, }, }, ), @@ -938,11 +1331,194 @@ TOOLS: list[Tool] = [ inputSchema={ "type": "object", "properties": { - "category": {"type": "string", "description": "Webcam category (traffic, weather, landscape, etc.)", "default": "traffic"}, - "limit": {"type": "integer", "description": "Max cameras to return (default 50)", "default": 50}, + "category": { + "type": "string", + "description": "Webcam category (traffic, weather, landscape, etc.)", + "default": "traffic", + }, + "limit": { + "type": "integer", + "description": "Max cameras to return (default 50)", + "default": 50, + }, }, }, ), + # --- Forex (3 tools) --- + Tool( + name="intel_forex_rates", + description="Get latest foreign exchange rates from ECB via Frankfurter API. Optional: base currency (default USD), target symbols list.", + inputSchema={ + "type": "object", + "properties": { + "base": { + "type": "string", + "description": "Base currency code (default: USD)", + "default": "USD", + }, + "symbols": { + "type": "array", + "items": {"type": "string"}, + "description": "Target currency codes (e.g., ['EUR', 'GBP', 'JPY'])", + }, + }, + }, + ), + Tool( + name="intel_forex_timeseries", + description="Get historical FX rate timeseries with trend analysis. Optional: base, symbol, days.", + inputSchema={ + "type": "object", + "properties": { + "base": { + "type": "string", + "description": "Base currency (default: USD)", + "default": "USD", + }, + "symbol": { + "type": "string", + "description": "Target currency (default: EUR)", + "default": "EUR", + }, + "days": { + "type": "integer", + "description": "Number of days of history (default: 30)", + "default": 30, + }, + }, + }, + ), + Tool( + name="intel_major_crosses", + description="Get all 8 major FX currency pairs (EUR/USD, USD/JPY, GBP/USD, etc.) with cross rates and DXY proxy.", + inputSchema={"type": "object", "properties": {}}, + ), + # --- Bonds & Yields (2 tools) --- + Tool( + name="intel_yield_curve", + description="Get US Treasury yield curve (2Y-30Y maturities), 2s10s and 3m10y spreads, and inversion detection. Uses FRED or Yahoo Finance fallback.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="intel_bond_indices", + description="Get major bond ETF prices and performance: AGG (total bond), TLT (20Y+ Treasury), HYG (high yield), LQD (investment grade), TIP (TIPS).", + inputSchema={"type": "object", "properties": {}}, + ), + # --- Earnings (2 tools) --- + Tool( + name="intel_earnings_calendar", + description="Get upcoming earnings announcements for top 20 mega-cap stocks (AAPL, MSFT, GOOGL, etc.) with EPS estimates and days until report.", + inputSchema={ + "type": "object", + "properties": { + "days_ahead": { + "type": "integer", + "description": "Days to look ahead for 'this_week' filter (default: 7)", + "default": 7, + }, + }, + }, + ), + Tool( + name="intel_earnings_surprise", + description="Get recent earnings surprises for a specific stock — past quarter actual vs estimate, surprise %, and forward estimates.", + inputSchema={ + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Stock ticker symbol (e.g., 'AAPL')", + }, + }, + "required": ["symbol"], + }, + ), + # --- SEC Filings (3 tools) --- + Tool( + name="intel_sec_filings", + description="Search SEC EDGAR filings via full-text search. Filter by form type (10-K, 10-Q, 8-K) and date range.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (company name, keyword, etc.)", + }, + "form_type": { + "type": "string", + "description": "Comma-separated form types (e.g., '10-K,10-Q,8-K')", + }, + "date_range": { + "type": "string", + "description": "Date range as 'YYYY-MM-DD,YYYY-MM-DD' (default: last 30 days)", + }, + "limit": { + "type": "integer", + "description": "Max results (default: 25, max: 100)", + "default": 25, + }, + }, + }, + ), + Tool( + name="intel_company_filings", + description="Get recent SEC filings for a company by ticker symbol (10-K, 10-Q, 8-K). Resolves ticker to CIK automatically.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol (e.g., 'AAPL')", + }, + "form_types": { + "type": "array", + "items": {"type": "string"}, + "description": "Form types to include (default: ['10-K', '10-Q', '8-K'])", + }, + "limit": { + "type": "integer", + "description": "Max filings (default: 10)", + "default": 10, + }, + }, + "required": ["ticker"], + }, + ), + Tool( + name="intel_recent_8k", + description="Get most recent 8-K filings (material corporate events: M&A, executive changes, earnings releases) across all companies.", + inputSchema={ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Max filings (default: 25, max: 100)", + "default": 25, + }, + }, + }, + ), + # --- Company Enrichment (1 tool) --- + Tool( + name="intel_company_profile", + description="Get comprehensive company profile: stock quote, financials, sector/industry, recent news, SEC filings, and GitHub repos (for tech companies). Accepts ticker or company name.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Ticker symbol (e.g., 'AAPL') or company name", + }, + }, + "required": ["query"], + }, + ), + # --- Macro Composite (1 tool) --- + Tool( + name="intel_macro_composite", + description="Get weighted macro market composite score (0-100) synthesizing Fear & Greed, VIX, sector breadth, DXY, BTC technicals, and 10Y yield into an actionable verdict (RISK_ON / CONSTRUCTIVE / NEUTRAL / CAUTIOUS / STRONG_CAUTION).", + inputSchema={"type": "object", "properties": {}}, + ), # --- System (1 tool) --- Tool( name="intel_status", @@ -956,14 +1532,19 @@ TOOLS: list[Tool] = [ # Tool dispatch # --------------------------------------------------------------------------- + async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: """Route tool call to the appropriate source function.""" match name: # Markets case "intel_market_quotes": - return await markets.fetch_market_quotes(fetcher, symbols=arguments.get("symbols")) + return await markets.fetch_market_quotes( + fetcher, symbols=arguments.get("symbols") + ) case "intel_crypto_quotes": - return await markets.fetch_crypto_quotes(fetcher, limit=arguments.get("limit", 20)) + return await markets.fetch_crypto_quotes( + fetcher, limit=arguments.get("limit", 20) + ) case "intel_stablecoin_status": return await markets.fetch_stablecoin_status(fetcher) case "intel_etf_flows": @@ -1000,7 +1581,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: limit=arguments.get("limit", 50), ) case "intel_wildfires": - return await wildfire.fetch_wildfires(fetcher, region=arguments.get("region")) + return await wildfire.fetch_wildfires( + fetcher, region=arguments.get("region") + ) # Conflict case "intel_acled_events": @@ -1018,16 +1601,21 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: ) case "intel_humanitarian_summary": return await conflict.fetch_humanitarian_summary( - fetcher, country=arguments.get("country"), + fetcher, + country=arguments.get("country"), ) # Military case "intel_military_flights": - return await military.fetch_military_flights(fetcher, bbox=arguments.get("bbox")) + return await military.fetch_military_flights( + fetcher, bbox=arguments.get("bbox") + ) case "intel_theater_posture": return await military.fetch_theater_posture(fetcher) case "intel_aircraft_details": - return await military.fetch_aircraft_details(fetcher, icao24=arguments["icao24"]) + return await military.fetch_aircraft_details( + fetcher, icao24=arguments["icao24"] + ) # Infrastructure case "intel_internet_outages": @@ -1037,19 +1625,27 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Maritime case "intel_nav_warnings": - return await maritime.fetch_nav_warnings(fetcher, navarea=arguments.get("navarea")) + return await maritime.fetch_nav_warnings( + fetcher, navarea=arguments.get("navarea") + ) # Climate case "intel_climate_anomalies": - return await climate.fetch_climate_anomalies(fetcher, zones=arguments.get("zones")) + return await climate.fetch_climate_anomalies( + fetcher, zones=arguments.get("zones") + ) # Prediction case "intel_prediction_markets": - return await prediction.fetch_prediction_markets(fetcher, limit=arguments.get("limit", 20)) + return await prediction.fetch_prediction_markets( + fetcher, limit=arguments.get("limit", 20) + ) # Displacement case "intel_displacement_summary": - return await displacement.fetch_displacement_summary(fetcher, year=arguments.get("year")) + return await displacement.fetch_displacement_summary( + fetcher, year=arguments.get("year") + ) # Aviation case "intel_airport_delays": @@ -1057,7 +1653,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Cyber case "intel_cyber_threats": - return await cyber.fetch_cyber_threats(fetcher, limit=arguments.get("limit", 50)) + return await cyber.fetch_cyber_threats( + fetcher, limit=arguments.get("limit", 50) + ) # News case "intel_news_feed": @@ -1067,7 +1665,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: limit=arguments.get("limit", 50), ) case "intel_trending_keywords": - return await news.fetch_trending_keywords(fetcher, min_count=arguments.get("min_count", 3)) + return await news.fetch_trending_keywords( + fetcher, min_count=arguments.get("min_count", 3) + ) case "intel_gdelt_search": return await news.fetch_gdelt_search( fetcher, @@ -1078,14 +1678,23 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Intelligence case "intel_country_brief": - return await intelligence.fetch_country_brief(fetcher, country_code=arguments.get("country_code", "US")) + return await intelligence.fetch_country_brief( + fetcher, country_code=arguments.get("country_code", "US") + ) case "intel_country_dossier": from .analysis.dossier import fetch_country_dossier - return await fetch_country_dossier(fetcher, country=arguments.get("country", "US")) + + return await fetch_country_dossier( + fetcher, country=arguments.get("country", "US") + ) case "intel_risk_scores": - return await intelligence.fetch_risk_scores(fetcher, limit=arguments.get("limit", 20)) + return await intelligence.fetch_risk_scores( + fetcher, limit=arguments.get("limit", 20) + ) case "intel_instability_index": - return await intelligence.fetch_instability_index(fetcher, country_code=arguments.get("country_code")) + return await intelligence.fetch_instability_index( + fetcher, country_code=arguments.get("country_code") + ) case "intel_signal_convergence": return await intelligence.fetch_signal_convergence( fetcher, @@ -1096,7 +1705,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: case "intel_focal_points": return await intelligence.fetch_focal_points(fetcher) case "intel_signal_summary": - return await intelligence.fetch_signal_summary(fetcher, country=arguments.get("country")) + return await intelligence.fetch_signal_summary( + fetcher, country=arguments.get("country") + ) case "intel_temporal_anomalies": return await intelligence.fetch_temporal_anomalies(fetcher) case "intel_unrest_events": @@ -1114,7 +1725,8 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: return await intelligence.fetch_vessel_snapshot(fetcher) case "intel_cascade_analysis": return await intelligence.fetch_cascade_analysis( - fetcher, corridor=arguments.get("corridor"), + fetcher, + corridor=arguments.get("corridor"), ) # Space Weather @@ -1123,11 +1735,15 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # AI Watch case "intel_ai_releases": - return await ai_watch.fetch_ai_watch(fetcher, limit=arguments.get("limit", 50)) + return await ai_watch.fetch_ai_watch( + fetcher, limit=arguments.get("limit", 50) + ) # Health case "intel_disease_outbreaks": - return await health.fetch_disease_outbreaks(fetcher, limit=arguments.get("limit", 50)) + return await health.fetch_disease_outbreaks( + fetcher, limit=arguments.get("limit", 50) + ) # Sanctions case "intel_sanctions_search": @@ -1142,7 +1758,8 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Elections case "intel_election_calendar": return await elections.fetch_election_calendar( - fetcher, country=arguments.get("country"), + fetcher, + country=arguments.get("country"), ) # Shipping @@ -1152,30 +1769,34 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Social case "intel_social_signals": return await social.fetch_social_signals( - fetcher, limit=arguments.get("limit", 25), + fetcher, + limit=arguments.get("limit", 25), ) # Nuclear case "intel_nuclear_monitor": return await nuclear.fetch_nuclear_monitor( - fetcher, hours=arguments.get("hours", 72), + fetcher, + hours=arguments.get("hours", 72), ) # Alert Digest case "intel_alert_digest": from .analysis.alerts import fetch_alert_digest + return await fetch_alert_digest(fetcher) # Weekly Trends case "intel_weekly_trends": from .analysis.alerts import fetch_weekly_trends - return await fetch_weekly_trends(fetcher) + return await fetch_weekly_trends(fetcher) # Service Status case "intel_service_status": return await service_status.fetch_service_status( - fetcher, provider=arguments.get("provider"), + fetcher, + provider=arguments.get("provider"), ) # Geospatial datasets @@ -1206,15 +1827,19 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Strategic Synthesis case "intel_strategic_posture": from .analysis.posture import fetch_strategic_posture + return await fetch_strategic_posture(fetcher) case "intel_world_brief": from .analysis.world_brief import fetch_world_brief + return await fetch_world_brief(fetcher) case "intel_fleet_report": from .sources.fleet import fetch_fleet_report + return await fetch_fleet_report(fetcher) case "intel_population_exposure": from .analysis.exposure import fetch_population_exposure + return await fetch_population_exposure( fetcher, radius_km=arguments.get("radius_km", 200), @@ -1260,19 +1885,22 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Markets Extended case "intel_country_stocks": return await markets.fetch_country_stocks( - fetcher, country=arguments.get("country", "USA"), + fetcher, + country=arguments.get("country", "USA"), ) # Military Extended case "intel_aircraft_batch": return await military.fetch_aircraft_details_batch( - fetcher, icao24_list=arguments["icao24_list"], + fetcher, + icao24_list=arguments["icao24_list"], ) # Tech & Science case "intel_hacker_news": return await hacker_news.fetch_hacker_news( - fetcher, limit=arguments.get("limit", 30), + fetcher, + limit=arguments.get("limit", 30), ) case "intel_trending_repos": return await github_trending.fetch_trending_repos( @@ -1348,12 +1976,15 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # NLP Intelligence case "intel_extract_entities": from .analysis.entities import fetch_entity_extraction + return await fetch_entity_extraction(fetcher, text=arguments.get("text")) case "intel_classify_event": from .analysis.classifier import fetch_classify_event + return await fetch_classify_event(fetcher, text=arguments["text"]) case "intel_news_clusters": from .analysis.clustering import fetch_news_clusters + return await fetch_news_clusters( fetcher, category=arguments.get("category"), @@ -1362,6 +1993,7 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: ) case "intel_keyword_spikes": from .analysis.spikes import fetch_keyword_spikes + return await fetch_keyword_spikes( fetcher, min_count=arguments.get("min_count", 3), @@ -1371,9 +2003,11 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Traffic case "intel_traffic_flow": from .sources.traffic import fetch_traffic_flow + return await fetch_traffic_flow(fetcher) case "intel_traffic_incidents": from .sources.traffic import fetch_traffic_incidents + return await fetch_traffic_incidents(fetcher) # Aviation domestic @@ -1383,12 +2017,95 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # Webcams case "intel_webcams": from .sources.webcams import fetch_webcams + return await fetch_webcams( fetcher, category=arguments.get("category", "traffic"), limit=arguments.get("limit", 50), ) + # Forex + case "intel_forex_rates": + from .sources.forex import fetch_forex_rates + + return await fetch_forex_rates( + fetcher, + base=arguments.get("base", "USD"), + symbols=arguments.get("symbols"), + ) + case "intel_forex_timeseries": + from .sources.forex import fetch_forex_timeseries + + return await fetch_forex_timeseries( + fetcher, + base=arguments.get("base", "USD"), + symbol=arguments.get("symbol", "EUR"), + days=arguments.get("days", 30), + ) + case "intel_major_crosses": + from .sources.forex import fetch_major_crosses + + return await fetch_major_crosses(fetcher) + + # Bonds & Yields + case "intel_yield_curve": + from .sources.bonds import fetch_yield_curve + + return await fetch_yield_curve(fetcher) + case "intel_bond_indices": + from .sources.bonds import fetch_bond_indices + + return await fetch_bond_indices(fetcher) + + # Earnings + case "intel_earnings_calendar": + from .sources.earnings import fetch_earnings_calendar + + return await fetch_earnings_calendar( + fetcher, days_ahead=arguments.get("days_ahead", 7) + ) + case "intel_earnings_surprise": + from .sources.earnings import fetch_earnings_surprise + + return await fetch_earnings_surprise(fetcher, symbol=arguments["symbol"]) + + # SEC Filings + case "intel_sec_filings": + from .sources.sec_edgar import fetch_sec_filings + + return await fetch_sec_filings( + fetcher, + query=arguments.get("query"), + form_type=arguments.get("form_type"), + date_range=arguments.get("date_range"), + limit=arguments.get("limit", 25), + ) + case "intel_company_filings": + from .sources.sec_edgar import fetch_company_filings + + return await fetch_company_filings( + fetcher, + ticker=arguments["ticker"], + form_types=arguments.get("form_types"), + limit=arguments.get("limit", 10), + ) + case "intel_recent_8k": + from .sources.sec_edgar import fetch_recent_8k + + return await fetch_recent_8k(fetcher, limit=arguments.get("limit", 25)) + + # Company Enrichment + case "intel_company_profile": + from .analysis.company import fetch_company_profile + + return await fetch_company_profile(fetcher, query=arguments["query"]) + + # Macro Composite + case "intel_macro_composite": + from .analysis.macro_composite import fetch_macro_composite + + return await fetch_macro_composite(fetcher) + # System case "intel_status": return { @@ -1396,7 +2113,12 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: "cache": cache.stats(), "cache_freshness": cache.freshness(), "sources": { - "markets": ["yahoo-finance", "coingecko", "alternative-me", "mempool"], + "markets": [ + "yahoo-finance", + "coingecko", + "alternative-me", + "mempool", + ], "economic": ["eia", "fred", "world-bank"], "natural": ["usgs", "nasa-firms"], "conflict": ["acled", "ucdp", "hdx"], @@ -1419,12 +2141,39 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: "social": ["reddit-public"], "nuclear": ["usgs-nuclear-monitor"], "service_status": ["aws", "azure", "gcp", "cloudflare", "github"], - "geospatial": ["static-datasets (bases, ports, pipelines, nuclear, cables, datacenters, spaceports, minerals, exchanges, trade-routes, cloud-regions, financial-centers)"], - "nlp": ["regex-ner", "keyword-classifier", "jaccard-clustering", "keyword-spike-detector"], - "synthesis": ["strategic-posture", "world-brief", "fleet-report", "population-exposure"], + "geospatial": [ + "static-datasets (bases, ports, pipelines, nuclear, cables, datacenters, spaceports, minerals, exchanges, trade-routes, cloud-regions, financial-centers)" + ], + "nlp": [ + "regex-ner", + "keyword-classifier", + "jaccard-clustering", + "keyword-spike-detector", + ], + "synthesis": [ + "strategic-posture", + "world-brief", + "fleet-report", + "population-exposure", + ], "tech": ["hackernews", "github", "arxiv"], "government": ["usaspending-gov"], "environmental": ["eonet", "gdacs"], + "forex": ["ecb-frankfurter"], + "bonds": ["fred", "yahoo-finance"], + "earnings": ["yahoo-finance"], + "sec_filings": ["sec-edgar"], + "company_enrichment": [ + "yahoo-finance", + "gdelt", + "sec-edgar", + "github", + ], + "macro_composite": [ + "yahoo-finance", + "coingecko", + "alternative-me", + ], }, } @@ -1436,6 +2185,7 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: # MCP handlers # --------------------------------------------------------------------------- + @server.list_tools() async def list_tools() -> list[Tool]: return TOOLS @@ -1453,10 +2203,13 @@ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextCon # Entry point # --------------------------------------------------------------------------- + async def _run() -> None: logger.info("World Intelligence MCP Server starting (%d tools)", len(TOOLS)) async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, server.create_initialization_options()) + await server.run( + read_stream, write_stream, server.create_initialization_options() + ) def run() -> None: diff --git a/src/world_intel_mcp/sources/bonds.py b/src/world_intel_mcp/sources/bonds.py new file mode 100644 index 0000000..306a975 --- /dev/null +++ b/src/world_intel_mcp/sources/bonds.py @@ -0,0 +1,286 @@ +"""Bond market data sources for world-intel-mcp. + +Provides US Treasury yield curve data (via Treasury Fiscal Data API, FRED, +or Yahoo Finance fallback) and bond ETF index quotes. Every function takes +a Fetcher instance as its first argument and returns a dict. +""" + +import asyncio +import logging +import os +from datetime import datetime, timezone + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.sources.bonds") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_TREASURY_URL = ( + "https://api.fiscaldata.treasury.gov/services/api/fiscal_service" + "/v2/accounting/od/avg_interest_rates" +) + +_FRED_URL = "https://api.stlouisfed.org/fred/series/observations" + +_YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" + +# FRED series IDs for individual Treasury yields (1-month through 30-year). +_FRED_YIELD_SERIES: dict[str, str] = { + "DGS1MO": "1M", + "DGS3MO": "3M", + "DGS6MO": "6M", + "DGS1": "1Y", + "DGS2": "2Y", + "DGS5": "5Y", + "DGS10": "10Y", + "DGS20": "20Y", + "DGS30": "30Y", +} + +# Yahoo Finance Treasury yield symbols (fewer maturities, no key required). +_YAHOO_YIELD_SYMBOLS: dict[str, str] = { + "^IRX": "3M", + "^FVX": "5Y", + "^TNX": "10Y", + "^TYX": "30Y", +} + +# Bond ETF index symbols. +_BOND_INDICES: dict[str, str] = { + "AGG": "US Aggregate Bond", + "TLT": "20+ Year Treasury", + "HYG": "High Yield Corporate", + "LQD": "Investment Grade Corporate", + "TIP": "TIPS", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def _fetch_yahoo_quote( + fetcher: Fetcher, + symbol: str, + cache_key: str, + cache_ttl: int, +) -> dict | None: + """Fetch a single Yahoo Finance v8 chart quote and extract meta fields.""" + url = _YAHOO_CHART_URL.format(symbol=symbol) + data = await fetcher.get_json( + url, + source="yahoo-finance", + cache_key=cache_key, + cache_ttl=cache_ttl, + params={"range": "1d", "interval": "5m"}, + yahoo_rate_limit=True, + ) + if not isinstance(data, dict): + return None + + try: + meta = data["chart"]["result"][0]["meta"] + price = meta.get("regularMarketPrice") + change_pct = meta.get("regularMarketChangePercent") + if change_pct is None and price is not None: + prev = meta.get("previousClose") or meta.get("chartPreviousClose") + if prev and prev > 0: + change_pct = round(((price - prev) / prev) * 100, 4) + return { + "symbol": symbol, + "price": price, + "change_pct": change_pct, + "currency": meta.get("currency"), + } + except (KeyError, IndexError, TypeError): + logger.warning("Unexpected Yahoo chart structure for %s", symbol) + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def fetch_yield_curve(fetcher: Fetcher) -> dict: + """Fetch US Treasury yield curve data. + + Strategy: + 1. Always fetch from the Treasury Fiscal Data API for average interest rates. + 2. If ``FRED_API_KEY`` is set, fetch individual constant-maturity yields + from FRED (9 maturities from 1-month to 30-year). + 3. Otherwise, fall back to Yahoo Finance for 4 key maturities (3M, 5Y, + 10Y, 30Y). + + Returns a dict with ``yields`` list, ``spread_2s10s``, ``spread_3m10y``, + ``inverted`` flag, and metadata. + """ + # --- Treasury Fiscal Data API (always attempted) ------------------------- + treasury_data = await fetcher.get_json( + _TREASURY_URL, + source="treasury", + cache_key="bonds:yield-curve:treasury", + cache_ttl=3600, + params={ + "sort": "-record_date", + "page[size]": "20", + }, + ) + + # --- Individual maturity yields (FRED or Yahoo) -------------------------- + fred_key = os.environ.get("FRED_API_KEY") + yields: list[dict] = [] + + if fred_key: + yields = await _fetch_yields_from_fred(fetcher, fred_key) + else: + yields = await _fetch_yields_from_yahoo(fetcher) + + # --- Compute spreads ----------------------------------------------------- + yield_map: dict[str, float] = { + y["maturity"]: y["rate"] for y in yields if y["rate"] is not None + } + + rate_2y = yield_map.get("2Y") + rate_3m = yield_map.get("3M") + rate_10y = yield_map.get("10Y") + + spread_2s10s: float | None = None + spread_3m10y: float | None = None + inverted = False + + if rate_2y is not None and rate_10y is not None: + spread_2s10s = round(rate_10y - rate_2y, 4) + if rate_3m is not None and rate_10y is not None: + spread_3m10y = round(rate_10y - rate_3m, 4) + + if spread_2s10s is not None and spread_2s10s < 0: + inverted = True + elif spread_3m10y is not None and spread_3m10y < 0: + inverted = True + + # --- Parse Treasury Fiscal Data for supplementary info ------------------- + treasury_records: list[dict] = [] + if isinstance(treasury_data, dict): + try: + treasury_records = treasury_data.get("data", []) + except (AttributeError, TypeError): + pass + + return { + "yields": yields, + "spread_2s10s": spread_2s10s, + "spread_3m10y": spread_3m10y, + "inverted": inverted, + "treasury_records": len(treasury_records), + "fetched_at": _utc_now_iso(), + "source": "treasury", + } + + +async def _fetch_yields_from_fred( + fetcher: Fetcher, + api_key: str, +) -> list[dict]: + """Fetch Treasury yields from FRED (9 maturities).""" + + async def _fetch_one(series_id: str, maturity: str) -> dict: + data = await fetcher.get_json( + _FRED_URL, + source="fred", + cache_key=f"bonds:fred:{series_id}", + cache_ttl=3600, + params={ + "series_id": series_id, + "api_key": api_key, + "file_type": "json", + "sort_order": "desc", + "limit": 1, + }, + ) + rate: float | None = None + date: str | None = None + if isinstance(data, dict): + try: + obs = data.get("observations", []) + if obs: + val = obs[0].get("value") + date = obs[0].get("date") + if val not in (None, ".", ""): + rate = float(val) + except (KeyError, TypeError, ValueError, IndexError) as exc: + logger.warning("Failed to parse FRED %s: %s", series_id, exc) + return {"maturity": maturity, "rate": rate, "date": date, "series": series_id} + + tasks = [ + _fetch_one(series_id, maturity) + for series_id, maturity in _FRED_YIELD_SERIES.items() + ] + return list(await asyncio.gather(*tasks)) + + +async def _fetch_yields_from_yahoo(fetcher: Fetcher) -> list[dict]: + """Fetch Treasury yields from Yahoo Finance (4 maturities, no key).""" + + async def _fetch_one(symbol: str, maturity: str) -> dict: + quote = await _fetch_yahoo_quote( + fetcher, + symbol, + f"bonds:yahoo:{symbol}", + 3600, + ) + rate: float | None = None + if quote is not None and quote.get("price") is not None: + # Yahoo yields are quoted as price (e.g., 4.52 means 4.52%) + rate = quote["price"] + return {"maturity": maturity, "rate": rate, "symbol": symbol} + + tasks = [ + _fetch_one(symbol, maturity) + for symbol, maturity in _YAHOO_YIELD_SYMBOLS.items() + ] + return list(await asyncio.gather(*tasks)) + + +async def fetch_bond_indices(fetcher: Fetcher) -> dict: + """Fetch bond ETF index quotes from Yahoo Finance. + + Covers AGG (US Agg), TLT (Long Treasury), HYG (High Yield Corp), + LQD (Investment Grade Corp), and TIP (TIPS). + + Returns:: + + {"indices": [{symbol, name, price, change_pct}], ...} + """ + tasks = [ + _fetch_yahoo_quote(fetcher, sym, f"bonds:index:{sym}", 1800) + for sym in _BOND_INDICES + ] + results = await asyncio.gather(*tasks) + + indices: list[dict] = [] + for sym, quote in zip(_BOND_INDICES, results): + if quote is None: + continue + indices.append( + { + "symbol": sym, + "name": _BOND_INDICES[sym], + "price": quote["price"], + "change_pct": quote["change_pct"], + } + ) + + return { + "indices": indices, + "fetched_at": _utc_now_iso(), + "source": "yahoo-finance", + } diff --git a/src/world_intel_mcp/sources/earnings.py b/src/world_intel_mcp/sources/earnings.py new file mode 100644 index 0000000..9107add --- /dev/null +++ b/src/world_intel_mcp/sources/earnings.py @@ -0,0 +1,312 @@ +"""Earnings calendar and surprise data for world-intel-mcp. + +Fetches upcoming earnings dates and historical earnings surprises for +mega-cap stocks via Yahoo Finance quoteSummary API. Every function takes +a Fetcher instance as its first argument and returns a dict. +""" + +import asyncio +import logging +from datetime import datetime, timezone, timedelta + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.sources.earnings") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_YAHOO_SUMMARY_URL = ( + "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{symbol}" +) + +# Top 20 mega-cap stocks to check for upcoming earnings. +_MEGACAP_SYMBOLS = [ + "AAPL", + "MSFT", + "GOOGL", + "AMZN", + "NVDA", + "META", + "TSLA", + "BRK-B", + "JPM", + "V", + "UNH", + "MA", + "HD", + "PG", + "JNJ", + "LLY", + "ABBV", + "XOM", + "CVX", + "BAC", +] + +_BATCH_SIZE = 5 # Concurrent requests per batch to respect Yahoo rate limits. + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_earnings_date(raw: dict | None) -> str | None: + """Extract an ISO date string from a Yahoo calendarEvents earnings date.""" + if raw is None: + return None + # Yahoo returns {"raw": 1714003200, "fmt": "2026-04-24"} + fmt = raw.get("fmt") + if fmt: + return fmt + raw_ts = raw.get("raw") + if raw_ts is not None: + try: + return datetime.fromtimestamp(int(raw_ts), tz=timezone.utc).strftime( + "%Y-%m-%d" + ) + except (ValueError, TypeError, OSError): + pass + return None + + +def _parse_float(raw: dict | float | None) -> float | None: + """Extract a float from a Yahoo value object or plain number.""" + if raw is None: + return None + if isinstance(raw, (int, float)): + return float(raw) + if isinstance(raw, dict): + val = raw.get("raw") + if val is not None: + try: + return float(val) + except (ValueError, TypeError): + pass + return None + + +def _parse_quarter_label(date_str: str | None) -> str | None: + """Convert a date string like '2025-12-31' to a quarter label like 'Q4 2025'.""" + if not date_str: + return None + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + q = (dt.month - 1) // 3 + 1 + return f"Q{q} {dt.year}" + except (ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def fetch_earnings_calendar( + fetcher: Fetcher, + days_ahead: int = 7, +) -> dict: + """Fetch upcoming earnings announcements for mega-cap stocks. + + Checks each symbol's ``calendarEvents`` and ``earningsHistory`` modules + via Yahoo Finance quoteSummary. Requests are batched (5 at a time) to + respect Yahoo rate limits. + + Args: + fetcher: Shared HTTP fetcher. + days_ahead: Number of days to look ahead for "this_week" filtering. + + Returns a dict with ``upcoming`` (all found earnings dates sorted by + date), ``this_week`` (subset within *days_ahead*), and metadata. + """ + now = datetime.now(timezone.utc) + cutoff = now + timedelta(days=days_ahead) + + async def _fetch_symbol(symbol: str) -> dict | None: + url = _YAHOO_SUMMARY_URL.format(symbol=symbol) + data = await fetcher.get_json( + url, + source="yahoo-finance", + cache_key=f"earnings:calendar:{symbol}", + cache_ttl=3600, + params={"modules": "calendarEvents,earningsHistory"}, + yahoo_rate_limit=True, + ) + if not isinstance(data, dict): + return None + try: + result_obj = data["quoteSummary"]["result"][0] + + # --- Calendar events (next earnings date) --- + cal = result_obj.get("calendarEvents", {}) + earnings = cal.get("earnings", {}) + earnings_dates = earnings.get("earningsDate", []) + + earnings_date_str: str | None = None + if earnings_dates: + earnings_date_str = _parse_earnings_date(earnings_dates[0]) + + eps_estimate = _parse_float(earnings.get("earningsAverage")) + + # --- Company name from earnings or symbol fallback --- + company = symbol + + # --- Most recent EPS from earningsHistory --- + hist = result_obj.get("earningsHistory", {}) + history_records = hist.get("history", []) + eps_previous: float | None = None + if history_records: + # Most recent quarter is first after sorting by date desc + latest = history_records[-1] + eps_previous = _parse_float(latest.get("epsActual")) + + if earnings_date_str is None: + return None + + # Compute days until earnings + try: + ed = datetime.strptime(earnings_date_str, "%Y-%m-%d").replace( + tzinfo=timezone.utc + ) + days_until = (ed - now).days + except (ValueError, TypeError): + days_until = None + + return { + "symbol": symbol, + "company": company, + "earnings_date": earnings_date_str, + "days_until": days_until, + "eps_estimate": eps_estimate, + "eps_previous": eps_previous, + } + except (KeyError, IndexError, TypeError) as exc: + logger.warning("Failed to parse earnings for %s: %s", symbol, exc) + return None + + # Batch requests to respect rate limits + all_results: list[dict | None] = [] + for i in range(0, len(_MEGACAP_SYMBOLS), _BATCH_SIZE): + batch = _MEGACAP_SYMBOLS[i : i + _BATCH_SIZE] + batch_results = await asyncio.gather(*[_fetch_symbol(sym) for sym in batch]) + all_results.extend(batch_results) + + # Filter and sort + upcoming: list[dict] = [r for r in all_results if r is not None] + upcoming.sort(key=lambda x: x.get("earnings_date") or "9999-99-99") + + # This-week subset + this_week: list[dict] = [] + for entry in upcoming: + ed_str = entry.get("earnings_date") + if ed_str: + try: + ed = datetime.strptime(ed_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + if ed <= cutoff: + this_week.append(entry) + except (ValueError, TypeError): + pass + + return { + "upcoming": upcoming, + "this_week": this_week, + "fetched_at": _utc_now_iso(), + "source": "yahoo-finance", + } + + +async def fetch_earnings_surprise( + fetcher: Fetcher, + symbol: str, +) -> dict: + """Fetch recent earnings surprises for a specific stock. + + Uses Yahoo Finance quoteSummary ``earningsHistory`` and + ``earningsTrend`` modules. + + Args: + fetcher: Shared HTTP fetcher. + symbol: Stock ticker symbol (e.g., "AAPL"). + + Returns a dict with ``history`` (past quarter surprises) and ``trend`` + (current/next quarter estimates), plus metadata. + """ + url = _YAHOO_SUMMARY_URL.format(symbol=symbol) + data = await fetcher.get_json( + url, + source="yahoo-finance", + cache_key=f"earnings:surprise:{symbol}", + cache_ttl=3600, + params={"modules": "earningsHistory,earningsTrend"}, + yahoo_rate_limit=True, + ) + + result: dict = { + "symbol": symbol, + "history": [], + "trend": { + "current_quarter_estimate": None, + "next_quarter_estimate": None, + }, + "fetched_at": _utc_now_iso(), + "source": "yahoo-finance", + } + + if not isinstance(data, dict): + return result + + try: + summary = data["quoteSummary"]["result"][0] + except (KeyError, IndexError, TypeError): + return result + + # --- Earnings history (past quarter surprises) --------------------------- + hist = summary.get("earningsHistory", {}) + for rec in hist.get("history", []): + eps_estimate = _parse_float(rec.get("epsEstimate")) + eps_actual = _parse_float(rec.get("epsActual")) + surprise_pct = _parse_float(rec.get("surprisePercent")) + + quarter_date = _parse_earnings_date(rec.get("quarter")) + quarter_label = _parse_quarter_label(quarter_date) + + # Compute surprise_pct if Yahoo didn't provide it + if ( + surprise_pct is None + and eps_estimate + and eps_estimate != 0 + and eps_actual is not None + ): + surprise_pct = round( + ((eps_actual - eps_estimate) / abs(eps_estimate)) * 100, 2 + ) + + result["history"].append( + { + "quarter": quarter_label or quarter_date, + "eps_estimate": eps_estimate, + "eps_actual": eps_actual, + "surprise_pct": surprise_pct, + } + ) + + # --- Earnings trend (forward estimates) ---------------------------------- + trend = summary.get("earningsTrend", {}) + for t in trend.get("trend", []): + period = t.get("period") + earnings_est = t.get("earningsEstimate", {}) + avg = _parse_float(earnings_est.get("avg")) + + if period == "0q": + result["trend"]["current_quarter_estimate"] = avg + elif period == "+1q": + result["trend"]["next_quarter_estimate"] = avg + + return result diff --git a/src/world_intel_mcp/sources/forex.py b/src/world_intel_mcp/sources/forex.py new file mode 100644 index 0000000..981b733 --- /dev/null +++ b/src/world_intel_mcp/sources/forex.py @@ -0,0 +1,238 @@ +"""Foreign exchange rate data from the European Central Bank. + +Uses the Frankfurter API (free ECB daily reference rate mirror) to provide +live forex rates, historical time-series, and major cross-rate calculations. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.sources.forex") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_FRANKFURTER_LATEST = "https://api.frankfurter.dev/v1/latest" +_FRANKFURTER_HISTORY = "https://api.frankfurter.dev/v1/{start}..{end}" + +_MAJOR_SYMBOLS = "EUR,GBP,JPY,CHF,AUD,CAD,NZD,CNY" + +# Trade-weighted USD index proxy weights (simplified, based on DXY composition) +# DXY weights: EUR 57.6%, JPY 13.6%, GBP 11.9%, CAD 9.1%, SEK 4.2%, CHF 3.6% +# We use what's available from our major pairs: +_DXY_WEIGHTS: dict[str, float] = { + "EUR": 0.576, + "JPY": 0.136, + "GBP": 0.119, + "CAD": 0.091, + "CHF": 0.036, +} + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def fetch_forex_rates( + fetcher: Fetcher, + base: str = "USD", + symbols: str | None = None, +) -> dict: + """Fetch latest ECB daily reference exchange rates. + + Args: + fetcher: Shared HTTP fetcher. + base: Base currency code (default ``"USD"``). + symbols: Comma-separated target currencies (e.g. ``"EUR,GBP,JPY"``). + If *None*, returns all available currencies. + + Returns: + Dict with ``base``, ``date``, ``rates``, plus metadata. + """ + params: dict[str, str] = {"base": base} + if symbols: + params["symbols"] = symbols + + data = await fetcher.get_json( + _FRANKFURTER_LATEST, + source="ecb-forex", + cache_key=f"forex:rates:{base}", + cache_ttl=1800, + params=params, + ) + + if not isinstance(data, dict): + return { + "base": base, + "date": None, + "rates": {}, + "fetched_at": _utc_now_iso(), + "source": "ecb-forex", + } + + return { + "base": data.get("base", base), + "date": data.get("date"), + "rates": data.get("rates", {}), + "fetched_at": _utc_now_iso(), + "source": "ecb-forex", + } + + +async def fetch_forex_timeseries( + fetcher: Fetcher, + base: str = "USD", + symbol: str = "EUR", + days: int = 30, +) -> dict: + """Fetch historical exchange rate time-series from ECB. + + Args: + fetcher: Shared HTTP fetcher. + base: Base currency code. + symbol: Target currency code. + days: Number of days of history (default 30). + + Returns: + Dict with ``rates`` list, ``trend`` summary, plus metadata. + """ + today = datetime.now(timezone.utc).date() + start_date = today - timedelta(days=days) + + url = _FRANKFURTER_HISTORY.format( + start=start_date.isoformat(), end=today.isoformat() + ) + params: dict[str, str] = {"base": base, "symbols": symbol} + + data = await fetcher.get_json( + url, + source="ecb-forex", + cache_key=f"forex:history:{base}:{symbol}:{days}", + cache_ttl=3600, + params=params, + ) + + result: dict = { + "base": base, + "symbol": symbol, + "days": days, + "rates": [], + "trend": None, + "fetched_at": _utc_now_iso(), + "source": "ecb-forex", + } + + if not isinstance(data, dict): + return result + + try: + raw_rates = data.get("rates", {}) + # Frankfurter returns {"rates": {"2026-03-01": {"EUR": 0.92}, ...}} + sorted_dates = sorted(raw_rates.keys()) + rate_list: list[dict] = [] + for date_str in sorted_dates: + day_rates = raw_rates[date_str] + rate_val = day_rates.get(symbol) + if rate_val is not None: + rate_list.append({"date": date_str, "rate": rate_val}) + + result["rates"] = rate_list + + # Compute trend + if len(rate_list) >= 2: + start_rate = rate_list[0]["rate"] + end_rate = rate_list[-1]["rate"] + change_pct = ( + round(((end_rate - start_rate) / start_rate) * 100, 4) + if start_rate + else 0 + ) + result["trend"] = { + "start": start_rate, + "end": end_rate, + "change_pct": change_pct, + } + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + "Failed to parse ECB timeseries for %s/%s: %s", base, symbol, exc + ) + + return result + + +async def fetch_major_crosses(fetcher: Fetcher) -> dict: + """Fetch USD-based rates for the 8 major currency pairs and compute crosses. + + Returns major pairs, derived cross rates (EUR/GBP, EUR/JPY, GBP/JPY), + and a DXY-proxy trade-weighted USD strength estimate. + """ + rates_data = await fetch_forex_rates( + fetcher, + base="USD", + symbols=_MAJOR_SYMBOLS, + ) + + rates = rates_data.get("rates", {}) + + # Build major pairs list + major_pairs: list[dict] = [] + for sym in _MAJOR_SYMBOLS.split(","): + rate = rates.get(sym) + if rate is not None: + major_pairs.append({"pair": f"USD/{sym}", "rate": rate}) + + # Compute cross rates from USD-based rates + # Cross rate: A/B = (USD/B) / (USD/A) + cross_rates: dict[str, float | None] = {} + eur = rates.get("EUR") + gbp = rates.get("GBP") + jpy = rates.get("JPY") + + if eur and gbp: + cross_rates["EUR/GBP"] = round(gbp / eur, 6) + if eur and jpy: + cross_rates["EUR/JPY"] = round(jpy / eur, 4) + if gbp and jpy: + cross_rates["GBP/JPY"] = round(jpy / gbp, 4) + + # DXY proxy: trade-weighted geometric average + # DXY = product(rate^weight) — but ECB gives USD/X, while DXY uses X/USD for some. + # For simplicity, use inverse rates (since higher USD/EUR means weaker dollar): + # DXY proxy = 100 * product((1/rate)^weight) for available pairs + dxy_proxy: float | None = None + try: + product = 1.0 + total_weight = 0.0 + for sym, weight in _DXY_WEIGHTS.items(): + rate = rates.get(sym) + if rate and rate > 0: + # USD/X rate: higher means X is cheaper, i.e. USD is stronger + # DXY convention: higher = stronger USD + # Invert because USD/EUR > 1 means EUR costs more than 1 USD + product *= (1.0 / rate) ** weight + total_weight += weight + if total_weight > 0: + # Normalize if not all weights present + product = ( + product ** (1.0 / total_weight) if total_weight < 0.95 else product + ) + dxy_proxy = round(product * 100, 4) + except (TypeError, ValueError, ZeroDivisionError) as exc: + logger.warning("Failed to compute DXY proxy: %s", exc) + + return { + "major_pairs": major_pairs, + "cross_rates": cross_rates, + "dxy_proxy": dxy_proxy, + "date": rates_data.get("date"), + "fetched_at": _utc_now_iso(), + "source": "ecb-forex", + } diff --git a/src/world_intel_mcp/sources/sec_edgar.py b/src/world_intel_mcp/sources/sec_edgar.py new file mode 100644 index 0000000..a5cd2a9 --- /dev/null +++ b/src/world_intel_mcp/sources/sec_edgar.py @@ -0,0 +1,363 @@ +"""SEC EDGAR filing data sources. + +Fetches SEC filings via the EDGAR Full-Text Search System (EFTS) and +the submissions API. Free, no API key required. SEC mandates a +User-Agent header with contact info on every request. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from ..fetcher import Fetcher + +logger = logging.getLogger("world-intel-mcp.sources.sec_edgar") + +_SEC_HEADERS = { + "User-Agent": "PhoenixAGI-WorldIntel intel@2acrestudios.com", +} + +_EFTS_URL = "https://efts.sec.gov/LATEST/search-index" +_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json" +_SUBMISSIONS_URL = "https://data.sec.gov/submissions" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Full-text search across all EDGAR filings +# --------------------------------------------------------------------------- + + +async def fetch_sec_filings( + fetcher: Fetcher, + query: str | None = None, + form_type: str | None = None, + date_range: str | None = None, + limit: int = 25, +) -> dict: + """Search SEC EDGAR filings via the full-text search API. + + Args: + fetcher: Shared HTTP fetcher. + query: Free-text search query (company name, keyword, etc.). + form_type: Comma-separated form types to filter (e.g. ``"10-K,10-Q,8-K"``). + date_range: Custom date range as ``"YYYY-MM-DD,YYYY-MM-DD"`` (start,end). + Defaults to last 30 days. + limit: Maximum number of results (capped at 100). + + Returns: + Dict with ``query``, ``form_type``, ``filings`` list, ``total``, plus metadata. + """ + limit = min(limit, 100) + + params: dict = {"q": query or "*", "from": 0, "size": limit} + if form_type: + params["forms"] = form_type + + if date_range: + parts = date_range.split(",") + if len(parts) == 2: + params["dateRange"] = "custom" + params["startdt"] = parts[0].strip() + params["enddt"] = parts[1].strip() + else: + end = datetime.now(timezone.utc) + start = end - timedelta(days=30) + params["dateRange"] = "custom" + params["startdt"] = start.strftime("%Y-%m-%d") + params["enddt"] = end.strftime("%Y-%m-%d") + + cache_key = f"sec:search:{query}:{form_type}:{limit}" + + data = await fetcher.get_json( + _EFTS_URL, + source="sec-edgar", + cache_key=cache_key, + cache_ttl=1800, + headers=_SEC_HEADERS, + params=params, + ) + + result: dict = { + "query": query, + "form_type": form_type, + "filings": [], + "total": 0, + "fetched_at": _utc_now_iso(), + "source": "sec-edgar", + } + + if not isinstance(data, dict): + return result + + try: + hits = data.get("hits", {}) + if not isinstance(hits, dict): + return result + total_raw = hits.get("total", 0) + result["total"] = ( + total_raw.get("value", 0) if isinstance(total_raw, dict) else total_raw + ) + + for hit in hits.get("hits", []): + if not isinstance(hit, dict): + continue + src = hit.get("_source", {}) + if not isinstance(src, dict): + continue + filing = { + "company": src.get("display_names", [None])[0] + if src.get("display_names") + else src.get("entity_name"), + "form_type": src.get("form_type", ""), + "filed_date": src.get("file_date", ""), + "description": src.get( + "display_description", src.get("description", "") + ), + "url": f"https://www.sec.gov/Archives/edgar/data/{src.get('entity_id', '')}/{src.get('file_num', '')}".rstrip( + "/" + ), + } + file_id = hit.get("_id", "") + if file_id: + filing["url"] = ( + f"https://www.sec.gov/Archives/edgar/data/{file_id.replace(':', '/')}" + ) + result["filings"].append(filing) + except (KeyError, TypeError, IndexError) as exc: + logger.warning("Failed to parse EFTS search results: %s", exc) + + return result + + +# --------------------------------------------------------------------------- +# Company filings by ticker +# --------------------------------------------------------------------------- + + +async def _resolve_cik(fetcher: Fetcher, ticker: str) -> tuple[str | None, str | None]: + """Resolve a stock ticker to a zero-padded CIK and company name. + + Uses the SEC company_tickers.json file (cached for 24h). + Returns (cik_padded, company_name) or (None, None) if not found. + """ + data = await fetcher.get_json( + _TICKERS_URL, + source="sec-edgar", + cache_key="sec:company_tickers", + cache_ttl=86400, + headers=_SEC_HEADERS, + ) + + if not isinstance(data, dict): + return None, None + + try: + ticker_upper = ticker.upper() + for entry in data.values(): + if entry.get("ticker", "").upper() == ticker_upper: + cik = str(entry["cik_str"]) + padded = cik.zfill(10) + return padded, entry.get("title", "") + except (KeyError, TypeError, AttributeError) as exc: + logger.warning("Failed to resolve ticker %s: %s", ticker, exc) + + return None, None + + +async def fetch_company_filings( + fetcher: Fetcher, + ticker: str, + form_types: list[str] | None = None, + limit: int = 10, +) -> dict: + """Fetch recent SEC filings for a company by ticker symbol. + + Args: + fetcher: Shared HTTP fetcher. + ticker: Stock ticker symbol (e.g. ``"AAPL"``). + form_types: Filter by form types. Defaults to ``["10-K", "10-Q", "8-K"]``. + limit: Maximum number of filings to return. + + Returns: + Dict with ``ticker``, ``company_name``, ``cik``, ``filings`` list, plus metadata. + """ + allowed_forms = set(form_types or ["10-K", "10-Q", "8-K"]) + + result: dict = { + "ticker": ticker.upper(), + "company_name": "", + "cik": "", + "filings": [], + "fetched_at": _utc_now_iso(), + "source": "sec-edgar", + } + + cik, company_name = await _resolve_cik(fetcher, ticker) + if cik is None: + result["error"] = f"Ticker '{ticker}' not found in SEC company tickers" + return result + + result["cik"] = cik + result["company_name"] = company_name or "" + + submissions_url = f"{_SUBMISSIONS_URL}/CIK{cik}.json" + + data = await fetcher.get_json( + submissions_url, + source="sec-edgar", + cache_key=f"sec:company:{ticker.upper()}:{limit}", + cache_ttl=3600, + headers=_SEC_HEADERS, + ) + + if not isinstance(data, dict): + return result + + try: + # Use company name from submissions if available + if data.get("name"): + result["company_name"] = data["name"] + + filings_obj = data.get("filings", {}) + recent = filings_obj.get("recent", {}) if isinstance(filings_obj, dict) else {} + forms = recent.get("form", []) + dates = recent.get("filingDate", []) + primary_docs = recent.get("primaryDocument", []) + descriptions = recent.get("primaryDocDescription", []) + accession_numbers = recent.get("accessionNumber", []) + + count = 0 + for i in range(len(forms)): + if count >= limit: + break + form = forms[i] if i < len(forms) else "" + if form not in allowed_forms: + continue + + accession = ( + accession_numbers[i].replace("-", "") + if i < len(accession_numbers) + else "" + ) + primary_doc = primary_docs[i] if i < len(primary_docs) else "" + filing_url = ( + f"https://www.sec.gov/Archives/edgar/data/{cik.lstrip('0')}/{accession}/{primary_doc}" + if accession and primary_doc + else "" + ) + + result["filings"].append( + { + "form": form, + "filing_date": dates[i] if i < len(dates) else "", + "description": descriptions[i] if i < len(descriptions) else "", + "url": filing_url, + } + ) + count += 1 + except (KeyError, TypeError, IndexError) as exc: + logger.warning("Failed to parse submissions for %s: %s", ticker, exc) + + return result + + +# --------------------------------------------------------------------------- +# Recent 8-K filings (material events) +# --------------------------------------------------------------------------- + + +async def fetch_recent_8k( + fetcher: Fetcher, + limit: int = 25, +) -> dict: + """Fetch the most recent 8-K filings (material corporate events). + + 8-K filings cover M&A activity, executive changes, earnings releases, + and other material events. + + Args: + fetcher: Shared HTTP fetcher. + limit: Maximum number of filings to return. + + Returns: + Dict with ``filings`` list, ``total`` count, plus metadata. + """ + limit = min(limit, 100) + + end = datetime.now(timezone.utc) + start = end - timedelta(days=7) + + params: dict = { + "q": "*", + "forms": "8-K", + "dateRange": "custom", + "startdt": start.strftime("%Y-%m-%d"), + "enddt": end.strftime("%Y-%m-%d"), + "from": 0, + "size": limit, + } + + data = await fetcher.get_json( + _EFTS_URL, + source="sec-edgar", + cache_key=f"sec:recent-8k:{limit}", + cache_ttl=1800, + headers=_SEC_HEADERS, + params=params, + ) + + result: dict = { + "filings": [], + "total": 0, + "fetched_at": _utc_now_iso(), + "source": "sec-edgar", + } + + if not isinstance(data, dict): + return result + + try: + hits = data.get("hits", {}) + if not isinstance(hits, dict): + return result + total_raw = hits.get("total", 0) + result["total"] = ( + total_raw.get("value", 0) if isinstance(total_raw, dict) else total_raw + ) + + for hit in hits.get("hits", []): + if not isinstance(hit, dict): + continue + src = hit.get("_source", {}) + filing: dict = { + "company": src.get("display_names", [None])[0] + if src.get("display_names") + else src.get("entity_name"), + "ticker": None, + "filed_date": src.get("file_date", ""), + "description": src.get( + "display_description", src.get("description", "") + ), + "items": src.get("items", []), + "url": "", + } + + # Extract ticker from display_names if present + tickers = src.get("tickers", []) + if tickers: + filing["ticker"] = tickers[0] + + file_id = hit.get("_id", "") + if file_id: + filing["url"] = ( + f"https://www.sec.gov/Archives/edgar/data/{file_id.replace(':', '/')}" + ) + + result["filings"].append(filing) + except (KeyError, TypeError, IndexError) as exc: + logger.warning("Failed to parse recent 8-K results: %s", exc) + + return result diff --git a/src/world_intel_mcp/tests/conftest.py b/src/world_intel_mcp/tests/conftest.py index a9257a3..289d07a 100644 --- a/src/world_intel_mcp/tests/conftest.py +++ b/src/world_intel_mcp/tests/conftest.py @@ -1,14 +1,25 @@ """Test configuration — strips proxy env vars so httpx doesn't try SOCKS.""" import asyncio -import os +from pathlib import Path import pytest +from world_intel_mcp.cache import Cache +from world_intel_mcp.circuit_breaker import CircuitBreaker +from world_intel_mcp.fetcher import Fetcher + _PROXY_VARS = [ - "ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", - "HTTPS_PROXY", "https_proxy", "FTP_PROXY", "ftp_proxy", - "GRPC_PROXY", "grpc_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "FTP_PROXY", + "ftp_proxy", + "GRPC_PROXY", + "grpc_proxy", ] @@ -28,3 +39,14 @@ def _reset_fetcher_locks() -> None: fetcher_mod._yahoo_last_call = 0.0 fetcher_mod._source_locks.clear() fetcher_mod._source_last_call.clear() + + +@pytest.fixture +def cache(tmp_path: Path) -> Cache: + return Cache(db_path=tmp_path / "test_cache.db") + + +@pytest.fixture +def fetcher(cache: Cache) -> Fetcher: + breaker = CircuitBreaker() + return Fetcher(cache=cache, breaker=breaker, default_timeout=5.0) diff --git a/src/world_intel_mcp/tests/test_bonds.py b/src/world_intel_mcp/tests/test_bonds.py new file mode 100644 index 0000000..458e35d --- /dev/null +++ b/src/world_intel_mcp/tests/test_bonds.py @@ -0,0 +1,267 @@ +"""Tests for bonds source module — uses respx to mock HTTP calls.""" + +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +import respx + +from world_intel_mcp.cache import Cache +from world_intel_mcp.circuit_breaker import CircuitBreaker +from world_intel_mcp.fetcher import Fetcher + + +@pytest.fixture +def cache(tmp_path: Path) -> Cache: + return Cache(db_path=tmp_path / "test_cache.db") + + +@pytest.fixture +def fetcher(cache: Cache) -> Fetcher: + breaker = CircuitBreaker() + return Fetcher(cache=cache, breaker=breaker, default_timeout=5.0) + + +# --------------------------------------------------------------------------- +# Yield Curve — Yahoo Finance fallback (no FRED key) +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_yield_curve_yahoo_fallback(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.bonds import fetch_yield_curve + + # Mock Treasury Fiscal Data API + treasury_response = { + "data": [ + { + "record_date": "2026-03-01", + "security_desc": "Treasury Notes", + "avg_interest_rate_amt": "4.125", + } + ] + } + respx.get(url__regex=r".*api\.fiscaldata\.treasury\.gov.*").mock( + return_value=httpx.Response(200, json=treasury_response) + ) + + # Mock Yahoo Finance for yield symbols + def _yahoo_chart(symbol: str, price: float) -> dict: + return { + "chart": { + "result": [ + { + "meta": { + "symbol": symbol, + "regularMarketPrice": price, + "previousClose": price - 0.02, + "currency": "USD", + } + } + ] + } + } + + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EIRX").mock( + return_value=httpx.Response(200, json=_yahoo_chart("^IRX", 4.52)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EFVX").mock( + return_value=httpx.Response(200, json=_yahoo_chart("^FVX", 4.15)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5ETNX").mock( + return_value=httpx.Response(200, json=_yahoo_chart("^TNX", 4.33)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5ETYX").mock( + return_value=httpx.Response(200, json=_yahoo_chart("^TYX", 4.61)) + ) + + # Ensure no FRED key + with patch.dict("os.environ", {}, clear=False): + import os + + os.environ.pop("FRED_API_KEY", None) + result = await fetch_yield_curve(fetcher) + + assert "yields" in result + assert len(result["yields"]) == 4 + assert result["source"] == "treasury" + assert result["fetched_at"] is not None + + # Check spread computation: 3M=4.52, 10Y=4.33 -> spread_3m10y = 4.33-4.52 = -0.19 + assert result["spread_3m10y"] is not None + assert result["spread_3m10y"] < 0 + assert result["inverted"] is True + + # Verify yield maturities + maturities = {y["maturity"] for y in result["yields"]} + assert "3M" in maturities + assert "10Y" in maturities + + +# --------------------------------------------------------------------------- +# Yield Curve — FRED path +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_yield_curve_fred(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.bonds import fetch_yield_curve + + # Mock Treasury Fiscal Data API + respx.get(url__regex=r".*api\.fiscaldata\.treasury\.gov.*").mock( + return_value=httpx.Response(200, json={"data": []}) + ) + + # Mock FRED responses for each series + def _fred_response(value: str) -> dict: + return {"observations": [{"date": "2026-03-07", "value": value}]} + + fred_values = { + "DGS1MO": "3.80", + "DGS3MO": "3.95", + "DGS6MO": "4.05", + "DGS1": "4.10", + "DGS2": "4.20", + "DGS5": "4.30", + "DGS10": "4.45", + "DGS20": "4.55", + "DGS30": "4.61", + } + + # Route all FRED requests — respx matches on base URL, params distinguish + respx.get("https://api.stlouisfed.org/fred/series/observations").mock( + side_effect=lambda request: httpx.Response( + 200, + json=_fred_response( + fred_values.get( + dict(request.url.params).get("series_id", ""), + "0.0", + ) + ), + ) + ) + + with patch.dict("os.environ", {"FRED_API_KEY": "test-fred-key"}): + result = await fetch_yield_curve(fetcher) + + assert "yields" in result + assert len(result["yields"]) == 9 + assert result["source"] == "treasury" + + # Check spread: 2Y=4.20, 10Y=4.45 -> spread_2s10s = 0.25 (positive) + # 3M=3.95, 10Y=4.45 -> spread_3m10y = 0.50 (positive) -> not inverted + assert result["spread_2s10s"] is not None + assert result["spread_2s10s"] > 0 + assert result["spread_3m10y"] is not None + assert result["spread_3m10y"] > 0 + assert result["inverted"] is False + + # Verify all maturities present + maturities = {y["maturity"] for y in result["yields"]} + assert maturities == {"1M", "3M", "6M", "1Y", "2Y", "5Y", "10Y", "20Y", "30Y"} + + +# --------------------------------------------------------------------------- +# Bond Indices +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_bond_indices(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.bonds import fetch_bond_indices + + def _yahoo_chart(symbol: str, price: float) -> dict: + return { + "chart": { + "result": [ + { + "meta": { + "symbol": symbol, + "regularMarketPrice": price, + "previousClose": price + 0.15, + "currency": "USD", + } + } + ] + } + } + + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/AGG").mock( + return_value=httpx.Response(200, json=_yahoo_chart("AGG", 98.50)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/TLT").mock( + return_value=httpx.Response(200, json=_yahoo_chart("TLT", 92.30)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/HYG").mock( + return_value=httpx.Response(200, json=_yahoo_chart("HYG", 77.80)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/LQD").mock( + return_value=httpx.Response(200, json=_yahoo_chart("LQD", 108.20)) + ) + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/TIP").mock( + return_value=httpx.Response(200, json=_yahoo_chart("TIP", 106.40)) + ) + + result = await fetch_bond_indices(fetcher) + + assert "indices" in result + assert len(result["indices"]) == 5 + assert result["source"] == "yahoo-finance" + assert result["fetched_at"] is not None + + # Verify individual entries + by_symbol = {idx["symbol"]: idx for idx in result["indices"]} + assert by_symbol["AGG"]["name"] == "US Aggregate Bond" + assert by_symbol["AGG"]["price"] == 98.50 + assert by_symbol["TLT"]["name"] == "20+ Year Treasury" + assert by_symbol["HYG"]["name"] == "High Yield Corporate" + + # Change percent should be negative (price dropped from previousClose) + for idx in result["indices"]: + assert idx["change_pct"] is not None + assert idx["change_pct"] < 0 + + +# --------------------------------------------------------------------------- +# Bond Indices — partial failure +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_bond_indices_partial_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.bonds import fetch_bond_indices + + chart_ok = { + "chart": { + "result": [ + { + "meta": { + "symbol": "AGG", + "regularMarketPrice": 98.50, + "regularMarketChangePercent": -0.12, + "currency": "USD", + } + } + ] + } + } + + # Only AGG succeeds; rest fail with 500 + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/AGG").mock( + return_value=httpx.Response(200, json=chart_ok) + ) + respx.get(url__regex=r".*finance/chart/(?!AGG).*").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_bond_indices(fetcher) + + assert "indices" in result + # Only AGG should survive + assert len(result["indices"]) >= 1 + assert result["indices"][0]["symbol"] == "AGG" diff --git a/src/world_intel_mcp/tests/test_company.py b/src/world_intel_mcp/tests/test_company.py new file mode 100644 index 0000000..47888bc --- /dev/null +++ b/src/world_intel_mcp/tests/test_company.py @@ -0,0 +1,235 @@ +"""Tests for analysis.company — company enrichment composite.""" + +import httpx +import pytest +import respx + +from world_intel_mcp.fetcher import Fetcher + + +# --------------------------------------------------------------------------- +# Fixtures: Yahoo chart + quoteSummary + GDELT + GitHub mock responses +# --------------------------------------------------------------------------- + +_YAHOO_CHART_AAPL = { + "chart": { + "result": [ + { + "meta": { + "symbol": "AAPL", + "regularMarketPrice": 189.50, + "previousClose": 187.00, + "regularMarketVolume": 52_000_000, + "marketCap": 2_950_000_000_000, + "currency": "USD", + } + } + ] + } +} + +_YAHOO_SUMMARY_AAPL = { + "quoteSummary": { + "result": [ + { + "assetProfile": { + "sector": "Technology", + "industry": "Consumer Electronics", + "fullTimeEmployees": 164000, + "website": "https://www.apple.com", + "longBusinessSummary": "Apple Inc. designs, manufactures, and markets smartphones and personal computers.", + }, + "financialData": { + "totalRevenue": {"raw": 383_285_000_000, "fmt": "383.29B"}, + "profitMargins": {"raw": 0.2631, "fmt": "26.31%"}, + }, + "defaultKeyStatistics": { + "forwardPE": {"raw": 28.5, "fmt": "28.50"}, + "marketCap": {"raw": 2_950_000_000_000, "fmt": "2.95T"}, + }, + } + ] + } +} + +_GDELT_NEWS = { + "articles": [ + { + "title": "Apple launches new AI features", + "url": "https://example.com/apple-ai", + "seendate": "20260308T120000Z", + }, + { + "title": "AAPL stock hits record high", + "url": "https://example.com/aapl-record", + "seendate": "20260307T100000Z", + }, + ] +} + +_GITHUB_SEARCH = { + "items": [ + { + "full_name": "apple/swift", + "owner": {"login": "apple"}, + "stargazers_count": 67000, + "html_url": "https://github.com/apple/swift", + }, + { + "full_name": "apple/ml-ferret", + "owner": {"login": "apple"}, + "stargazers_count": 8200, + "html_url": "https://github.com/apple/ml-ferret", + }, + { + "full_name": "someone/unrelated", + "owner": {"login": "someone"}, + "stargazers_count": 100, + "html_url": "https://github.com/someone/unrelated", + }, + ] +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_profile_full(fetcher: Fetcher) -> None: + """Test company profile with all sources returning data.""" + from world_intel_mcp.analysis.company import fetch_company_profile + + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/APPLE").mock( + return_value=httpx.Response(200, json=_YAHOO_CHART_AAPL) + ) + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/APPLE").mock( + return_value=httpx.Response(200, json=_YAHOO_SUMMARY_AAPL) + ) + respx.get("https://api.gdeltproject.org/api/v2/doc/doc").mock( + return_value=httpx.Response(200, json=_GDELT_NEWS) + ) + respx.get("https://api.github.com/search/repositories").mock( + return_value=httpx.Response(200, json=_GITHUB_SEARCH) + ) + # SEC EDGAR ticker lookup (module exists, must be mocked) + respx.get(url__regex=r"sec\.gov").mock(return_value=httpx.Response(200, json={})) + respx.get(url__regex=r"data\.sec\.gov").mock( + return_value=httpx.Response(200, json={}) + ) + + result = await fetch_company_profile(fetcher, "apple") + + assert result["ticker"] == "APPLE" + assert result["source"] == "composite" + assert result["sector"] == "Technology" + assert result["industry"] == "Consumer Electronics" + + # Stock data + assert result["stock"]["price"] == 189.50 + assert result["stock"]["volume"] == 52_000_000 + assert result["stock"]["change_pct"] is not None + + # Financials + assert result["financials"]["revenue"] == 383_285_000_000 + assert result["financials"]["profit_margin"] == 0.2631 + assert result["financials"]["pe_ratio"] == 28.5 + assert result["financials"]["employees"] == 164000 + + # News + assert len(result["recent_news"]) == 2 + assert result["recent_news"][0]["title"] == "Apple launches new AI features" + + # GitHub — only "apple" org repos should be included, not "someone/unrelated" + assert "github" in result + assert len(result["github"]) == 2 + assert result["github"][0]["name"] == "apple/swift" + + assert "fetched_at" in result + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_profile_partial_failure(fetcher: Fetcher) -> None: + """Test that partial upstream failures produce a valid but incomplete result.""" + from world_intel_mcp.analysis.company import fetch_company_profile + + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/MSFT").mock( + return_value=httpx.Response( + 200, + json={ + "chart": { + "result": [ + { + "meta": { + "symbol": "MSFT", + "regularMarketPrice": 420.00, + "previousClose": 415.00, + "regularMarketVolume": 25_000_000, + "currency": "USD", + } + } + ] + } + }, + ) + ) + # quoteSummary fails + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/MSFT").mock( + return_value=httpx.Response(500) + ) + # GDELT fails + respx.get("https://api.gdeltproject.org/api/v2/doc/doc").mock( + return_value=httpx.Response(503) + ) + # GitHub fails + respx.get("https://api.github.com/search/repositories").mock( + return_value=httpx.Response(403) + ) + # SEC EDGAR (module exists, must be mocked) + respx.get(url__regex=r"sec\.gov").mock(return_value=httpx.Response(500)) + + result = await fetch_company_profile(fetcher, "MSFT") + + assert result["ticker"] == "MSFT" + assert result["source"] == "composite" + # Stock should still be populated + assert result["stock"]["price"] == 420.00 + # Financials empty when quoteSummary fails + assert result["financials"] == {} + # No news when GDELT fails + assert result["recent_news"] == [] + # No github key when GitHub fails + assert "github" not in result or result.get("github") == [] + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_profile_total_failure(fetcher: Fetcher) -> None: + """Test with all upstreams returning errors.""" + from world_intel_mcp.analysis.company import fetch_company_profile + + respx.get("https://query1.finance.yahoo.com/v8/finance/chart/XYZ").mock( + return_value=httpx.Response(404) + ) + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/XYZ").mock( + return_value=httpx.Response(404) + ) + respx.get("https://api.gdeltproject.org/api/v2/doc/doc").mock( + return_value=httpx.Response(500) + ) + respx.get("https://api.github.com/search/repositories").mock( + return_value=httpx.Response(500) + ) + # SEC EDGAR (module exists, must be mocked) + respx.get(url__regex=r"sec\.gov").mock(return_value=httpx.Response(500)) + + result = await fetch_company_profile(fetcher, "XYZ") + + assert result["ticker"] == "XYZ" + assert result["source"] == "composite" + assert result["stock"] == {} + assert result["recent_news"] == [] + assert "fetched_at" in result diff --git a/src/world_intel_mcp/tests/test_earnings.py b/src/world_intel_mcp/tests/test_earnings.py new file mode 100644 index 0000000..54f14a3 --- /dev/null +++ b/src/world_intel_mcp/tests/test_earnings.py @@ -0,0 +1,276 @@ +"""Tests for earnings source module — uses respx to mock HTTP calls.""" + +from pathlib import Path + +import httpx +import pytest +import respx + +from world_intel_mcp.cache import Cache +from world_intel_mcp.circuit_breaker import CircuitBreaker +from world_intel_mcp.fetcher import Fetcher + + +@pytest.fixture +def cache(tmp_path: Path) -> Cache: + return Cache(db_path=tmp_path / "test_cache.db") + + +@pytest.fixture +def fetcher(cache: Cache) -> Fetcher: + breaker = CircuitBreaker() + return Fetcher(cache=cache, breaker=breaker, default_timeout=5.0) + + +# --------------------------------------------------------------------------- +# Earnings Calendar +# --------------------------------------------------------------------------- + + +def _make_quote_summary( + symbol: str, + earnings_date_raw: int, + earnings_date_fmt: str, + eps_average: float, + eps_actual_prev: float, +) -> dict: + """Build a realistic Yahoo quoteSummary response for calendarEvents.""" + return { + "quoteSummary": { + "result": [ + { + "calendarEvents": { + "earnings": { + "earningsDate": [ + {"raw": earnings_date_raw, "fmt": earnings_date_fmt} + ], + "earningsAverage": { + "raw": eps_average, + "fmt": str(eps_average), + }, + } + }, + "earningsHistory": { + "history": [ + { + "quarter": {"raw": 1735603200, "fmt": "2024-12-31"}, + "epsEstimate": {"raw": 2.10, "fmt": "2.10"}, + "epsActual": { + "raw": eps_actual_prev, + "fmt": str(eps_actual_prev), + }, + "surprisePercent": {"raw": 0.038, "fmt": "3.8%"}, + } + ], + }, + } + ], + "error": None, + } + } + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_earnings_calendar(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.earnings import fetch_earnings_calendar + + # Mock all 20 symbols — give AAPL and MSFT earnings dates, rest return + # empty calendarEvents (so they get filtered out). + aapl_resp = _make_quote_summary("AAPL", 1777180800, "2026-04-24", 2.35, 2.18) + msft_resp = _make_quote_summary("MSFT", 1777440000, "2026-04-27", 3.22, 3.10) + + no_earnings_resp = { + "quoteSummary": { + "result": [ + { + "calendarEvents": {"earnings": {}}, + "earningsHistory": {"history": []}, + } + ], + "error": None, + } + } + + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL").mock( + return_value=httpx.Response(200, json=aapl_resp) + ) + + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/MSFT").mock( + return_value=httpx.Response(200, json=msft_resp) + ) + + # All other symbols return no earnings + respx.get(url__regex=r".*quoteSummary/(?!AAPL|MSFT).*").mock( + return_value=httpx.Response(200, json=no_earnings_resp) + ) + + result = await fetch_earnings_calendar(fetcher, days_ahead=60) + + assert "upcoming" in result + assert "this_week" in result + assert result["source"] == "yahoo-finance" + assert result["fetched_at"] is not None + + # Should have exactly 2 upcoming earnings (AAPL and MSFT) + assert len(result["upcoming"]) == 2 + + # Should be sorted by date — AAPL (Apr 24) before MSFT (Apr 27) + assert result["upcoming"][0]["symbol"] == "AAPL" + assert result["upcoming"][0]["earnings_date"] == "2026-04-24" + assert result["upcoming"][0]["eps_estimate"] == 2.35 + assert result["upcoming"][0]["eps_previous"] == 2.18 + + assert result["upcoming"][1]["symbol"] == "MSFT" + assert result["upcoming"][1]["earnings_date"] == "2026-04-27" + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_earnings_calendar_all_fail(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.earnings import fetch_earnings_calendar + + # All symbols return HTTP 500 + respx.get(url__regex=r".*quoteSummary/.*").mock(return_value=httpx.Response(500)) + + result = await fetch_earnings_calendar(fetcher) + + assert result["upcoming"] == [] + assert result["this_week"] == [] + assert result["source"] == "yahoo-finance" + + +# --------------------------------------------------------------------------- +# Earnings Surprise +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_earnings_surprise(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.earnings import fetch_earnings_surprise + + surprise_resp = { + "quoteSummary": { + "result": [ + { + "earningsHistory": { + "history": [ + { + "quarter": {"raw": 1727654400, "fmt": "2024-09-30"}, + "epsEstimate": {"raw": 1.95, "fmt": "1.95"}, + "epsActual": {"raw": 2.05, "fmt": "2.05"}, + "surprisePercent": {"raw": 0.0513, "fmt": "5.13%"}, + }, + { + "quarter": {"raw": 1735603200, "fmt": "2024-12-31"}, + "epsEstimate": {"raw": 2.10, "fmt": "2.10"}, + "epsActual": {"raw": 2.18, "fmt": "2.18"}, + "surprisePercent": {"raw": 0.038, "fmt": "3.8%"}, + }, + ], + }, + "earningsTrend": { + "trend": [ + { + "period": "0q", + "earningsEstimate": { + "avg": {"raw": 2.35, "fmt": "2.35"}, + }, + }, + { + "period": "+1q", + "earningsEstimate": { + "avg": {"raw": 2.42, "fmt": "2.42"}, + }, + }, + ], + }, + } + ], + "error": None, + } + } + + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL").mock( + return_value=httpx.Response(200, json=surprise_resp) + ) + + result = await fetch_earnings_surprise(fetcher, symbol="AAPL") + + assert result["symbol"] == "AAPL" + assert result["source"] == "yahoo-finance" + assert result["fetched_at"] is not None + + # History + assert len(result["history"]) == 2 + h0 = result["history"][0] + assert h0["eps_estimate"] == 1.95 + assert h0["eps_actual"] == 2.05 + assert h0["surprise_pct"] == 0.0513 + assert h0["quarter"] == "Q3 2024" + + h1 = result["history"][1] + assert h1["quarter"] == "Q4 2024" + assert h1["eps_actual"] == 2.18 + + # Trend + assert result["trend"]["current_quarter_estimate"] == 2.35 + assert result["trend"]["next_quarter_estimate"] == 2.42 + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_earnings_surprise_no_data(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.earnings import fetch_earnings_surprise + + # API returns 500 + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/XYZ").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_earnings_surprise(fetcher, symbol="XYZ") + + assert result["symbol"] == "XYZ" + assert result["history"] == [] + assert result["trend"]["current_quarter_estimate"] is None + assert result["trend"]["next_quarter_estimate"] is None + assert result["source"] == "yahoo-finance" + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_earnings_surprise_missing_surprise_pct(fetcher: Fetcher) -> None: + """If Yahoo omits surprisePercent, the module should compute it.""" + from world_intel_mcp.sources.earnings import fetch_earnings_surprise + + resp = { + "quoteSummary": { + "result": [ + { + "earningsHistory": { + "history": [ + { + "quarter": {"raw": 1735603200, "fmt": "2024-12-31"}, + "epsEstimate": {"raw": 2.00, "fmt": "2.00"}, + "epsActual": {"raw": 2.20, "fmt": "2.20"}, + # No surprisePercent field + }, + ], + }, + "earningsTrend": {"trend": []}, + } + ], + "error": None, + } + } + + respx.get("https://query1.finance.yahoo.com/v10/finance/quoteSummary/MSFT").mock( + return_value=httpx.Response(200, json=resp) + ) + + result = await fetch_earnings_surprise(fetcher, symbol="MSFT") + + assert len(result["history"]) == 1 + # (2.20 - 2.00) / 2.00 * 100 = 10.0 + assert result["history"][0]["surprise_pct"] == 10.0 diff --git a/src/world_intel_mcp/tests/test_forex.py b/src/world_intel_mcp/tests/test_forex.py new file mode 100644 index 0000000..192b143 --- /dev/null +++ b/src/world_intel_mcp/tests/test_forex.py @@ -0,0 +1,217 @@ +"""Tests for forex source module — uses respx to mock HTTP calls.""" + +from pathlib import Path + +import httpx +import pytest +import respx + +from world_intel_mcp.cache import Cache +from world_intel_mcp.circuit_breaker import CircuitBreaker +from world_intel_mcp.fetcher import Fetcher + + +@pytest.fixture +def cache(tmp_path: Path) -> Cache: + return Cache(db_path=tmp_path / "test_cache.db") + + +@pytest.fixture +def fetcher(cache: Cache) -> Fetcher: + breaker = CircuitBreaker() + return Fetcher(cache=cache, breaker=breaker, default_timeout=5.0) + + +# --------------------------------------------------------------------------- +# fetch_forex_rates +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_forex_rates(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_forex_rates + + api_response = { + "base": "USD", + "date": "2026-03-08", + "rates": {"EUR": 0.92, "GBP": 0.79, "JPY": 149.5}, + } + + respx.get("https://api.frankfurter.dev/v1/latest").mock( + return_value=httpx.Response(200, json=api_response) + ) + + result = await fetch_forex_rates(fetcher, base="USD", symbols="EUR,GBP,JPY") + assert result["base"] == "USD" + assert result["date"] == "2026-03-08" + assert result["rates"]["EUR"] == 0.92 + assert result["rates"]["GBP"] == 0.79 + assert result["rates"]["JPY"] == 149.5 + assert result["source"] == "ecb-forex" + assert "fetched_at" in result + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_forex_rates_all_currencies(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_forex_rates + + api_response = { + "base": "EUR", + "date": "2026-03-08", + "rates": {"USD": 1.087, "GBP": 0.858, "JPY": 162.4, "CHF": 0.965}, + } + + respx.get("https://api.frankfurter.dev/v1/latest").mock( + return_value=httpx.Response(200, json=api_response) + ) + + result = await fetch_forex_rates(fetcher, base="EUR") + assert result["base"] == "EUR" + assert len(result["rates"]) == 4 + assert result["source"] == "ecb-forex" + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_forex_rates_api_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_forex_rates + + respx.get("https://api.frankfurter.dev/v1/latest").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_forex_rates(fetcher, base="USD") + assert result["base"] == "USD" + assert result["rates"] == {} + assert result["source"] == "ecb-forex" + + +# --------------------------------------------------------------------------- +# fetch_forex_timeseries +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_forex_timeseries(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_forex_timeseries + + api_response = { + "base": "USD", + "start_date": "2026-02-06", + "end_date": "2026-03-08", + "rates": { + "2026-02-06": {"EUR": 0.93}, + "2026-02-07": {"EUR": 0.925}, + "2026-02-10": {"EUR": 0.92}, + "2026-03-07": {"EUR": 0.915}, + "2026-03-08": {"EUR": 0.92}, + }, + } + + respx.get(url__regex=r"https://api\.frankfurter\.dev/v1/.*\.\..*").mock( + return_value=httpx.Response(200, json=api_response) + ) + + result = await fetch_forex_timeseries(fetcher, base="USD", symbol="EUR", days=30) + assert result["base"] == "USD" + assert result["symbol"] == "EUR" + assert result["days"] == 30 + assert len(result["rates"]) == 5 + assert result["rates"][0]["date"] == "2026-02-06" + assert result["rates"][0]["rate"] == 0.93 + assert result["rates"][-1]["rate"] == 0.92 + assert result["trend"] is not None + assert result["trend"]["start"] == 0.93 + assert result["trend"]["end"] == 0.92 + assert result["trend"]["change_pct"] < 0 # EUR weakened + assert result["source"] == "ecb-forex" + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_forex_timeseries_api_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_forex_timeseries + + respx.get(url__regex=r"https://api\.frankfurter\.dev/v1/.*\.\..*").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_forex_timeseries(fetcher, base="USD", symbol="EUR", days=7) + assert result["base"] == "USD" + assert result["symbol"] == "EUR" + assert result["rates"] == [] + assert result["trend"] is None + assert result["source"] == "ecb-forex" + + +# --------------------------------------------------------------------------- +# fetch_major_crosses +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_major_crosses(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_major_crosses + + api_response = { + "base": "USD", + "date": "2026-03-08", + "rates": { + "EUR": 0.92, + "GBP": 0.79, + "JPY": 149.5, + "CHF": 0.88, + "AUD": 1.55, + "CAD": 1.36, + "NZD": 1.72, + "CNY": 7.24, + }, + } + + respx.get("https://api.frankfurter.dev/v1/latest").mock( + return_value=httpx.Response(200, json=api_response) + ) + + result = await fetch_major_crosses(fetcher) + assert len(result["major_pairs"]) == 8 + assert result["major_pairs"][0]["pair"] == "USD/EUR" + assert result["major_pairs"][0]["rate"] == 0.92 + + # Cross rates + assert "EUR/GBP" in result["cross_rates"] + assert "EUR/JPY" in result["cross_rates"] + assert "GBP/JPY" in result["cross_rates"] + # EUR/GBP = GBP/EUR = 0.79 / 0.92 + expected_eur_gbp = round(0.79 / 0.92, 6) + assert result["cross_rates"]["EUR/GBP"] == expected_eur_gbp + # EUR/JPY = JPY/EUR = 149.5 / 0.92 + expected_eur_jpy = round(149.5 / 0.92, 4) + assert result["cross_rates"]["EUR/JPY"] == expected_eur_jpy + + # DXY proxy should be a positive float + assert result["dxy_proxy"] is not None + assert isinstance(result["dxy_proxy"], float) + assert result["dxy_proxy"] > 0 + + assert result["source"] == "ecb-forex" + assert result["date"] == "2026-03-08" + assert "fetched_at" in result + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_major_crosses_api_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.forex import fetch_major_crosses + + respx.get("https://api.frankfurter.dev/v1/latest").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_major_crosses(fetcher) + assert result["major_pairs"] == [] + assert result["cross_rates"] == {} + assert result["source"] == "ecb-forex" diff --git a/src/world_intel_mcp/tests/test_macro_composite.py b/src/world_intel_mcp/tests/test_macro_composite.py new file mode 100644 index 0000000..9337078 --- /dev/null +++ b/src/world_intel_mcp/tests/test_macro_composite.py @@ -0,0 +1,292 @@ +"""Tests for analysis.macro_composite — macro market composite scoring.""" + +import httpx +import pytest +import respx + +from world_intel_mcp.fetcher import Fetcher + + +# --------------------------------------------------------------------------- +# Mock data +# --------------------------------------------------------------------------- + +_FEAR_GREED = {"data": [{"value": "72", "value_classification": "Greed"}]} + +_MEMPOOL_FEES = { + "fastestFee": 25, + "halfHourFee": 15, + "hourFee": 10, + "economyFee": 5, + "minimumFee": 1, +} + + +def _yahoo_chart(symbol: str, price: float, prev: float) -> dict: + return { + "chart": { + "result": [ + { + "meta": { + "symbol": symbol, + "regularMarketPrice": price, + "previousClose": prev, + "currency": "USD", + } + } + ] + } + } + + +_BTC_DOMINANCE = {"data": {"market_cap_percentage": {"btc": 54.3}}} + +# Sector ETFs — all 11 +_SECTOR_PRICES = { + "XLK": (210.0, 208.0), # Technology +0.96% + "XLF": (42.0, 41.5), # Financials +1.2% + "XLE": (88.0, 89.0), # Energy -1.1% + "XLV": (145.0, 144.0), # Healthcare +0.69% + "XLI": (120.0, 119.0), # Industrials +0.84% + "XLC": (82.0, 81.0), # Communication +1.23% + "XLY": (185.0, 184.0), # Consumer Disc +0.54% + "XLP": (76.0, 76.5), # Consumer Staples -0.65% + "XLRE": (40.0, 40.5), # Real Estate -1.23% + "XLU": (68.0, 68.5), # Utilities -0.73% + "XLB": (85.0, 84.0), # Materials +1.19% +} + +# BTC historical prices (200+ daily points) +_BTC_PRICES = [[i * 86400000, 40000 + i * 150] for i in range(201)] + + +# --------------------------------------------------------------------------- +# Helper to set up all mocks +# --------------------------------------------------------------------------- + + +def _mock_all_endpoints() -> None: + """Register respx mocks for every upstream used by macro_composite.""" + # Fear & Greed + respx.get("https://api.alternative.me/fng/").mock( + return_value=httpx.Response(200, json=_FEAR_GREED) + ) + # Mempool + respx.get("https://mempool.space/api/v1/fees/recommended").mock( + return_value=httpx.Response(200, json=_MEMPOOL_FEES) + ) + # Macro symbols: DXY, VIX, Gold, 10Y + for symbol, price, prev in [ + ("DX-Y.NYB", 103.2, 103.0), + ("%5EVIX", 16.5, 17.0), # ^VIX URL-encoded + ("GC%3DF", 2050.0, 2040.0), # GC=F URL-encoded + ("%5ETNX", 4.25, 4.20), # ^TNX URL-encoded + ]: + respx.get(f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}").mock( + return_value=httpx.Response(200, json=_yahoo_chart(symbol, price, prev)) + ) + # BTC dominance + respx.get("https://api.coingecko.com/api/v3/global").mock( + return_value=httpx.Response(200, json=_BTC_DOMINANCE) + ) + # Sector ETFs + for sym, (price, prev) in _SECTOR_PRICES.items(): + respx.get(f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}").mock( + return_value=httpx.Response(200, json=_yahoo_chart(sym, price, prev)) + ) + # BTC technicals (CoinGecko market_chart) + respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock( + return_value=httpx.Response(200, json={"prices": _BTC_PRICES}) + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_macro_composite_full(fetcher: Fetcher) -> None: + """Test macro composite with all upstreams returning data.""" + from world_intel_mcp.analysis.macro_composite import fetch_macro_composite + + _mock_all_endpoints() + + result = await fetch_macro_composite(fetcher) + + assert result["source"] == "composite" + assert "verdict" in result + assert result["verdict"] in ( + "STRONG_CAUTION", + "CAUTIOUS", + "NEUTRAL", + "CONSTRUCTIVE", + "RISK_ON", + ) + assert 0 <= result["score"] <= 100 + assert "fetched_at" in result + + # Check signal structure + signals = result["signals"] + assert signals["fear_greed"]["value"] == 72 + assert signals["fear_greed"]["weight"] == 0.25 + assert signals["vix"]["value"] == 16.5 + assert signals["vix"]["label"] == "calm" + assert signals["dxy"]["value"] == 103.2 + assert signals["dxy"]["label"] == "neutral" + assert signals["yield_10y"]["value"] == 4.25 + assert signals["yield_10y"]["label"] == "elevated" + + # Sector breadth: 7 positive, 4 negative + assert signals["sector_breadth"]["positive"] == 7 + assert signals["sector_breadth"]["negative"] == 4 + + # BTC signal should exist + assert "signal" in signals["btc"] + assert "mayer" in signals["btc"] + + # Top/bottom sectors + assert len(result["top_sectors"]) <= 3 + assert len(result["bottom_sectors"]) <= 3 + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_macro_composite_all_sources_fail(fetcher: Fetcher) -> None: + """Test that total upstream failure returns a valid neutral result.""" + from world_intel_mcp.analysis.macro_composite import fetch_macro_composite + + # Mock everything to fail + respx.get("https://api.alternative.me/fng/").mock(return_value=httpx.Response(500)) + respx.get("https://mempool.space/api/v1/fees/recommended").mock( + return_value=httpx.Response(500) + ) + respx.get(url__regex=r"query1\.finance\.yahoo\.com").mock( + return_value=httpx.Response(500) + ) + respx.get("https://api.coingecko.com/api/v3/global").mock( + return_value=httpx.Response(500) + ) + respx.get("https://api.coingecko.com/api/v3/coins/bitcoin/market_chart").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_macro_composite(fetcher) + + assert result["source"] == "composite" + assert result["verdict"] in ( + "STRONG_CAUTION", + "CAUTIOUS", + "NEUTRAL", + "CONSTRUCTIVE", + "RISK_ON", + ) + assert 0 <= result["score"] <= 100 + + +# --------------------------------------------------------------------------- +# Unit tests for classification helpers +# --------------------------------------------------------------------------- + + +def test_classify_vix() -> None: + from world_intel_mcp.analysis.macro_composite import _classify_vix + + label, score = _classify_vix(12.0) + assert label == "complacent" + assert score == 90.0 + + label, score = _classify_vix(18.0) + assert label == "calm" + + label, score = _classify_vix(25.0) + assert label == "cautious" + + label, score = _classify_vix(35.0) + assert label == "fear" + + label, score = _classify_vix(None) + assert label == "unavailable" + + +def test_classify_dxy() -> None: + from world_intel_mcp.analysis.macro_composite import _classify_dxy + + label, _ = _classify_dxy(98.0) + assert label == "weak dollar" + + label, _ = _classify_dxy(103.0) + assert label == "neutral" + + label, _ = _classify_dxy(108.0) + assert label == "strong dollar" + + +def test_classify_yield() -> None: + from world_intel_mcp.analysis.macro_composite import _classify_yield + + label, _ = _classify_yield(2.5) + assert label == "accommodative" + + label, _ = _classify_yield(3.5) + assert label == "moderate" + + label, _ = _classify_yield(4.5) + assert label == "elevated" + + label, _ = _classify_yield(5.5) + assert label == "restrictive" + + +def test_classify_btc() -> None: + from world_intel_mcp.analysis.macro_composite import _classify_btc + + label, score, mayer = _classify_btc( + {"cross_signal": "golden_cross", "mayer_multiple": 1.2} + ) + assert label == "bullish" + assert score == 75.0 + + label, score, mayer = _classify_btc( + {"cross_signal": "death_cross", "mayer_multiple": 0.7} + ) + assert label == "undervalued" # Mayer < 0.8 overrides + assert score == 45.0 # 25 + 20 + + label, score, mayer = _classify_btc( + {"cross_signal": "golden_cross", "mayer_multiple": 2.5} + ) + assert label == "overheated" # Mayer > 2.4 overrides + assert score == 55.0 # 75 - 20 + + +def test_verdict_mapping() -> None: + from world_intel_mcp.analysis.macro_composite import _verdict + + assert _verdict(90) == "RISK_ON" + assert _verdict(70) == "CONSTRUCTIVE" + assert _verdict(50) == "NEUTRAL" + assert _verdict(30) == "CAUTIOUS" + assert _verdict(10) == "STRONG_CAUTION" + + +def test_compute_sector_breadth() -> None: + from world_intel_mcp.analysis.macro_composite import _compute_sector_breadth + + heatmap = { + "sectors": [ + {"name": "Tech", "change_pct": 1.5}, + {"name": "Energy", "change_pct": -0.5}, + {"name": "Health", "change_pct": 0.3}, + ] + } + pos, neg, score = _compute_sector_breadth(heatmap) + assert pos == 2 + assert neg == 1 + assert round(score, 1) == 66.7 + + pos, neg, score = _compute_sector_breadth({}) + assert pos == 0 + assert neg == 0 + assert score == 50.0 diff --git a/src/world_intel_mcp/tests/test_sec_edgar.py b/src/world_intel_mcp/tests/test_sec_edgar.py new file mode 100644 index 0000000..ff1b519 --- /dev/null +++ b/src/world_intel_mcp/tests/test_sec_edgar.py @@ -0,0 +1,334 @@ +"""Tests for SEC EDGAR source module — uses respx to mock HTTP calls.""" + +import httpx +import pytest +import respx + +from world_intel_mcp.fetcher import Fetcher + + +# --------------------------------------------------------------------------- +# fetch_sec_filings +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_sec_filings(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_sec_filings + + efts_response = { + "hits": { + "total": {"value": 1, "relation": "eq"}, + "hits": [ + { + "_id": "0000320193/000032019326000015/aapl-20260101.htm", + "_source": { + "display_names": ["Apple Inc"], + "form_type": "10-K", + "file_date": "2026-01-15", + "display_description": "Annual report for fiscal year 2025", + "entity_id": "320193", + }, + } + ], + } + } + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(200, json=efts_response) + ) + + result = await fetch_sec_filings(fetcher, query="Apple", form_type="10-K", limit=5) + + assert result["source"] == "sec-edgar" + assert result["query"] == "Apple" + assert result["form_type"] == "10-K" + assert result["total"] == 1 + assert len(result["filings"]) == 1 + assert result["filings"][0]["company"] == "Apple Inc" + assert result["filings"][0]["form_type"] == "10-K" + assert result["filings"][0]["filed_date"] == "2026-01-15" + assert "fetched_at" in result + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_sec_filings_empty(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_sec_filings + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(200, json={"hits": {"total": 0, "hits": []}}) + ) + + result = await fetch_sec_filings(fetcher, query="nonexistentzzzxyz") + + assert result["source"] == "sec-edgar" + assert result["total"] == 0 + assert result["filings"] == [] + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_sec_filings_api_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_sec_filings + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(500) + ) + + result = await fetch_sec_filings(fetcher, query="Apple") + + assert result["source"] == "sec-edgar" + assert result["filings"] == [] + assert result["total"] == 0 + + +# --------------------------------------------------------------------------- +# fetch_company_filings +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_filings(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_company_filings + + # Mock the company tickers endpoint + tickers_response = { + "0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc"}, + "1": {"cik_str": 789019, "ticker": "MSFT", "title": "Microsoft Corp"}, + } + + respx.get("https://www.sec.gov/files/company_tickers.json").mock( + return_value=httpx.Response(200, json=tickers_response) + ) + + # Mock the submissions endpoint + submissions_response = { + "cik": "320193", + "name": "Apple Inc", + "filings": { + "recent": { + "form": ["10-K", "10-Q", "8-K", "4", "10-Q"], + "filingDate": [ + "2026-01-15", + "2025-11-01", + "2025-10-15", + "2025-10-01", + "2025-08-01", + ], + "primaryDocument": [ + "aapl-20260101.htm", + "aapl-20251001q.htm", + "aapl-20251015-8k.htm", + "form4.xml", + "aapl-20250801q.htm", + ], + "primaryDocDescription": [ + "Annual Report", + "Quarterly Report Q4", + "Current Report", + "Statement of Changes", + "Quarterly Report Q3", + ], + "accessionNumber": [ + "0000320193-26-000015", + "0000320193-25-000090", + "0000320193-25-000085", + "0000320193-25-000080", + "0000320193-25-000070", + ], + } + }, + } + + respx.get("https://data.sec.gov/submissions/CIK0000320193.json").mock( + return_value=httpx.Response(200, json=submissions_response) + ) + + result = await fetch_company_filings(fetcher, ticker="AAPL", limit=10) + + assert result["source"] == "sec-edgar" + assert result["ticker"] == "AAPL" + assert result["company_name"] == "Apple Inc" + assert result["cik"] == "0000320193" + assert "fetched_at" in result + + # Should have 10-K, 10-Q, 8-K but NOT the "4" (form type filter) + assert len(result["filings"]) == 4 + forms = [f["form"] for f in result["filings"]] + assert "4" not in forms + assert "10-K" in forms + assert "10-Q" in forms + assert "8-K" in forms + + # Verify first filing details + first = result["filings"][0] + assert first["form"] == "10-K" + assert first["filing_date"] == "2026-01-15" + assert first["description"] == "Annual Report" + assert "320193" in first["url"] + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_filings_unknown_ticker(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_company_filings + + tickers_response = { + "0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc"}, + } + + respx.get("https://www.sec.gov/files/company_tickers.json").mock( + return_value=httpx.Response(200, json=tickers_response) + ) + + result = await fetch_company_filings(fetcher, ticker="ZZZXYZ") + + assert result["source"] == "sec-edgar" + assert result["ticker"] == "ZZZXYZ" + assert "error" in result + assert result["filings"] == [] + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_company_filings_custom_form_types(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_company_filings + + tickers_response = { + "0": {"cik_str": 789019, "ticker": "MSFT", "title": "Microsoft Corp"}, + } + + respx.get("https://www.sec.gov/files/company_tickers.json").mock( + return_value=httpx.Response(200, json=tickers_response) + ) + + submissions_response = { + "cik": "789019", + "name": "Microsoft Corp", + "filings": { + "recent": { + "form": ["10-K", "10-Q", "8-K"], + "filingDate": ["2026-01-10", "2025-11-05", "2025-10-20"], + "primaryDocument": ["msft-10k.htm", "msft-10q.htm", "msft-8k.htm"], + "primaryDocDescription": [ + "Annual Report", + "Quarterly Report", + "Current Report", + ], + "accessionNumber": [ + "0000789019-26-000010", + "0000789019-25-000050", + "0000789019-25-000045", + ], + } + }, + } + + respx.get("https://data.sec.gov/submissions/CIK0000789019.json").mock( + return_value=httpx.Response(200, json=submissions_response) + ) + + result = await fetch_company_filings(fetcher, ticker="MSFT", form_types=["10-K"]) + + assert result["ticker"] == "MSFT" + assert len(result["filings"]) == 1 + assert result["filings"][0]["form"] == "10-K" + + +# --------------------------------------------------------------------------- +# fetch_recent_8k +# --------------------------------------------------------------------------- + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_recent_8k(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_recent_8k + + efts_response = { + "hits": { + "total": {"value": 2, "relation": "eq"}, + "hits": [ + { + "_id": "0000320193/000032019326000020/aapl-8k.htm", + "_source": { + "display_names": ["Apple Inc"], + "entity_name": "Apple Inc", + "form_type": "8-K", + "file_date": "2026-03-07", + "display_description": "Results of Operations and Financial Condition", + "tickers": ["AAPL"], + "items": ["2.02", "9.01"], + }, + }, + { + "_id": "0000789019/000078901926000030/msft-8k.htm", + "_source": { + "entity_name": "Microsoft Corp", + "form_type": "8-K", + "file_date": "2026-03-06", + "description": "Entry into Material Agreement", + "tickers": ["MSFT"], + "items": ["1.01"], + }, + }, + ], + } + } + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(200, json=efts_response) + ) + + result = await fetch_recent_8k(fetcher, limit=10) + + assert result["source"] == "sec-edgar" + assert result["total"] == 2 + assert len(result["filings"]) == 2 + assert "fetched_at" in result + + first = result["filings"][0] + assert first["company"] == "Apple Inc" + assert first["ticker"] == "AAPL" + assert first["filed_date"] == "2026-03-07" + assert first["items"] == ["2.02", "9.01"] + assert "url" in first + + second = result["filings"][1] + assert second["company"] == "Microsoft Corp" + assert second["ticker"] == "MSFT" + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_recent_8k_empty(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_recent_8k + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(200, json={"hits": {"total": 0, "hits": []}}) + ) + + result = await fetch_recent_8k(fetcher) + + assert result["source"] == "sec-edgar" + assert result["total"] == 0 + assert result["filings"] == [] + + +@respx.mock +@pytest.mark.asyncio +async def test_fetch_recent_8k_api_failure(fetcher: Fetcher) -> None: + from world_intel_mcp.sources.sec_edgar import fetch_recent_8k + + respx.get("https://efts.sec.gov/LATEST/search-index").mock( + return_value=httpx.Response(503) + ) + + result = await fetch_recent_8k(fetcher) + + assert result["source"] == "sec-edgar" + assert result["filings"] == [] + assert result["total"] == 0