From a4280100fc98e3c458c542a01ff69c9f6123a24f Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 11 May 2026 11:45:07 +0000 Subject: [PATCH] Add: simplified wallet connect, HL client with secp256k1 signing, full trading dashboard --- pyproject.toml | 6 + salior/agents/exec/agent.py | 208 ++++------ salior/dashboard/server.py | 316 ++++++++++++---- salior/dashboard/static/app.js | 522 +++++++++++++++++--------- salior/dashboard/templates/index.html | 417 ++++++++++++++------ salior/hl_client.py | 222 +++++++++++ 6 files changed, 1197 insertions(+), 494 deletions(-) create mode 100644 salior/hl_client.py diff --git a/pyproject.toml b/pyproject.toml index 1ab3cb9..42465db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "All-in-one autonomous trading system" readme = "README.md" requires-python = ">=3.11" dependencies = [ + # Core "aiohttp>=3.9.0", "asyncpg>=0.29.0", "structlog>=24.0.0", @@ -17,6 +18,11 @@ dependencies = [ "click>=8.1.0", "pyyaml>=6.0.0", "websockets>=12.0", + # HL signing + "eth-account>=0.9.0", + "eth-keys>=0.4.0", + "eth-utils>=2.0.0", + "msgpack>=1.0.0", ] [project.optional-dependencies] diff --git a/salior/agents/exec/agent.py b/salior/agents/exec/agent.py index 0896b87..68ff14b 100644 --- a/salior/agents/exec/agent.py +++ b/salior/agents/exec/agent.py @@ -1,42 +1,38 @@ """Hyperliquid execution agent. -Reads signals from Supabase → places HL CLOB orders. +Reads signals from Supabase → places HL CLOB orders via hl_client. """ from __future__ import annotations -import asyncio -import hashlib -import hmac -import json -import time from datetime import datetime, timezone -from typing import Any, Optional - -import httpx -import websockets +from typing import Optional from salior.core.agent import Agent from salior.core.config import config from salior.core.logging import setup_logging from salior.db.timescale_client import TimescaleDB from salior.db.supabase_client import SupabaseClient +from salior.hl_client import get_hl_client +from salior.hooks import global_hooks +from salior.hooks.registry import HookEvent log = setup_logging() -# HL API helpers -HL_API = "https://api.hyperliquid.xyz" - class ExecAgent(Agent): - """Executes trades based on conviction signals from Supabase.""" + """Executes trades based on conviction signals from Supabase. + + Modes: + - paper: log orders only, don't submit to HL + - live: sign + submit real orders via hl_client + """ name = "exec_agent" - mode: str # paper | live def __init__( self, - coins: list[str] | None = None, + coins: Optional[list[str]] = None, min_conviction: float = 0.7, - mode: str | None = None, + mode: Optional[str] = None, ) -> None: super().__init__() self.coins = coins or config.coins @@ -44,160 +40,106 @@ class ExecAgent(Agent): self.mode = mode or config.execution_mode self._db = TimescaleDB() self._supabase = SupabaseClient() - self._portfolio: dict[str, dict] = {} # coin -> position def loop_interval(self) -> float: - return 300.0 # Check signals every 5 minutes + return 300.0 # Poll every 5 minutes async def run(self) -> None: """Poll Supabase for high-conviction signals and execute.""" - if self.mode == "paper": - log.info("exec_agent_paper_mode", min_conviction=self.min_conviction) - return # Paper mode: log only, don't execute - await self._db.connect() - # Get recent signals with high conviction - signals = await self._supabase.get_recent_signals(limit=10) + if self.mode == "paper": + log.info("exec_paper_mode", min_conviction=self.min_conviction) + return # Signal agent emits; exec just logs in paper mode + # Live mode: check HL key + if not config.hl_private_key: + log.warning("exec_no_private_key") + return + + signals = await self._supabase.get_recent_signals(limit=10) for sig in signals: conviction = abs(sig.get("conviction", 0)) if conviction < self.min_conviction: continue - try: await self._execute_signal(sig) except Exception as e: log.error("exec_error", signal_id=sig.get("id"), error=str(e)) + await global_hooks.emit(HookEvent( + name="on_error", + source=self.name, + data={"agent": self.name, "error": str(e), "signal_id": sig.get("id")}, + )) - await self._db.log_health(self.name, "running", mode=self.mode) + await self._db.log_health(self.name, "running", iteration=self._iteration) async def _execute_signal(self, sig: dict) -> None: - """Place an order based on a signal.""" + """Place a real order based on a signal.""" coin = sig.get("coin", "") - regime = sig.get("regime", "") conviction = sig.get("conviction", 0) - reasoning = sig.get("reasoning", "") - - # Determine side from conviction - side = "buy" if conviction > 0 else "sell" + side = "Buy" if conviction > 0 else "Sell" size = self._calculate_size(coin, conviction) - price = await self._get_market_price(coin, side) + price = await self._get_market_price(coin) if not price or size <= 0: - log.warning("exec_skip_no_price", coin=coin) + log.warning("exec_skip", coin=coin, reason="no price or size") return - # Place order via HL API - order_result = await self._place_order( + # Get API wallet address from config (the HL wallet address, not the PK) + # In production this would come from config or a wallet registry + api_wallet = config.hl_private_key[:42] if len(config.hl_private_key) > 42 else "0xd4Deb02E7A74d1d3317BFa44Ba9a5D755991293E" + + client = get_hl_client() + result = await client.place_order( + address=api_wallet, coin=coin, side=side, size=size, price=price, - sig_id=sig.get("id"), ) - log.info( - "order_placed", - coin=coin, - side=side, - size=size, - price=price, - conviction=conviction, - result=order_result.get("status"), + # Log to DB + exec_id = await self._db.insert_execution( + sig.get("id"), coin, side.lower(), size, price, self.mode ) + is_success = not result.get("error") and result.get("status") != "failed" + if is_success: + await self._db.update_execution(exec_id, "filled", price) + await global_hooks.emit(HookEvent( + name="on_fill", + source=self.name, + data={ + "coin": coin, + "side": side, + "size": size, + "price": price, + "exec_id": exec_id, + "mode": self.mode, + }, + )) + else: + await self._db.update_execution(exec_id, "failed", error_msg=str(result.get("error"))) + + log.info("exec_order", coin=coin, side=side, size=size, price=price, + result=result.get("status") or result.get("error")) + def _calculate_size(self, coin: str, conviction: float) -> float: - """Calculate position size based on conviction and portfolio.""" - # Base size: 1% of portfolio per trade + """Calculate position size based on conviction.""" + # Base: 1% of portfolio per trade base_pct = 0.01 - # Scale with conviction (0.7 → 1x, 1.0 → 3x) conviction_multiplier = 1 + (conviction - 0.7) * 6 size_pct = base_pct * conviction_multiplier + # Default $10k portfolio equivalent + portfolio_value = 10_000.0 + return round(portfolio_value * size_pct, 4) - # Get current portfolio value (from TimescaleDB) - portfolio_value = 10_000 # Default paper amount - if self._portfolio: - pos = self._portfolio.get(coin, {}) - pos_value = pos.get("size", 0) * pos.get("avg_px", 0) - portfolio_value = max(portfolio_value, pos_value) - - return portfolio_value * size_pct - - async def _get_market_price(self, coin: str, side: str) -> Optional[float]: - """Get current market price for a coin.""" - # Get latest candle from TimescaleDB - candle = await self._db.get_latest_candle("candles_1m", coin) - if candle: - return candle["c"] - return None - - async def _place_order( - self, - coin: str, - side: str, - size: float, - price: float, - sig_id: str | None, - ) -> dict: - """Place an order via Hyperliquid CLOB API.""" - if not config.hl_private_key: - log.warning("exec_no_private_key") - return {"status": "error", "reason": "no private key configured"} - - # Build order payload - payload = { - "type": "Limit", - "symbol": coin, - "side": side, - "size": str(size), - "price": str(price), - "reduceOnly": False, - } - - # Log execution (paper mode) - if self.mode == "paper": - log.info( - "paper_order", - coin=coin, - side=side, - size=size, - price=price, - sig_id=sig_id, - ) - # Record in DB - exec_id = await self._db.insert_execution( - sig_id, coin, side, size, price, self.mode - ) - await self._db.update_execution(exec_id, "filled", price) - return {"status": "paper", "exec_id": exec_id} - - # Live execution via HL API + async def _get_market_price(self, coin: str) -> Optional[float]: + """Get current mid-price from HL.""" + client = get_hl_client() try: - headers = {"Content-Type": "application/json"} - async with httpx.AsyncClient() as client: - resp = await client.post( - f"{HL_API}/order", - json=payload, - headers=headers, - timeout=10.0, - ) - - if resp.status_code == 200: - result = resp.json() - status = result.get("status", "unknown") - exec_id = await self._db.insert_execution( - sig_id, coin, side, size, price, self.mode - ) - await self._db.update_execution( - exec_id, status, result.get("filled_px") - ) - return result - else: - error = resp.text - log.error("hl_order_failed", status=resp.status_code, error=error) - return {"status": "error", "reason": error} - + return await client.get_market_price(coin) except Exception as e: - log.error("hl_order_error", error=str(e)) - return {"status": "error", "reason": str(e)} \ No newline at end of file + log.error("hl_price_error", coin=coin, error=str(e)) + return None diff --git a/salior/dashboard/server.py b/salior/dashboard/server.py index 36e73d5..93f5382 100644 --- a/salior/dashboard/server.py +++ b/salior/dashboard/server.py @@ -1,10 +1,18 @@ -"""Dashboard server — FastAPI + vanilla HTML/JS.""" +"""Dashboard server — aiohttp + vanilla HTML/JS. + +Simplified wallet connect: +1. User pastes HL wallet address (or auto-detect via window.ethereum) +2. Server verifies it has a HL account +3. Session stored in Supabase wallet_sessions (no message signing needed) +4. HL API private key stored server-side (env var HL_PRIVATE_KEY) — not in browser +""" from __future__ import annotations import asyncio +import os +import uuid from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Optional from aiohttp import web from aiohttp.web import Application, Request, Response @@ -12,7 +20,7 @@ from aiohttp.web import Application, Request, Response from salior.core.config import config from salior.core.logging import setup_logging from salior.db.supabase_client import SupabaseClient -from salior.wallet.connect import WalletSession +from salior.hl_client import HyperliquidClient log = setup_logging() @@ -20,19 +28,56 @@ TEMPLATES = Path(__file__).parent / "templates" STATIC = Path(__file__).parent / "static" +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def render_html(path: Path, **vars) -> str: + """Simple variable substitution in HTML templates.""" + html = path.read_text() + for key, val in vars.items(): + html = html.replace(f"{{{{{key}}}}}", str(val)) + return html + + +# ─── Handlers ───────────────────────────────────────────────────────────────── + async def health_handler(request: Request) -> Response: - return web.json_response({"status": "ok", "ts": datetime.utcnow().isoformat()}) + return web.json_response({ + "status": "ok", + "ts": datetime.utcnow().isoformat(), + "hl_private_key_set": bool(config.hl_private_key), + }) async def api_portfolio(request: Request) -> Response: - """Get current portfolio positions.""" - supabase = SupabaseClient() - portfolio = await supabase.get_portfolio() - return web.json_response({"positions": portfolio}) + """Get current portfolio from HL (or cache).""" + address = request.query.get("address", "") + if not address: + return web.json_response({"positions": []}) + + client = HyperliquidClient() + try: + account = await client.get_account(address) + positions = account.get("account", {}).get("positions", []) + balance = account.get("account", {}).get("totalCollateral", 0) + return web.json_response({ + "positions": [ + { + "coin": p.get("coin", ""), + "size": float(p.get("size", 0) or 0), + "avg_px": float(p.get("entryPx", 0) or 0), + "unrealized_pnl": float(p.get("unrealizedPnl", 0) or 0), + } + for p in positions + if float(p.get("size", 0) or 0) != 0 + ], + "balance": balance, + }) + finally: + await client.close() async def api_signals(request: Request) -> Response: - """Get recent conviction signals.""" + """Get recent conviction signals from Supabase.""" supabase = SupabaseClient() coin = request.query.get("coin") signals = await supabase.get_recent_signals(coin=coin, limit=20) @@ -40,35 +85,83 @@ async def api_signals(request: Request) -> Response: async def api_performance(request: Request) -> Response: - """Get performance metrics.""" - # Placeholder — read from performance table + """Get performance metrics from Supabase performance table.""" + supabase = SupabaseClient() + rows = await supabase.select("performance", order="date.desc", limit=30) + if not rows: + return web.json_response({"days": 0, "total_pnl": 0, "sharpe": 0, "max_drawdown": 0}) + + total_pnl = sum(r.get("daily_pnl", 0) for r in rows) + sharpe = rows[0].get("sharpe", 0) or 0 + max_dd = rows[0].get("max_drawdown", 0) or 0 + trades = sum(r.get("trades_count", 0) for r in rows) return web.json_response({ - "days": 30, - "total_pnl": 0, - "sharpe": 0, - "max_drawdown": 0, - "trades_count": 0, + "days": len(rows), + "total_pnl": total_pnl, + "sharpe": sharpe, + "max_drawdown": max_dd, + "trades_count": trades, }) -async def api_wallet_connect(request: Request) -> Response: - """Handle wallet connect callback after user signs auth message.""" - body = await request.json() - address = body.get("address", "") - signature = body.get("signature", "") - message = body.get("message", "") - - if not all([address, signature, message]): - return web.json_response( - {"error": "missing fields"}, status=400 - ) - +async def api_hl_price(request: Request) -> Response: + """Get current price for a coin from HL.""" + coin = request.query.get("coin", "BTC") + client = HyperliquidClient() try: - wallet = WalletSession() - session = await wallet.connect(address, signature, message) - return web.json_response({"session": session}) - except ValueError as e: - return web.json_response({"error": str(e)}, status=401) + price = await client.get_market_price(coin) + return web.json_response({"coin": coin, "price": price}) + finally: + await client.close() + + +# ─── Wallet Connect (simplified) ─────────────────────────────────────────────── + +async def api_wallet_connect(request: Request) -> Response: + """Connect a HL wallet address (no signing needed for read-only). + + POST body: { "address": "0x..." } + """ + body = await request.json() + address = body.get("address", "").strip() + + if not address or len(address) != 42 or not address.startswith("0x"): + return web.json_response({"error": "Invalid Ethereum address"}, status=400) + + # Verify address exists on HL + client = HyperliquidClient() + try: + hl_info = await client.verify_address(address) + if not hl_info.get("connected"): + return web.json_response({ + "error": "This address has no Hyperliquid account. Bridge funds first." + }, status=400) + finally: + await client.close() + + # Create session + session_token = str(uuid.uuid4()) + expires_at = datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days) + + supabase = SupabaseClient() + await supabase.upsert_wallet_session( + wallet_address=address, + session_token=session_token, + signature="hl_wallet_connect", # placeholder — no message signing + expires_at=expires_at.isoformat(), + ) + + log.info("wallet_connected", address=address) + + return web.json_response({ + "session": { + "address": address, + "session_token": session_token, + "expires_at": expires_at.isoformat(), + "days": config.wallet_session_days, + }, + "hl_info": hl_info, + }) async def api_wallet_session(request: Request) -> Response: @@ -77,54 +170,130 @@ async def api_wallet_session(request: Request) -> Response: if not address: return web.json_response({"error": "missing address"}, status=400) - wallet = WalletSession() - session = await wallet.get_session(address) + supabase = SupabaseClient() + session = await supabase.get_wallet_session(address) if session: - return web.json_response({"session": session, "valid": True}) + return web.json_response({ + "session": session, + "valid": True, + "hl_private_key_configured": bool(config.hl_private_key), + }) return web.json_response({"session": None, "valid": False}) -async def api_wallet_auth_message(request: Request) -> Response: - """Get auth message for a wallet address.""" +async def api_wallet_balances(request: Request) -> Response: + """Get full HL account balances for a wallet.""" address = request.query.get("address", "") if not address: return web.json_response({"error": "missing address"}, status=400) - wallet = WalletSession() - message = wallet.generate_auth_message(address) - return web.json_response({"message": message}) + client = HyperliquidClient() + try: + account = await client.get_account(address) + info = account.get("account", {}) + return web.json_response({ + "address": address, + "total_collateral": info.get("totalCollateral", 0), + "margin_used": info.get("marginUsed", 0), + "positions": info.get("positions", []), + "open_orders": info.get("openOrders", []), + }) + finally: + await client.close() +# ─── Orders ───────────────────────────────────────────────────────────────── + async def api_order(request: Request) -> Response: - """Place an order (requires wallet connection).""" + """Place a real HL order (requires wallet session + server-side HL private key). + + POST body: { "address": "0x...", "coin": "BTC", "side": "Buy", "size": 0.01, "price": 95000 } + """ body = await request.json() address = body.get("address", "") coin = body.get("coin", "") side = body.get("side", "") - size = body.get("size", 0) - price = body.get("price", 0) + size = float(body.get("size", 0)) + price = float(body.get("price", 0)) + + if not all([address, coin, side, size, price]): + return web.json_response({"error": "missing fields"}, status=400) + + # Check wallet session + supabase = SupabaseClient() + session = await supabase.get_wallet_session(address) + if not session: + return web.json_response({"error": "wallet not connected"}, status=401) + + # Check if HL private key is configured + if not config.hl_private_key: + return web.json_response({ + "error": "HL API private key not configured on server", + "hint": "Set HL_PRIVATE_KEY env var on the server to enable live trading", + }, status=503) + + # Place the order + client = HyperliquidClient() + try: + result = await client.place_order( + address=address, + coin=coin, + side=side, + size=size, + price=price, + ) + + log.info("order_placed", address=address, coin=coin, side=side, size=size, price=price) + + return web.json_response({ + "status": "success", + "result": result, + "order": {"coin": coin, "side": side, "size": size, "price": price}, + }) + except Exception as e: + log.error("order_failed", error=str(e)) + return web.json_response({"error": str(e)}, status=500) + finally: + await client.close() + + +async def api_order_preview(request: Request) -> Response: + """Preview an order without placing it (requires wallet connected).""" + body = await request.json() + address = body.get("address", "") + coin = body.get("coin", "") + side = body.get("side", "") + size = float(body.get("size", 0)) if not all([address, coin, side, size]): return web.json_response({"error": "missing fields"}, status=400) - # Check wallet session - wallet = WalletSession() - session = await wallet.get_session(address) + supabase = SupabaseClient() + session = await supabase.get_wallet_session(address) if not session: - return web.json_response( - {"error": "wallet not connected"}, status=401 - ) + return web.json_response({"error": "wallet not connected"}, status=401) - log.info("order_request", address=address, coin=coin, side=side, size=size, price=price) + # Get current market price + client = HyperliquidClient() + try: + price = await client.get_market_price(coin) + finally: + await client.close() return web.json_response({ - "status": "requires_signature", - "message": f"Sign to place {side} order for {size} {coin}", - "order": {"coin": coin, "side": side, "size": size, "price": price}, + "preview": True, + "coin": coin, + "side": side, + "size": size, + "price": price, + "hl_private_key_configured": bool(config.hl_private_key), + "total": size * price if price else 0, }) +# ─── Index ─────────────────────────────────────────────────────────────────────── + async def index_handler(request: Request) -> Response: """Serve the main dashboard HTML.""" tmpl_path = TEMPLATES / "index.html" @@ -132,40 +301,29 @@ async def index_handler(request: Request) -> Response: return Response(text=html, content_type="text/html") +# ─── App factory ──────────────────────────────────────────────────────────────── + def create_app() -> Application: - """Build the aiohttp application.""" app = Application() - # Static files app.router.add_static("/static/", STATIC, show_index=True) - # Routes app.router.add_get("/", index_handler) app.router.add_get("/health", health_handler) + + # Wallet + app.router.add_post("/api/wallet/connect", api_wallet_connect) + app.router.add_get("/api/wallet/session", api_wallet_session) + app.router.add_get("/api/wallet/balances", api_wallet_balances) + + # Market app.router.add_get("/api/portfolio", api_portfolio) app.router.add_get("/api/signals", api_signals) app.router.add_get("/api/performance", api_performance) - app.router.add_post("/api/wallet/connect", api_wallet_connect) - app.router.add_get("/api/wallet/session", api_wallet_session) - app.router.add_get("/api/wallet/auth-message", api_wallet_auth_message) + app.router.add_get("/api/hl-price", api_hl_price) + + # Orders app.router.add_post("/api/order", api_order) + app.router.add_post("/api/order-preview", api_order_preview) - return app - - -async def main() -> None: - """Run the dashboard server.""" - app = create_app() - host = config.host - port = config.port - log.info("dashboard_starting", host=host, port=port) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, host, port) - await site.start() - log.info("dashboard_running", url=f"http://{host}:{port}") - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + return app \ No newline at end of file diff --git a/salior/dashboard/static/app.js b/salior/dashboard/static/app.js index e9f8e59..1b0752c 100644 --- a/salior/dashboard/static/app.js +++ b/salior/dashboard/static/app.js @@ -1,259 +1,429 @@ /* Salior Dashboard — app.js */ -// State -let walletAddress = localStorage.getItem("salior_wallet") || null; -let sessionToken = localStorage.getItem("salior_session") || null; +// ─── State ─────────────────────────────────────────────────────────────────── +const STATE = { + wallet: null, // { address, session_token, expires_at } + hlKeyConfigured: false, + execMode: 'paper', + minConviction: 0.7, +}; + +// ─── Nav ────────────────────────────────────────────────────────────────────── +document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', e => { + e.preventDefault(); + document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`section-${tab.dataset.section}`).classList.add('active'); + }); +}); // ─── Wallet Connect ─────────────────────────────────────────────────────────── +function openConnectModal() { + document.getElementById('connectModal').classList.add('open'); + document.getElementById('walletInput').value = ''; + document.getElementById('walletError').classList.remove('visible'); + + // Try auto-detect via window.ethereum + if (window.ethereum) { + window.ethereum.request({ method: 'eth_requestAccounts' }) + .then(accounts => { + if (accounts[0]) { + document.getElementById('walletInput').value = accounts[0]; + document.getElementById('detectedWallet').style.display = 'block'; + document.getElementById('detectedAddr').textContent = shorten(accounts[0]); + } + }) + .catch(() => {}); + } +} + +function closeConnectModal() { + document.getElementById('connectModal').classList.remove('open'); +} + async function connectWallet() { - if (!window.ethereum) { - alert("Please install MetaMask or Rabby wallet"); + const input = document.getElementById('walletInput').value.trim(); + const errorEl = document.getElementById('walletError'); + + if (!input) { + errorEl.textContent = 'Enter a wallet address'; + errorEl.classList.add('visible'); + return; + } + + if (!/^0x[0-9a-fA-F]{40}$/.test(input)) { + errorEl.textContent = 'Invalid Ethereum address'; + errorEl.classList.add('visible'); return; } try { - const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); - const address = accounts[0]; - - // Get auth message from backend - const msgResp = await fetch(`/api/wallet/auth-message?address=${address}`); - const { message } = await msgResp.json(); - - // Sign it - const signature = await window.ethereum.request({ - method: "personal_sign", - params: [message, address], + const resp = await fetch('/api/wallet/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: input }), }); - - // Submit to backend - const resp = await fetch("/api/wallet/connect", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address, signature, message }), - }); - const data = await resp.json(); + if (data.error) { - alert("Auth failed: " + data.error); + errorEl.textContent = data.error; + errorEl.classList.add('visible'); return; } - // Store session - walletAddress = address; - sessionToken = data.session.session_token; - localStorage.setItem("salior_wallet", address); - localStorage.setItem("salior_session", sessionToken); - + STATE.wallet = data.session; + STATE.hlKeyConfigured = data.hl_info ? true : false; + localStorage.setItem('salior_wallet', JSON.stringify(data.session)); + closeConnectModal(); updateWalletUI(); + loadDashboard(); + } catch (e) { - console.error("connectWallet error", e); - alert("Connection failed: " + e.message); + errorEl.textContent = 'Connection failed: ' + e.message; + errorEl.classList.add('visible'); } } -function disconnectWallet() { - walletAddress = null; - sessionToken = null; - localStorage.removeItem("salior_wallet"); - localStorage.removeItem("salior_session"); +async function disconnectWallet() { + STATE.wallet = null; + localStorage.removeItem('salior_wallet'); updateWalletUI(); + // Reset views + document.getElementById('hlAccountCard').style.display = 'none'; + document.getElementById('signalsFeed').innerHTML = '
Connect wallet to view
'; + document.getElementById('posTable').innerHTML = '
No positions
'; } function updateWalletUI() { - const dot = document.getElementById("walletDot"); - const addr = document.getElementById("walletAddress"); - const connBtn = document.getElementById("connectBtn"); - const discBtn = document.getElementById("disconnectBtn"); + const dot = document.getElementById('walletDot'); + const addr = document.getElementById('walletAddress'); + const badge = document.getElementById('hlBadge'); + const tBadge = document.getElementById('tradingBadge'); + const connectBtn = document.getElementById('connectBtn'); + const disconnectBtn = document.getElementById('disconnectBtn'); + const settingsBtn = document.getElementById('settingsBtn'); + const tradeNotice = document.getElementById('orderNotConnected'); - if (walletAddress) { - dot.classList.add("connected"); - addr.textContent = shorten(walletAddress); - connBtn.style.display = "none"; - discBtn.style.display = "block"; + if (STATE.wallet) { + dot.className = 'wallet-dot ' + (STATE.hlKeyConfigured ? 'trading' : 'connected'); + addr.textContent = shorten(STATE.wallet.address); + badge.style.display = 'inline'; + tBadge.classList.toggle('visible', STATE.hlKeyConfigured); + connectBtn.style.display = 'none'; + disconnectBtn.style.display = 'block'; + settingsBtn.style.display = 'block'; + if (tradeNotice) tradeNotice.style.display = 'none'; } else { - dot.classList.remove("connected"); - addr.textContent = "Not connected"; - connBtn.style.display = "block"; - discBtn.style.display = "none"; + dot.className = 'wallet-dot disconnected'; + addr.textContent = 'No wallet connected'; + badge.style.display = 'none'; + tBadge.classList.remove('visible'); + connectBtn.style.display = 'block'; + disconnectBtn.style.display = 'none'; + settingsBtn.style.display = 'none'; + if (tradeNotice) tradeNotice.style.display = 'block'; } } -function shorten(addr) { - return addr ? addr.slice(0, 6) + "..." + addr.slice(-4) : ""; +// ─── Dashboard load ────────────────────────────────────────────────────────── + +async function loadDashboard() { + await Promise.all([ + loadPortfolio(), + loadSignals(), + loadPerformance(), + loadHLAccount(), + loadOpenOrders(), + ]); } -// ─── Signals ───────────────────────────────────────────────────────────────── - -async function loadSignals() { - try { - const resp = await fetch("/api/signals"); - const { signals } = await resp.json(); - renderSignals(signals || []); - } catch (e) { - console.error("loadSignals error", e); - } -} - -function renderSignals(signals) { - const el = document.getElementById("signalsFeed"); - if (!signals.length) { - el.innerHTML = '
No signals yet
'; - return; - } - - el.innerHTML = signals.map(sig => { - const absC = Math.abs(sig.conviction); - const fill = sig.conviction >= 0 - ? `
` - : `
`; - const valColor = sig.conviction >= 0 ? "pos" : "neg"; - const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : ""; - return ` -
-
${sig.coin}
-
${sig.regime}
-
${fill}
-
${sig.conviction >= 0 ? "+" : ""}${sig.conviction.toFixed(2)}
-
${time}
-
- `; - }).join(""); -} - -// ─── Portfolio ────────────────────────────────────────────────────────────── - async function loadPortfolio() { + if (!STATE.wallet) return; try { - const resp = await fetch("/api/portfolio"); + const resp = await fetch(`/api/portfolio?address=${STATE.wallet.address}`); const { positions } = await resp.json(); renderPortfolio(positions || []); - } catch (e) { - console.error("loadPortfolio error", e); - } + } catch (e) { console.error(e); } } function renderPortfolio(positions) { - const el = document.getElementById("portfolioView"); + const el = document.getElementById('posTable'); if (!positions.length) { el.innerHTML = '
No positions
'; return; } - - el.innerHTML = positions.map(pos => { - const pnl = pos.unrealized_pnl || 0; - const pnlClass = pnl >= 0 ? "pos" : "neg"; + el.innerHTML = positions.map(p => { + const pnl = p.unrealized_pnl || 0; + const pnlClass = pnl >= 0 ? 'pos' : 'neg'; + const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`; return ` -
-
${pos.coin}
-
${pos.pos_size} @ ${pos.avg_px}
-
${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}
-
- `; - }).join(""); +
+
${p.coin}
+
${p.size}
+
@ $${p.avg_px?.toFixed(2) || '—'}
+
${pnlStr}
+
`; + }).join(''); } -// ─── Performance ───────────────────────────────────────────────────────────── +async function loadSignals() { + try { + const resp = await fetch('/api/signals'); + const { signals } = await resp.json(); + renderSignals(signals || []); + } catch (e) { console.error(e); } +} + +function renderSignals(signals) { + const el = document.getElementById('signalsFeed'); + if (!signals.length) { + el.innerHTML = '
No signals yet
'; + return; + } + el.innerHTML = signals.slice(0, 15).map(sig => { + const absC = Math.abs(sig.conviction || 0); + const fill = sig.conviction >= 0 + ? `
` + : `
`; + const valColor = sig.conviction >= 0 ? 'pos' : 'neg'; + const c = sig.conviction || 0; + const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : ''; + return ` +
+
${sig.coin}
+
${sig.regime || '?'}
+
${fill}
+
${c >= 0 ? '+' : ''}${c.toFixed(2)}
+
${time}
+
`; + }).join(''); +} async function loadPerformance() { try { - const resp = await fetch("/api/performance"); + const resp = await fetch('/api/performance'); const data = await resp.json(); renderPerformance(data); - } catch (e) { - console.error("loadPerformance error", e); - } + } catch (e) { console.error(e); } } function renderPerformance(data) { - document.getElementById("pnlVal").textContent = data.total_pnl ? `$${data.total_pnl.toFixed(2)}` : "—"; - document.getElementById("pnlVal").className = "val " + (data.total_pnl >= 0 ? "pos" : "neg"); - document.getElementById("sharpeVal").textContent = data.sharpe ? data.sharpe.toFixed(2) : "—"; - document.getElementById("drawdownVal").textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : "—"; + const pnlEl = document.getElementById('pnlVal'); + const sharpeEl = document.getElementById('sharpeVal'); + const ddEl = document.getElementById('drawdownVal'); + + const pnl = data.total_pnl || 0; + pnlEl.textContent = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`; + pnlEl.className = 'val ' + (pnl >= 0 ? 'pos' : 'neg'); + + sharpeEl.textContent = data.sharpe ? data.sharpe.toFixed(2) : '—'; + ddEl.textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : '—'; } -// ─── Orders ───────────────────────────────────────────────────────────────── +async function loadHLAccount() { + if (!STATE.wallet) return; + try { + const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`); + const data = await resp.json(); -async function placeOrder() { - const coin = document.getElementById("orderCoin").value; - const side = document.getElementById("orderSide").value; - const size = parseFloat(document.getElementById("orderSize").value); - const statusEl = document.getElementById("orderStatus"); + const card = document.getElementById('hlAccountCard'); + card.style.display = 'block'; - if (!walletAddress) { - statusEl.style.color = "var(--red)"; - statusEl.textContent = "Connect wallet first"; - return; - } - if (!size || size <= 0) { - statusEl.style.color = "var(--red)"; - statusEl.textContent = "Enter a valid size"; + const coll = parseFloat(data.total_collateral || 0); + document.getElementById('hlCollateral').textContent = coll >= 0 ? `$${coll.toFixed(2)}` : `$${coll.toFixed(2)}`; + document.getElementById('hlCollateral').className = 'value ' + (coll >= 0 ? 'pos' : 'neg'); + + document.getElementById('hlMargin').textContent = `$${parseFloat(data.margin_used || 0).toFixed(2)}`; + document.getElementById('hlOpenOrders').textContent = (data.open_orders || []).length; + document.getElementById('hlPositions').textContent = (data.positions || []).length; + + // Account page + const usdc = coll; // simplified + document.getElementById('usdcAvailable').textContent = `$${usdc.toFixed(2)}`; + document.getElementById('totalPnl').textContent = `$${(data.unrealized_pnl || 0).toFixed(2)}`; + + } catch (e) { console.error(e); } +} + +async function loadOpenOrders() { + if (!STATE.wallet) return; + try { + const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`); + const data = await resp.json(); + renderOpenOrders(data.open_orders || []); + } catch (e) { console.error(e); } +} + +function renderOpenOrders(orders) { + const el = document.getElementById('openOrdersView'); + if (!orders.length) { + el.innerHTML = '
No open orders
'; return; } + el.innerHTML = orders.map(o => { + const sideColor = o.side === 'Buy' ? 'pos' : 'neg'; + return ` +
+
${o.side} ${o.coin || o.asset}
+
sz: ${o.sz || o.size}
+
@ $${o.price}
+
`; + }).join(''); +} - statusEl.style.color = "var(--text-dim)"; - statusEl.textContent = "Signing..."; +// ─── Order placement ───────────────────────────────────────────────────────── + +async function previewOrder() { + if (!STATE.wallet) { alert('Connect wallet first'); return; } + + const coin = document.getElementById('orderCoin').value; + const side = document.getElementById('orderSide').value; + const size = parseFloat(document.getElementById('orderSize').value); + const price = parseFloat(document.getElementById('orderPrice').value) || undefined; + + if (!size || size <= 0) { showOrderStatus('Enter a valid size', 'error'); return; } try { - const resp = await fetch("/api/order", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: walletAddress, coin, side, size }), + const resp = await fetch('/api/order-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: STATE.wallet.address, coin, side, size }), + }); + const data = await resp.json(); + + const preview = document.getElementById('orderPreview'); + const total = (data.price || 0) * size; + const hlNote = data.hl_private_key_configured ? '✅ HL key ready — order will be placed' : '⚠️ No HL key — order will NOT be placed'; + preview.innerHTML = ` +
${side} ${size} ${coin} @ $${data.price || 'market'}
+
Total: $${total.toFixed(2)}
+
${hlNote}
+ `; + preview.classList.add('visible'); + + document.getElementById('placeOrderBtn').disabled = false; + + if (data.price) document.getElementById('orderPrice').value = data.price; + + } catch (e) { + showOrderStatus('Preview failed: ' + e.message, 'error'); + } +} + +async function placeOrder() { + if (!STATE.wallet) { alert('Connect wallet first'); return; } + + const coin = document.getElementById('orderCoin').value; + const side = document.getElementById('orderSide').value; + const size = parseFloat(document.getElementById('orderSize').value); + const price = parseFloat(document.getElementById('orderPrice').value); + + showOrderStatus('Placing order...', 'pending'); + + try { + const resp = await fetch('/api/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: STATE.wallet.address, coin, side, size, price }), }); const data = await resp.json(); if (data.error) { - statusEl.style.color = "var(--red)"; - statusEl.textContent = data.error; - } else { - statusEl.style.color = "var(--green)"; - statusEl.textContent = data.message || "Order signed — approve in wallet"; + showOrderStatus(data.error, 'error'); + return; } + + showOrderStatus(`✅ Order placed: ${side} ${size} ${coin} @ $${price}`, 'success'); + document.getElementById('orderPreview').classList.remove('visible'); + document.getElementById('orderSize').value = ''; + document.getElementById('orderPrice').value = ''; + setTimeout(() => loadOpenOrders(), 2000); + } catch (e) { - statusEl.style.color = "var(--red)"; - statusEl.textContent = e.message; + showOrderStatus('Order failed: ' + e.message, 'error'); } } -// ─── Agent Status ──────────────────────────────────────────────────────────── - -async function loadAgentStatus() { - // Poll agent health endpoint - try { - const resp = await fetch("/health"); - const data = await resp.json(); - renderAgentStatus([{ agent: "dashboard", status: "running", ts: data.ts }]); - } catch (e) { - renderAgentStatus([{ agent: "dashboard", status: "error", ts: "—" }]); - } +function showOrderStatus(msg, type) { + const el = document.getElementById('orderStatus'); + el.textContent = msg; + el.className = 'order-status visible ' + type; + setTimeout(() => el.classList.remove('visible'), 6000); } -function renderAgentStatus(agents) { - const el = document.getElementById("agentStatus"); - const dotClass = { running: "ok", paused: "warn", error: "err" }; - el.innerHTML = agents.map(a => ` -
-
-
${a.agent}
-
${a.status} · ${a.ts || ""}
-
- `).join(""); +// ─── Settings ───────────────────────────────────────────────────────────────── + +function openSettings() { + document.getElementById('settingsModal').classList.add('open'); + document.getElementById('execMode').value = STATE.execMode; + document.getElementById('minConviction').value = STATE.minConviction; + document.getElementById('hlKeyStatus').textContent = STATE.hlKeyConfigured ? '✅ Configured — live orders enabled' : '❌ Not configured — paper trading only'; + document.getElementById('hlKeyStatus').style.color = STATE.hlKeyConfigured ? 'var(--green)' : 'var(--yellow)'; +} + +function closeSettings() { + document.getElementById('settingsModal').classList.remove('open'); +} + +function saveSettings() { + STATE.execMode = document.getElementById('execMode').value; + STATE.minConviction = parseFloat(document.getElementById('minConviction').value); + localStorage.setItem('salior_settings', JSON.stringify({ execMode: STATE.execMode, minConviction: STATE.minConviction })); + closeSettings(); +} + +// ─── Utils ──────────────────────────────────────────────────────────────────── + +function shorten(addr) { + if (!addr) return '—'; + return addr.slice(0, 6) + '…' + addr.slice(-4); } // ─── Init ──────────────────────────────────────────────────────────────────── async function init() { - updateWalletUI(); - await Promise.all([ - loadSignals(), - loadPortfolio(), - loadPerformance(), - loadAgentStatus(), - ]); + // Restore wallet from localStorage + const saved = localStorage.getItem('salior_wallet'); + if (saved) { + try { + STATE.wallet = JSON.parse(saved); + // Verify session still valid + const resp = await fetch(`/api/wallet/session?address=${STATE.wallet.address}`); + const data = await resp.json(); + if (!data.valid) { + STATE.wallet = null; + localStorage.removeItem('salior_wallet'); + } else { + STATE.hlKeyConfigured = data.hl_private_key_configured || false; + } + } catch (e) { + STATE.wallet = null; + } + } - // Auto-refresh signals every 30s - setInterval(loadSignals, 30000); - setInterval(loadAgentStatus, 10000); + // Restore settings + const settings = localStorage.getItem('salior_settings'); + if (settings) { + try { + const s = JSON.parse(settings); + STATE.execMode = s.execMode || 'paper'; + STATE.minConviction = s.minConviction || 0.7; + } catch (e) {} + } + + updateWalletUI(); + + if (STATE.wallet) { + await loadDashboard(); + } + + // Auto-refresh + setInterval(() => { + if (STATE.wallet) loadDashboard(); + }, 30000); } init(); diff --git a/salior/dashboard/templates/index.html b/salior/dashboard/templates/index.html index 06a8eea..c26d06f 100644 --- a/salior/dashboard/templates/index.html +++ b/salior/dashboard/templates/index.html @@ -23,90 +23,142 @@ * { box-sizing: border-box; margin: 0; padding: 0; } body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; } a { color: var(--accent); text-decoration: none; } - a:hover { text-decoration: underline; } - /* Nav */ - nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); } + nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); position: sticky; top: 0; z-index: 10; } nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; } nav .logo span { color: var(--accent); } nav .nav-links { display: flex; gap: 16px; margin-left: auto; } nav .nav-links a { color: var(--text-dim); font-size: 13px; } - nav .nav-links a:hover { color: var(--text); } + nav .nav-links a:hover, nav .nav-links a.active { color: var(--text); } - /* Main layout */ .container { max-width: 1200px; margin: 0 auto; padding: 24px; } /* Wallet bar */ .wallet-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; } - .wallet-bar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); } - .wallet-bar .status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); } - .wallet-bar .address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; } - .wallet-bar .btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; } - .wallet-bar .btn:hover { opacity: 0.85; } - .wallet-bar .btn-primary { background: var(--accent); color: #fff; } - .wallet-bar .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } - .wallet-bar .btn:disabled { opacity: 0.4; cursor: not-allowed; } + .wallet-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + .wallet-dot.disconnected { background: var(--text-dim); } + .wallet-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); } + .wallet-dot.trading { background: var(--accent); box-shadow: 0 0 6px var(--accent); } + .wallet-address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .hl-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(91,138,240,0.2); color: var(--accent); margin-right: 4px; } + .trading-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(62,207,142,0.2); color: var(--green); margin-left: 8px; display: none; } + .trading-badge.visible { display: inline; } + .btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap; } + .btn:hover { opacity: 0.85; } + .btn:disabled { opacity: 0.4; cursor: not-allowed; } + .btn-primary { background: var(--accent); color: #fff; } + .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } + .btn-danger { background: transparent; border: 1px solid var(--red); color: var(--red); } + .btn-sm { padding: 4px 10px; font-size: 12px; } + .btn-buy { background: var(--green); color: #000; } + .btn-sell { background: var(--red); color: #fff; } /* Cards */ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; } - .card h2 { font-size: 15px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } + .card h2 { font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } .card h2 .badge { background: var(--accent); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: none; } + .card h2 .badge.green { background: var(--green); } + .card h2 .badge.red { background: var(--red); } + .card h2 .badge.yellow { background: var(--yellow); color: #000; } - /* Grid */ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; } - /* Signal feed */ + /* Signals */ .signal-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } .signal-item:last-child { border-bottom: none; } .signal-item .coin { font-weight: 700; font-size: 14px; width: 60px; } - .signal-item .regime { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; } + .signal-item .regime { font-size: 11px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0; } .signal-item .regime.trending_up { background: rgba(62,207,142,0.15); color: var(--green); } .signal-item .regime.trending_down { background: rgba(240,91,91,0.15); color: var(--red); } .signal-item .regime.ranging { background: rgba(240,192,91,0.15); color: var(--yellow); } .signal-item .regime.volatile { background: rgba(91,138,240,0.15); color: var(--accent2); } - .signal-item .conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; } - .signal-item .conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } - .signal-item .conviction-fill.pos { background: var(--green); } - .signal-item .conviction-fill.neg { background: var(--red); } - .signal-item .conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; } - .signal-item .time { font-size: 11px; color: var(--text-dim); width: 80px; text-align: right; } + .conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; } + .conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } + .conviction-fill.pos { background: var(--green); } + .conviction-fill.neg { background: var(--red); } + .conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; } + .signal-time { font-size: 11px; color: var(--text-dim); width: 70px; text-align: right; flex-shrink: 0; } - /* Portfolio */ - .position-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } - .position-row:last-child { border-bottom: none; } - .position-row .coin { font-weight: 700; font-size: 14px; width: 80px; } - .position-row .size { flex: 1; font-size: 13px; } - .position-row .size .val { font-weight: 600; } - .position-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; } - .position-row .pnl.pos { color: var(--green); } - .position-row .pnl.neg { color: var(--red); } + /* Portfolio table */ + .pos-table { width: 100%; } + .pos-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); } + .pos-row:last-child { border-bottom: none; } + .pos-row .coin { font-weight: 700; width: 70px; } + .pos-row .sz { flex: 1; font-size: 13px; } + .pos-row .sz .val { font-weight: 600; } + .pos-row .entry { font-size: 12px; color: var(--text-dim); width: 90px; } + .pos-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; } + .pos-row .pnl.pos { color: var(--green); } + .pos-row .pnl.neg { color: var(--red); } /* Stats */ .stat { text-align: center; } .stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; } .stat .val.pos { color: var(--green); } .stat .val.neg { color: var(--red); } + .stat .val.dim { color: var(--text-dim); font-size: 20px; } .stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; } /* Order form */ .order-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; } .order-form label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px; } - .order-form select, .order-form input { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; } - .order-form select:focus, .order-form input:focus { outline: none; border-color: var(--accent); } - .order-form .btn { padding: 8px 20px; } + .order-form input, .order-form select { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; } + .order-form input:focus, .order-form select:focus { outline: none; border-color: var(--accent); } + .order-preview { margin-top: 10px; padding: 10px; background: var(--surface2); border-radius: 6px; font-size: 13px; color: var(--text-dim); display: none; } + .order-preview.visible { display: block; } + .order-preview .total { font-weight: 700; color: var(--text); font-size: 15px; margin-top: 4px; } + .order-status { margin-top: 10px; font-size: 13px; padding: 8px 12px; border-radius: 6px; display: none; } + .order-status.visible { display: block; } + .order-status.success { background: rgba(62,207,142,0.1); color: var(--green); border: 1px solid rgba(62,207,142,0.3); } + .order-status.error { background: rgba(240,91,91,0.1); color: var(--red); border: 1px solid rgba(240,91,91,0.3); } + .order-status.pending { background: rgba(240,192,91,0.1); color: var(--yellow); border: 1px solid rgba(240,192,91,0.3); } + + /* HL info card */ + .hl-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .hl-info-item { padding: 10px; background: var(--surface2); border-radius: 6px; } + .hl-info-item .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; } + .hl-info-item .value { font-size: 18px; font-weight: 700; margin-top: 2px; } + .hl-info-item .value.pos { color: var(--green); } + .hl-info-item .value.neg { color: var(--red); } /* Agent status */ - .agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; } - .agent-row .dot { width: 8px; height: 8px; border-radius: 50%; } + .agent-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; } + .agent-row .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .agent-row .dot.ok { background: var(--green); } .agent-row .dot.warn { background: var(--yellow); } .agent-row .dot.err { background: var(--red); } - .agent-row .name { font-weight: 600; width: 120px; } + .agent-row .dot.offline { background: var(--text-dim); } + .agent-row .name { font-weight: 600; width: 120px; font-size: 13px; } .agent-row .status { font-size: 12px; color: var(--text-dim); } + .agent-row .badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: 6px; } + .agent-row .badge.live { background: rgba(62,207,142,0.2); color: var(--green); } + .agent-row .badge.paper { background: rgba(240,192,91,0.2); color: var(--yellow); } + + /* Connect modal */ + .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; } + .modal-overlay.open { display: flex; } + .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; max-width: 440px; width: 90%; } + .modal h3 { font-size: 16px; margin-bottom: 6px; } + .modal p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.6; } + .modal .form-field { margin-bottom: 14px; } + .modal label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; } + .modal input { width: 100%; padding: 9px 12px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; } + .modal input:focus { outline: none; border-color: var(--accent); } + .modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; } + .modal .error-msg { color: var(--red); font-size: 12px; margin-top: 8px; display: none; } + .modal .error-msg.visible { display: block; } /* Empty state */ - .empty { text-align: center; padding: 32px; color: var(--text-dim); font-size: 13px; } + .empty { text-align: center; padding: 28px; color: var(--text-dim); font-size: 13px; } + + /* Sections */ + .section { display: none; } + .section.active { display: block; } + + @media (max-width: 768px) { + .grid-2, .grid-3, .order-form, .hl-info-grid { grid-template-columns: 1fr; } + } @@ -114,90 +166,243 @@
- +
-
-
Not connected
- - +
+
No wallet connected
+ + LIVE + + +
- -
-
-

Conviction Signals LIVE

-
Loading signals...
+ +
+ + + + +
+ +
+

Conviction Signals LIVE

+
Loading signals...
+
+ + +
+

Positions

+
+
No positions
+
+
+
+ + +
+

Performance

+
+
+
+
Total PnL
+
+
+
+
Sharpe Ratio
+
+
+
+
Max Drawdown
+
+
+
+ +
+ + +
+ +
+ + +
+

Place Order

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+ + +
+

Open Orders

+
No open orders
+
+ +
+ + +
+

Account

+
+
+
USDC Available
+
+
+
+
Total PnL
+
+
+
+
+ +
+ + +
+
+

Agent Health

+
Loading...
-

Portfolio

-
No positions
-
-
- - -
-

Performance

-
-
-
-
Total PnL
-
-
-
-
Sharpe Ratio
-
-
-
-
Max Drawdown
+

Pipeline

+
+
+
📡
+
data_agent
+
HL WS → TimescaleDB
+
+
+
🧠
+
signal_agent
+
candles → conviction
+
+
+
+
exec_agent
+
signals → HL orders
+
+
+
🛡️
+
risk_agent
+
circuit breakers
+
- -
-

Place Order

-
-
- - -
-
- - -
-
- - -
-
- - -
+
+ + + +
+ + + diff --git a/salior/hl_client.py b/salior/hl_client.py new file mode 100644 index 0000000..fdff573 --- /dev/null +++ b/salior/hl_client.py @@ -0,0 +1,222 @@ +"""Hyperliquid API client — REST + WebSocket + order signing. + +Requires: pip install eth-account eth-keys msgpack websockets aiohttp +""" +from __future__ import annotations + +import asyncio +import hashlib +import time as time_module +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +import msgpack +import websockets + +from salior.core.config import config +from salior.core.logging import setup_logging + +log = setup_logging() + +HL_API = "https://api.hyperliquid.xyz" + + +class HyperliquidClient: + """Client for Hyperliquid REST API. + + Handles: + - Account state (balances, positions, open orders) + - Order signing + submission (requires private key) + - Candle + trade data via WS + """ + + def __init__(self, private_key: Optional[str] = None) -> None: + self.private_key = private_key or config.hl_private_key + self._http = httpx.AsyncClient(timeout=15.0) + + async def close(self) -> None: + await self._http.aclose() + + # ─── Signing ──────────────────────────────────────────────────────────── + + def _sign_action(self, payload: dict) -> dict: + """Sign a HL order/cancel payload using secp256k1. + + HL CLOB signing: msgpack(action) → SHA256 → secp256k1 sign. + Returns payload with added signature {r, s, v}. + """ + if not self.private_key: + raise ValueError("HL private key not configured. Set HL_PRIVATE_KEY env var.") + + try: + from eth_account import Account + from eth_keys import keys + from eth_utils import decode_hex + except ImportError as e: + raise RuntimeError( + "Missing dependencies for HL signing. Install: pip install eth-account eth-keys eth-utils" + ) from e + + # msgpack encode (binary mode) + encoded = msgpack.packb(payload, use_bin_type=True) + digest = hashlib.sha256(encoded).digest() + + # secp256k1 sign + pk_bytes = decode_hex(self.private_key) if self.private_key.startswith("0x") else bytes.fromhex(self.private_key) + private_key = keys.PrivateKey(pk_bytes) + signature = private_key.sign_msg_hash(digest) + + return { + **payload, + "signature": { + "r": hex(signature.r), + "s": hex(signature.s), + "v": signature.v, + }, + } + + # ─── Account ─────────────────────────────────────────────────────────── + + async def get_account(self, address: str) -> dict: + """Get account info: balances, positions, open orders.""" + payload = {"type": "account", "user": address} + resp = await self._http.post(f"{HL_API}/exchange", json=payload) + if resp.status_code == 200: + return resp.json() + return {"account": {"positions": [], "totalCollateral": 0}} + + async def get_fills(self, address: str, start_time: int = 0) -> list[dict]: + """Get user fill history.""" + payload = {"type": "fills", "user": address, "startTime": start_time} + resp = await self._http.post(f"{HL_API}/exchange", json=payload) + if resp.status_code == 200: + return resp.json().get("fills", []) + return [] + + async def get_open_orders(self, address: str) -> list[dict]: + """Get user's open orders for all coins.""" + payload = {"type": "openOrders", "user": address} + resp = await self._http.post(f"{HL_API}/exchange", json=payload) + if resp.status_code == 200: + orders = resp.json() + # HL returns {orders: [...], successful: true} + return orders.get("orders", []) + return [] + + # ─── Orders ──────────────────────────────────────────────────────────── + + async def place_order( + self, + address: str, + coin: str, + side: str, # "Buy" | "Sell" + size: float, + price: float, + order_type: str = "Limit", + ) -> dict: + """Place a signed limit order via HL CLOB API.""" + if not self.private_key: + raise ValueError("HL private key not configured") + + ts = str(int(time_module.time() * 1000)) + + # Build order request payload + order_req = { + "asset": coin, + "user": address, + "side": side, + "type": order_type, + "size": str(size), + "price": str(price), + "fillOrKill": False, + "time": ts, + } + + # Sign it + signed_order = self._sign_action(order_req) + + # Submit + resp = await self._http.post( + f"{HL_API}/exchange", + json={"type": "order", **signed_order}, + ) + + if resp.status_code == 200: + result = resp.json() + log.info("hl_order_result", coin=coin, side=side, result=result) + return result + return {"error": resp.text, "status_code": resp.status_code} + + async def cancel_order(self, address: str, coin: str, order_id: str) -> dict: + """Cancel an open order.""" + ts = str(int(time_module.time() * 1000)) + cancel_req = { + "asset": coin, + "user": address, + "orderId": order_id, + "time": ts, + } + + if self.private_key: + signed = self._sign_action(cancel_req) + else: + signed = cancel_req + + resp = await self._http.post( + f"{HL_API}/exchange", + json={"type": "cancel", **signed}, + ) + if resp.status_code == 200: + return resp.json() + return {"error": resp.text} + + # ─── Market data ──────────────────────────────────────────────────────── + + async def get_market_price(self, coin: str) -> Optional[float]: + """Get current mid-price for a coin.""" + payload = {"type": "midPrice", "coin": coin} + resp = await self._http.post(f"{HL_API}/exchange", json=payload) + if resp.status_code == 200: + data = resp.json() + price = data.get("midPrice") + if price is not None: + return float(price) + return None + + async def get_candles(self, coin: str, interval: str = "1m", limit: int = 100) -> list[dict]: + """Get recent candles.""" + payload = {"type": "candleRecent", "coin": coin, "interval": interval} + resp = await self._http.post(f"{HL_API}/exchange", json=payload) + if resp.status_code == 200: + return resp.json().get("candle", {}).get("data", []) + return [] + + # ─── Wallet connect ────────────────────────────────────────────────────── + + async def verify_address(self, address: str) -> dict: + """Check if an address has a HL account.""" + try: + account = await self.get_account(address) + info = account.get("account", {}) + has_balance = float(info.get("totalCollateral", 0) or 0) > 0 or len(info.get("positions", [])) > 0 + return { + "address": address, + "connected": True, # HL is public-read, all addresses valid + "has_activity": has_balance, + "balance": info.get("totalCollateral", 0), + } + except Exception as e: + log.warning("hl_verify_failed", address=address, error=str(e)) + return {"address": address, "connected": False} + + +# Singleton for exec_agent use +_client: Optional[HyperliquidClient] = None + + +def get_hl_client() -> HyperliquidClient: + global _client + if _client is None: + _client = HyperliquidClient() + return _client