فهرست منبع

feature update, snapshot, price, timeframes

Lukas Goldschmidt 1 هفته پیش
والد
کامیت
aa53d01a00
9فایلهای تغییر یافته به همراه313 افزوده شده و 36 حذف شده
  1. 2 0
      .env.example
  2. 48 0
      AGENTS.md
  3. 9 7
      Dockerfile
  4. 11 9
      README.md
  5. 1 1
      discover_swissquote_api.py
  6. 135 0
      src/metals_mcp/mcp_tools.py
  7. 100 19
      src/metals_mcp/poller.py
  8. 7 0
      src/metals_mcp/server_fastmcp.py
  9. 0 0
      src/metals_mcp/storage.py

+ 2 - 0
.env.example

@@ -10,3 +10,5 @@ uvicorn.log
 server.log
 server.pid
 .env
+*.backup*
+.codex

+ 48 - 0
AGENTS.md

@@ -0,0 +1,48 @@
+# AGENTS.md for metals-mcp
+
+## Purpose
+This file provides guidance for agents working on the metals-mcp server.
+
+## Key Documentation
+- README.md: Overview, runtime instructions, tool list, and housekeeping scripts.
+- PROJECT.md: Purpose, current interface, tool set, candle model, done/planned items, and notes.
+- metals-mcp_first_idea.md: Detailed design principles and system architecture.
+
+## Runtime
+1. Source the virtual environment: `source .venv/bin/activate`
+2. Install dependencies: `pip install -r requirements.txt`
+3. Run the server: `./run.sh` (or `docker compose up -d --build` for Docker)
+4. Health check: `GET http://127.0.0.1:8515/health`
+5. Stop server: `./killserver.sh`
+6. Restart: `./restart.sh`
+
+## Source Structure
+- `src/metals_mcp/`: Main package
+  - `server_fastmcp.py`: FastAPI/FastMCP entry point and tool definitions
+  - `mcp_tools.py`: Implementation of MCP tools (get_price, get_ohlcv, etc.)
+  - `poller.py`: Background candle poller
+  - `storage.py`: SQLite candle storage and initialization
+  - `swissquote.py`: Swissquote API client
+  - `config.py`: Configuration (DB_PATH, etc.)
+- `data/`: SQLite candle database
+- `logs/`: Server logs and PID file
+- `scripts/`: Helper scripts (discover_swissquote_api.py, etc.)
+
+## Important Conventions
+- Always use the local `.venv` before claiming missing dependencies.
+- The server uses clock-aligned 5m candles as the base market view.
+- Default instruments: XAU/USD, XAG/USD, XPT/USD, XPD/USD, EUR/USD, USD/JPY (configurable via METALS_PAIRS environment variable)
+- Default port: 8515 (configurable via METALS_PORT environment variable)
+- Candle retention: Controlled by `METALS_CANDLE_RETENTION_DAYS` (default 30)
+- MCP tools mirror `crypto-mcp` where practical, with `get_last_candle` as a metals-specific convenience, plus `get_snapshot` for multi-timeframe analysis
+
+## Verification
+- Run tests: `./tests.sh`
+- Manual verification: `./run.sh` and check endpoints
+
+## Notes for Agents
+- When modifying tools, ensure changes align with the shared MCP surface.
+- Keep the transport small and predictable (SSE mount at `/mcp`).
+- Store raw ticks only if useful; the system can remain simple with 5m candles as base.
+- The goal is market orientation and regime reading, not execution.
+- Preserve the deterministic design: consistent data over theoretically perfect data.

+ 9 - 7
Dockerfile

@@ -18,13 +18,15 @@ Tool calls are performed via FastMCP’s message transport under `/mcp/messages/
 
 ## Tool set
 
-- `get_price`
-- `get_ohlcv`
-- `get_indicator`
-- `get_market_snapshot`
-- `get_top_movers`
-- `get_capabilities`
-- `get_regime`
+- `get_price` - Fetch live Swissquote quotes for metals
+- `get_ohlcv` - Get OHLCV candle data for a symbol and timeframe
+- `get_indicator` - Calculate technical indicators (SMA, EMA, RSI, ATR, return_pct, volatility)
+- `get_market_snapshot` - Get comprehensive market analysis for a single symbol
+- `get_top_movers` - Get top moving symbols by percentage change
+- `get_capabilities` - Get server capabilities and available tools
+- `get_regime` - Get market regime classification
+- `get_last_candle` - Get the most recent completed candle
+- `get_snapshot` - Get comprehensive multi-timeframe, multi-symbol market snapshot
 
 ## Candle model
 

+ 11 - 9
README.md

@@ -35,23 +35,25 @@ Reload is off by default in container runs, so the poller won't trigger watcher
 
 ## Tools
 
-- `get_price`
-- `get_ohlcv`
-- `get_last_candle`
-- `get_market_snapshot`
-- `get_indicator`
-- `get_top_movers`
-- `get_capabilities`
-- `get_regime`
+- `get_price` - Fetch live Swissquote quotes for metals
+- `get_ohlcv` - Get OHLCV candle data for a symbol and timeframe
+- `get_last_candle` - Get the most recent completed candle
+- `get_market_snapshot` - Get comprehensive market analysis for a single symbol (price, trend, regime, etc.)
+- `get_indicator` - Calculate technical indicators (SMA, EMA, RSI, ATR, return_pct, volatility)
+- `get_top_movers` - Get top moving symbols by percentage change
+- `get_capabilities` - Get server capabilities and available tools
+- `get_regime` - Get market regime classification (bullish, bearish, compression, neutral)
+- `get_snapshot` - Get comprehensive multi-timeframe, multi-symbol market snapshot with cross-asset analysis
 
 ## Notes
-
 ### Done
+
 - Shared MCP surface mirrors `crypto-mcp` tool names, with `get_last_candle` kept as a small metals-specific convenience.
 - `get_price` fetches live Swissquote quotes for metals like `XAU`.
 - An internal background poller keeps the server self-sufficient.
 - 5m candles are clock-aligned and persisted in SQLite for `XAU/USD`, `XAG/USD`, `XPT/USD`, and `XPD/USD` by default.
 - `get_market_snapshot` and `get_regime` now provide the useful metals context in crypto-style naming.
+- `get_snapshot` provides comprehensive multi-timeframe, multi-symbol analysis with cross-asset insights.
 - Candle retention is bounded by `METALS_CANDLE_RETENTION_DAYS` (default 30), with periodic pruning to keep the DB bounded.
 
 ### Planned

+ 1 - 1
discover_swissquote_api.py

@@ -10,5 +10,5 @@ DATA_DIR = Path(os.getenv("METALS_DATA_DIR", BASE_DIR / "data"))
 LOG_DIR = Path(os.getenv("METALS_LOG_DIR", BASE_DIR / "logs"))
 DB_PATH = Path(os.getenv("METALS_DB_PATH", DATA_DIR / "metals.sqlite3"))
 POLL_INTERVAL_SECONDS = float(os.getenv("SWISSQUOTE_POLL_INTERVAL_SECONDS", "0.2"))
-METALS_PAIRS = [p.strip().upper() for p in os.getenv("METALS_PAIRS", "XAU/USD,XAG/USD,XPT/USD,XPD/USD").split(",") if p.strip()]
+METALS_PAIRS = [p.strip().upper() for p in os.getenv("METALS_PAIRS", "XAU/USD,XAG/USD,XPT/USD,XPD/USD,EUR/USD,USD/JPY").split(",") if p.strip()]
 METALS_CANDLE_RETENTION_DAYS = int(os.getenv("METALS_CANDLE_RETENTION_DAYS", "30"))

+ 135 - 0
src/metals_mcp/mcp_tools.py

@@ -387,3 +387,138 @@ def get_health() -> dict[str, Any]:
     except Exception:
         store = {"ticks": 0, "candles": 0}
     return {"ok": True, "store": store}
+
+def get_snapshot(timeframes: list[str] | None = None) -> dict[str, Any]:
+    """
+    Comprehensive market snapshot across all symbols and timeframes.
+    Provides comparison and signals from all available data.
+    
+    Args:
+        timeframes: List of timeframes to include (default: ["5m", "15m", "1h", "4h", "1d"])
+    """
+    if timeframes is None:
+        timeframes = ["5m", "15m", "1h", "4h", "1d"]
+    
+    symbols = WATCHLIST
+    all_data = {}
+    timeframe_snapshots = {}
+    
+    for tf in timeframes:
+        tf_data = {}
+        for symbol in symbols:
+            snapshot = _symbol_snapshot(symbol, tf, limit=20)
+            tf_data[symbol] = snapshot
+        timeframe_snapshots[tf] = tf_data
+        all_data[tf] = tf_data
+    
+    # Cross-symbol analysis per timeframe
+    cross_symbol = {}
+    for tf in timeframes:
+        regimes = [s["regime"] for s in timeframe_snapshots[tf].values() if s["regime"] != "no_data"]
+        avg_trend = []
+        avg_rsi = []
+        avg_confidence = []
+        for s in timeframe_snapshots[tf].values():
+            if s["change_pct"] is not None:
+                avg_trend.append(s["change_pct"])
+            if s["rsi"] is not None:
+                avg_rsi.append(s["rsi"])
+            avg_confidence.append(s["confidence"])
+        
+        if regimes:
+            if all(r == "bullish" for r in regimes):
+                regime = "hard_asset_bid"
+                summary = "All metals showing bullish momentum across the timeframe."
+            elif all(r == "bearish" for r in regimes):
+                regime = "hard_asset_pressure"
+                summary = "All metals showing bearish pressure across the timeframe."
+            elif any(r == "compression" for r in regimes):
+                regime = "compression"
+                summary = "Metals are compressed, awaiting a directional break."
+            else:
+                regime = "mixed"
+                summary = "Metals showing mixed signals across the timeframe."
+        else:
+            regime = "no_data"
+            summary = "No data available."
+        
+        cross_symbol[tf] = {
+            "regime": regime,
+            "summary": summary,
+            "avg_trend_pct": sum(avg_trend) / len(avg_trend) if avg_trend else None,
+            "avg_rsi": sum(avg_rsi) / len(avg_rsi) if avg_rsi else None,
+            "avg_confidence": sum(avg_confidence) / len(avg_confidence) if avg_confidence else 0.0,
+        }
+    
+    # Cross-timeframe analysis per symbol
+    cross_timeframe = {}
+    for symbol in symbols:
+        tf_analysis = {}
+        for tf in timeframes:
+            s = timeframe_snapshots[tf][symbol]
+            tf_analysis[tf] = {
+                "regime": s["regime"],
+                "trend_pct": s["change_pct"],
+                "rsi": s["rsi"],
+                "confidence": s["confidence"],
+            }
+        
+        regimes = [tf_analysis[tf]["regime"] for tf in timeframes if tf_analysis[tf]["regime"] != "no_data"]
+        if regimes:
+            if all(r == "bullish" for r in regimes):
+                trend_alignment = "strong_bullish"
+            elif all(r == "bearish" for r in regimes):
+                trend_alignment = "strong_bearish"
+            elif any(r == "bullish" for r in regimes) and not any(r == "bearish" for r in regimes):
+                trend_alignment = "mild_bullish"
+            elif any(r == "bearish" for r in regimes) and not any(r == "bullish" for r in regimes):
+                trend_alignment = "mild_bearish"
+            else:
+                trend_alignment = "mixed"
+        else:
+            trend_alignment = "no_data"
+        
+        cross_timeframe[symbol] = {
+            "timeframe_analysis": tf_analysis,
+            "trend_alignment": trend_alignment,
+        }
+    
+    # Market regime across all timeframes
+    regimes_across_all = []
+    for tf in timeframes:
+        regimes_across_all.extend([s["regime"] for s in timeframe_snapshots[tf].values() if s["regime"] != "no_data"])
+    
+    if not regimes_across_all:
+        overall_regime = "no_data"
+        overall_summary = "No market data available."
+    elif all(r == "bullish" for r in regimes_across_all):
+        overall_regime = "strong_bullish"
+        overall_summary = "Bullish momentum across all timeframes and symbols."
+    elif all(r == "bearish" for r in regimes_across_all):
+        overall_regime = "strong_bearish"
+        overall_summary = "Bearish pressure across all timeframes and symbols."
+    elif regimes_across_all.count("bullish") > regimes_across_all.count("bearish"):
+        overall_regime = "bullish_bias"
+        overall_summary = "More bullish than bearish signals in the market."
+    elif regimes_across_all.count("bearish") > regimes_across_all.count("bullish"):
+        overall_regime = "bearish_bias"
+        overall_summary = "More bearish than bullish signals in the market."
+    else:
+        overall_regime = "neutral"
+        overall_summary = "Market is balanced between bullish and bearish forces."
+    
+    from datetime import timezone
+    return {
+        "server": "metals-mcp",
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "timeframes": timeframes,
+        "symbols": list(symbols),
+        "overall_market": {
+            "regime": overall_regime,
+            "summary": overall_summary,
+        },
+        "by_timeframe": cross_symbol,
+        "by_symbol": cross_timeframe,
+        "raw_snapshots": all_data,
+        "status": "ok",
+    }

+ 100 - 19
src/metals_mcp/poller.py

@@ -2,14 +2,21 @@ from __future__ import annotations
 
 import time
 from dataclasses import dataclass
-from typing import Any
+from typing import Any, Optional
 import logging
 
 from .config import DB_PATH, METALS_CANDLE_RETENTION_DAYS, METALS_PAIRS, POLL_INTERVAL_SECONDS
-from .storage import init_db, prune_candles_older_than, upsert_candle
+from .storage import init_db, prune_candles_older_than, upsert_candle, latest_candles
 from .swissquote import SwissquoteClient
 
-TIMEFRAME_SECONDS = 300
+TIMEFRAME_SECONDS = {
+    "5m": 300,
+    "15m": 900,
+    "1h": 3600,
+    "4h": 14400,
+    "1d": 86400,
+}
+
 logger = logging.getLogger(__name__)
 
 
@@ -29,6 +36,7 @@ class CandleState:
         self.close = price
 
     def to_row(self) -> dict[str, Any]:
+        secs = TIMEFRAME_SECONDS.get(self.timeframe, 300)
         return {
             "symbol": self.symbol,
             "timeframe": self.timeframe,
@@ -37,31 +45,96 @@ class CandleState:
             "low": self.low,
             "close": self.close,
             "start_ts": self.start_ts,
-            "end_ts": self.start_ts + TIMEFRAME_SECONDS * 1000,
+            "end_ts": self.start_ts + secs * 1000,
         }
 
 
+def _bucket_start(ts_ms: int, timeframe: str) -> int:
+    secs = TIMEFRAME_SECONDS.get(timeframe, 300)
+    return (ts_ms // (secs * 1000)) * (secs * 1000)
+
+
+def _derive_higher_timeframe_candles(
+    symbol: str,
+    higher_timeframe: str,
+    lower_timeframe: str,
+    db_path: str,
+) -> Optional[CandleState]:
+    lower_secs = TIMEFRAME_SECONDS.get(lower_timeframe, 300)
+    higher_secs = TIMEFRAME_SECONDS.get(higher_timeframe, 3600)
+    
+    needed = higher_secs // lower_secs
+    if needed <= 1:
+        return None
+    
+    candles = latest_candles(db_path, symbol, lower_timeframe, limit=needed)
+    if len(candles) < needed:
+        return None
+    
+    first = candles[0]
+    last = candles[-1]
+    
+    # Determine which higher timeframe bucket the FIRST candle belongs to
+    # This is the bucket we're trying to fill
+    higher_start = _bucket_start(first["start_ts"], higher_timeframe)
+    higher_end = higher_start + higher_secs * 1000
+    
+    # Check if the span of lower candles fits within this higher bucket
+    # The last lower candle must end at or before the higher bucket ends
+    last_end = last["end_ts"]
+    if last_end > higher_end:
+        # The lower candles span across higher boundaries
+        # Can't derive a complete higher candle yet
+        return None
+    
+    # Check if we have enough lower candles that start within this higher bucket
+    # (i.e., the first lower candle starts at or after the higher bucket start)
+    if first["start_ts"] < higher_start:
+        # First candle starts before the higher bucket - can't derive
+        return None
+    
+    opens = [c["open"] for c in candles]
+    highs = [c["high"] for c in candles]
+    lows = [c["low"] for c in candles]
+    closes = [c["close"] for c in candles]
+    return CandleState(
+        symbol=symbol,
+        timeframe=higher_timeframe,
+        start_ts=higher_start,
+        open=opens[0],
+        high=max(highs),
+        low=min(lows),
+        close=closes[-1],
+    )
+
+
+def _finalize_candle(state: CandleState, db_path: str) -> None:
+    upsert_candle(db_path, state.to_row())
+
+
 class CandlePoller:
     def __init__(self) -> None:
         self.client = SwissquoteClient()
-        self.states: dict[str, CandleState] = {}
+        self.states_5m: dict[str, CandleState] = {}
         self._last_prune_ts = 0.0
         init_db(DB_PATH)
 
-    def bucket_start(self, ts_ms: int) -> int:
-        return (ts_ms // (TIMEFRAME_SECONDS * 1000)) * (TIMEFRAME_SECONDS * 1000)
-
     def step(self) -> None:
+        now_ms = int(time.time() * 1000)
+        
         for symbol in METALS_PAIRS:
             quote = self.client.fetch_quote(symbol)
             if not quote:
                 continue
-            start_ts = self.bucket_start(quote.timestamp)
-            state = self.states.get(symbol)
+            
+            start_ts = _bucket_start(quote.timestamp, "5m")
+            state = self.states_5m.get(symbol)
+            
             if state is None or state.start_ts != start_ts:
                 if state is not None:
-                    upsert_candle(DB_PATH, state.to_row())
-                self.states[symbol] = CandleState(
+                    _finalize_candle(state, DB_PATH)
+                
+                self.states_5m[symbol] = CandleState(
                     symbol=symbol,
                     timeframe="5m",
                     start_ts=start_ts,
@@ -72,17 +145,25 @@ class CandlePoller:
                 )
             else:
                 state.update(quote.mid)
-
-        for state in self.states.values():
+        
+        for state in self.states_5m.values():
             upsert_candle(DB_PATH, state.to_row())
-
-        now = time.monotonic()
-        if now - self._last_prune_ts >= 3600:
+        
+        higher_timeframes = ["15m", "1h", "4h", "1d"]
+        for symbol in METALS_PAIRS:
+            for higher_tf in higher_timeframes:
+                derived = _derive_higher_timeframe_candles(
+                    symbol, higher_tf, "5m", DB_PATH
+                )
+                if derived is not None:
+                    upsert_candle(DB_PATH, derived.to_row())
+        
+        if now_ms - self._last_prune_ts >= 3600 * 1000:
             prune_candles_older_than(DB_PATH, METALS_CANDLE_RETENTION_DAYS)
-            self._last_prune_ts = now
+            self._last_prune_ts = float(now_ms)
 
     def flush(self) -> None:
-        for state in self.states.values():
+        for state in self.states_5m.values():
             upsert_candle(DB_PATH, state.to_row())
 
     def run_forever(self) -> None:

+ 7 - 0
src/metals_mcp/server_fastmcp.py

@@ -16,6 +16,7 @@ from .mcp_tools import (
     get_price as _get_price,
     get_regime as _get_regime,
     get_top_movers as _get_top_movers,
+    get_snapshot as _get_snapshot,
 )
 from .poller import CandlePoller
 from .storage import init_db
@@ -69,6 +70,11 @@ def get_regime(symbol: str, timeframe: str = "5m"):
     return _get_regime(symbol, timeframe=timeframe)
 
 
+@mcp.tool()
+def get_snapshot(timeframes: list[str] | None = None):
+    return _get_snapshot(timeframes=timeframes)
+
+
 @app.get("/")
 def root():
     return {
@@ -80,6 +86,7 @@ def root():
             "get_last_candle",
             "get_indicator",
             "get_market_snapshot",
+            "get_snapshot",
             "get_top_movers",
             "get_regime",
             "get_capabilities",

+ 0 - 0
src/metals_mcp/storage.py