Add: simplified wallet connect, HL client with secp256k1 signing, full trading dashboard

This commit is contained in:
Hermes 2026-05-11 11:45:07 +00:00
parent 3e72c0a805
commit a4280100fc
6 changed files with 1197 additions and 494 deletions

View File

@ -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]

View File

@ -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)}
log.error("hl_price_error", coin=coin, error=str(e))
return None

View File

@ -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", "")
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:
price = await client.get_market_price(coin)
return web.json_response({"coin": coin, "price": price})
finally:
await client.close()
if not all([address, signature, message]):
return web.json_response(
{"error": "missing fields"}, status=400
# ─── 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(),
)
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)
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())

View File

@ -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 = '<div class="empty">Connect wallet to view</div>';
document.getElementById('posTable').innerHTML = '<div class="empty">No positions</div>';
}
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 = '<div class="empty">No signals yet</div>';
return;
}
el.innerHTML = signals.map(sig => {
const absC = Math.abs(sig.conviction);
const fill = sig.conviction >= 0
? `<div class="conviction-fill pos" style="width:${absC*100}%"></div>`
: `<div class="conviction-fill neg" style="width:${absC*100}%"></div>`;
const valColor = sig.conviction >= 0 ? "pos" : "neg";
const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : "";
return `
<div class="signal-item">
<div class="coin">${sig.coin}</div>
<div class="regime ${sig.regime}">${sig.regime}</div>
<div class="conviction-bar">${fill}</div>
<div class="conviction-val ${valColor}">${sig.conviction >= 0 ? "+" : ""}${sig.conviction.toFixed(2)}</div>
<div class="time">${time}</div>
</div>
`;
}).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 = '<div class="empty">No positions</div>';
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 `
<div class="position-row">
<div class="coin">${pos.coin}</div>
<div class="size"><span class="val">${pos.pos_size}</span> @ ${pos.avg_px}</div>
<div class="pnl ${pnlClass}">${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}</div>
</div>
`;
}).join("");
<div class="pos-row">
<div class="coin">${p.coin}</div>
<div class="sz"><span class="val">${p.size}</span></div>
<div class="entry">@ $${p.avg_px?.toFixed(2) || '—'}</div>
<div class="pnl ${pnlClass}">${pnlStr}</div>
</div>`;
}).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 = '<div class="empty">No signals yet</div>';
return;
}
el.innerHTML = signals.slice(0, 15).map(sig => {
const absC = Math.abs(sig.conviction || 0);
const fill = sig.conviction >= 0
? `<div class="conviction-fill pos" style="width:${absC*100}%"></div>`
: `<div class="conviction-fill neg" style="width:${absC*100}%"></div>`;
const valColor = sig.conviction >= 0 ? 'pos' : 'neg';
const c = sig.conviction || 0;
const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : '';
return `
<div class="signal-item">
<div class="coin">${sig.coin}</div>
<div class="regime ${sig.regime || 'ranging'}">${sig.regime || '?'}</div>
<div class="conviction-bar">${fill}</div>
<div class="conviction-val ${valColor}">${c >= 0 ? '+' : ''}${c.toFixed(2)}</div>
<div class="signal-time">${time}</div>
</div>`;
}).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 = '<div class="empty">No open orders</div>';
return;
}
el.innerHTML = orders.map(o => {
const sideColor = o.side === 'Buy' ? 'pos' : 'neg';
return `
<div class="pos-row">
<div class="coin" style="color:var(--${sideColor === 'pos' ? 'green' : 'red'})">${o.side} ${o.coin || o.asset}</div>
<div class="sz">sz: <span class="val">${o.sz || o.size}</span></div>
<div class="entry">@ $${o.price}</div>
</div>`;
}).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 = `
<div>${side} <strong>${size}</strong> ${coin} @ <strong>$${data.price || 'market'}</strong></div>
<div class="total">Total: $${total.toFixed(2)}</div>
<div style="font-size:11px; margin-top:4px; color:var(--text-dim);">${hlNote}</div>
`;
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 => `
<div class="agent-row">
<div class="dot ${dotClass[a.status] || "warn"}"></div>
<div class="name">${a.agent}</div>
<div class="status">${a.status} · ${a.ts || ""}</div>
</div>
`).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();

View File

@ -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; }
}
</style>
</head>
<body>
@ -114,56 +166,97 @@
<nav>
<div class="logo">sali<span>or</span></div>
<div class="nav-links">
<a href="#signals">Signals</a>
<a href="#portfolio">Portfolio</a>
<a href="#performance">Performance</a>
<a href="#agents">Agents</a>
<a href="#" class="nav-tab active" data-section="dashboard">Dashboard</a>
<a href="#" class="nav-tab" data-section="trade">Trade</a>
<a href="#" class="nav-tab" data-section="agents">Agents</a>
</div>
</nav>
<div class="container">
<!-- Wallet Bar -->
<!-- Wallet Bar (always visible) -->
<div class="wallet-bar">
<div class="status-dot" id="walletDot"></div>
<div class="address" id="walletAddress">Not connected</div>
<button class="btn btn-primary" id="connectBtn" onclick="connectWallet()">Connect Wallet</button>
<button class="btn btn-outline" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
<div class="wallet-dot disconnected" id="walletDot"></div>
<div class="wallet-address" id="walletAddress">No wallet connected</div>
<span class="hl-badge" id="hlBadge" style="display:none">HL</span>
<span class="trading-badge" id="tradingBadge">LIVE</span>
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="openSettings()" style="display:none">Settings</button>
<button class="btn btn-primary btn-sm" id="connectBtn" onclick="openConnectModal()">Connect Wallet</button>
<button class="btn btn-outline btn-sm" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
</div>
<!-- ─── Dashboard section ─── -->
<div class="section active" id="section-dashboard">
<!-- HL Account Summary (shown when connected) -->
<div class="card" id="hlAccountCard" style="display:none">
<h2>Hyperliquid Account</h2>
<div class="hl-info-grid">
<div class="hl-info-item">
<div class="label">Total Collateral</div>
<div class="value" id="hlCollateral"></div>
</div>
<div class="hl-info-item">
<div class="label">Margin Used</div>
<div class="value" id="hlMargin"></div>
</div>
</div>
<div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div class="hl-info-item">
<div class="label">Open Orders</div>
<div class="value dim" id="hlOpenOrders"></div>
</div>
<div class="hl-info-item">
<div class="label">Positions</div>
<div class="value dim" id="hlPositions"></div>
</div>
</div>
</div>
<!-- Signals -->
<div class="grid-2">
<div class="card" id="signals-card">
<h2>Conviction Signals <span class="badge">LIVE</span></h2>
<!-- Signals -->
<div class="card">
<h2>Conviction Signals <span class="badge" id="signalBadge">LIVE</span></h2>
<div id="signalsFeed"><div class="empty">Loading signals...</div></div>
</div>
<!-- Portfolio -->
<div class="card">
<h2>Portfolio</h2>
<div id="portfolioView"><div class="empty">No positions</div></div>
<h2>Positions</h2>
<div class="pos-table" id="posTable">
<div class="empty">No positions</div>
</div>
</div>
</div>
<!-- Performance -->
<div class="card" id="performance">
<div class="card">
<h2>Performance</h2>
<div class="grid-3">
<div class="stat">
<div class="val" id="pnlVal"></div>
<div class="val dim" id="pnlVal"></div>
<div class="lbl">Total PnL</div>
</div>
<div class="stat">
<div class="val" id="sharpeVal"></div>
<div class="val dim" id="sharpeVal"></div>
<div class="lbl">Sharpe Ratio</div>
</div>
<div class="stat">
<div class="val" id="drawdownVal"></div>
<div class="val dim" id="drawdownVal"></div>
<div class="lbl">Max Drawdown</div>
</div>
</div>
</div>
<!-- Order Form -->
<div class="card" id="portfolio">
</div>
<!-- ─── Trade section ─── -->
<div class="section" id="section-trade">
<div class="grid-2">
<!-- Place Order -->
<div class="card">
<h2>Place Order</h2>
<div class="order-form">
<div>
@ -176,8 +269,8 @@
<div>
<label>Side</label>
<select id="orderSide">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
<option value="Buy">Buy / Long</option>
<option value="Sell">Sell / Short</option>
</select>
</div>
<div>
@ -185,21 +278,133 @@
<input type="number" id="orderSize" placeholder="0.01" step="0.001" min="0.001" />
</div>
<div>
<label>&nbsp;</label>
<button class="btn btn-primary btn" onclick="placeOrder()">Place Order</button>
<label>Price</label>
<input type="number" id="orderPrice" placeholder="Market" step="0.01" />
</div>
</div>
<div id="orderStatus" style="margin-top:12px;font-size:13px;color:var(--text-dim);"></div>
<div class="order-preview" id="orderPreview"></div>
<div style="margin-top:12px; display:flex; gap:8px;">
<button class="btn btn-primary" id="previewBtn" onclick="previewOrder()">Preview</button>
<button class="btn btn-primary" id="placeOrderBtn" onclick="placeOrder()" disabled>Place Order</button>
</div>
<div class="order-status" id="orderStatus"></div>
<div id="orderNotConnected" class="empty" style="display:none; padding:16px;">
Connect your HL wallet above to trade.
</div>
</div>
<!-- Agent Status -->
<div class="card" id="agents">
<!-- Open Orders -->
<div class="card">
<h2>Open Orders</h2>
<div id="openOrdersView"><div class="empty">No open orders</div></div>
</div>
</div>
<!-- Balance -->
<div class="card">
<h2>Account</h2>
<div class="hl-info-grid">
<div class="hl-info-item">
<div class="label">USDC Available</div>
<div class="value" id="usdcAvailable"></div>
</div>
<div class="hl-info-item">
<div class="label">Total PnL</div>
<div class="value" id="totalPnl"></div>
</div>
</div>
</div>
</div>
<!-- ─── Agents section ─── -->
<div class="section" id="section-agents">
<div class="card">
<h2>Agent Health</h2>
<div id="agentStatus"><div class="empty">Loading...</div></div>
</div>
<div class="card">
<h2>Pipeline</h2>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:12px; text-align:center; font-size:12px; color:var(--text-dim);">
<div>
<div style="font-size:20px; margin-bottom:4px;">📡</div>
<div style="font-weight:600;">data_agent</div>
<div>HL WS → TimescaleDB</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;">🧠</div>
<div style="font-weight:600;">signal_agent</div>
<div>candles → conviction</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;"></div>
<div style="font-weight:600;">exec_agent</div>
<div>signals → HL orders</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;">🛡️</div>
<div style="font-weight:600;">risk_agent</div>
<div>circuit breakers</div>
</div>
</div>
</div>
</div>
</div>
<!-- ─── Connect Modal ─── -->
<div class="modal-overlay" id="connectModal">
<div class="modal">
<h3>Connect Hyperliquid Wallet</h3>
<p>Enter your Hyperliquid wallet address to connect. Your wallet is read-only — orders are signed server-side using your configured API key.</p>
<div class="form-field">
<label>HL Wallet Address</label>
<input type="text" id="walletInput" placeholder="0x..." maxlength="42" />
<div class="error-msg" id="walletError"></div>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-bottom:4px; display:none" id="detectedWallet">
Detected: <span id="detectedAddr"></span>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeConnectModal()">Cancel</button>
<button class="btn btn-primary" onclick="connectWallet()">Connect</button>
</div>
</div>
</div>
<!-- ─── Settings Modal ─── -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<h3>Settings</h3>
<p>Trading configuration. The HL API key is set server-side by your administrator.</p>
<div class="form-field">
<label>Execution Mode</label>
<select id="execMode">
<option value="paper">Paper Trading</option>
<option value="live">Live Trading</option>
</select>
</div>
<div class="form-field">
<label>Min Conviction to Trade</label>
<input type="number" id="minConviction" value="0.7" step="0.05" min="0.3" max="1.0" />
</div>
<div class="form-field">
<label>HL API Key Configured</label>
<div id="hlKeyStatus" style="font-size:13px; color:var(--green);">✅ Configured</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeSettings()">Close</button>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

222
salior/hl_client.py Normal file
View File

@ -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