feat(world-intel): Phase 15 — business intelligence tools (101 total)

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.
This commit is contained in:
Marc Shade
2026-03-08 08:22:41 -04:00
parent d075bbaf1a
commit baba564b84
16 changed files with 4291 additions and 159 deletions
+267
View File
@@ -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
@@ -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",
}
+21 -14
View File
@@ -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
File diff suppressed because it is too large Load Diff
+286
View File
@@ -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",
}
+312
View File
@@ -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
+238
View File
@@ -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",
}
+363
View File
@@ -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
+26 -4
View File
@@ -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)
+267
View File
@@ -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"
+235
View File
@@ -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
+276
View File
@@ -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
+217
View File
@@ -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"
@@ -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
+334
View File
@@ -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