Bladeren bron

Fix Hermes dashboard sparklines

Lukas Goldschmidt 3 weken geleden
bovenliggende
commit
7fd1f6a72b
4 gewijzigde bestanden met toevoegingen van 295 en 27 verwijderingen
  1. 20 0
      src/hermes_mcp/crypto_client.py
  2. 105 24
      src/hermes_mcp/dashboard.py
  3. 156 3
      src/hermes_mcp/server.py
  4. 14 0
      src/hermes_mcp/store.py

+ 20 - 0
src/hermes_mcp/crypto_client.py

@@ -27,3 +27,23 @@ async def get_regime(base_url: str, symbol: str, timeframe: str = "1h") -> dict[
                 return json.loads(text)
             except Exception:
                 return {"raw": text, "symbol": symbol, "timeframe": timeframe}
+
+
+async def get_price(base_url: str, symbol: str) -> dict[str, Any]:
+    async with sse_client(base_url) as (read_stream, write_stream):
+        async with ClientSession(read_stream, write_stream) as session:
+            await session.initialize()
+            result = await session.call_tool("get_price", {"symbol": symbol})
+            content = getattr(result, "content", None)
+            if not content:
+                return {"error": "EMPTY_RESULT", "symbol": symbol}
+            first = content[0]
+            text = getattr(first, "text", None)
+            if text is None and isinstance(first, dict):
+                text = first.get("text")
+            if text is None:
+                return {"error": "UNPARSEABLE_RESULT", "symbol": symbol}
+            try:
+                return json.loads(text)
+            except Exception:
+                return {"raw": text, "symbol": symbol}

+ 105 - 24
src/hermes_mcp/dashboard.py

@@ -1,25 +1,22 @@
 from fastapi import APIRouter
 from fastapi.responses import HTMLResponse
 
-from .store import list_concerns, latest_cycle, latest_regime_samples
+from .store import latest_cycle, latest_regime_samples
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
 
 @router.get("/", response_class=HTMLResponse)
 def overview():
-    concerns = list_concerns()
     cycle = latest_cycle() or {}
+    concerns = []
     regimes = latest_regime_samples(10)
+    concern_rows = "<tr><td colspan='5' class='muted'>Loading live data…</td></tr>"
     regime_rows = "".join(
         f"<tr><td>{r.get('concern_id','')}</td><td>{r.get('timeframe','')}</td><td><pre style='white-space:pre-wrap;margin:0'>{r.get('regime_json','')}</pre></td><td>{r.get('captured_at','')}</td></tr>"
         for r in regimes
     ) or "<tr><td colspan='4' class='muted'>No regime samples yet.</td></tr>"
-    concern_rows = "".join(
-        f"<tr><td>{c.get('id','')}</td><td>{c.get('account_id','')}</td><td>{c.get('market_symbol','')}</td><td>{c.get('base_currency','')}</td><td>{c.get('quote_currency','')}</td><td>{c.get('strategy_id','')}</td><td>{c.get('source','')}</td><td>{c.get('status','')}</td></tr>"
-        for c in concerns
-    ) or "<tr><td colspan='8' class='muted'>No concerns yet.</td></tr>"
-    return """
+    template = """
     <html>
     <head>
       <title>Hermes MCP Dashboard</title>
@@ -36,7 +33,89 @@ def overview():
         .nav {{ display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }}
         .nav a {{ text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }}
         pre {{ white-space: pre-wrap; margin: 0; }}
+        .grid {{ display:grid; grid-template-columns: 1fr; gap: 16px; }}
+        .small {{ font-size: 0.92rem; color:#4b5563; }}
+        .regime-grid {{ display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }}
+        .regime-card {{ border:1px solid #e5e7eb; border-radius: 14px; padding: 10px; background: linear-gradient(180deg, #fff, #fafafa); min-width: 0; }}
+        .chips {{ display:flex; gap:6px; flex-wrap:wrap; margin: 8px 0; }}
+        .chip {{ display:inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }}
+        .good {{ background:#dcfce7; color:#166534; }}
+        .warn {{ background:#fef3c7; color:#92400e; }}
+        .bad {{ background:#fee2e2; color:#991b1b; }}
+        .neutral {{ background:#e5e7eb; color:#374151; }}
+        .spark {{ width:100%; height:40px; display:block; margin-top:8px; }}
+        .spark-block {{ margin-top: 8px; }}
+        .spark-label {{ display:flex; justify-content:space-between; gap:8px; font-size:12px; color:#6b7280; }}
       </style>
+      <script>
+        function regimeColor(state) {{
+          const s = String(state || '').toLowerCase();
+          if (['bull', 'bullish', 'strong', 'up', 'positive'].includes(s)) return 'good';
+          if (['bear', 'bearish', 'down', 'negative'].includes(s)) return 'bad';
+          if (['neutral', 'range', 'chop', 'sideways'].includes(s)) return 'neutral';
+          return 'warn';
+        }}
+        function sparkline(values, stroke='#2563eb') {{
+          if (!values.length) return '';
+          const min = Math.min(...values), max = Math.max(...values);
+          const span = (max - min) || 1;
+          const points = values.map((v, i) => `${{(i/(values.length-1||1))*100}},${{40 - ((v-min)/span)*40}}`).join(' ');
+          return `<svg class='spark' viewBox='0 0 100 40' preserveAspectRatio='none'><polyline fill='none' stroke='${{stroke}}' stroke-width='2' points='${{points}}' /></svg>`;
+        }}
+        async function refreshData() {{
+          const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
+          const data = await res.json();
+          document.getElementById('cycle-status').textContent = data.latest_cycle?.status || 'none';
+          document.getElementById('cycle-started').textContent = data.latest_cycle?.started_at || '-';
+          document.getElementById('cycle-finished').textContent = data.latest_cycle?.finished_at || '-';
+          document.getElementById('cycle-notes').textContent = data.latest_cycle?.notes || '-';
+          document.getElementById('concern-count').textContent = String(data.concerns.length);
+          document.getElementById('concerns-body').innerHTML = data.concerns.map(c => `
+            <tr>
+              <td><strong>${{c.account_display || ''}}</strong><div class='small'>${{c.id || ''}}</div></td>
+              <td>${{c.market_display || c.market_symbol || ''}}<div class='small'>${{c.market_description || ''}}</div></td>
+              <td>${{c.balance_summary || '-'}}<div class='small'>Total value: ${{typeof c.total_value_usd === 'number' ? c.total_value_usd.toFixed(2) : '-'}}</div></td>
+              <td>${{c.source || ''}}</td>
+              <td>${{c.status || ''}}</td>
+            </tr>`).join('') || "<tr><td colspan='5' class='muted'>No concerns yet.</td></tr>";
+          const histories = data.regime_histories || {};
+          const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
+          const samples = data.regime_samples || [];
+          const cards = desiredOrder.map(tf => samples.find(r => String(r.timeframe || '').toLowerCase() === tf)).filter(Boolean).map(r => {{
+            const parsed = (() => {{ try {{ return JSON.parse(r.regime_json); }} catch {{ return {{}}; }} }})();
+            const trend = parsed.trend?.state || 'neutral';
+            const momentum = parsed.momentum?.state || 'neutral';
+            const reversal = parsed.reversal?.direction || 'none';
+            const strength = parsed.reversal?.score ?? 0;
+            const rawMarket = r.market_display || r.market_symbol || 'Market';
+            const tf = String(r.timeframe || '').trim();
+            const market = tf && rawMarket.toLowerCase().endsWith(tf.toLowerCase())
+              ? rawMarket.slice(0, -tf.length).trim().replace(/[·\\-\\s]+$/, '').trim()
+              : rawMarket;
+            const title = tf ? `${{market}} · ${{tf}}` : market;
+            const key = `${{r.concern_id}}::${{r.timeframe}}`;
+            const hist = (histories[key] || []).map(x => {{ try {{ return JSON.parse(x.regime_json); }} catch {{ return null; }} }}).filter(Boolean);
+            const prices = hist.map(x => Number(x.price)).filter(Number.isFinite).slice(-24);
+            const atrs = hist.map(x => Number(x.volatility?.atr_percent)).filter(Number.isFinite).slice(-24);
+            const rsis = hist.map(x => Number(x.momentum?.rsi)).filter(Number.isFinite).slice(-24);
+            return `
+              <div class='regime-card'>
+                <div><strong>${{title}}</strong></div>
+                <div class='chips'>
+                  <span class='chip ${{regimeColor(trend)}}'>trend: ${{trend}}</span>
+                  <span class='chip ${{regimeColor(momentum)}}'>momentum: ${{momentum}}</span>
+                  <span class='chip ${{regimeColor(reversal)}}'>reversal: ${{reversal}}</span>
+                  <span class='chip neutral'>strength: ${{strength}}</span>
+                </div>
+                <div class='spark-block'><div class='spark-label'><span>Price: ${{parsed.price ?? '-'}}</span><span>${{prices.length}} / 24</span></div>${{sparkline(prices, '#2563eb')}}</div>
+                <div class='spark-block'><div class='spark-label'><span>ATR %: ${{parsed.volatility?.atr_percent ?? '-'}}</span><span>${{atrs.length}} / 24</span></div>${{sparkline(atrs, '#d97706')}}</div>
+                <div class='spark-block'><div class='spark-label'><span>Momentum RSI: ${{parsed.momentum?.rsi ?? '-'}}</span><span>${{rsis.length}} / 24</span></div>${{sparkline(rsis, '#16a34a')}}</div>
+              </div>`;
+          }}).join('') || "<div class='muted'>No regime samples yet.</div>";
+          document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
+        }}
+        window.addEventListener('load', () => {{ refreshData(); setInterval(refreshData, 15000); }});
+      </script>
     </head>
     <body>
       <div class="page"><div class="card">
@@ -47,29 +126,31 @@ def overview():
         <a href="/dashboard/tech">Tech monitor</a>
       </div>
       <h2>Last poll</h2>
-      <p><span class="pill">{cycle_status}</span></p>
-      <p><strong>started:</strong> {cycle_started}</p>
-      <p><strong>finished:</strong> {cycle_finished}</p>
-      <p><strong>notes:</strong> {cycle_notes}</p>
+      <p><span class="pill" id="cycle-status">__CYCLE_STATUS__</span></p>
+      <p><strong>started:</strong> <span id="cycle-started">__CYCLE_STARTED__</span></p>
+      <p><strong>finished:</strong> <span id="cycle-finished">__CYCLE_FINISHED__</span></p>
+      <p><strong>notes:</strong> <span id="cycle-notes">__CYCLE_NOTES__</span></p>
+      <p class="small"><span id="concern-count">__CONCERN_COUNT__</span> concerns</p>
       <h2>Concerns</h2>
       <table>
-        <tr><th>id</th><th>account</th><th>market</th><th>base</th><th>quote</th><th>strategy</th><th>source</th><th>status</th></tr>
-        {concern_rows}
+        <tr><th>account</th><th>market</th><th>balances</th><th>source</th><th>status</th></tr>
+        <tbody id="concerns-body">__CONCERN_ROWS__</tbody>
       </table>
       <h2>Latest regime samples</h2>
-      <table>
-        <tr><th>concern</th><th>timeframe</th><th>regime</th><th>captured</th></tr>
-        {regime_rows}
-      </table>
+      <div id="regimes-body">__REGIME_ROWS__</div>
       </div></div>
     </body></html>
-    """.format(
-        cycle_status=cycle.get("status", "none"),
-        cycle_started=cycle.get("started_at", "-"),
-        cycle_finished=cycle.get("finished_at", "-"),
-        cycle_notes=cycle.get("notes", "-"),
-        concern_rows=concern_rows,
-        regime_rows=regime_rows,
+    """
+    template = template.replace("{{", "{").replace("}}", "}")
+    return HTMLResponse(
+        template
+        .replace("__CYCLE_STATUS__", cycle.get("status", "none"))
+        .replace("__CYCLE_STARTED__", cycle.get("started_at", "-"))
+        .replace("__CYCLE_FINISHED__", cycle.get("finished_at", "-"))
+        .replace("__CYCLE_NOTES__", cycle.get("notes", "-"))
+        .replace("__CONCERN_COUNT__", str(len(concerns)))
+        .replace("__CONCERN_ROWS__", concern_rows)
+        .replace("__REGIME_ROWS__", regime_rows)
     )
 
 

+ 156 - 3
src/hermes_mcp/server.py

@@ -2,16 +2,21 @@ from __future__ import annotations
 
 from contextlib import asynccontextmanager
 import asyncio
+import json
 from datetime import datetime, timezone
 from uuid import uuid4
 
+import anyio
 from fastapi import FastAPI
+from fastapi.responses import JSONResponse
 from mcp.server.fastmcp import FastMCP
 from mcp.server.transport_security import TransportSecuritySettings
+from mcp import ClientSession
+from mcp.client.sse import sse_client
 
 from .config import load_config
-from .crypto_client import get_regime
-from .store import get_state, init_db, list_concerns, latest_cycle, latest_regime_samples, prune_older_than, sync_concerns_from_strategies, upsert_cycle, upsert_regime_sample
+from .crypto_client import get_price, get_regime
+from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_regime_sample
 from .trader_client import list_strategies
 
 mcp = FastMCP(
@@ -62,7 +67,7 @@ async def lifespan(_: FastAPI):
                         cycle_id=cycle_id,
                         concern_id=str(concern["id"]),
                         timeframe=timeframe,
-                        regime_json=str(regime),
+                        regime_json=json.dumps(regime, ensure_ascii=False),
                         captured_at=datetime.now(timezone.utc).isoformat(),
                     )
             upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
@@ -84,3 +89,151 @@ def root() -> dict:
 @app.get("/health")
 def health() -> dict:
     return {"status": "ok", "db": "sqlite", "tool": "report"}
+
+
+def _strip_sse(url: str) -> str:
+    root = url.rstrip("/")
+    return root[:-8] if root.endswith("/mcp/sse") else root
+
+
+async def _call_exec_tool(exec_url: str, tool: str, arguments: dict) -> object:
+    async with sse_client(exec_url) as (read_stream, write_stream):
+        async with ClientSession(read_stream, write_stream) as session:
+            await session.initialize()
+            result = await session.call_tool(tool, arguments)
+            content = getattr(result, "content", None) or []
+            if not content:
+                return None
+            first = content[0]
+            text = getattr(first, "text", None) if not isinstance(first, dict) else first.get("text")
+            if text is None:
+                return None
+            try:
+                return json.loads(text)
+            except Exception:
+                return text
+
+
+async def _load_exec_enrichment(exec_url: str, crypto_url: str, concerns: list[dict]) -> tuple[dict[str, dict], dict[str, dict], dict[str, float | None]]:
+    account_ids = sorted({str(c.get("account_id") or "").strip() for c in concerns if str(c.get("account_id") or "").strip()})
+    market_symbols = sorted({str(c.get("market_symbol") or "").strip().lower() for c in concerns if str(c.get("market_symbol") or "").strip()})
+    account_payloads = await asyncio.gather(*[_call_exec_tool(exec_url, "get_account_info", {"account_id": account_id}) for account_id in account_ids])
+    market_payload = await _call_exec_tool(exec_url, "list_markets", {})
+    accounts_by_id = {account_id: payload for account_id, payload in zip(account_ids, account_payloads) if isinstance(payload, dict)}
+    total_values: dict[str, float | None] = {}
+    for account_id, payload in accounts_by_id.items():
+        total_values[account_id] = await _live_total_value(crypto_url, payload)
+    markets_by_symbol: dict[str, dict] = {}
+    if isinstance(market_payload, list):
+        for market in market_payload:
+            if not isinstance(market, dict):
+                continue
+            symbol = str(market.get("symbol") or market.get("market_symbol") or "").strip().lower()
+            if symbol in market_symbols or symbol:
+                markets_by_symbol[symbol] = market
+    return accounts_by_id, markets_by_symbol, total_values
+
+
+async def _live_total_value(crypto_url: str, account_info: dict) -> float | None:
+    balances = account_info.get("balances")
+    if not isinstance(balances, list):
+        return None
+    total = 0.0
+    seen = False
+    for item in balances:
+        if not isinstance(item, dict):
+            continue
+        asset = str(item.get("asset_code") or item.get("asset") or "").strip().lower()
+        amount = item.get("total")
+        if not asset or amount is None:
+            continue
+        try:
+            amount_f = float(amount)
+        except Exception:
+            continue
+        seen = True
+        if asset == "usd":
+            total += amount_f
+            continue
+        price_payload = await get_price(crypto_url, asset)
+        try:
+            price = float(price_payload.get("price"))
+        except Exception:
+            price = 0.0
+        total += amount_f * price
+    return total if seen else None
+
+
+def _compact_balances(payload: object) -> str:
+    if not isinstance(payload, list):
+        return "-"
+    parts: list[str] = []
+    for item in payload:
+        if not isinstance(item, dict):
+            continue
+        asset = str(item.get("asset_code") or item.get("asset") or "").upper().strip()
+        total = item.get("total")
+        available = item.get("available")
+        value_usd = item.get("value_usd")
+        if not asset:
+            continue
+        segment = f"{asset} {float(total):.8g}" if total is not None else asset
+        if available is not None and total is not None and available != total:
+            segment += f" (avail {float(available):.8g})"
+        if isinstance(value_usd, (int, float)):
+            segment += f" ≈ ${float(value_usd):,.2f}"
+        parts.append(segment)
+    return " | ".join(parts[:5]) or "-"
+
+
+@app.get("/dashboard/data")
+def dashboard_data() -> JSONResponse:
+    cfg = load_config()
+    concerns = list_concerns()
+    accounts_by_id: dict[str, dict] = {}
+    markets_by_symbol: dict[str, dict] = {}
+    try:
+        accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
+    except Exception:
+        total_values = {}
+        pass
+    enriched = []
+    concern_lookup: dict[str, dict] = {}
+    for concern in concerns:
+        account_id = str(concern.get("account_id") or "").strip()
+        market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+        account_info = accounts_by_id.get(account_id, {})
+        market_info = markets_by_symbol.get(market_symbol, {})
+        enriched.append({
+            **concern,
+            "account_display": account_info.get("display_name") or account_id,
+            "balances": account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or [],
+            "balance_summary": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
+            "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
+            "market_display": market_info.get("name") or concern.get("market_symbol") or "",
+            "market_description": market_info.get("description") or "",
+        })
+        concern_lookup[str(concern.get("id") or "")] = enriched[-1]
+    regimes = []
+    histories_by_key: dict[str, list[dict]] = {}
+    for sample in recent_regime_samples(1000):
+        concern_id = str(sample.get("concern_id") or "")
+        timeframe = str(sample.get("timeframe") or "")
+        key = f"{concern_id}::{timeframe}"
+        bucket = histories_by_key.setdefault(key, [])
+        if len(bucket) < 24:
+            bucket.append(sample)
+    for sample in latest_regime_samples(20):
+        concern_meta = concern_lookup.get(str(sample.get("concern_id") or ""), {})
+        regimes.append({**sample, **{
+            "account_display": concern_meta.get("account_display"),
+            "market_display": concern_meta.get("market_display"),
+            "market_symbol": concern_meta.get("market_symbol"),
+        }})
+    return JSONResponse({
+        "latest_cycle": latest_cycle(),
+        "cycles": latest_cycles(10),
+        "concerns": enriched,
+        "regime_samples": regimes,
+        "regime_histories": histories_by_key,
+    })

+ 14 - 0
src/hermes_mcp/store.py

@@ -299,6 +299,13 @@ def latest_cycle() -> dict[str, Any] | None:
     return dict(row) if row else None
 
 
+def latest_cycles(limit: int = 20) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from cycles order by started_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]
+
+
 def upsert_cycle(*, id: str, started_at: str, finished_at: str | None, status: str, trigger: str, notes: str | None = None) -> None:
     init_db()
     with _connect() as conn:
@@ -340,3 +347,10 @@ def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
     with _connect() as conn:
         rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
     return [dict(r) for r in rows]
+
+
+def recent_regime_samples(limit: int = 200) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]