Add: simplified wallet connect, HL client with secp256k1 signing, full trading dashboard
This commit is contained in:
parent
3e72c0a805
commit
a4280100fc
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
@ -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();
|
||||
|
||||
@ -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> </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
222
salior/hl_client.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user