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
+
HL
+
LIVE
+
+
+
-
-
-
-
Conviction Signals LIVE
-
+
+
+
+
+
+
Hyperliquid Account
+
+
+
+
+
+
+
+
Conviction Signals LIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Place Order
+
+
+
+
+
+
+
+
+ Connect your HL wallet above to trade.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
Performance
-
-
-
-
-
—
-
Max Drawdown
+
Pipeline
+
+
+
📡
+
data_agent
+
HL WS → TimescaleDB
+
+
+
🧠
+
signal_agent
+
candles → conviction
+
+
+
⚡
+
exec_agent
+
signals → HL orders
+
+
+
🛡️
+
risk_agent
+
circuit breakers
+
-
-
-
Place Order
-
+
+
+
+
+
Connect Hyperliquid Wallet
+
Enter your Hyperliquid wallet address to connect. Your wallet is read-only — orders are signed server-side using your configured API key.
+
+
-
-
-
-
+
+ Detected:
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Trading configuration. The HL API key is set server-side by your administrator.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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