feat: import 6 MCP services + common workspace
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "mcp-macro"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"option-mcp-common",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcp_macro"]
|
||||
|
||||
[tool.uv.sources]
|
||||
option-mcp-common = { workspace = true }
|
||||
|
||||
[project.scripts]
|
||||
mcp-macro = "mcp_macro.__main__:main"
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from option_mcp_common.auth import load_token_store_from_files
|
||||
|
||||
from option_mcp_common.logging import configure_root_logging
|
||||
|
||||
from mcp_macro.server import create_app
|
||||
|
||||
|
||||
configure_root_logging() # CER-P5-009
|
||||
|
||||
def main():
|
||||
creds_file = os.environ["MACRO_CREDENTIALS_FILE"]
|
||||
with open(creds_file) as f:
|
||||
creds = json.load(f)
|
||||
|
||||
token_store = load_token_store_from_files(
|
||||
core_token_file=os.environ.get("CORE_TOKEN_FILE"),
|
||||
observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"),
|
||||
)
|
||||
app = create_app(
|
||||
fred_api_key=creds.get("fred_api_key", ""),
|
||||
finnhub_api_key=creds.get("finnhub_api_key", ""),
|
||||
token_store=token_store,
|
||||
)
|
||||
uvicorn.run(
|
||||
app,
|
||||
log_config=None, # CER-P5-009: delega al root JSON logger
|
||||
host=os.environ.get("HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("PORT", "9013")),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,516 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
FRED_BASE = "https://api.stlouisfed.org/fred/series/observations"
|
||||
FINNHUB_CALENDAR = "https://finnhub.io/api/v1/calendar/economic"
|
||||
COINGECKO_GLOBAL = "https://api.coingecko.com/api/v3/global"
|
||||
COINGECKO_SIMPLE = "https://api.coingecko.com/api/v3/simple/price"
|
||||
DERIBIT_DVOL = "https://www.deribit.com/api/v2/public/get_volatility_index_data"
|
||||
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
|
||||
ASSET_TICKER_MAP: dict[str, tuple[str, str]] = {
|
||||
"WTI": ("CL=F", "WTI Crude Oil"),
|
||||
"BRENT": ("BZ=F", "Brent Crude Oil"),
|
||||
"GOLD": ("GC=F", "Gold Futures"),
|
||||
"SILVER": ("SI=F", "Silver Futures"),
|
||||
"COPPER": ("HG=F", "Copper Futures"),
|
||||
"NATGAS": ("NG=F", "Natural Gas"),
|
||||
"DXY": ("DX-Y.NYB", "US Dollar Index"),
|
||||
"SPX": ("^GSPC", "S&P 500"),
|
||||
"NDX": ("^NDX", "Nasdaq 100"),
|
||||
"DJI": ("^DJI", "Dow Jones"),
|
||||
"RUT": ("^RUT", "Russell 2000"),
|
||||
"VIX": ("^VIX", "CBOE Volatility Index"),
|
||||
"US5Y": ("^FVX", "US 5-Year Treasury"),
|
||||
"US10Y": ("^TNX", "US 10-Year Treasury"),
|
||||
"US30Y": ("^TYX", "US 30-Year Treasury"),
|
||||
"US2Y": ("^UST2YR", "US 2-Year Treasury"),
|
||||
"EURUSD": ("EURUSD=X", "EUR/USD"),
|
||||
"USDJPY": ("JPY=X", "USD/JPY"),
|
||||
"GBPUSD": ("GBPUSD=X", "GBP/USD"),
|
||||
"BTCUSD": ("BTC-USD", "Bitcoin/USD"),
|
||||
"ETHUSD": ("ETH-USD", "Ethereum/USD"),
|
||||
"ES": ("ES=F", "E-mini S&P 500 Futures"),
|
||||
"NQ": ("NQ=F", "E-mini Nasdaq 100 Futures"),
|
||||
"YM": ("YM=F", "E-mini Dow Futures"),
|
||||
"RTY": ("RTY=F", "E-mini Russell 2000 Futures"),
|
||||
}
|
||||
|
||||
_ASSET_CACHE: dict[str, dict] = {}
|
||||
_ASSET_CACHE_TTL = 60.0
|
||||
|
||||
|
||||
async def _fetch_yahoo_meta(client: httpx.AsyncClient, symbol: str, range_: str = "10d") -> dict:
|
||||
try:
|
||||
resp = await client.get(
|
||||
YAHOO_CHART.format(symbol=symbol),
|
||||
params={"interval": "1d", "range": range_},
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {}
|
||||
result = (resp.json().get("chart") or {}).get("result") or []
|
||||
if not result:
|
||||
return {}
|
||||
r0 = result[0]
|
||||
meta = r0.get("meta") or {}
|
||||
closes = ((r0.get("indicators") or {}).get("quote") or [{}])[0].get("close") or []
|
||||
closes = [c for c in closes if c is not None]
|
||||
return {"meta": meta, "closes": closes}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def fetch_asset_price(ticker: str) -> dict[str, Any]:
|
||||
import time
|
||||
|
||||
key = ticker.upper()
|
||||
now = time.monotonic()
|
||||
cached = _ASSET_CACHE.get(key)
|
||||
if cached and (now - cached["ts"]) < _ASSET_CACHE_TTL:
|
||||
return cached["data"]
|
||||
|
||||
mapping = ASSET_TICKER_MAP.get(key)
|
||||
if not mapping:
|
||||
return {"ticker": ticker, "error": f"unknown ticker {ticker}"}
|
||||
symbol, name = mapping
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
info = await _fetch_yahoo_meta(client, symbol, "10d")
|
||||
meta = info.get("meta") or {}
|
||||
closes = info.get("closes") or []
|
||||
price = meta.get("regularMarketPrice")
|
||||
prev_close = meta.get("previousClose")
|
||||
change_24h_pct = None
|
||||
if price is not None and prev_close:
|
||||
try:
|
||||
change_24h_pct = round((float(price) - float(prev_close)) / float(prev_close) * 100, 3)
|
||||
except Exception:
|
||||
change_24h_pct = None
|
||||
change_7d_pct = None
|
||||
if len(closes) >= 6 and price is not None:
|
||||
try:
|
||||
change_7d_pct = round((float(price) - float(closes[-6])) / float(closes[-6]) * 100, 3)
|
||||
except Exception:
|
||||
change_7d_pct = None
|
||||
|
||||
out = {
|
||||
"ticker": key,
|
||||
"name": name,
|
||||
"price": float(price) if price is not None else None,
|
||||
"change_24h_pct": change_24h_pct,
|
||||
"change_7d_pct": change_7d_pct,
|
||||
"source": f"yfinance:{symbol}",
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
_ASSET_CACHE[key] = {"data": out, "ts": now}
|
||||
return out
|
||||
|
||||
|
||||
_TREASURY_CACHE: dict[str, Any] = {"data": None, "ts": 0.0}
|
||||
_TREASURY_TTL = 300.0
|
||||
|
||||
|
||||
async def fetch_treasury_yields() -> dict[str, Any]:
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
if _TREASURY_CACHE["data"] and (now - _TREASURY_CACHE["ts"]) < _TREASURY_TTL:
|
||||
return _TREASURY_CACHE["data"]
|
||||
|
||||
symbols = [
|
||||
("us2y", "^UST2YR"),
|
||||
("us5y", "^FVX"),
|
||||
("us10y", "^TNX"),
|
||||
("us30y", "^TYX"),
|
||||
]
|
||||
yields: dict[str, float | None] = {}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for key, sym in symbols:
|
||||
info = await _fetch_yahoo_meta(client, sym, "5d")
|
||||
meta = info.get("meta") or {}
|
||||
price = meta.get("regularMarketPrice")
|
||||
yields[key] = float(price) if price is not None else None
|
||||
|
||||
spread = None
|
||||
if yields.get("us10y") is not None and yields.get("us2y") is not None:
|
||||
spread = round(yields["us10y"] - yields["us2y"], 3)
|
||||
shape = "unknown"
|
||||
if spread is not None:
|
||||
if spread > 0.25:
|
||||
shape = "normal"
|
||||
elif spread < -0.1:
|
||||
shape = "inverted"
|
||||
else:
|
||||
shape = "flat"
|
||||
|
||||
out = {
|
||||
"yields": yields,
|
||||
"spread_2y10y": spread,
|
||||
"yield_curve_shape": shape,
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
_TREASURY_CACHE["data"] = out
|
||||
_TREASURY_CACHE["ts"] = now
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_equity_futures() -> dict[str, Any]:
|
||||
"""Fetch ES/NQ/YM/RTY futures con session detection."""
|
||||
tickers = [("es", "ES=F"), ("nq", "NQ=F"), ("ym", "YM=F"), ("rty", "RTY=F")]
|
||||
now = datetime.now(UTC)
|
||||
weekday = now.weekday() # 0=Mon
|
||||
hour_utc = now.hour
|
||||
cash_open = (weekday < 5) and (13 <= hour_utc < 20)
|
||||
if cash_open:
|
||||
session = "regular"
|
||||
elif weekday >= 5:
|
||||
session = "weekend"
|
||||
elif hour_utc < 13:
|
||||
session = "pre-market"
|
||||
else:
|
||||
session = "after-hours"
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for key, sym in tickers:
|
||||
info = await _fetch_yahoo_meta(client, sym, "5d")
|
||||
meta = info.get("meta") or {}
|
||||
price = meta.get("regularMarketPrice")
|
||||
prev = meta.get("previousClose") or meta.get("chartPreviousClose")
|
||||
change_pct = None
|
||||
if price is not None and prev:
|
||||
try:
|
||||
change_pct = round((float(price) - float(prev)) / float(prev) * 100, 3)
|
||||
except Exception:
|
||||
change_pct = None
|
||||
out[key] = {
|
||||
"price": float(price) if price is not None else None,
|
||||
"change_pct": change_pct,
|
||||
"session": session,
|
||||
}
|
||||
|
||||
next_open = None
|
||||
if weekday < 5 and hour_utc < 13:
|
||||
next_open = now.replace(hour=13, minute=30, second=0, microsecond=0).isoformat()
|
||||
else:
|
||||
days_ahead = (7 - weekday) if weekday >= 5 else 1
|
||||
nd = (now.replace(hour=13, minute=30, second=0, microsecond=0) + timedelta(days=days_ahead))
|
||||
next_open = nd.isoformat()
|
||||
|
||||
return {
|
||||
"futures": out,
|
||||
"session_status": {
|
||||
"cash_open": cash_open,
|
||||
"session": session,
|
||||
"next_open_utc": next_open,
|
||||
},
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
_MARKET_CACHE: dict[str, Any] = {"data": None, "ts": 0.0}
|
||||
_MARKET_CACHE_TTL = 120.0
|
||||
|
||||
|
||||
async def _fetch_yahoo_price(client: httpx.AsyncClient, symbol: str) -> float | None:
|
||||
try:
|
||||
resp = await client.get(
|
||||
YAHOO_CHART.format(symbol=symbol),
|
||||
params={"interval": "1d", "range": "5d"},
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
result = (resp.json().get("chart") or {}).get("result") or []
|
||||
if not result:
|
||||
return None
|
||||
price = (result[0].get("meta") or {}).get("regularMarketPrice")
|
||||
return float(price) if price is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_dvol_latest(client: httpx.AsyncClient, currency: str) -> float | None:
|
||||
now_ms = int(datetime.now(UTC).timestamp() * 1000)
|
||||
start_ms = now_ms - 7 * 24 * 3600 * 1000
|
||||
try:
|
||||
resp = await client.get(
|
||||
DERIBIT_DVOL,
|
||||
params={
|
||||
"currency": currency,
|
||||
"start_timestamp": start_ms,
|
||||
"end_timestamp": now_ms,
|
||||
"resolution": "1D",
|
||||
},
|
||||
)
|
||||
rows = (resp.json().get("result") or {}).get("data") or []
|
||||
if not rows:
|
||||
return None
|
||||
return float(rows[-1][4])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_economic_indicators(
|
||||
fred_api_key: str = "",
|
||||
indicators: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
series_map = {
|
||||
"fed_rate": "FEDFUNDS",
|
||||
"cpi": "CPIAUCSL",
|
||||
"unemployment": "UNRATE",
|
||||
"us10y_yield": "DGS10",
|
||||
}
|
||||
result: dict[str, Any] = {}
|
||||
if not fred_api_key:
|
||||
return {"indicators": result, "error": "No FRED API key configured"}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for name, series_id in series_map.items():
|
||||
if indicators and name not in indicators:
|
||||
continue
|
||||
resp = await client.get(
|
||||
FRED_BASE,
|
||||
params={
|
||||
"series_id": series_id,
|
||||
"api_key": fred_api_key,
|
||||
"file_type": "json",
|
||||
"sort_order": "desc",
|
||||
"limit": 1,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
obs = data.get("observations", [])
|
||||
result[name] = float(obs[0]["value"]) if obs else None
|
||||
result["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return result
|
||||
|
||||
|
||||
CURRENCY_TO_COUNTRY = {
|
||||
"USD": ("US", "United States"),
|
||||
"EUR": ("EU", "Euro Area"),
|
||||
"JPY": ("JP", "Japan"),
|
||||
"GBP": ("UK", "United Kingdom"),
|
||||
"CAD": ("CA", "Canada"),
|
||||
"AUD": ("AU", "Australia"),
|
||||
"NZD": ("NZ", "New Zealand"),
|
||||
"CHF": ("CH", "Switzerland"),
|
||||
"CNY": ("CN", "China"),
|
||||
}
|
||||
|
||||
_HIGH_IMPACT_EVENTS = (
|
||||
"fomc", "fed", "cpi", "nfp", "non-farm", "nonfarm", "ppi",
|
||||
"ecb", "boj", "boe", "gdp", "unemployment rate",
|
||||
)
|
||||
|
||||
|
||||
def _market_impact_historical(name: str) -> str:
|
||||
n = (name or "").lower()
|
||||
for kw in _HIGH_IMPACT_EVENTS:
|
||||
if kw in n:
|
||||
return "high_vol_spike"
|
||||
return "normal"
|
||||
|
||||
|
||||
async def fetch_macro_calendar(
|
||||
finnhub_api_key: str = "",
|
||||
days_ahead: int = 7,
|
||||
country_filter: list[str] | None = None,
|
||||
importance_min: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch economic calendar con filtri country/importance/date range."""
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
importance_order = {"low": 0, "medium": 1, "high": 2}
|
||||
min_level = importance_order.get(
|
||||
(importance_min or "").lower(), 0
|
||||
) if importance_min else 0
|
||||
|
||||
start_dt: datetime | None = None
|
||||
end_dt: datetime | None = None
|
||||
if start:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start).replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
if end:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end).replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
end_dt = datetime.strptime(end, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
|
||||
country_filter_set = (
|
||||
{c.upper() for c in country_filter} if country_filter else None
|
||||
)
|
||||
|
||||
# Try Forex Factory free feed first
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get("https://nfs.faireconomy.media/ff_calendar_thisweek.json")
|
||||
if resp.status_code == 200:
|
||||
raw = resp.json()
|
||||
now = datetime.now(UTC)
|
||||
for e in raw:
|
||||
date_str = e.get("date", "")
|
||||
event_dt: datetime | None = None
|
||||
try:
|
||||
event_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
if event_dt < now:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
currency = (e.get("country", "") or "").upper()
|
||||
country_code, country_name = CURRENCY_TO_COUNTRY.get(
|
||||
currency, (currency or "", e.get("country", "") or "")
|
||||
)
|
||||
|
||||
if country_filter_set and country_code not in country_filter_set:
|
||||
continue
|
||||
|
||||
impact = (e.get("impact", "") or "").lower()
|
||||
importance = (
|
||||
"high" if impact == "high" else "medium" if impact == "medium" else "low"
|
||||
)
|
||||
if importance_order[importance] < min_level:
|
||||
continue
|
||||
|
||||
if start_dt and event_dt and event_dt < start_dt:
|
||||
continue
|
||||
if end_dt and event_dt and event_dt > end_dt:
|
||||
continue
|
||||
|
||||
name = e.get("title", "")
|
||||
events.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"datetime_utc": event_dt.isoformat() if event_dt else date_str,
|
||||
"name": name,
|
||||
"event": name,
|
||||
"country": country_name,
|
||||
"country_code": country_code,
|
||||
"importance": importance,
|
||||
"forecast": e.get("forecast", ""),
|
||||
"previous": e.get("previous", ""),
|
||||
"actual": e.get("actual"),
|
||||
"market_impact_historical": _market_impact_historical(name),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to Finnhub if we have a key and no events
|
||||
if not events and finnhub_api_key:
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
end_default = now + timedelta(days=days_ahead)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
FINNHUB_CALENDAR,
|
||||
params={
|
||||
"from": (start_dt or now).strftime("%Y-%m-%d"),
|
||||
"to": (end_dt or end_default).strftime("%Y-%m-%d"),
|
||||
"token": finnhub_api_key,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
return {"events": [], "error": data["error"]}
|
||||
raw = data if isinstance(data, list) else data.get("economicCalendar", [])
|
||||
for e in raw:
|
||||
importance_raw = (
|
||||
e.get("importance")
|
||||
or e.get("impact")
|
||||
or "medium"
|
||||
)
|
||||
if isinstance(importance_raw, int):
|
||||
importance = (
|
||||
"high" if importance_raw >= 3 else
|
||||
"medium" if importance_raw >= 2 else
|
||||
"low"
|
||||
)
|
||||
else:
|
||||
importance = str(importance_raw).lower()
|
||||
if importance not in ("low", "medium", "high"):
|
||||
importance = "medium"
|
||||
if importance_order[importance] < min_level:
|
||||
continue
|
||||
country_code = (e.get("country", "") or "").upper()
|
||||
country_name = CURRENCY_TO_COUNTRY.get(
|
||||
country_code, (country_code, country_code)
|
||||
)[1]
|
||||
if country_filter_set and country_code not in country_filter_set:
|
||||
continue
|
||||
name = e.get("event", "")
|
||||
date_str = e.get("date", e.get("time", ""))
|
||||
events.append({
|
||||
"date": date_str,
|
||||
"datetime_utc": date_str,
|
||||
"name": name,
|
||||
"event": name,
|
||||
"country": country_name,
|
||||
"country_code": country_code,
|
||||
"importance": importance,
|
||||
"forecast": e.get("forecast", ""),
|
||||
"previous": e.get("previous", e.get("prev", "")),
|
||||
"actual": e.get("actual"),
|
||||
"market_impact_historical": _market_impact_historical(name),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not events:
|
||||
return {"events": [], "note": "No calendar source available"}
|
||||
|
||||
return {"events": events}
|
||||
|
||||
|
||||
async def fetch_market_overview() -> dict[str, Any]:
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
if _MARKET_CACHE["data"] is not None and (now - _MARKET_CACHE["ts"]) < _MARKET_CACHE_TTL:
|
||||
return _MARKET_CACHE["data"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
global_data: dict[str, Any] = {}
|
||||
prices: dict[str, Any] = {}
|
||||
try:
|
||||
global_resp = await client.get(COINGECKO_GLOBAL)
|
||||
global_data = global_resp.json().get("data", {}) or {}
|
||||
except Exception:
|
||||
global_data = {}
|
||||
try:
|
||||
price_resp = await client.get(
|
||||
COINGECKO_SIMPLE,
|
||||
params={"ids": "bitcoin,ethereum", "vs_currencies": "usd"},
|
||||
)
|
||||
prices = price_resp.json() or {}
|
||||
except Exception:
|
||||
prices = {}
|
||||
dvol_btc = await _fetch_dvol_latest(client, "BTC")
|
||||
dvol_eth = await _fetch_dvol_latest(client, "ETH")
|
||||
sp500 = await _fetch_yahoo_price(client, "^GSPC")
|
||||
gold = await _fetch_yahoo_price(client, "GC=F")
|
||||
vix = await _fetch_yahoo_price(client, "^VIX")
|
||||
|
||||
out = {
|
||||
"btc_dominance": global_data.get("market_cap_percentage", {}).get("btc"),
|
||||
"total_market_cap": global_data.get("total_market_cap", {}).get("usd"),
|
||||
"btc_price": prices.get("bitcoin", {}).get("usd"),
|
||||
"eth_price": prices.get("ethereum", {}).get("usd"),
|
||||
"sp500": sp500,
|
||||
"gold": gold,
|
||||
"vix": vix,
|
||||
"dvol_btc": dvol_btc,
|
||||
"dvol_eth": dvol_eth,
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
_MARKET_CACHE["data"] = out
|
||||
_MARKET_CACHE["ts"] = now
|
||||
return out
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from option_mcp_common.auth import Principal, TokenStore, require_principal
|
||||
from option_mcp_common.mcp_bridge import mount_mcp_endpoint
|
||||
from option_mcp_common.server import build_app
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp_macro.fetchers import (
|
||||
fetch_asset_price,
|
||||
fetch_economic_indicators,
|
||||
fetch_equity_futures,
|
||||
fetch_macro_calendar,
|
||||
fetch_market_overview,
|
||||
fetch_treasury_yields,
|
||||
)
|
||||
|
||||
# --- Body models ---
|
||||
|
||||
class GetEconomicIndicatorsReq(BaseModel):
|
||||
indicators: list[str] | None = None
|
||||
|
||||
|
||||
class GetMacroCalendarReq(BaseModel):
|
||||
days: int = 7
|
||||
country_filter: list[str] | None = None
|
||||
importance_min: str | None = None
|
||||
start: str | None = None
|
||||
end: str | None = None
|
||||
|
||||
|
||||
class GetMarketOverviewReq(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class GetAssetPriceReq(BaseModel):
|
||||
ticker: str
|
||||
|
||||
|
||||
class GetTreasuryYieldsReq(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class GetEquityFuturesReq(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
# --- ACL helper ---
|
||||
|
||||
def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None:
|
||||
allowed: set[str] = set()
|
||||
if core:
|
||||
allowed.add("core")
|
||||
if observer:
|
||||
allowed.add("observer")
|
||||
if not (principal.capabilities & allowed):
|
||||
raise HTTPException(403, f"capability required: {allowed}")
|
||||
|
||||
|
||||
# --- App factory ---
|
||||
|
||||
def create_app(*, fred_api_key: str = "", finnhub_api_key: str = "", token_store: TokenStore) -> FastAPI:
|
||||
app = build_app(name="mcp-macro", version="0.1.0", token_store=token_store)
|
||||
|
||||
@app.post("/tools/get_economic_indicators", tags=["reads"])
|
||||
async def t_get_economic_indicators(
|
||||
body: GetEconomicIndicatorsReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_economic_indicators(
|
||||
fred_api_key=fred_api_key, indicators=body.indicators
|
||||
)
|
||||
|
||||
@app.post("/tools/get_macro_calendar", tags=["reads"])
|
||||
async def t_get_macro_calendar(
|
||||
body: GetMacroCalendarReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_macro_calendar(
|
||||
finnhub_api_key=finnhub_api_key,
|
||||
days_ahead=body.days,
|
||||
country_filter=body.country_filter,
|
||||
importance_min=body.importance_min,
|
||||
start=body.start,
|
||||
end=body.end,
|
||||
)
|
||||
|
||||
@app.post("/tools/get_market_overview", tags=["reads"])
|
||||
async def t_get_market_overview(
|
||||
body: GetMarketOverviewReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_market_overview()
|
||||
|
||||
@app.post("/tools/get_asset_price", tags=["reads"])
|
||||
async def t_get_asset_price(
|
||||
body: GetAssetPriceReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_asset_price(body.ticker)
|
||||
|
||||
@app.post("/tools/get_treasury_yields", tags=["reads"])
|
||||
async def t_get_treasury_yields(
|
||||
body: GetTreasuryYieldsReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_treasury_yields()
|
||||
|
||||
@app.post("/tools/get_equity_futures", tags=["reads"])
|
||||
async def t_get_equity_futures(
|
||||
body: GetEquityFuturesReq, principal: Principal = Depends(require_principal)
|
||||
):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await fetch_equity_futures()
|
||||
|
||||
# ───── MCP endpoint (/mcp) — bridge verso /tools/* ─────
|
||||
port = int(os.environ.get("PORT", "9013"))
|
||||
mount_mcp_endpoint(
|
||||
app,
|
||||
name="cerbero-macro",
|
||||
version="0.1.0",
|
||||
token_store=token_store,
|
||||
internal_base_url=f"http://localhost:{port}",
|
||||
tools=[
|
||||
{"name": "get_economic_indicators", "description": "FRED economic indicators (Fed rate, CPI, ecc)."},
|
||||
{"name": "get_macro_calendar", "description": "Eventi macro con filtri country/importance/date range."},
|
||||
{"name": "get_market_overview", "description": "Snapshot overview mercato macro."},
|
||||
{"name": "get_asset_price", "description": "Prezzo cross-asset: WTI, DXY, SPX, VIX, yields, FX, ecc."},
|
||||
{"name": "get_treasury_yields", "description": "Curva US Treasury 2y/5y/10y/30y + shape detection."},
|
||||
{"name": "get_equity_futures", "description": "Futures ES/NQ/YM/RTY con session status."},
|
||||
],
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_httpx
|
||||
from mcp_macro.fetchers import (
|
||||
fetch_economic_indicators,
|
||||
fetch_macro_calendar,
|
||||
fetch_market_overview,
|
||||
)
|
||||
|
||||
# --- fetch_economic_indicators ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_economic_indicators_no_key():
|
||||
result = await fetch_economic_indicators(fred_api_key="")
|
||||
assert "error" in result
|
||||
assert result["error"] == "No FRED API key configured"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_economic_indicators_happy_path(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
for series_id in ("FEDFUNDS", "CPIAUCSL", "UNRATE", "DGS10"):
|
||||
httpx_mock.add_response(
|
||||
url=httpx.URL(
|
||||
"https://api.stlouisfed.org/fred/series/observations",
|
||||
params={
|
||||
"series_id": series_id,
|
||||
"api_key": "testkey",
|
||||
"file_type": "json",
|
||||
"sort_order": "desc",
|
||||
"limit": "1",
|
||||
},
|
||||
),
|
||||
json={"observations": [{"value": "5.25"}]},
|
||||
)
|
||||
result = await fetch_economic_indicators(fred_api_key="testkey")
|
||||
assert result["fed_rate"] == 5.25
|
||||
assert result["cpi"] == 5.25
|
||||
assert result["unemployment"] == 5.25
|
||||
assert result["us10y_yield"] == 5.25
|
||||
assert "updated_at" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_economic_indicators_filter(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url=httpx.URL(
|
||||
"https://api.stlouisfed.org/fred/series/observations",
|
||||
params={
|
||||
"series_id": "FEDFUNDS",
|
||||
"api_key": "k",
|
||||
"file_type": "json",
|
||||
"sort_order": "desc",
|
||||
"limit": "1",
|
||||
},
|
||||
),
|
||||
json={"observations": [{"value": "5.33"}]},
|
||||
)
|
||||
result = await fetch_economic_indicators(fred_api_key="k", indicators=["fed_rate"])
|
||||
assert "fed_rate" in result
|
||||
assert "cpi" not in result
|
||||
|
||||
|
||||
# --- fetch_macro_calendar ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_macro_calendar_forex_factory_happy(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
future = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||
httpx_mock.add_response(
|
||||
url="https://nfs.faireconomy.media/ff_calendar_thisweek.json",
|
||||
json=[
|
||||
{
|
||||
"date": future,
|
||||
"title": "CPI",
|
||||
"country": "US",
|
||||
"impact": "High",
|
||||
"forecast": "3.0%",
|
||||
"previous": "3.2%",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = await fetch_macro_calendar()
|
||||
assert "events" in result
|
||||
assert len(result["events"]) >= 1
|
||||
assert result["events"][0]["name"] == "CPI"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_macro_calendar_no_source(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://nfs.faireconomy.media/ff_calendar_thisweek.json",
|
||||
status_code=500,
|
||||
)
|
||||
result = await fetch_macro_calendar(finnhub_api_key="")
|
||||
assert result == {"events": [], "note": "No calendar source available"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False, assert_all_requests_were_expected=False)
|
||||
async def test_macro_calendar_finnhub_fallback(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://nfs.faireconomy.media/ff_calendar_thisweek.json",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
def dispatch(request: httpx.Request) -> httpx.Response:
|
||||
if "finnhub.io" in str(request.url):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"date": "2024-01-15", "event": "FOMC", "importance": "high", "forecast": "", "prev": ""}],
|
||||
)
|
||||
return httpx.Response(500)
|
||||
|
||||
httpx_mock.add_callback(dispatch)
|
||||
result = await fetch_macro_calendar(finnhub_api_key="fkey")
|
||||
assert "events" in result
|
||||
assert result["events"][0]["name"] == "FOMC"
|
||||
|
||||
|
||||
# --- fetch_market_overview ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_overview_happy(httpx_mock: pytest_httpx.HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://api.coingecko.com/api/v3/global",
|
||||
json={
|
||||
"data": {
|
||||
"market_cap_percentage": {"btc": 52.3},
|
||||
"total_market_cap": {"usd": 2_000_000_000_000},
|
||||
}
|
||||
},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=httpx.URL(
|
||||
"https://api.coingecko.com/api/v3/simple/price",
|
||||
params={"ids": "bitcoin,ethereum", "vs_currencies": "usd"},
|
||||
),
|
||||
json={"bitcoin": {"usd": 65000}, "ethereum": {"usd": 3500}},
|
||||
)
|
||||
import re as _re
|
||||
httpx_mock.add_response(
|
||||
url=_re.compile(
|
||||
r"https://www\.deribit\.com/api/v2/public/get_volatility_index_data\?currency=BTC.*"
|
||||
),
|
||||
json={"result": {"data": [[1, 50, 52, 49, 51.5]], "continuation": None}},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=_re.compile(
|
||||
r"https://www\.deribit\.com/api/v2/public/get_volatility_index_data\?currency=ETH.*"
|
||||
),
|
||||
json={"result": {"data": [[1, 60, 62, 59, 61.2]], "continuation": None}},
|
||||
)
|
||||
import re as _re
|
||||
httpx_mock.add_response(
|
||||
url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/\^GSPC.*"),
|
||||
json={"chart": {"result": [{"meta": {"regularMarketPrice": 5830.12}}]}},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/GC[%=].*"),
|
||||
json={"chart": {"result": [{"meta": {"regularMarketPrice": 2412.5}}]}},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=_re.compile(r"https://query1\.finance\.yahoo\.com/v8/finance/chart/\^VIX.*"),
|
||||
json={"chart": {"result": [{"meta": {"regularMarketPrice": 18.3}}]}},
|
||||
)
|
||||
# Clear module cache to force fresh fetch
|
||||
from mcp_macro import fetchers as _f
|
||||
_f._MARKET_CACHE["data"] = None
|
||||
_f._MARKET_CACHE["ts"] = 0.0
|
||||
result = await fetch_market_overview()
|
||||
assert result["btc_dominance"] == 52.3
|
||||
assert result["btc_price"] == 65000
|
||||
assert result["eth_price"] == 3500
|
||||
assert result["total_market_cap"] == 2_000_000_000_000
|
||||
assert result["dvol_btc"] == 51.5
|
||||
assert result["dvol_eth"] == 61.2
|
||||
assert result["sp500"] == 5830.12
|
||||
assert result["gold"] == 2412.5
|
||||
assert result["vix"] == 18.3
|
||||
assert "data_timestamp" in result
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from mcp_macro.server import create_app
|
||||
from option_mcp_common.auth import Principal, TokenStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http():
|
||||
store = TokenStore(
|
||||
tokens={
|
||||
"ct": Principal("core", {"core"}),
|
||||
"ot": Principal("observer", {"observer"}),
|
||||
}
|
||||
)
|
||||
app = create_app(fred_api_key="testfred", finnhub_api_key="testfinn", token_store=store)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --- Health ---
|
||||
|
||||
def test_health(http):
|
||||
assert http.get("/health").status_code == 200
|
||||
|
||||
|
||||
# --- get_economic_indicators ---
|
||||
|
||||
def test_get_economic_indicators_core_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_economic_indicators",
|
||||
new=AsyncMock(return_value={"fed_rate": 5.25, "updated_at": "2024-01-01T00:00:00+00:00"}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_economic_indicators",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["fed_rate"] == 5.25
|
||||
|
||||
|
||||
def test_get_economic_indicators_observer_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_economic_indicators",
|
||||
new=AsyncMock(return_value={"fed_rate": 5.25}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_economic_indicators",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_economic_indicators_no_auth_401(http):
|
||||
r = http.post("/tools/get_economic_indicators", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --- get_macro_calendar ---
|
||||
|
||||
def test_get_macro_calendar_core_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_macro_calendar",
|
||||
new=AsyncMock(return_value={"events": []}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_macro_calendar",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"days": 7},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_macro_calendar_observer_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_macro_calendar",
|
||||
new=AsyncMock(return_value={"events": []}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_macro_calendar",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_macro_calendar_no_auth_401(http):
|
||||
r = http.post("/tools/get_macro_calendar", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --- get_market_overview ---
|
||||
|
||||
def test_get_market_overview_core_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_market_overview",
|
||||
new=AsyncMock(return_value={"btc_dominance": 52.0, "btc_price": 65000}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_market_overview",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["btc_price"] == 65000
|
||||
|
||||
|
||||
def test_get_market_overview_observer_ok(http):
|
||||
with patch(
|
||||
"mcp_macro.server.fetch_market_overview",
|
||||
new=AsyncMock(return_value={"btc_dominance": 52.0}),
|
||||
):
|
||||
r = http.post(
|
||||
"/tools/get_market_overview",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_market_overview_no_auth_401(http):
|
||||
r = http.post("/tools/get_market_overview", json={})
|
||||
assert r.status_code == 401
|
||||
Reference in New Issue
Block a user