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:
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+893
-140
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user