329 lines
12 KiB
Python
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 |