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" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
# Core
"aiohttp>=3.9.0", "aiohttp>=3.9.0",
"asyncpg>=0.29.0", "asyncpg>=0.29.0",
"structlog>=24.0.0", "structlog>=24.0.0",
@ -17,6 +18,11 @@ dependencies = [
"click>=8.1.0", "click>=8.1.0",
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"websockets>=12.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] [project.optional-dependencies]

View File

@ -1,42 +1,38 @@
"""Hyperliquid execution agent. """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 from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Optional
import httpx
import websockets
from salior.core.agent import Agent from salior.core.agent import Agent
from salior.core.config import config from salior.core.config import config
from salior.core.logging import setup_logging from salior.core.logging import setup_logging
from salior.db.timescale_client import TimescaleDB from salior.db.timescale_client import TimescaleDB
from salior.db.supabase_client import SupabaseClient 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() log = setup_logging()
# HL API helpers
HL_API = "https://api.hyperliquid.xyz"
class ExecAgent(Agent): 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" name = "exec_agent"
mode: str # paper | live
def __init__( def __init__(
self, self,
coins: list[str] | None = None, coins: Optional[list[str]] = None,
min_conviction: float = 0.7, min_conviction: float = 0.7,
mode: str | None = None, mode: Optional[str] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.coins = coins or config.coins self.coins = coins or config.coins
@ -44,160 +40,106 @@ class ExecAgent(Agent):
self.mode = mode or config.execution_mode self.mode = mode or config.execution_mode
self._db = TimescaleDB() self._db = TimescaleDB()
self._supabase = SupabaseClient() self._supabase = SupabaseClient()
self._portfolio: dict[str, dict] = {} # coin -> position
def loop_interval(self) -> float: 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: async def run(self) -> None:
"""Poll Supabase for high-conviction signals and execute.""" """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() await self._db.connect()
# Get recent signals with high conviction if self.mode == "paper":
signals = await self._supabase.get_recent_signals(limit=10) 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: for sig in signals:
conviction = abs(sig.get("conviction", 0)) conviction = abs(sig.get("conviction", 0))
if conviction < self.min_conviction: if conviction < self.min_conviction:
continue continue
try: try:
await self._execute_signal(sig) await self._execute_signal(sig)
except Exception as e: except Exception as e:
log.error("exec_error", signal_id=sig.get("id"), error=str(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: 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", "") coin = sig.get("coin", "")
regime = sig.get("regime", "")
conviction = sig.get("conviction", 0) conviction = sig.get("conviction", 0)
reasoning = sig.get("reasoning", "") side = "Buy" if conviction > 0 else "Sell"
# Determine side from conviction
side = "buy" if conviction > 0 else "sell"
size = self._calculate_size(coin, conviction) 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: 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 return
# Place order via HL API # Get API wallet address from config (the HL wallet address, not the PK)
order_result = await self._place_order( # 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, coin=coin,
side=side, side=side,
size=size, size=size,
price=price, price=price,
sig_id=sig.get("id"),
) )
log.info( # Log to DB
"order_placed", exec_id = await self._db.insert_execution(
coin=coin, sig.get("id"), coin, side.lower(), size, price, self.mode
side=side,
size=size,
price=price,
conviction=conviction,
result=order_result.get("status"),
) )
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: def _calculate_size(self, coin: str, conviction: float) -> float:
"""Calculate position size based on conviction and portfolio.""" """Calculate position size based on conviction."""
# Base size: 1% of portfolio per trade # Base: 1% of portfolio per trade
base_pct = 0.01 base_pct = 0.01
# Scale with conviction (0.7 → 1x, 1.0 → 3x)
conviction_multiplier = 1 + (conviction - 0.7) * 6 conviction_multiplier = 1 + (conviction - 0.7) * 6
size_pct = base_pct * conviction_multiplier 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) async def _get_market_price(self, coin: str) -> Optional[float]:
portfolio_value = 10_000 # Default paper amount """Get current mid-price from HL."""
if self._portfolio: client = get_hl_client()
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
try: try:
headers = {"Content-Type": "application/json"} return await client.get_market_price(coin)
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}
except Exception as e: except Exception as e:
log.error("hl_order_error", error=str(e)) log.error("hl_price_error", coin=coin, error=str(e))
return {"status": "error", "reason": 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 from __future__ import annotations
import asyncio import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Optional
from aiohttp import web from aiohttp import web
from aiohttp.web import Application, Request, Response 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.config import config
from salior.core.logging import setup_logging from salior.core.logging import setup_logging
from salior.db.supabase_client import SupabaseClient from salior.db.supabase_client import SupabaseClient
from salior.wallet.connect import WalletSession from salior.hl_client import HyperliquidClient
log = setup_logging() log = setup_logging()
@ -20,19 +28,56 @@ TEMPLATES = Path(__file__).parent / "templates"
STATIC = Path(__file__).parent / "static" 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: 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: async def api_portfolio(request: Request) -> Response:
"""Get current portfolio positions.""" """Get current portfolio from HL (or cache)."""
supabase = SupabaseClient() address = request.query.get("address", "")
portfolio = await supabase.get_portfolio() if not address:
return web.json_response({"positions": portfolio}) 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: async def api_signals(request: Request) -> Response:
"""Get recent conviction signals.""" """Get recent conviction signals from Supabase."""
supabase = SupabaseClient() supabase = SupabaseClient()
coin = request.query.get("coin") coin = request.query.get("coin")
signals = await supabase.get_recent_signals(coin=coin, limit=20) 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: async def api_performance(request: Request) -> Response:
"""Get performance metrics.""" """Get performance metrics from Supabase performance table."""
# Placeholder — read from 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({ return web.json_response({
"days": 30, "days": len(rows),
"total_pnl": 0, "total_pnl": total_pnl,
"sharpe": 0, "sharpe": sharpe,
"max_drawdown": 0, "max_drawdown": max_dd,
"trades_count": 0, "trades_count": trades,
}) })
async def api_wallet_connect(request: Request) -> Response: async def api_hl_price(request: Request) -> Response:
"""Handle wallet connect callback after user signs auth message.""" """Get current price for a coin from HL."""
body = await request.json() coin = request.query.get("coin", "BTC")
address = body.get("address", "") client = HyperliquidClient()
signature = body.get("signature", "") try:
message = body.get("message", "") 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( # ─── Wallet Connect (simplified) ───────────────────────────────────────────────
{"error": "missing fields"}, status=400
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: log.info("wallet_connected", address=address)
wallet = WalletSession()
session = await wallet.connect(address, signature, message) return web.json_response({
return web.json_response({"session": session}) "session": {
except ValueError as e: "address": address,
return web.json_response({"error": str(e)}, status=401) "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: async def api_wallet_session(request: Request) -> Response:
@ -77,54 +170,130 @@ async def api_wallet_session(request: Request) -> Response:
if not address: if not address:
return web.json_response({"error": "missing address"}, status=400) return web.json_response({"error": "missing address"}, status=400)
wallet = WalletSession() supabase = SupabaseClient()
session = await wallet.get_session(address) session = await supabase.get_wallet_session(address)
if session: 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}) return web.json_response({"session": None, "valid": False})
async def api_wallet_auth_message(request: Request) -> Response: async def api_wallet_balances(request: Request) -> Response:
"""Get auth message for a wallet address.""" """Get full HL account balances for a wallet."""
address = request.query.get("address", "") address = request.query.get("address", "")
if not address: if not address:
return web.json_response({"error": "missing address"}, status=400) return web.json_response({"error": "missing address"}, status=400)
wallet = WalletSession() client = HyperliquidClient()
message = wallet.generate_auth_message(address) try:
return web.json_response({"message": message}) 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: 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() body = await request.json()
address = body.get("address", "") address = body.get("address", "")
coin = body.get("coin", "") coin = body.get("coin", "")
side = body.get("side", "") side = body.get("side", "")
size = body.get("size", 0) size = float(body.get("size", 0))
price = body.get("price", 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]): if not all([address, coin, side, size]):
return web.json_response({"error": "missing fields"}, status=400) return web.json_response({"error": "missing fields"}, status=400)
# Check wallet session supabase = SupabaseClient()
wallet = WalletSession() session = await supabase.get_wallet_session(address)
session = await wallet.get_session(address)
if not session: if not session:
return web.json_response( return web.json_response({"error": "wallet not connected"}, status=401)
{"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({ return web.json_response({
"status": "requires_signature", "preview": True,
"message": f"Sign to place {side} order for {size} {coin}", "coin": coin,
"order": {"coin": coin, "side": side, "size": size, "price": price}, "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: async def index_handler(request: Request) -> Response:
"""Serve the main dashboard HTML.""" """Serve the main dashboard HTML."""
tmpl_path = TEMPLATES / "index.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") return Response(text=html, content_type="text/html")
# ─── App factory ────────────────────────────────────────────────────────────────
def create_app() -> Application: def create_app() -> Application:
"""Build the aiohttp application."""
app = Application() app = Application()
# Static files
app.router.add_static("/static/", STATIC, show_index=True) app.router.add_static("/static/", STATIC, show_index=True)
# Routes
app.router.add_get("/", index_handler) app.router.add_get("/", index_handler)
app.router.add_get("/health", health_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/portfolio", api_portfolio)
app.router.add_get("/api/signals", api_signals) app.router.add_get("/api/signals", api_signals)
app.router.add_get("/api/performance", api_performance) app.router.add_get("/api/performance", api_performance)
app.router.add_post("/api/wallet/connect", api_wallet_connect) app.router.add_get("/api/hl-price", api_hl_price)
app.router.add_get("/api/wallet/session", api_wallet_session)
app.router.add_get("/api/wallet/auth-message", api_wallet_auth_message) # Orders
app.router.add_post("/api/order", api_order) app.router.add_post("/api/order", api_order)
app.router.add_post("/api/order-preview", api_order_preview)
return app 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 */ /* Salior Dashboard — app.js */
// State // ─── State ───────────────────────────────────────────────────────────────────
let walletAddress = localStorage.getItem("salior_wallet") || null; const STATE = {
let sessionToken = localStorage.getItem("salior_session") || null; 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 ─────────────────────────────────────────────────────────── // ─── 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() { async function connectWallet() {
if (!window.ethereum) { const input = document.getElementById('walletInput').value.trim();
alert("Please install MetaMask or Rabby wallet"); 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; return;
} }
try { try {
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); const resp = await fetch('/api/wallet/connect', {
const address = accounts[0]; method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Get auth message from backend body: JSON.stringify({ address: input }),
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],
}); });
// 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(); const data = await resp.json();
if (data.error) { if (data.error) {
alert("Auth failed: " + data.error); errorEl.textContent = data.error;
errorEl.classList.add('visible');
return; return;
} }
// Store session STATE.wallet = data.session;
walletAddress = address; STATE.hlKeyConfigured = data.hl_info ? true : false;
sessionToken = data.session.session_token; localStorage.setItem('salior_wallet', JSON.stringify(data.session));
localStorage.setItem("salior_wallet", address); closeConnectModal();
localStorage.setItem("salior_session", sessionToken);
updateWalletUI(); updateWalletUI();
loadDashboard();
} catch (e) { } catch (e) {
console.error("connectWallet error", e); errorEl.textContent = 'Connection failed: ' + e.message;
alert("Connection failed: " + e.message); errorEl.classList.add('visible');
} }
} }
function disconnectWallet() { async function disconnectWallet() {
walletAddress = null; STATE.wallet = null;
sessionToken = null; localStorage.removeItem('salior_wallet');
localStorage.removeItem("salior_wallet");
localStorage.removeItem("salior_session");
updateWalletUI(); 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() { function updateWalletUI() {
const dot = document.getElementById("walletDot"); const dot = document.getElementById('walletDot');
const addr = document.getElementById("walletAddress"); const addr = document.getElementById('walletAddress');
const connBtn = document.getElementById("connectBtn"); const badge = document.getElementById('hlBadge');
const discBtn = document.getElementById("disconnectBtn"); 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) { if (STATE.wallet) {
dot.classList.add("connected"); dot.className = 'wallet-dot ' + (STATE.hlKeyConfigured ? 'trading' : 'connected');
addr.textContent = shorten(walletAddress); addr.textContent = shorten(STATE.wallet.address);
connBtn.style.display = "none"; badge.style.display = 'inline';
discBtn.style.display = "block"; 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 { } else {
dot.classList.remove("connected"); dot.className = 'wallet-dot disconnected';
addr.textContent = "Not connected"; addr.textContent = 'No wallet connected';
connBtn.style.display = "block"; badge.style.display = 'none';
discBtn.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) { // ─── Dashboard load ──────────────────────────────────────────────────────────
return addr ? addr.slice(0, 6) + "..." + addr.slice(-4) : "";
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() { async function loadPortfolio() {
if (!STATE.wallet) return;
try { try {
const resp = await fetch("/api/portfolio"); const resp = await fetch(`/api/portfolio?address=${STATE.wallet.address}`);
const { positions } = await resp.json(); const { positions } = await resp.json();
renderPortfolio(positions || []); renderPortfolio(positions || []);
} catch (e) { } catch (e) { console.error(e); }
console.error("loadPortfolio error", e);
}
} }
function renderPortfolio(positions) { function renderPortfolio(positions) {
const el = document.getElementById("portfolioView"); const el = document.getElementById('posTable');
if (!positions.length) { if (!positions.length) {
el.innerHTML = '<div class="empty">No positions</div>'; el.innerHTML = '<div class="empty">No positions</div>';
return; return;
} }
el.innerHTML = positions.map(p => {
el.innerHTML = positions.map(pos => { const pnl = p.unrealized_pnl || 0;
const pnl = pos.unrealized_pnl || 0; const pnlClass = pnl >= 0 ? 'pos' : 'neg';
const pnlClass = pnl >= 0 ? "pos" : "neg"; const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
return ` return `
<div class="position-row"> <div class="pos-row">
<div class="coin">${pos.coin}</div> <div class="coin">${p.coin}</div>
<div class="size"><span class="val">${pos.pos_size}</span> @ ${pos.avg_px}</div> <div class="sz"><span class="val">${p.size}</span></div>
<div class="pnl ${pnlClass}">${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}</div> <div class="entry">@ $${p.avg_px?.toFixed(2) || '—'}</div>
</div> <div class="pnl ${pnlClass}">${pnlStr}</div>
`; </div>`;
}).join(""); }).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() { async function loadPerformance() {
try { try {
const resp = await fetch("/api/performance"); const resp = await fetch('/api/performance');
const data = await resp.json(); const data = await resp.json();
renderPerformance(data); renderPerformance(data);
} catch (e) { } catch (e) { console.error(e); }
console.error("loadPerformance error", e);
}
} }
function renderPerformance(data) { function renderPerformance(data) {
document.getElementById("pnlVal").textContent = data.total_pnl ? `$${data.total_pnl.toFixed(2)}` : "—"; const pnlEl = document.getElementById('pnlVal');
document.getElementById("pnlVal").className = "val " + (data.total_pnl >= 0 ? "pos" : "neg"); const sharpeEl = document.getElementById('sharpeVal');
document.getElementById("sharpeVal").textContent = data.sharpe ? data.sharpe.toFixed(2) : "—"; const ddEl = document.getElementById('drawdownVal');
document.getElementById("drawdownVal").textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : "—";
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 card = document.getElementById('hlAccountCard');
const coin = document.getElementById("orderCoin").value; card.style.display = 'block';
const side = document.getElementById("orderSide").value;
const size = parseFloat(document.getElementById("orderSize").value);
const statusEl = document.getElementById("orderStatus");
if (!walletAddress) { const coll = parseFloat(data.total_collateral || 0);
statusEl.style.color = "var(--red)"; document.getElementById('hlCollateral').textContent = coll >= 0 ? `$${coll.toFixed(2)}` : `$${coll.toFixed(2)}`;
statusEl.textContent = "Connect wallet first"; document.getElementById('hlCollateral').className = 'value ' + (coll >= 0 ? 'pos' : 'neg');
return;
} document.getElementById('hlMargin').textContent = `$${parseFloat(data.margin_used || 0).toFixed(2)}`;
if (!size || size <= 0) { document.getElementById('hlOpenOrders').textContent = (data.open_orders || []).length;
statusEl.style.color = "var(--red)"; document.getElementById('hlPositions').textContent = (data.positions || []).length;
statusEl.textContent = "Enter a valid size";
// 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; 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)"; // ─── Order placement ─────────────────────────────────────────────────────────
statusEl.textContent = "Signing...";
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 { try {
const resp = await fetch("/api/order", { const resp = await fetch('/api/order-preview', {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: walletAddress, coin, side, size }), 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(); const data = await resp.json();
if (data.error) { if (data.error) {
statusEl.style.color = "var(--red)"; showOrderStatus(data.error, 'error');
statusEl.textContent = data.error; return;
} else {
statusEl.style.color = "var(--green)";
statusEl.textContent = data.message || "Order signed — approve in wallet";
} }
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) { } catch (e) {
statusEl.style.color = "var(--red)"; showOrderStatus('Order failed: ' + e.message, 'error');
statusEl.textContent = e.message;
} }
} }
// ─── Agent Status ──────────────────────────────────────────────────────────── function showOrderStatus(msg, type) {
const el = document.getElementById('orderStatus');
async function loadAgentStatus() { el.textContent = msg;
// Poll agent health endpoint el.className = 'order-status visible ' + type;
try { setTimeout(() => el.classList.remove('visible'), 6000);
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 renderAgentStatus(agents) { // ─── Settings ─────────────────────────────────────────────────────────────────
const el = document.getElementById("agentStatus");
const dotClass = { running: "ok", paused: "warn", error: "err" }; function openSettings() {
el.innerHTML = agents.map(a => ` document.getElementById('settingsModal').classList.add('open');
<div class="agent-row"> document.getElementById('execMode').value = STATE.execMode;
<div class="dot ${dotClass[a.status] || "warn"}"></div> document.getElementById('minConviction').value = STATE.minConviction;
<div class="name">${a.agent}</div> document.getElementById('hlKeyStatus').textContent = STATE.hlKeyConfigured ? '✅ Configured — live orders enabled' : '❌ Not configured — paper trading only';
<div class="status">${a.status} · ${a.ts || ""}</div> document.getElementById('hlKeyStatus').style.color = STATE.hlKeyConfigured ? 'var(--green)' : 'var(--yellow)';
</div> }
`).join("");
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 ──────────────────────────────────────────────────────────────────── // ─── Init ────────────────────────────────────────────────────────────────────
async function init() { async function init() {
updateWalletUI(); // Restore wallet from localStorage
await Promise.all([ const saved = localStorage.getItem('salior_wallet');
loadSignals(), if (saved) {
loadPortfolio(), try {
loadPerformance(), STATE.wallet = JSON.parse(saved);
loadAgentStatus(), // 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 // Restore settings
setInterval(loadSignals, 30000); const settings = localStorage.getItem('salior_settings');
setInterval(loadAgentStatus, 10000); 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(); init();

View File

@ -23,90 +23,142 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; } body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; }
a { color: var(--accent); text-decoration: none; } 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); position: sticky; top: 0; z-index: 10; }
nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); }
nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; } nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; }
nav .logo span { color: var(--accent); } nav .logo span { color: var(--accent); }
nav .nav-links { display: flex; gap: 16px; margin-left: auto; } 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 { 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; } .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
/* Wallet bar */ /* 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 { 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-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.wallet-bar .status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); } .wallet-dot.disconnected { background: var(--text-dim); }
.wallet-bar .address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; } .wallet-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.wallet-bar .btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; } .wallet-dot.trading { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.wallet-bar .btn:hover { opacity: 0.85; } .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; }
.wallet-bar .btn-primary { background: var(--accent); color: #fff; } .hl-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(91,138,240,0.2); color: var(--accent); margin-right: 4px; }
.wallet-bar .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } .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; }
.wallet-bar .btn:disabled { opacity: 0.4; cursor: not-allowed; } .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 */ /* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; } .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 { 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-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 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 { 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:last-child { border-bottom: none; }
.signal-item .coin { font-weight: 700; font-size: 14px; width: 60px; } .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_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.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.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 .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; } .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; } .conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.signal-item .conviction-fill.pos { background: var(--green); } .conviction-fill.pos { background: var(--green); }
.signal-item .conviction-fill.neg { background: var(--red); } .conviction-fill.neg { background: var(--red); }
.signal-item .conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; } .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; } .signal-time { font-size: 11px; color: var(--text-dim); width: 70px; text-align: right; flex-shrink: 0; }
/* Portfolio */ /* Portfolio table */
.position-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); } .pos-table { width: 100%; }
.position-row:last-child { border-bottom: none; } .pos-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.position-row .coin { font-weight: 700; font-size: 14px; width: 80px; } .pos-row:last-child { border-bottom: none; }
.position-row .size { flex: 1; font-size: 13px; } .pos-row .coin { font-weight: 700; width: 70px; }
.position-row .size .val { font-weight: 600; } .pos-row .sz { flex: 1; font-size: 13px; }
.position-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; } .pos-row .sz .val { font-weight: 600; }
.position-row .pnl.pos { color: var(--green); } .pos-row .entry { font-size: 12px; color: var(--text-dim); width: 90px; }
.position-row .pnl.neg { color: var(--red); } .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 */ /* Stats */
.stat { text-align: center; } .stat { text-align: center; }
.stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; } .stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; }
.stat .val.pos { color: var(--green); } .stat .val.pos { color: var(--green); }
.stat .val.neg { color: var(--red); } .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; } .stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
/* Order form */ /* Order form */
.order-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; } .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 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 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 select:focus, .order-form input:focus { outline: none; border-color: var(--accent); } .order-form input:focus, .order-form select:focus { outline: none; border-color: var(--accent); }
.order-form .btn { padding: 8px 20px; } .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 status */
.agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; } .agent-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; } .agent-row .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.agent-row .dot.ok { background: var(--green); } .agent-row .dot.ok { background: var(--green); }
.agent-row .dot.warn { background: var(--yellow); } .agent-row .dot.warn { background: var(--yellow); }
.agent-row .dot.err { background: var(--red); } .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 .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 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> </style>
</head> </head>
<body> <body>
@ -114,56 +166,97 @@
<nav> <nav>
<div class="logo">sali<span>or</span></div> <div class="logo">sali<span>or</span></div>
<div class="nav-links"> <div class="nav-links">
<a href="#signals">Signals</a> <a href="#" class="nav-tab active" data-section="dashboard">Dashboard</a>
<a href="#portfolio">Portfolio</a> <a href="#" class="nav-tab" data-section="trade">Trade</a>
<a href="#performance">Performance</a> <a href="#" class="nav-tab" data-section="agents">Agents</a>
<a href="#agents">Agents</a>
</div> </div>
</nav> </nav>
<div class="container"> <div class="container">
<!-- Wallet Bar --> <!-- Wallet Bar (always visible) -->
<div class="wallet-bar"> <div class="wallet-bar">
<div class="status-dot" id="walletDot"></div> <div class="wallet-dot disconnected" id="walletDot"></div>
<div class="address" id="walletAddress">Not connected</div> <div class="wallet-address" id="walletAddress">No wallet connected</div>
<button class="btn btn-primary" id="connectBtn" onclick="connectWallet()">Connect Wallet</button> <span class="hl-badge" id="hlBadge" style="display:none">HL</span>
<button class="btn btn-outline" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button> <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> </div>
<!-- Signals -->
<div class="grid-2"> <div class="grid-2">
<div class="card" id="signals-card"> <!-- Signals -->
<h2>Conviction Signals <span class="badge">LIVE</span></h2> <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 id="signalsFeed"><div class="empty">Loading signals...</div></div>
</div> </div>
<!-- Portfolio -->
<div class="card"> <div class="card">
<h2>Portfolio</h2> <h2>Positions</h2>
<div id="portfolioView"><div class="empty">No positions</div></div> <div class="pos-table" id="posTable">
<div class="empty">No positions</div>
</div>
</div> </div>
</div> </div>
<!-- Performance --> <!-- Performance -->
<div class="card" id="performance"> <div class="card">
<h2>Performance</h2> <h2>Performance</h2>
<div class="grid-3"> <div class="grid-3">
<div class="stat"> <div class="stat">
<div class="val" id="pnlVal"></div> <div class="val dim" id="pnlVal"></div>
<div class="lbl">Total PnL</div> <div class="lbl">Total PnL</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="val" id="sharpeVal"></div> <div class="val dim" id="sharpeVal"></div>
<div class="lbl">Sharpe Ratio</div> <div class="lbl">Sharpe Ratio</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="val" id="drawdownVal"></div> <div class="val dim" id="drawdownVal"></div>
<div class="lbl">Max Drawdown</div> <div class="lbl">Max Drawdown</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Order Form --> </div>
<div class="card" id="portfolio">
<!-- ─── Trade section ─── -->
<div class="section" id="section-trade">
<div class="grid-2">
<!-- Place Order -->
<div class="card">
<h2>Place Order</h2> <h2>Place Order</h2>
<div class="order-form"> <div class="order-form">
<div> <div>
@ -176,8 +269,8 @@
<div> <div>
<label>Side</label> <label>Side</label>
<select id="orderSide"> <select id="orderSide">
<option value="buy">Buy</option> <option value="Buy">Buy / Long</option>
<option value="sell">Sell</option> <option value="Sell">Sell / Short</option>
</select> </select>
</div> </div>
<div> <div>
@ -185,21 +278,133 @@
<input type="number" id="orderSize" placeholder="0.01" step="0.001" min="0.001" /> <input type="number" id="orderSize" placeholder="0.01" step="0.001" min="0.001" />
</div> </div>
<div> <div>
<label>&nbsp;</label> <label>Price</label>
<button class="btn btn-primary btn" onclick="placeOrder()">Place Order</button> <input type="number" id="orderPrice" placeholder="Market" step="0.01" />
</div> </div>
</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> </div>
<!-- Agent Status --> <!-- Open Orders -->
<div class="card" id="agents"> <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> <h2>Agent Health</h2>
<div id="agentStatus"><div class="empty">Loading...</div></div> <div id="agentStatus"><div class="empty">Loading...</div></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> </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> <script src="/static/app.js"></script>
</body> </body>
</html> </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