Quellcode durchsuchen

Refine dashboard strategy market UI

Lukas Goldschmidt vor 1 Monat
Ursprung
Commit
503ffe98a4
3 geänderte Dateien mit 247 neuen und 28 gelöschten Zeilen
  1. 203 24
      src/trader_mcp/dashboard.py
  2. 13 0
      src/trader_mcp/exec_client.py
  3. 31 4
      src/trader_mcp/strategy_store.py

+ 203 - 24
src/trader_mcp/dashboard.py

@@ -6,10 +6,11 @@ from uuid import uuid4
 from fastapi import APIRouter, Form
 from fastapi.responses import HTMLResponse, RedirectResponse
 
-from .exec_client import list_accounts
+from .exec_client import list_accounts, list_markets
 from .strategy_engine import get_running_strategy, pause_strategy, reconcile_instance, render_strategy, resume_strategy
 from .strategy_registry import get_strategy_default_config, list_available_strategy_modules
 from .strategy_store import add_strategy_instance, delete_strategy_instance, list_strategy_instances, synthesize_client_id, update_strategy_config, update_strategy_mode, update_strategy_name
+from .crypto_client import call_crypto_tool
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
@@ -19,6 +20,33 @@ def dashboard_home():
     accounts = list_accounts()
     strategies = list_strategy_instances()
     available_modules = list_available_strategy_modules()
+    markets = list_markets()
+
+    # human label: "<name> (<description capped>)" when description exists
+    def market_label(m: dict) -> str:
+        name = m.get("name") or m.get("market_symbol") or ""
+        desc = m.get("description") or ""
+        desc = str(desc)
+        if desc:
+            desc = desc[:32]
+            return f"{name} ({desc})"
+        return str(name)
+
+    market_by_symbol = {
+        (m.get("market_symbol") or ""): m
+        for m in (markets or [])
+        if isinstance(m, dict) and m.get("market_symbol")
+    }
+
+    market_options = "".join(
+        f'<option value="{m.get("market_symbol")}">{market_label(m)}</option>'
+        for m in (markets or [])
+        if isinstance(m, dict) and m.get("market_symbol")
+    )
+    if market_options:
+        market_options = '<option value="" disabled selected>Select market</option>' + market_options
+    else:
+        market_options = '<option value="xrpusd" selected>xrpusd (default)</option>'
 
     account_options = "".join(
         f'<option value="{a.get("id")}">{a.get("display_name") or a.get("venue_account_ref") or a.get("id")}</option>'
@@ -32,21 +60,19 @@ def dashboard_home():
         if isinstance(a, dict)
     }
 
+    # Initial snapshot (balances are refreshed client-side via an endpoint).
     account_rows = "".join(
         """
-        <tr>
+        <tr data-account-id="{id}">
           <td>{display_name}</td>
-          <td>{venue}</td>
-          <td>{venue_account_ref}</td>
-          <td>{description}</td>
-          <td>{enabled}</td>
+          <td class="balances">(loading)</td>
+          <td class="total-usd">(loading)</td>
         </tr>
         """.strip().format(
-            display_name=(a.get("display_name") or "") if isinstance(a, dict) else "",
-            venue=(a.get("venue") or "") if isinstance(a, dict) else "",
-            venue_account_ref=(a.get("venue_account_ref") or "") if isinstance(a, dict) else "",
-            description=(a.get("description") or "") if isinstance(a, dict) else "",
-            enabled=("yes" if (a.get("enabled") if isinstance(a, dict) else False) else "no"),
+            id=(a.get("id") or "") if isinstance(a, dict) else "",
+            display_name=(a.get("display_name") or a.get("venue") or a.get("id") or "")
+            if isinstance(a, dict)
+            else "",
         )
         for a in (accounts or [])
     )
@@ -58,8 +84,7 @@ def dashboard_home():
           <td>{name}</td>
           <td>{strategy_type}</td>
           <td>{account_name}</td>
-          <td>{mode}</td>
-          <td>{config}</td>
+          <td>{market_label}</td>
           <td class="actions">
             <button type="button" class="ghost" onclick="toggleDetails('{id}')">Details</button>
             <form method="post" action="/dashboard/strategies/{id}/power"><button type="submit" class="ghost">{power_label}</button></form>
@@ -69,7 +94,7 @@ def dashboard_home():
           </td>
         </tr>
         <tr id="details-{id}" class="detail-row" style="display:none;">
-          <td colspan="7">
+          <td colspan="5">
             <div style="margin-top:10px; display:grid; gap:12px;">
               <div>
                 <strong>Render</strong>
@@ -97,11 +122,18 @@ def dashboard_home():
             activation_disabled="disabled" if s.mode == "off" or (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else "",
             pause_label=("Resume" if (get_running_strategy(s.id).paused if get_running_strategy(s.id) else False) else "Pause"),
             pause_disabled="disabled" if s.mode == "off" else "",
+            market_label=market_label(
+                market_by_symbol.get(s.market_symbol or "")
+                or {"name": s.market_symbol or "", "description": ""}
+            ),
         )
         for s in strategies
     )
 
     module_options = "".join(f'<option value="{m.module_name}">{m.module_name}</option>' for m in available_modules)
+    module_options = '<option value="" disabled selected>Select strategy blueprint</option>' + (module_options or "")
+
+    account_options = '<option value="" disabled selected>Select account</option>' + (account_options or "")
 
     return f"""<!doctype html>
 <html>
@@ -131,8 +163,8 @@ def dashboard_home():
       <h1>Trader MCP Dashboard</h1>
       <p class="muted">Strategies and exec-mcp accounts</p>
 
-      <section>
-        <h2>Strategies</h2>
+      <details id="strategy-form-section">
+        <summary>Add strategy</summary>
         <form method="post" action="/dashboard/strategies/add" style="display:grid; gap:10px; max-width: 720px; margin-top: 12px;">
           <input name="name" placeholder="strategy name, e.g. My super Grid 0.5" required />
           <select name="strategy_type" required>
@@ -141,17 +173,22 @@ def dashboard_home():
           <select name="account_id" required>
             {account_options}
           </select>
+          <select name="market_symbol" required>
+            {market_options}
+          </select>
           <button type="submit">Add strategy</button>
         </form>
+      </details>
 
+      <section>
+        <h2>Strategies</h2>
         <table>
           <tr>
             <th>state</th>
             <th>name</th>
             <th>type</th>
             <th>account</th>
-            <th>mode</th>
-            <th>config</th>
+            <th>market</th>
             <th>actions</th>
           </tr>
           {strategy_rows}
@@ -164,11 +201,9 @@ def dashboard_home():
 
         <table>
           <tr>
-            <th>name</th>
-            <th>venue</th>
-            <th>exchange account ref</th>
-            <th>description</th>
-            <th>enabled</th>
+            <th>account</th>
+            <th>balances</th>
+            <th>total value (USD)</th>
           </tr>
           {account_rows}
         </table>
@@ -244,6 +279,36 @@ def dashboard_home():
           const open = localStorage.getItem('trader-mcp:accounts-section') === 'open';
           accounts.open = open;
         }}
+
+        const strategyForm = document.getElementById('strategy-form-section');
+        if (strategyForm) {{
+          const open = localStorage.getItem('trader-mcp:strategy-form-section') === 'open';
+          strategyForm.open = open;
+        }}
+      }}
+
+      async function refreshAccounts() {{
+        try {{
+          const res = await fetch('/dashboard/accounts/overview');
+          const payload = await res.json();
+          const byId = payload && payload.by_account_id ? payload.by_account_id : {{}};
+
+          document.querySelectorAll('tr[data-account-id]').forEach((row) => {{
+            const accId = row.dataset.accountId;
+            const info = byId[accId];
+            const balancesCell = row.querySelector('td.balances');
+            const totalCell = row.querySelector('td.total-usd');
+            if (!info) {{
+              if (balancesCell) balancesCell.textContent = '';
+              if (totalCell) totalCell.textContent = '';
+              return;
+            }}
+            if (balancesCell) balancesCell.textContent = info.balances_text || '';
+            if (totalCell) totalCell.textContent = info.total_value_usd_formatted || '';
+          }});
+        }} catch (err) {{
+          // best-effort: don’t break the whole dashboard
+        }}
       }}
 
       const accountsSection = document.getElementById('accounts-section');
@@ -253,9 +318,17 @@ def dashboard_home():
         }});
       }}
 
+      const strategyFormSection = document.getElementById('strategy-form-section');
+      if (strategyFormSection) {{
+        strategyFormSection.addEventListener('toggle', () => {{
+          localStorage.setItem('trader-mcp:strategy-form-section', strategyFormSection.open ? 'open' : 'closed');
+        }});
+      }}
+
       restoreDetails();
       refreshAll();
-      setInterval(refreshAll, 6000);
+      refreshAccounts();
+      setInterval(() => {{ refreshAll(); refreshAccounts(); }}, 6000);
     </script>
   </body>
 </html>"""
@@ -266,16 +339,32 @@ def dashboard_strategies_add(
     name: str = Form(...),
     strategy_type: str = Form(...),
     account_id: str = Form(...),
+    market_symbol: str = Form(...),
 ):
     strategy_id = str(uuid4())
     default_config = get_strategy_default_config(strategy_type.strip())
     client_id = synthesize_client_id(strategy_type.strip(), strategy_id, name.strip())
+
+    markets = list_markets()
+    chosen = next((m for m in markets or [] if isinstance(m, dict) and m.get("market_symbol") == market_symbol), None)
+    base_currency = chosen.get("base_currency") if isinstance(chosen, dict) else None
+    counter_currency = chosen.get("counter_currency") if isinstance(chosen, dict) else None
+    # fallback backfill
+    if not base_currency:
+        base_currency = "XRP"
+    if not counter_currency:
+        counter_currency = "USD"
+    market_symbol = market_symbol.strip() if market_symbol else "xrpusd"
+
     add_strategy_instance(
         id=strategy_id,
         strategy_type=strategy_type.strip(),
         account_id=account_id.strip(),
         client_id=client_id,
         mode="off",
+        market_symbol=market_symbol,
+        base_currency=base_currency,
+        counter_currency=counter_currency,
         config=default_config,
     )
     update_strategy_name(strategy_id, name.strip())
@@ -337,3 +426,93 @@ def dashboard_strategies_config(strategy_id: str, config_json: str = Form(...)):
 @router.get("/strategies/{strategy_id}/render")
 def dashboard_strategies_render(strategy_id: str):
     return render_strategy(strategy_id)
+
+
+@router.get("/accounts/overview")
+def dashboard_accounts_overview():
+    accounts = list_accounts()
+    by_account_id: dict[str, dict[str, str | float]] = {}
+
+    for a in accounts or []:
+        if not isinstance(a, dict):
+            continue
+        account_id = a.get("id")
+        if not account_id:
+            continue
+
+        info = None
+        try:
+            # exec-mcp should return: balances[{asset_code, available/total, value_usd, ...}], total_value_usd.
+            info = _safe_get_account_info(account_id)
+        except Exception:
+            info = None
+
+        if not info or not isinstance(info, dict):
+            by_account_id[str(account_id)] = {
+                "balances_text": "",
+                "total_value_usd_formatted": "",
+            }
+            continue
+
+        balances = info.get("balances")
+        balances_parts: list[str] = []
+        if isinstance(balances, list):
+            for b in balances:
+                if not isinstance(b, dict):
+                    continue
+                asset = b.get("asset_code")
+                # use available if present, else total.
+                qty = b.get("available", b.get("total"))
+                try:
+                    qty_str = f"{float(qty):.8g}" if qty is not None else ""
+                except Exception:
+                    qty_str = str(qty) if qty is not None else ""
+                value_usd = b.get("value_usd")
+                if asset and qty_str != "":
+                    balances_parts.append(f"{asset} {qty_str}")
+
+        total_value_usd = 0.0
+        if isinstance(balances, list):
+            for b in balances:
+                if not isinstance(b, dict):
+                    continue
+                asset = str(b.get("asset_code") or "").upper()
+                if not asset:
+                    continue
+                try:
+                    qty = float(b.get("total") if b.get("total") is not None else b.get("available") or 0)
+                except Exception:
+                    qty = 0.0
+
+                if asset in {"EUR", "USD"}:
+                    try:
+                        total_value_usd += float(b.get("value_usd") or 0)
+                    except Exception:
+                        pass
+                    continue
+
+                try:
+                    price_payload = call_crypto_tool("get_price", {"symbol": asset})
+                    price = float(price_payload.get("price")) if isinstance(price_payload, dict) and price_payload.get("price") is not None else 0.0
+                    total_value_usd += qty * price
+                except Exception:
+                    try:
+                        total_value_usd += float(b.get("value_usd") or 0)
+                    except Exception:
+                        pass
+
+        total_value_usd_formatted = f"{total_value_usd:.2f} $" if total_value_usd else ""
+
+        by_account_id[str(account_id)] = {
+            "balances_text": ", ".join(balances_parts),
+            "total_value_usd_formatted": total_value_usd_formatted,
+        }
+
+    return {"by_account_id": by_account_id}
+
+
+def _safe_get_account_info(account_id: str):
+    # isolated helper so we can keep the loop above readable
+    from .exec_client import get_account_info
+
+    return get_account_info(account_id)

+ 13 - 0
src/trader_mcp/exec_client.py

@@ -38,6 +38,19 @@ def list_open_orders(account_id: str, client_id: str | None = None) -> Any:
     return _mcp.call_tool("get_open_orders", args)
 
 
+def get_account_info(account_id: str) -> Any:
+    return _mcp.call_tool("get_account_info", {"account_id": account_id})
+
+
+def list_markets() -> list[dict[str, Any]]:
+    payload = _mcp.call_tool("list_markets", {})
+    if isinstance(payload, list):
+        return payload
+    if isinstance(payload, dict) and "markets" in payload and isinstance(payload["markets"], list):
+        return payload["markets"]
+    return []
+
+
 def cancel_all_orders(account_id: str, client_id: str | None = None) -> Any:
     args: dict[str, Any] = {"account_id": account_id}
     if client_id is not None:

+ 31 - 4
src/trader_mcp/strategy_store.py

@@ -24,6 +24,9 @@ class StrategyRecord:
     account_id: str
     client_id: str | None
     mode: str
+    market_symbol: str | None
+    base_currency: str | None
+    counter_currency: str | None
     config: dict[str, Any]
     started_at: str | None
     activated_at: str | None
@@ -54,6 +57,9 @@ def init_db() -> None:
                 account_id TEXT NOT NULL,
                 client_id TEXT,
                 mode TEXT NOT NULL DEFAULT 'off',
+                market_symbol TEXT,
+                base_currency TEXT,
+                counter_currency TEXT,
                 config_json TEXT NOT NULL DEFAULT '{}',
                 started_at TEXT,
                 activated_at TEXT,
@@ -65,6 +71,24 @@ def init_db() -> None:
         columns = {row[1] for row in conn.execute("PRAGMA table_info(strategy_instances)").fetchall()}
         if "name" not in columns:
             conn.execute("ALTER TABLE strategy_instances ADD COLUMN name TEXT NOT NULL DEFAULT ''")
+        if "market_symbol" not in columns:
+            conn.execute("ALTER TABLE strategy_instances ADD COLUMN market_symbol TEXT")
+        if "base_currency" not in columns:
+            conn.execute("ALTER TABLE strategy_instances ADD COLUMN base_currency TEXT")
+        if "counter_currency" not in columns:
+            conn.execute("ALTER TABLE strategy_instances ADD COLUMN counter_currency TEXT")
+
+        # Backfill missing market identity for existing rows.
+        # You can treat this as a lightweight bootstrap; it won’t override rows that already have market set.
+        conn.execute(
+            """
+            UPDATE strategy_instances
+            SET
+              market_symbol = COALESCE(market_symbol, 'xrpusd'),
+              base_currency = COALESCE(base_currency, 'XRP'),
+              counter_currency = COALESCE(counter_currency, 'USD')
+            """
+        )
         conn.commit()
 
 
@@ -82,7 +106,7 @@ def get_strategy_instance(instance_id: str) -> StrategyRecord | None:
     return _row_to_record(row) if row else None
 
 
-def add_strategy_instance(*, id: str, strategy_type: str, account_id: str, client_id: str | None = None, mode: str = "off", config: dict[str, Any] | None = None, started_at: str | None = None, activated_at: str | None = None) -> StrategyRecord:
+def add_strategy_instance(*, id: str, strategy_type: str, account_id: str, client_id: str | None = None, mode: str = "off", market_symbol: str | None = None, base_currency: str | None = None, counter_currency: str | None = None, config: dict[str, Any] | None = None, started_at: str | None = None, activated_at: str | None = None) -> StrategyRecord:
     init_db()
     now = _utc_now()
     config = config or {}
@@ -90,10 +114,10 @@ def add_strategy_instance(*, id: str, strategy_type: str, account_id: str, clien
         conn.execute(
             """
             INSERT INTO strategy_instances
-            (id, name, strategy_type, account_id, client_id, mode, config_json, started_at, activated_at, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            (id, name, strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, config_json, started_at, activated_at, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             """,
-            (id, "", strategy_type, account_id, client_id, mode, json.dumps(config), started_at, activated_at, now, now),
+            (id, "", strategy_type, account_id, client_id, mode, market_symbol, base_currency, counter_currency, json.dumps(config), started_at, activated_at, now, now),
         )
         conn.commit()
     return get_strategy_instance(id)  # type: ignore[return-value]
@@ -157,6 +181,9 @@ def _row_to_record(row: sqlite3.Row | None) -> StrategyRecord | None:
         account_id=row["account_id"],
         client_id=row["client_id"],
         mode=row["mode"],
+        market_symbol=row["market_symbol"] if "market_symbol" in row.keys() else None,
+        base_currency=row["base_currency"] if "base_currency" in row.keys() else None,
+        counter_currency=row["counter_currency"] if "counter_currency" in row.keys() else None,
         config=json.loads(row["config_json"] or "{}"),
         started_at=row["started_at"],
         activated_at=row["activated_at"],