Преглед изворни кода

Integrate metals MCP context

Lukas Goldschmidt пре 3 недеља
родитељ
комит
dd094b08c9

+ 10 - 0
ARGUS_PAPER.md

@@ -67,6 +67,12 @@ Current policy:
 
 This means Argus should still remain useful even if Twelve Data is removed.
 
+### Metals MCP
+
+Metals MCP is the dedicated hard-asset source.
+Use it for spot metals context and for the precious-metal leg of real-asset pressure.
+It should be cached and treated as read-only context, not as a decision engine.
+
 ## Current signal domains
 
 ### 1. Risk appetite
@@ -110,6 +116,10 @@ measure hard-asset, inflation, and commodity pressure.
 
 Current proxies:
 - `GLD`
+- `XAU`
+- `XAG`
+- `XPT`
+- `XPD`
 - `XLE`
 - `USO`
 

+ 1 - 1
README.md

@@ -4,7 +4,7 @@ Argus MCP is a read-only market context feed for Hermes. It watches cross-market
 
 ## What it does
 
-- ingests context from Finnhub and Twelve Data
+- ingests context from Finnhub, Twelve Data, and metals-mcp
 - classifies a market regime from domain scores (risk appetite, stress, liquidity, real-asset pressure, transport pressure)
 - stores immutable snapshots in SQLite
 - exposes read-only MCP tools

+ 3 - 1
src/argus_mcp/config.py

@@ -54,9 +54,10 @@ def _split_csv(value: str, default: tuple[str, ...]) -> tuple[str, ...]:
 class ArgusConfig:
     app_name: str = "argus-mcp"
     sqlite_path: Path = Path("data/argus_mcp.sqlite3")
+    metals_mcp_url: str = "http://127.0.0.1:8515/mcp/sse"
     finnhub_token: str = ""
     twelve_data_key: str = ""
-    symbols: tuple[str, ...] = ("QQQ", "SPY", "XLK", "SMH", "HYG", "VXX", "UVXY", "UUP", "TLT", "GLD", "XLE", "USO", "IYT", "JETS", "ZIM", "SBLK", "DHT", "CHRW", "BTCUSD", "ETHUSD")
+    symbols: tuple[str, ...] = ("QQQ", "SPY", "XLK", "SMH", "HYG", "VXX", "UVXY", "UUP", "TLT", "GLD", "XLE", "USO", "IYT", "JETS", "ZIM", "SBLK", "DHT", "CHRW", "XAU", "XAG", "XPT", "XPD", "BTCUSD", "ETHUSD")
     interval: str = "1d"
     finnhub_ttl_seconds: int = 60
     twelve_data_ttl_seconds: int = 10800
@@ -73,6 +74,7 @@ def load_config() -> ArgusConfig:
     canonical_symbols = tuple(dict.fromkeys(canonicalize_symbol(symbol) for symbol in raw_symbols))
     return ArgusConfig(
         sqlite_path=Path(_env("ARGUS_SQLITE_PATH", "data/argus_mcp.sqlite3")),
+        metals_mcp_url=_env("ARGUS_METALS_MCP_URL", "http://127.0.0.1:8515/mcp/sse"),
         finnhub_token=_env("FINNHUB_TOKEN"),
         twelve_data_key=_env("TWELVE_DATA_KEY"),
         symbols=canonical_symbols,

+ 73 - 0
src/argus_mcp/providers/metals_mcp.py

@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone, timedelta
+from dataclasses import dataclass
+from typing import Any
+
+from mcp.client.session import ClientSession
+from mcp.client.sse import sse_client
+
+from argus_mcp.models import MarketQuote
+
+
+@dataclass(slots=True)
+class MetalsMcpProvider:
+    endpoint_url: str
+    timeout_seconds: float = 8.0
+
+    name = "metals_mcp"
+
+    @property
+    def enabled(self) -> bool:
+        return bool(self.endpoint_url.strip())
+
+    def _endpoint(self) -> str:
+        url = self.endpoint_url.strip()
+        if url.endswith("/mcp/sse"):
+            return url
+        return url.rstrip("/") + "/mcp/sse"
+
+    def _quote_from_payload(self, symbol: str, payload: dict[str, Any]) -> MarketQuote | None:
+        if not payload or payload.get("status") not in {None, "ok"}:
+            return None
+
+        price = payload.get("price")
+        change_pct = payload.get("change_pct")
+        if change_pct is None:
+            components = payload.get("components") or {}
+            change_pct = components.get("trend_pct")
+
+        if price is None and change_pct is None:
+            return None
+
+        return MarketQuote(
+            symbol=symbol,
+            source=self.name,
+            timestamp=datetime.now(timezone.utc),
+            last=price,
+            change_pct=change_pct,
+            raw=payload,
+        )
+
+    async def fetch_quotes(self, symbols: list[str]) -> dict[str, MarketQuote | None]:
+        if not self.enabled or not symbols:
+            return {}
+
+        endpoint = self._endpoint()
+        results: dict[str, MarketQuote | None] = {}
+
+        async with sse_client(endpoint, timeout=self.timeout_seconds, sse_read_timeout=self.timeout_seconds) as streams:
+            async with ClientSession(*streams, read_timeout_seconds=timedelta(seconds=self.timeout_seconds)) as session:
+                await session.initialize()
+                for symbol in symbols:
+                    try:
+                        result = await session.call_tool("get_market_snapshot", {"symbol": symbol})
+                        payload = result.structuredContent or {}
+                        results[symbol] = self._quote_from_payload(symbol, payload if isinstance(payload, dict) else {})
+                    except Exception:
+                        results[symbol] = None
+        return results
+
+    async def fetch_quote(self, symbol: str) -> MarketQuote | None:
+        quotes = await self.fetch_quotes([symbol])
+        return quotes.get(symbol)

+ 12 - 0
src/argus_mcp/regime.py

@@ -37,6 +37,10 @@ def build_regime_snapshot(quotes: Iterable[MarketQuote]) -> RegimeSnapshot:
     uup = by_symbol.get("UUP") or by_symbol.get("DXY")
     tlt = by_symbol.get("TLT")
     gld = by_symbol.get("GLD")
+    xau = by_symbol.get("XAU") or by_symbol.get("XAU/USD")
+    xag = by_symbol.get("XAG") or by_symbol.get("XAG/USD")
+    xpt = by_symbol.get("XPT") or by_symbol.get("XPT/USD")
+    xpd = by_symbol.get("XPD") or by_symbol.get("XPD/USD")
     xle = by_symbol.get("XLE")
     uso = by_symbol.get("USO")
     xlk = by_symbol.get("XLK")
@@ -116,6 +120,14 @@ def build_regime_snapshot(quotes: Iterable[MarketQuote]) -> RegimeSnapshot:
     real_asset_pressure = 0.0
     if gld:
         real_asset_pressure += _append_impact(impacts, "gld_strength", gld_move, 0.55, "Gold strength / hard-asset demand")
+    if xau:
+        real_asset_pressure += _append_impact(impacts, "xau_strength", _norm(_change(xau), 1.0), 0.30, "Spot gold strength from metals-mcp")
+    if xag:
+        real_asset_pressure += _append_impact(impacts, "xag_strength", _norm(_change(xag), 1.2), 0.20, "Spot silver strength from metals-mcp")
+    if xpt:
+        real_asset_pressure += _append_impact(impacts, "xpt_strength", _norm(_change(xpt), 1.4), 0.12, "Platinum strength from metals-mcp")
+    if xpd:
+        real_asset_pressure += _append_impact(impacts, "xpd_strength", _norm(_change(xpd), 1.6), 0.12, "Palladium strength from metals-mcp")
     if xle:
         real_asset_pressure += _append_impact(impacts, "xle_strength", xle_move, 0.45, "Energy / commodity cycle pressure")
     if uso:

+ 17 - 0
src/argus_mcp/service.py

@@ -6,6 +6,7 @@ from datetime import datetime, timezone
 from argus_mcp.config import ArgusConfig, load_config
 from argus_mcp.models import MarketQuote, RegimeSnapshot
 from argus_mcp.providers.finnhub import FinnhubProvider
+from argus_mcp.providers.metals_mcp import MetalsMcpProvider
 from argus_mcp.providers.twelve_data import TwelveDataProvider
 from argus_mcp.regime import build_regime_snapshot
 from argus_mcp.storage import SnapshotStore
@@ -16,6 +17,7 @@ from argus_mcp.symbols import get_symbol_spec
 class ArgusService:
     config: ArgusConfig
     store: SnapshotStore
+    metals: MetalsMcpProvider
     finnhub: FinnhubProvider
     twelve_data: TwelveDataProvider
     _last_source_status: dict[str, str] = field(default_factory=dict, init=False, repr=False)
@@ -26,12 +28,14 @@ class ArgusService:
         return cls(
             config=cfg,
             store=SnapshotStore(cfg.sqlite_path),
+            metals=MetalsMcpProvider(cfg.metals_mcp_url),
             finnhub=FinnhubProvider(cfg.finnhub_token),
             twelve_data=TwelveDataProvider(cfg.twelve_data_key),
         )
 
     def provider_summary(self) -> dict[str, bool]:
         return {
+            "metals_mcp": self.metals.enabled,
             "finnhub": self.finnhub.enabled,
             "twelve_data": self.twelve_data.enabled,
         }
@@ -73,10 +77,23 @@ class ArgusService:
     async def fetch_quotes(self) -> list[MarketQuote]:
         quotes: list[MarketQuote] = []
         source_status: dict[str, str] = {}
+        metals_specs = [get_symbol_spec(symbol) for symbol in self.config.symbols if get_symbol_spec(symbol).metals_mcp]
+        metals_results: dict[str, MarketQuote | None] = {}
+        if metals_specs and self.metals.enabled:
+            requested = [spec.metals_mcp or spec.canonical for spec in metals_specs]
+            metals_results = await self.metals.fetch_quotes(requested)
         for symbol in self.config.symbols:
             spec = get_symbol_spec(symbol)
             quote = None
 
+            if spec.metals_mcp:
+                quote = metals_results.get(spec.metals_mcp or spec.canonical)
+                source_status[f"{spec.canonical}:metals_mcp"] = "fetched:metals_mcp" if quote is not None else "missing:metals_mcp"
+                if quote is not None:
+                    quote.symbol = spec.canonical
+                    quotes.append(quote)
+                continue
+
             if spec.finnhub:
                 quote, status = await self._fetch_or_cache(
                     spec.canonical,

+ 5 - 0
src/argus_mcp/symbols.py

@@ -8,6 +8,7 @@ class SymbolSpec:
     canonical: str
     finnhub: str | None = None
     twelve_data: str | None = None
+    metals_mcp: str | None = None
     note: str = ""
 
 
@@ -31,6 +32,10 @@ SYMBOL_REGISTRY: dict[str, SymbolSpec] = {
     "SBLK": SymbolSpec("SBLK", finnhub="SBLK", twelve_data="SBLK", note="Dry bulk shipping proxy"),
     "DHT": SymbolSpec("DHT", finnhub="DHT", twelve_data="DHT", note="Tanker shipping proxy"),
     "CHRW": SymbolSpec("CHRW", finnhub="CHRW", twelve_data="CHRW", note="Logistics / freight brokerage proxy"),
+    "XAU": SymbolSpec("XAU", metals_mcp="XAU", note="Gold spot from metals-mcp"),
+    "XAG": SymbolSpec("XAG", metals_mcp="XAG", note="Silver spot from metals-mcp"),
+    "XPT": SymbolSpec("XPT", metals_mcp="XPT", note="Platinum spot from metals-mcp"),
+    "XPD": SymbolSpec("XPD", metals_mcp="XPD", note="Palladium spot from metals-mcp"),
     "SLV": SymbolSpec("SLV", finnhub="SLV", twelve_data="SLV", note="Silver ETF proxy"),
     "BTCUSD": SymbolSpec("BTCUSD", finnhub="BINANCE:BTCUSDT", twelve_data="BTC/USD", note="Crypto beta proxy"),
     "BTC/USD": SymbolSpec("BTCUSD", finnhub="BINANCE:BTCUSDT", twelve_data="BTC/USD", note="Crypto beta proxy"),

+ 14 - 0
tests/test_regime.py

@@ -43,3 +43,17 @@ def test_regime_handles_no_data():
 
     assert snapshot.regime == "no_data"
     assert snapshot.confidence == 0.0
+
+
+def test_regime_responds_to_metals_strength():
+    snapshot = build_regime_snapshot(
+        [
+            MarketQuote(symbol="XAU", source="metals_mcp", change_pct=1.8),
+            MarketQuote(symbol="XAG", source="metals_mcp", change_pct=1.2),
+            MarketQuote(symbol="XPT", source="metals_mcp", change_pct=0.9),
+            MarketQuote(symbol="XPD", source="metals_mcp", change_pct=0.7),
+        ]
+    )
+
+    assert snapshot.components["real_asset_pressure"] > 0
+    assert snapshot.regime in {"real_asset_inflation", "neutral", "risk_off", "compression"}

+ 7 - 0
tests/test_symbols.py

@@ -22,3 +22,10 @@ def test_symbol_spec_covers_transport_proxies():
     assert get_symbol_spec("IYT").finnhub == "IYT"
     assert get_symbol_spec("JETS").twelve_data == "JETS"
     assert get_symbol_spec("ZIM").canonical == "ZIM"
+
+
+def test_symbol_spec_covers_metals_mcp_proxies():
+    assert get_symbol_spec("XAU").metals_mcp == "XAU"
+    assert get_symbol_spec("XAG").metals_mcp == "XAG"
+    assert get_symbol_spec("XPT").metals_mcp == "XPT"
+    assert get_symbol_spec("XPD").metals_mcp == "XPD"