salior/salior/dashboard/server.py

329 lines
12 KiB
Python

"""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 aiohttp import web
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.hl_client import HyperliquidClient
log = setup_logging()
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(),
"hl_private_key_set": bool(config.hl_private_key),
})
async def api_portfolio(request: Request) -> Response:
"""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 from Supabase."""
supabase = SupabaseClient()
coin = request.query.get("coin")
signals = await supabase.get_recent_signals(coin=coin, limit=20)
return web.json_response({"signals": signals})
async def api_performance(request: Request) -> Response:
"""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": len(rows),
"total_pnl": total_pnl,
"sharpe": sharpe,
"max_drawdown": max_dd,
"trades_count": trades,
})
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()
# ─── Wallet Connect (simplified) ───────────────────────────────────────────────
async def api_wallet_connect(request: Request) -> Response:
"""Connect a HL wallet address (no signing needed for read-only).
POST body: { "address": "0x..." }
"""
body = await request.json()
address = body.get("address", "").strip()
if not address or len(address) != 42 or not address.startswith("0x"):
return web.json_response({"error": "Invalid Ethereum address"}, status=400)
# Verify address exists on HL
client = HyperliquidClient()
try:
hl_info = await client.verify_address(address)
if not hl_info.get("connected"):
return web.json_response({
"error": "This address has no Hyperliquid account. Bridge funds first."
}, status=400)
finally:
await client.close()
# Create session
session_token = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days)
supabase = SupabaseClient()
await supabase.upsert_wallet_session(
wallet_address=address,
session_token=session_token,
signature="hl_wallet_connect", # placeholder — no message signing
expires_at=expires_at.isoformat(),
)
log.info("wallet_connected", address=address)
return web.json_response({
"session": {
"address": address,
"session_token": session_token,
"expires_at": expires_at.isoformat(),
"days": config.wallet_session_days,
},
"hl_info": hl_info,
})
async def api_wallet_session(request: Request) -> Response:
"""Check existing wallet session."""
address = request.query.get("address", "")
if not address:
return web.json_response({"error": "missing address"}, status=400)
supabase = SupabaseClient()
session = await supabase.get_wallet_session(address)
if session:
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_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)
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 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 = 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)
supabase = SupabaseClient()
session = await supabase.get_wallet_session(address)
if not session:
return web.json_response({"error": "wallet not connected"}, status=401)
# Get current market price
client = HyperliquidClient()
try:
price = await client.get_market_price(coin)
finally:
await client.close()
return web.json_response({
"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"
html = tmpl_path.read_text()
return Response(text=html, content_type="text/html")
# ─── App factory ────────────────────────────────────────────────────────────────
def create_app() -> Application:
app = Application()
app.router.add_static("/static/", STATIC, show_index=True)
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_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