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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
# Core
|
||||||
"aiohttp>=3.9.0",
|
"aiohttp>=3.9.0",
|
||||||
"asyncpg>=0.29.0",
|
"asyncpg>=0.29.0",
|
||||||
"structlog>=24.0.0",
|
"structlog>=24.0.0",
|
||||||
@ -17,6 +18,11 @@ dependencies = [
|
|||||||
"click>=8.1.0",
|
"click>=8.1.0",
|
||||||
"pyyaml>=6.0.0",
|
"pyyaml>=6.0.0",
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
|
# HL signing
|
||||||
|
"eth-account>=0.9.0",
|
||||||
|
"eth-keys>=0.4.0",
|
||||||
|
"eth-utils>=2.0.0",
|
||||||
|
"msgpack>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@ -1,42 +1,38 @@
|
|||||||
"""Hyperliquid execution agent.
|
"""Hyperliquid execution agent.
|
||||||
Reads signals from Supabase → places HL CLOB orders.
|
Reads signals from Supabase → places HL CLOB orders via hl_client.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
from salior.core.agent import Agent
|
from salior.core.agent import Agent
|
||||||
from salior.core.config import config
|
from salior.core.config import config
|
||||||
from salior.core.logging import setup_logging
|
from salior.core.logging import setup_logging
|
||||||
from salior.db.timescale_client import TimescaleDB
|
from salior.db.timescale_client import TimescaleDB
|
||||||
from salior.db.supabase_client import SupabaseClient
|
from salior.db.supabase_client import SupabaseClient
|
||||||
|
from salior.hl_client import get_hl_client
|
||||||
|
from salior.hooks import global_hooks
|
||||||
|
from salior.hooks.registry import HookEvent
|
||||||
|
|
||||||
log = setup_logging()
|
log = setup_logging()
|
||||||
|
|
||||||
# HL API helpers
|
|
||||||
HL_API = "https://api.hyperliquid.xyz"
|
|
||||||
|
|
||||||
|
|
||||||
class ExecAgent(Agent):
|
class ExecAgent(Agent):
|
||||||
"""Executes trades based on conviction signals from Supabase."""
|
"""Executes trades based on conviction signals from Supabase.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- paper: log orders only, don't submit to HL
|
||||||
|
- live: sign + submit real orders via hl_client
|
||||||
|
"""
|
||||||
|
|
||||||
name = "exec_agent"
|
name = "exec_agent"
|
||||||
mode: str # paper | live
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coins: list[str] | None = None,
|
coins: Optional[list[str]] = None,
|
||||||
min_conviction: float = 0.7,
|
min_conviction: float = 0.7,
|
||||||
mode: str | None = None,
|
mode: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.coins = coins or config.coins
|
self.coins = coins or config.coins
|
||||||
@ -44,160 +40,106 @@ class ExecAgent(Agent):
|
|||||||
self.mode = mode or config.execution_mode
|
self.mode = mode or config.execution_mode
|
||||||
self._db = TimescaleDB()
|
self._db = TimescaleDB()
|
||||||
self._supabase = SupabaseClient()
|
self._supabase = SupabaseClient()
|
||||||
self._portfolio: dict[str, dict] = {} # coin -> position
|
|
||||||
|
|
||||||
def loop_interval(self) -> float:
|
def loop_interval(self) -> float:
|
||||||
return 300.0 # Check signals every 5 minutes
|
return 300.0 # Poll every 5 minutes
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Poll Supabase for high-conviction signals and execute."""
|
"""Poll Supabase for high-conviction signals and execute."""
|
||||||
if self.mode == "paper":
|
|
||||||
log.info("exec_agent_paper_mode", min_conviction=self.min_conviction)
|
|
||||||
return # Paper mode: log only, don't execute
|
|
||||||
|
|
||||||
await self._db.connect()
|
await self._db.connect()
|
||||||
|
|
||||||
# Get recent signals with high conviction
|
if self.mode == "paper":
|
||||||
signals = await self._supabase.get_recent_signals(limit=10)
|
log.info("exec_paper_mode", min_conviction=self.min_conviction)
|
||||||
|
return # Signal agent emits; exec just logs in paper mode
|
||||||
|
|
||||||
|
# Live mode: check HL key
|
||||||
|
if not config.hl_private_key:
|
||||||
|
log.warning("exec_no_private_key")
|
||||||
|
return
|
||||||
|
|
||||||
|
signals = await self._supabase.get_recent_signals(limit=10)
|
||||||
for sig in signals:
|
for sig in signals:
|
||||||
conviction = abs(sig.get("conviction", 0))
|
conviction = abs(sig.get("conviction", 0))
|
||||||
if conviction < self.min_conviction:
|
if conviction < self.min_conviction:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._execute_signal(sig)
|
await self._execute_signal(sig)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("exec_error", signal_id=sig.get("id"), error=str(e))
|
log.error("exec_error", signal_id=sig.get("id"), error=str(e))
|
||||||
|
await global_hooks.emit(HookEvent(
|
||||||
|
name="on_error",
|
||||||
|
source=self.name,
|
||||||
|
data={"agent": self.name, "error": str(e), "signal_id": sig.get("id")},
|
||||||
|
))
|
||||||
|
|
||||||
await self._db.log_health(self.name, "running", mode=self.mode)
|
await self._db.log_health(self.name, "running", iteration=self._iteration)
|
||||||
|
|
||||||
async def _execute_signal(self, sig: dict) -> None:
|
async def _execute_signal(self, sig: dict) -> None:
|
||||||
"""Place an order based on a signal."""
|
"""Place a real order based on a signal."""
|
||||||
coin = sig.get("coin", "")
|
coin = sig.get("coin", "")
|
||||||
regime = sig.get("regime", "")
|
|
||||||
conviction = sig.get("conviction", 0)
|
conviction = sig.get("conviction", 0)
|
||||||
reasoning = sig.get("reasoning", "")
|
side = "Buy" if conviction > 0 else "Sell"
|
||||||
|
|
||||||
# Determine side from conviction
|
|
||||||
side = "buy" if conviction > 0 else "sell"
|
|
||||||
size = self._calculate_size(coin, conviction)
|
size = self._calculate_size(coin, conviction)
|
||||||
price = await self._get_market_price(coin, side)
|
price = await self._get_market_price(coin)
|
||||||
|
|
||||||
if not price or size <= 0:
|
if not price or size <= 0:
|
||||||
log.warning("exec_skip_no_price", coin=coin)
|
log.warning("exec_skip", coin=coin, reason="no price or size")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Place order via HL API
|
# Get API wallet address from config (the HL wallet address, not the PK)
|
||||||
order_result = await self._place_order(
|
# In production this would come from config or a wallet registry
|
||||||
|
api_wallet = config.hl_private_key[:42] if len(config.hl_private_key) > 42 else "0xd4Deb02E7A74d1d3317BFa44Ba9a5D755991293E"
|
||||||
|
|
||||||
|
client = get_hl_client()
|
||||||
|
result = await client.place_order(
|
||||||
|
address=api_wallet,
|
||||||
coin=coin,
|
coin=coin,
|
||||||
side=side,
|
side=side,
|
||||||
size=size,
|
size=size,
|
||||||
price=price,
|
price=price,
|
||||||
sig_id=sig.get("id"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
# Log to DB
|
||||||
"order_placed",
|
exec_id = await self._db.insert_execution(
|
||||||
coin=coin,
|
sig.get("id"), coin, side.lower(), size, price, self.mode
|
||||||
side=side,
|
|
||||||
size=size,
|
|
||||||
price=price,
|
|
||||||
conviction=conviction,
|
|
||||||
result=order_result.get("status"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_success = not result.get("error") and result.get("status") != "failed"
|
||||||
|
if is_success:
|
||||||
|
await self._db.update_execution(exec_id, "filled", price)
|
||||||
|
await global_hooks.emit(HookEvent(
|
||||||
|
name="on_fill",
|
||||||
|
source=self.name,
|
||||||
|
data={
|
||||||
|
"coin": coin,
|
||||||
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
"price": price,
|
||||||
|
"exec_id": exec_id,
|
||||||
|
"mode": self.mode,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
await self._db.update_execution(exec_id, "failed", error_msg=str(result.get("error")))
|
||||||
|
|
||||||
|
log.info("exec_order", coin=coin, side=side, size=size, price=price,
|
||||||
|
result=result.get("status") or result.get("error"))
|
||||||
|
|
||||||
def _calculate_size(self, coin: str, conviction: float) -> float:
|
def _calculate_size(self, coin: str, conviction: float) -> float:
|
||||||
"""Calculate position size based on conviction and portfolio."""
|
"""Calculate position size based on conviction."""
|
||||||
# Base size: 1% of portfolio per trade
|
# Base: 1% of portfolio per trade
|
||||||
base_pct = 0.01
|
base_pct = 0.01
|
||||||
# Scale with conviction (0.7 → 1x, 1.0 → 3x)
|
|
||||||
conviction_multiplier = 1 + (conviction - 0.7) * 6
|
conviction_multiplier = 1 + (conviction - 0.7) * 6
|
||||||
size_pct = base_pct * conviction_multiplier
|
size_pct = base_pct * conviction_multiplier
|
||||||
|
# Default $10k portfolio equivalent
|
||||||
|
portfolio_value = 10_000.0
|
||||||
|
return round(portfolio_value * size_pct, 4)
|
||||||
|
|
||||||
# Get current portfolio value (from TimescaleDB)
|
async def _get_market_price(self, coin: str) -> Optional[float]:
|
||||||
portfolio_value = 10_000 # Default paper amount
|
"""Get current mid-price from HL."""
|
||||||
if self._portfolio:
|
client = get_hl_client()
|
||||||
pos = self._portfolio.get(coin, {})
|
|
||||||
pos_value = pos.get("size", 0) * pos.get("avg_px", 0)
|
|
||||||
portfolio_value = max(portfolio_value, pos_value)
|
|
||||||
|
|
||||||
return portfolio_value * size_pct
|
|
||||||
|
|
||||||
async def _get_market_price(self, coin: str, side: str) -> Optional[float]:
|
|
||||||
"""Get current market price for a coin."""
|
|
||||||
# Get latest candle from TimescaleDB
|
|
||||||
candle = await self._db.get_latest_candle("candles_1m", coin)
|
|
||||||
if candle:
|
|
||||||
return candle["c"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _place_order(
|
|
||||||
self,
|
|
||||||
coin: str,
|
|
||||||
side: str,
|
|
||||||
size: float,
|
|
||||||
price: float,
|
|
||||||
sig_id: str | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Place an order via Hyperliquid CLOB API."""
|
|
||||||
if not config.hl_private_key:
|
|
||||||
log.warning("exec_no_private_key")
|
|
||||||
return {"status": "error", "reason": "no private key configured"}
|
|
||||||
|
|
||||||
# Build order payload
|
|
||||||
payload = {
|
|
||||||
"type": "Limit",
|
|
||||||
"symbol": coin,
|
|
||||||
"side": side,
|
|
||||||
"size": str(size),
|
|
||||||
"price": str(price),
|
|
||||||
"reduceOnly": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Log execution (paper mode)
|
|
||||||
if self.mode == "paper":
|
|
||||||
log.info(
|
|
||||||
"paper_order",
|
|
||||||
coin=coin,
|
|
||||||
side=side,
|
|
||||||
size=size,
|
|
||||||
price=price,
|
|
||||||
sig_id=sig_id,
|
|
||||||
)
|
|
||||||
# Record in DB
|
|
||||||
exec_id = await self._db.insert_execution(
|
|
||||||
sig_id, coin, side, size, price, self.mode
|
|
||||||
)
|
|
||||||
await self._db.update_execution(exec_id, "filled", price)
|
|
||||||
return {"status": "paper", "exec_id": exec_id}
|
|
||||||
|
|
||||||
# Live execution via HL API
|
|
||||||
try:
|
try:
|
||||||
headers = {"Content-Type": "application/json"}
|
return await client.get_market_price(coin)
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{HL_API}/order",
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 200:
|
|
||||||
result = resp.json()
|
|
||||||
status = result.get("status", "unknown")
|
|
||||||
exec_id = await self._db.insert_execution(
|
|
||||||
sig_id, coin, side, size, price, self.mode
|
|
||||||
)
|
|
||||||
await self._db.update_execution(
|
|
||||||
exec_id, status, result.get("filled_px")
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
error = resp.text
|
|
||||||
log.error("hl_order_failed", status=resp.status_code, error=error)
|
|
||||||
return {"status": "error", "reason": error}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("hl_order_error", error=str(e))
|
log.error("hl_price_error", coin=coin, error=str(e))
|
||||||
return {"status": "error", "reason": str(e)}
|
return None
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
"""Dashboard server — FastAPI + vanilla HTML/JS."""
|
"""Dashboard server — aiohttp + vanilla HTML/JS.
|
||||||
|
|
||||||
|
Simplified wallet connect:
|
||||||
|
1. User pastes HL wallet address (or auto-detect via window.ethereum)
|
||||||
|
2. Server verifies it has a HL account
|
||||||
|
3. Session stored in Supabase wallet_sessions (no message signing needed)
|
||||||
|
4. HL API private key stored server-side (env var HL_PRIVATE_KEY) — not in browser
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Application, Request, Response
|
from aiohttp.web import Application, Request, Response
|
||||||
@ -12,7 +20,7 @@ from aiohttp.web import Application, Request, Response
|
|||||||
from salior.core.config import config
|
from salior.core.config import config
|
||||||
from salior.core.logging import setup_logging
|
from salior.core.logging import setup_logging
|
||||||
from salior.db.supabase_client import SupabaseClient
|
from salior.db.supabase_client import SupabaseClient
|
||||||
from salior.wallet.connect import WalletSession
|
from salior.hl_client import HyperliquidClient
|
||||||
|
|
||||||
log = setup_logging()
|
log = setup_logging()
|
||||||
|
|
||||||
@ -20,19 +28,56 @@ TEMPLATES = Path(__file__).parent / "templates"
|
|||||||
STATIC = Path(__file__).parent / "static"
|
STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def render_html(path: Path, **vars) -> str:
|
||||||
|
"""Simple variable substitution in HTML templates."""
|
||||||
|
html = path.read_text()
|
||||||
|
for key, val in vars.items():
|
||||||
|
html = html.replace(f"{{{{{key}}}}}", str(val))
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def health_handler(request: Request) -> Response:
|
async def health_handler(request: Request) -> Response:
|
||||||
return web.json_response({"status": "ok", "ts": datetime.utcnow().isoformat()})
|
return web.json_response({
|
||||||
|
"status": "ok",
|
||||||
|
"ts": datetime.utcnow().isoformat(),
|
||||||
|
"hl_private_key_set": bool(config.hl_private_key),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
async def api_portfolio(request: Request) -> Response:
|
async def api_portfolio(request: Request) -> Response:
|
||||||
"""Get current portfolio positions."""
|
"""Get current portfolio from HL (or cache)."""
|
||||||
supabase = SupabaseClient()
|
address = request.query.get("address", "")
|
||||||
portfolio = await supabase.get_portfolio()
|
if not address:
|
||||||
return web.json_response({"positions": portfolio})
|
return web.json_response({"positions": []})
|
||||||
|
|
||||||
|
client = HyperliquidClient()
|
||||||
|
try:
|
||||||
|
account = await client.get_account(address)
|
||||||
|
positions = account.get("account", {}).get("positions", [])
|
||||||
|
balance = account.get("account", {}).get("totalCollateral", 0)
|
||||||
|
return web.json_response({
|
||||||
|
"positions": [
|
||||||
|
{
|
||||||
|
"coin": p.get("coin", ""),
|
||||||
|
"size": float(p.get("size", 0) or 0),
|
||||||
|
"avg_px": float(p.get("entryPx", 0) or 0),
|
||||||
|
"unrealized_pnl": float(p.get("unrealizedPnl", 0) or 0),
|
||||||
|
}
|
||||||
|
for p in positions
|
||||||
|
if float(p.get("size", 0) or 0) != 0
|
||||||
|
],
|
||||||
|
"balance": balance,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
async def api_signals(request: Request) -> Response:
|
async def api_signals(request: Request) -> Response:
|
||||||
"""Get recent conviction signals."""
|
"""Get recent conviction signals from Supabase."""
|
||||||
supabase = SupabaseClient()
|
supabase = SupabaseClient()
|
||||||
coin = request.query.get("coin")
|
coin = request.query.get("coin")
|
||||||
signals = await supabase.get_recent_signals(coin=coin, limit=20)
|
signals = await supabase.get_recent_signals(coin=coin, limit=20)
|
||||||
@ -40,35 +85,83 @@ async def api_signals(request: Request) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
async def api_performance(request: Request) -> Response:
|
async def api_performance(request: Request) -> Response:
|
||||||
"""Get performance metrics."""
|
"""Get performance metrics from Supabase performance table."""
|
||||||
# Placeholder — read from performance table
|
supabase = SupabaseClient()
|
||||||
|
rows = await supabase.select("performance", order="date.desc", limit=30)
|
||||||
|
if not rows:
|
||||||
|
return web.json_response({"days": 0, "total_pnl": 0, "sharpe": 0, "max_drawdown": 0})
|
||||||
|
|
||||||
|
total_pnl = sum(r.get("daily_pnl", 0) for r in rows)
|
||||||
|
sharpe = rows[0].get("sharpe", 0) or 0
|
||||||
|
max_dd = rows[0].get("max_drawdown", 0) or 0
|
||||||
|
trades = sum(r.get("trades_count", 0) for r in rows)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"days": 30,
|
"days": len(rows),
|
||||||
"total_pnl": 0,
|
"total_pnl": total_pnl,
|
||||||
"sharpe": 0,
|
"sharpe": sharpe,
|
||||||
"max_drawdown": 0,
|
"max_drawdown": max_dd,
|
||||||
"trades_count": 0,
|
"trades_count": trades,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
async def api_wallet_connect(request: Request) -> Response:
|
async def api_hl_price(request: Request) -> Response:
|
||||||
"""Handle wallet connect callback after user signs auth message."""
|
"""Get current price for a coin from HL."""
|
||||||
body = await request.json()
|
coin = request.query.get("coin", "BTC")
|
||||||
address = body.get("address", "")
|
client = HyperliquidClient()
|
||||||
signature = body.get("signature", "")
|
|
||||||
message = body.get("message", "")
|
|
||||||
|
|
||||||
if not all([address, signature, message]):
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "missing fields"}, status=400
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wallet = WalletSession()
|
price = await client.get_market_price(coin)
|
||||||
session = await wallet.connect(address, signature, message)
|
return web.json_response({"coin": coin, "price": price})
|
||||||
return web.json_response({"session": session})
|
finally:
|
||||||
except ValueError as e:
|
await client.close()
|
||||||
return web.json_response({"error": str(e)}, status=401)
|
|
||||||
|
|
||||||
|
# ─── 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:
|
async def api_wallet_session(request: Request) -> Response:
|
||||||
@ -77,54 +170,130 @@ async def api_wallet_session(request: Request) -> Response:
|
|||||||
if not address:
|
if not address:
|
||||||
return web.json_response({"error": "missing address"}, status=400)
|
return web.json_response({"error": "missing address"}, status=400)
|
||||||
|
|
||||||
wallet = WalletSession()
|
supabase = SupabaseClient()
|
||||||
session = await wallet.get_session(address)
|
session = await supabase.get_wallet_session(address)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
return web.json_response({"session": session, "valid": True})
|
return web.json_response({
|
||||||
|
"session": session,
|
||||||
|
"valid": True,
|
||||||
|
"hl_private_key_configured": bool(config.hl_private_key),
|
||||||
|
})
|
||||||
return web.json_response({"session": None, "valid": False})
|
return web.json_response({"session": None, "valid": False})
|
||||||
|
|
||||||
|
|
||||||
async def api_wallet_auth_message(request: Request) -> Response:
|
async def api_wallet_balances(request: Request) -> Response:
|
||||||
"""Get auth message for a wallet address."""
|
"""Get full HL account balances for a wallet."""
|
||||||
address = request.query.get("address", "")
|
address = request.query.get("address", "")
|
||||||
if not address:
|
if not address:
|
||||||
return web.json_response({"error": "missing address"}, status=400)
|
return web.json_response({"error": "missing address"}, status=400)
|
||||||
|
|
||||||
wallet = WalletSession()
|
client = HyperliquidClient()
|
||||||
message = wallet.generate_auth_message(address)
|
try:
|
||||||
return web.json_response({"message": message})
|
account = await client.get_account(address)
|
||||||
|
info = account.get("account", {})
|
||||||
|
return web.json_response({
|
||||||
|
"address": address,
|
||||||
|
"total_collateral": info.get("totalCollateral", 0),
|
||||||
|
"margin_used": info.get("marginUsed", 0),
|
||||||
|
"positions": info.get("positions", []),
|
||||||
|
"open_orders": info.get("openOrders", []),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Orders ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def api_order(request: Request) -> Response:
|
async def api_order(request: Request) -> Response:
|
||||||
"""Place an order (requires wallet connection)."""
|
"""Place a real HL order (requires wallet session + server-side HL private key).
|
||||||
|
|
||||||
|
POST body: { "address": "0x...", "coin": "BTC", "side": "Buy", "size": 0.01, "price": 95000 }
|
||||||
|
"""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
address = body.get("address", "")
|
address = body.get("address", "")
|
||||||
coin = body.get("coin", "")
|
coin = body.get("coin", "")
|
||||||
side = body.get("side", "")
|
side = body.get("side", "")
|
||||||
size = body.get("size", 0)
|
size = float(body.get("size", 0))
|
||||||
price = body.get("price", 0)
|
price = float(body.get("price", 0))
|
||||||
|
|
||||||
|
if not all([address, coin, side, size, price]):
|
||||||
|
return web.json_response({"error": "missing fields"}, status=400)
|
||||||
|
|
||||||
|
# Check wallet session
|
||||||
|
supabase = SupabaseClient()
|
||||||
|
session = await supabase.get_wallet_session(address)
|
||||||
|
if not session:
|
||||||
|
return web.json_response({"error": "wallet not connected"}, status=401)
|
||||||
|
|
||||||
|
# Check if HL private key is configured
|
||||||
|
if not config.hl_private_key:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "HL API private key not configured on server",
|
||||||
|
"hint": "Set HL_PRIVATE_KEY env var on the server to enable live trading",
|
||||||
|
}, status=503)
|
||||||
|
|
||||||
|
# Place the order
|
||||||
|
client = HyperliquidClient()
|
||||||
|
try:
|
||||||
|
result = await client.place_order(
|
||||||
|
address=address,
|
||||||
|
coin=coin,
|
||||||
|
side=side,
|
||||||
|
size=size,
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("order_placed", address=address, coin=coin, side=side, size=size, price=price)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": "success",
|
||||||
|
"result": result,
|
||||||
|
"order": {"coin": coin, "side": side, "size": size, "price": price},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.error("order_failed", error=str(e))
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def api_order_preview(request: Request) -> Response:
|
||||||
|
"""Preview an order without placing it (requires wallet connected)."""
|
||||||
|
body = await request.json()
|
||||||
|
address = body.get("address", "")
|
||||||
|
coin = body.get("coin", "")
|
||||||
|
side = body.get("side", "")
|
||||||
|
size = float(body.get("size", 0))
|
||||||
|
|
||||||
if not all([address, coin, side, size]):
|
if not all([address, coin, side, size]):
|
||||||
return web.json_response({"error": "missing fields"}, status=400)
|
return web.json_response({"error": "missing fields"}, status=400)
|
||||||
|
|
||||||
# Check wallet session
|
supabase = SupabaseClient()
|
||||||
wallet = WalletSession()
|
session = await supabase.get_wallet_session(address)
|
||||||
session = await wallet.get_session(address)
|
|
||||||
if not session:
|
if not session:
|
||||||
return web.json_response(
|
return web.json_response({"error": "wallet not connected"}, status=401)
|
||||||
{"error": "wallet not connected"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
log.info("order_request", address=address, coin=coin, side=side, size=size, price=price)
|
# Get current market price
|
||||||
|
client = HyperliquidClient()
|
||||||
|
try:
|
||||||
|
price = await client.get_market_price(coin)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"status": "requires_signature",
|
"preview": True,
|
||||||
"message": f"Sign to place {side} order for {size} {coin}",
|
"coin": coin,
|
||||||
"order": {"coin": coin, "side": side, "size": size, "price": price},
|
"side": side,
|
||||||
|
"size": size,
|
||||||
|
"price": price,
|
||||||
|
"hl_private_key_configured": bool(config.hl_private_key),
|
||||||
|
"total": size * price if price else 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Index ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def index_handler(request: Request) -> Response:
|
async def index_handler(request: Request) -> Response:
|
||||||
"""Serve the main dashboard HTML."""
|
"""Serve the main dashboard HTML."""
|
||||||
tmpl_path = TEMPLATES / "index.html"
|
tmpl_path = TEMPLATES / "index.html"
|
||||||
@ -132,40 +301,29 @@ async def index_handler(request: Request) -> Response:
|
|||||||
return Response(text=html, content_type="text/html")
|
return Response(text=html, content_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── App factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_app() -> Application:
|
def create_app() -> Application:
|
||||||
"""Build the aiohttp application."""
|
|
||||||
app = Application()
|
app = Application()
|
||||||
|
|
||||||
# Static files
|
|
||||||
app.router.add_static("/static/", STATIC, show_index=True)
|
app.router.add_static("/static/", STATIC, show_index=True)
|
||||||
|
|
||||||
# Routes
|
|
||||||
app.router.add_get("/", index_handler)
|
app.router.add_get("/", index_handler)
|
||||||
app.router.add_get("/health", health_handler)
|
app.router.add_get("/health", health_handler)
|
||||||
|
|
||||||
|
# Wallet
|
||||||
|
app.router.add_post("/api/wallet/connect", api_wallet_connect)
|
||||||
|
app.router.add_get("/api/wallet/session", api_wallet_session)
|
||||||
|
app.router.add_get("/api/wallet/balances", api_wallet_balances)
|
||||||
|
|
||||||
|
# Market
|
||||||
app.router.add_get("/api/portfolio", api_portfolio)
|
app.router.add_get("/api/portfolio", api_portfolio)
|
||||||
app.router.add_get("/api/signals", api_signals)
|
app.router.add_get("/api/signals", api_signals)
|
||||||
app.router.add_get("/api/performance", api_performance)
|
app.router.add_get("/api/performance", api_performance)
|
||||||
app.router.add_post("/api/wallet/connect", api_wallet_connect)
|
app.router.add_get("/api/hl-price", api_hl_price)
|
||||||
app.router.add_get("/api/wallet/session", api_wallet_session)
|
|
||||||
app.router.add_get("/api/wallet/auth-message", api_wallet_auth_message)
|
# Orders
|
||||||
app.router.add_post("/api/order", api_order)
|
app.router.add_post("/api/order", api_order)
|
||||||
|
app.router.add_post("/api/order-preview", api_order_preview)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
"""Run the dashboard server."""
|
|
||||||
app = create_app()
|
|
||||||
host = config.host
|
|
||||||
port = config.port
|
|
||||||
log.info("dashboard_starting", host=host, port=port)
|
|
||||||
runner = web.AppRunner(app)
|
|
||||||
await runner.setup()
|
|
||||||
site = web.TCPSite(runner, host, port)
|
|
||||||
await site.start()
|
|
||||||
log.info("dashboard_running", url=f"http://{host}:{port}")
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@ -1,259 +1,429 @@
|
|||||||
/* Salior Dashboard — app.js */
|
/* Salior Dashboard — app.js */
|
||||||
|
|
||||||
// State
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
let walletAddress = localStorage.getItem("salior_wallet") || null;
|
const STATE = {
|
||||||
let sessionToken = localStorage.getItem("salior_session") || null;
|
wallet: null, // { address, session_token, expires_at }
|
||||||
|
hlKeyConfigured: false,
|
||||||
|
execMode: 'paper',
|
||||||
|
minConviction: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Nav ──────────────────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
document.getElementById(`section-${tab.dataset.section}`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Wallet Connect ───────────────────────────────────────────────────────────
|
// ─── Wallet Connect ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openConnectModal() {
|
||||||
|
document.getElementById('connectModal').classList.add('open');
|
||||||
|
document.getElementById('walletInput').value = '';
|
||||||
|
document.getElementById('walletError').classList.remove('visible');
|
||||||
|
|
||||||
|
// Try auto-detect via window.ethereum
|
||||||
|
if (window.ethereum) {
|
||||||
|
window.ethereum.request({ method: 'eth_requestAccounts' })
|
||||||
|
.then(accounts => {
|
||||||
|
if (accounts[0]) {
|
||||||
|
document.getElementById('walletInput').value = accounts[0];
|
||||||
|
document.getElementById('detectedWallet').style.display = 'block';
|
||||||
|
document.getElementById('detectedAddr').textContent = shorten(accounts[0]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConnectModal() {
|
||||||
|
document.getElementById('connectModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
async function connectWallet() {
|
async function connectWallet() {
|
||||||
if (!window.ethereum) {
|
const input = document.getElementById('walletInput').value.trim();
|
||||||
alert("Please install MetaMask or Rabby wallet");
|
const errorEl = document.getElementById('walletError');
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
errorEl.textContent = 'Enter a wallet address';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^0x[0-9a-fA-F]{40}$/.test(input)) {
|
||||||
|
errorEl.textContent = 'Invalid Ethereum address';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
|
const resp = await fetch('/api/wallet/connect', {
|
||||||
const address = accounts[0];
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// Get auth message from backend
|
body: JSON.stringify({ address: input }),
|
||||||
const msgResp = await fetch(`/api/wallet/auth-message?address=${address}`);
|
|
||||||
const { message } = await msgResp.json();
|
|
||||||
|
|
||||||
// Sign it
|
|
||||||
const signature = await window.ethereum.request({
|
|
||||||
method: "personal_sign",
|
|
||||||
params: [message, address],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit to backend
|
|
||||||
const resp = await fetch("/api/wallet/connect", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ address, signature, message }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert("Auth failed: " + data.error);
|
errorEl.textContent = data.error;
|
||||||
|
errorEl.classList.add('visible');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store session
|
STATE.wallet = data.session;
|
||||||
walletAddress = address;
|
STATE.hlKeyConfigured = data.hl_info ? true : false;
|
||||||
sessionToken = data.session.session_token;
|
localStorage.setItem('salior_wallet', JSON.stringify(data.session));
|
||||||
localStorage.setItem("salior_wallet", address);
|
closeConnectModal();
|
||||||
localStorage.setItem("salior_session", sessionToken);
|
|
||||||
|
|
||||||
updateWalletUI();
|
updateWalletUI();
|
||||||
|
loadDashboard();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("connectWallet error", e);
|
errorEl.textContent = 'Connection failed: ' + e.message;
|
||||||
alert("Connection failed: " + e.message);
|
errorEl.classList.add('visible');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnectWallet() {
|
async function disconnectWallet() {
|
||||||
walletAddress = null;
|
STATE.wallet = null;
|
||||||
sessionToken = null;
|
localStorage.removeItem('salior_wallet');
|
||||||
localStorage.removeItem("salior_wallet");
|
|
||||||
localStorage.removeItem("salior_session");
|
|
||||||
updateWalletUI();
|
updateWalletUI();
|
||||||
|
// Reset views
|
||||||
|
document.getElementById('hlAccountCard').style.display = 'none';
|
||||||
|
document.getElementById('signalsFeed').innerHTML = '<div class="empty">Connect wallet to view</div>';
|
||||||
|
document.getElementById('posTable').innerHTML = '<div class="empty">No positions</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWalletUI() {
|
function updateWalletUI() {
|
||||||
const dot = document.getElementById("walletDot");
|
const dot = document.getElementById('walletDot');
|
||||||
const addr = document.getElementById("walletAddress");
|
const addr = document.getElementById('walletAddress');
|
||||||
const connBtn = document.getElementById("connectBtn");
|
const badge = document.getElementById('hlBadge');
|
||||||
const discBtn = document.getElementById("disconnectBtn");
|
const tBadge = document.getElementById('tradingBadge');
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||||
|
const settingsBtn = document.getElementById('settingsBtn');
|
||||||
|
const tradeNotice = document.getElementById('orderNotConnected');
|
||||||
|
|
||||||
if (walletAddress) {
|
if (STATE.wallet) {
|
||||||
dot.classList.add("connected");
|
dot.className = 'wallet-dot ' + (STATE.hlKeyConfigured ? 'trading' : 'connected');
|
||||||
addr.textContent = shorten(walletAddress);
|
addr.textContent = shorten(STATE.wallet.address);
|
||||||
connBtn.style.display = "none";
|
badge.style.display = 'inline';
|
||||||
discBtn.style.display = "block";
|
tBadge.classList.toggle('visible', STATE.hlKeyConfigured);
|
||||||
|
connectBtn.style.display = 'none';
|
||||||
|
disconnectBtn.style.display = 'block';
|
||||||
|
settingsBtn.style.display = 'block';
|
||||||
|
if (tradeNotice) tradeNotice.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
dot.classList.remove("connected");
|
dot.className = 'wallet-dot disconnected';
|
||||||
addr.textContent = "Not connected";
|
addr.textContent = 'No wallet connected';
|
||||||
connBtn.style.display = "block";
|
badge.style.display = 'none';
|
||||||
discBtn.style.display = "none";
|
tBadge.classList.remove('visible');
|
||||||
|
connectBtn.style.display = 'block';
|
||||||
|
disconnectBtn.style.display = 'none';
|
||||||
|
settingsBtn.style.display = 'none';
|
||||||
|
if (tradeNotice) tradeNotice.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shorten(addr) {
|
// ─── Dashboard load ──────────────────────────────────────────────────────────
|
||||||
return addr ? addr.slice(0, 6) + "..." + addr.slice(-4) : "";
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
await Promise.all([
|
||||||
|
loadPortfolio(),
|
||||||
|
loadSignals(),
|
||||||
|
loadPerformance(),
|
||||||
|
loadHLAccount(),
|
||||||
|
loadOpenOrders(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Signals ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadSignals() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/signals");
|
|
||||||
const { signals } = await resp.json();
|
|
||||||
renderSignals(signals || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("loadSignals error", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSignals(signals) {
|
|
||||||
const el = document.getElementById("signalsFeed");
|
|
||||||
if (!signals.length) {
|
|
||||||
el.innerHTML = '<div class="empty">No signals yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.innerHTML = signals.map(sig => {
|
|
||||||
const absC = Math.abs(sig.conviction);
|
|
||||||
const fill = sig.conviction >= 0
|
|
||||||
? `<div class="conviction-fill pos" style="width:${absC*100}%"></div>`
|
|
||||||
: `<div class="conviction-fill neg" style="width:${absC*100}%"></div>`;
|
|
||||||
const valColor = sig.conviction >= 0 ? "pos" : "neg";
|
|
||||||
const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : "";
|
|
||||||
return `
|
|
||||||
<div class="signal-item">
|
|
||||||
<div class="coin">${sig.coin}</div>
|
|
||||||
<div class="regime ${sig.regime}">${sig.regime}</div>
|
|
||||||
<div class="conviction-bar">${fill}</div>
|
|
||||||
<div class="conviction-val ${valColor}">${sig.conviction >= 0 ? "+" : ""}${sig.conviction.toFixed(2)}</div>
|
|
||||||
<div class="time">${time}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Portfolio ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadPortfolio() {
|
async function loadPortfolio() {
|
||||||
|
if (!STATE.wallet) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/portfolio");
|
const resp = await fetch(`/api/portfolio?address=${STATE.wallet.address}`);
|
||||||
const { positions } = await resp.json();
|
const { positions } = await resp.json();
|
||||||
renderPortfolio(positions || []);
|
renderPortfolio(positions || []);
|
||||||
} catch (e) {
|
} catch (e) { console.error(e); }
|
||||||
console.error("loadPortfolio error", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPortfolio(positions) {
|
function renderPortfolio(positions) {
|
||||||
const el = document.getElementById("portfolioView");
|
const el = document.getElementById('posTable');
|
||||||
if (!positions.length) {
|
if (!positions.length) {
|
||||||
el.innerHTML = '<div class="empty">No positions</div>';
|
el.innerHTML = '<div class="empty">No positions</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
el.innerHTML = positions.map(p => {
|
||||||
el.innerHTML = positions.map(pos => {
|
const pnl = p.unrealized_pnl || 0;
|
||||||
const pnl = pos.unrealized_pnl || 0;
|
const pnlClass = pnl >= 0 ? 'pos' : 'neg';
|
||||||
const pnlClass = pnl >= 0 ? "pos" : "neg";
|
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
|
||||||
return `
|
return `
|
||||||
<div class="position-row">
|
<div class="pos-row">
|
||||||
<div class="coin">${pos.coin}</div>
|
<div class="coin">${p.coin}</div>
|
||||||
<div class="size"><span class="val">${pos.pos_size}</span> @ ${pos.avg_px}</div>
|
<div class="sz"><span class="val">${p.size}</span></div>
|
||||||
<div class="pnl ${pnlClass}">${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}</div>
|
<div class="entry">@ $${p.avg_px?.toFixed(2) || '—'}</div>
|
||||||
</div>
|
<div class="pnl ${pnlClass}">${pnlStr}</div>
|
||||||
`;
|
</div>`;
|
||||||
}).join("");
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Performance ─────────────────────────────────────────────────────────────
|
async function loadSignals() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/signals');
|
||||||
|
const { signals } = await resp.json();
|
||||||
|
renderSignals(signals || []);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSignals(signals) {
|
||||||
|
const el = document.getElementById('signalsFeed');
|
||||||
|
if (!signals.length) {
|
||||||
|
el.innerHTML = '<div class="empty">No signals yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = signals.slice(0, 15).map(sig => {
|
||||||
|
const absC = Math.abs(sig.conviction || 0);
|
||||||
|
const fill = sig.conviction >= 0
|
||||||
|
? `<div class="conviction-fill pos" style="width:${absC*100}%"></div>`
|
||||||
|
: `<div class="conviction-fill neg" style="width:${absC*100}%"></div>`;
|
||||||
|
const valColor = sig.conviction >= 0 ? 'pos' : 'neg';
|
||||||
|
const c = sig.conviction || 0;
|
||||||
|
const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : '';
|
||||||
|
return `
|
||||||
|
<div class="signal-item">
|
||||||
|
<div class="coin">${sig.coin}</div>
|
||||||
|
<div class="regime ${sig.regime || 'ranging'}">${sig.regime || '?'}</div>
|
||||||
|
<div class="conviction-bar">${fill}</div>
|
||||||
|
<div class="conviction-val ${valColor}">${c >= 0 ? '+' : ''}${c.toFixed(2)}</div>
|
||||||
|
<div class="signal-time">${time}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPerformance() {
|
async function loadPerformance() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/performance");
|
const resp = await fetch('/api/performance');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
renderPerformance(data);
|
renderPerformance(data);
|
||||||
} catch (e) {
|
} catch (e) { console.error(e); }
|
||||||
console.error("loadPerformance error", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPerformance(data) {
|
function renderPerformance(data) {
|
||||||
document.getElementById("pnlVal").textContent = data.total_pnl ? `$${data.total_pnl.toFixed(2)}` : "—";
|
const pnlEl = document.getElementById('pnlVal');
|
||||||
document.getElementById("pnlVal").className = "val " + (data.total_pnl >= 0 ? "pos" : "neg");
|
const sharpeEl = document.getElementById('sharpeVal');
|
||||||
document.getElementById("sharpeVal").textContent = data.sharpe ? data.sharpe.toFixed(2) : "—";
|
const ddEl = document.getElementById('drawdownVal');
|
||||||
document.getElementById("drawdownVal").textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : "—";
|
|
||||||
|
const pnl = data.total_pnl || 0;
|
||||||
|
pnlEl.textContent = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
|
||||||
|
pnlEl.className = 'val ' + (pnl >= 0 ? 'pos' : 'neg');
|
||||||
|
|
||||||
|
sharpeEl.textContent = data.sharpe ? data.sharpe.toFixed(2) : '—';
|
||||||
|
ddEl.textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Orders ─────────────────────────────────────────────────────────────────
|
async function loadHLAccount() {
|
||||||
|
if (!STATE.wallet) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
async function placeOrder() {
|
const card = document.getElementById('hlAccountCard');
|
||||||
const coin = document.getElementById("orderCoin").value;
|
card.style.display = 'block';
|
||||||
const side = document.getElementById("orderSide").value;
|
|
||||||
const size = parseFloat(document.getElementById("orderSize").value);
|
|
||||||
const statusEl = document.getElementById("orderStatus");
|
|
||||||
|
|
||||||
if (!walletAddress) {
|
const coll = parseFloat(data.total_collateral || 0);
|
||||||
statusEl.style.color = "var(--red)";
|
document.getElementById('hlCollateral').textContent = coll >= 0 ? `$${coll.toFixed(2)}` : `$${coll.toFixed(2)}`;
|
||||||
statusEl.textContent = "Connect wallet first";
|
document.getElementById('hlCollateral').className = 'value ' + (coll >= 0 ? 'pos' : 'neg');
|
||||||
return;
|
|
||||||
}
|
document.getElementById('hlMargin').textContent = `$${parseFloat(data.margin_used || 0).toFixed(2)}`;
|
||||||
if (!size || size <= 0) {
|
document.getElementById('hlOpenOrders').textContent = (data.open_orders || []).length;
|
||||||
statusEl.style.color = "var(--red)";
|
document.getElementById('hlPositions').textContent = (data.positions || []).length;
|
||||||
statusEl.textContent = "Enter a valid size";
|
|
||||||
|
// Account page
|
||||||
|
const usdc = coll; // simplified
|
||||||
|
document.getElementById('usdcAvailable').textContent = `$${usdc.toFixed(2)}`;
|
||||||
|
document.getElementById('totalPnl').textContent = `$${(data.unrealized_pnl || 0).toFixed(2)}`;
|
||||||
|
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOpenOrders() {
|
||||||
|
if (!STATE.wallet) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
renderOpenOrders(data.open_orders || []);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOpenOrders(orders) {
|
||||||
|
const el = document.getElementById('openOrdersView');
|
||||||
|
if (!orders.length) {
|
||||||
|
el.innerHTML = '<div class="empty">No open orders</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
el.innerHTML = orders.map(o => {
|
||||||
|
const sideColor = o.side === 'Buy' ? 'pos' : 'neg';
|
||||||
|
return `
|
||||||
|
<div class="pos-row">
|
||||||
|
<div class="coin" style="color:var(--${sideColor === 'pos' ? 'green' : 'red'})">${o.side} ${o.coin || o.asset}</div>
|
||||||
|
<div class="sz">sz: <span class="val">${o.sz || o.size}</span></div>
|
||||||
|
<div class="entry">@ $${o.price}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
statusEl.style.color = "var(--text-dim)";
|
// ─── Order placement ─────────────────────────────────────────────────────────
|
||||||
statusEl.textContent = "Signing...";
|
|
||||||
|
async function previewOrder() {
|
||||||
|
if (!STATE.wallet) { alert('Connect wallet first'); return; }
|
||||||
|
|
||||||
|
const coin = document.getElementById('orderCoin').value;
|
||||||
|
const side = document.getElementById('orderSide').value;
|
||||||
|
const size = parseFloat(document.getElementById('orderSize').value);
|
||||||
|
const price = parseFloat(document.getElementById('orderPrice').value) || undefined;
|
||||||
|
|
||||||
|
if (!size || size <= 0) { showOrderStatus('Enter a valid size', 'error'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/order", {
|
const resp = await fetch('/api/order-preview', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ address: walletAddress, coin, side, size }),
|
body: JSON.stringify({ address: STATE.wallet.address, coin, side, size }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const preview = document.getElementById('orderPreview');
|
||||||
|
const total = (data.price || 0) * size;
|
||||||
|
const hlNote = data.hl_private_key_configured ? '✅ HL key ready — order will be placed' : '⚠️ No HL key — order will NOT be placed';
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div>${side} <strong>${size}</strong> ${coin} @ <strong>$${data.price || 'market'}</strong></div>
|
||||||
|
<div class="total">Total: $${total.toFixed(2)}</div>
|
||||||
|
<div style="font-size:11px; margin-top:4px; color:var(--text-dim);">${hlNote}</div>
|
||||||
|
`;
|
||||||
|
preview.classList.add('visible');
|
||||||
|
|
||||||
|
document.getElementById('placeOrderBtn').disabled = false;
|
||||||
|
|
||||||
|
if (data.price) document.getElementById('orderPrice').value = data.price;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
showOrderStatus('Preview failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function placeOrder() {
|
||||||
|
if (!STATE.wallet) { alert('Connect wallet first'); return; }
|
||||||
|
|
||||||
|
const coin = document.getElementById('orderCoin').value;
|
||||||
|
const side = document.getElementById('orderSide').value;
|
||||||
|
const size = parseFloat(document.getElementById('orderSize').value);
|
||||||
|
const price = parseFloat(document.getElementById('orderPrice').value);
|
||||||
|
|
||||||
|
showOrderStatus('Placing order...', 'pending');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ address: STATE.wallet.address, coin, side, size, price }),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
statusEl.style.color = "var(--red)";
|
showOrderStatus(data.error, 'error');
|
||||||
statusEl.textContent = data.error;
|
return;
|
||||||
} else {
|
|
||||||
statusEl.style.color = "var(--green)";
|
|
||||||
statusEl.textContent = data.message || "Order signed — approve in wallet";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showOrderStatus(`✅ Order placed: ${side} ${size} ${coin} @ $${price}`, 'success');
|
||||||
|
document.getElementById('orderPreview').classList.remove('visible');
|
||||||
|
document.getElementById('orderSize').value = '';
|
||||||
|
document.getElementById('orderPrice').value = '';
|
||||||
|
setTimeout(() => loadOpenOrders(), 2000);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.style.color = "var(--red)";
|
showOrderStatus('Order failed: ' + e.message, 'error');
|
||||||
statusEl.textContent = e.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Agent Status ────────────────────────────────────────────────────────────
|
function showOrderStatus(msg, type) {
|
||||||
|
const el = document.getElementById('orderStatus');
|
||||||
async function loadAgentStatus() {
|
el.textContent = msg;
|
||||||
// Poll agent health endpoint
|
el.className = 'order-status visible ' + type;
|
||||||
try {
|
setTimeout(() => el.classList.remove('visible'), 6000);
|
||||||
const resp = await fetch("/health");
|
|
||||||
const data = await resp.json();
|
|
||||||
renderAgentStatus([{ agent: "dashboard", status: "running", ts: data.ts }]);
|
|
||||||
} catch (e) {
|
|
||||||
renderAgentStatus([{ agent: "dashboard", status: "error", ts: "—" }]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentStatus(agents) {
|
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||||
const el = document.getElementById("agentStatus");
|
|
||||||
const dotClass = { running: "ok", paused: "warn", error: "err" };
|
function openSettings() {
|
||||||
el.innerHTML = agents.map(a => `
|
document.getElementById('settingsModal').classList.add('open');
|
||||||
<div class="agent-row">
|
document.getElementById('execMode').value = STATE.execMode;
|
||||||
<div class="dot ${dotClass[a.status] || "warn"}"></div>
|
document.getElementById('minConviction').value = STATE.minConviction;
|
||||||
<div class="name">${a.agent}</div>
|
document.getElementById('hlKeyStatus').textContent = STATE.hlKeyConfigured ? '✅ Configured — live orders enabled' : '❌ Not configured — paper trading only';
|
||||||
<div class="status">${a.status} · ${a.ts || ""}</div>
|
document.getElementById('hlKeyStatus').style.color = STATE.hlKeyConfigured ? 'var(--green)' : 'var(--yellow)';
|
||||||
</div>
|
}
|
||||||
`).join("");
|
|
||||||
|
function closeSettings() {
|
||||||
|
document.getElementById('settingsModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
STATE.execMode = document.getElementById('execMode').value;
|
||||||
|
STATE.minConviction = parseFloat(document.getElementById('minConviction').value);
|
||||||
|
localStorage.setItem('salior_settings', JSON.stringify({ execMode: STATE.execMode, minConviction: STATE.minConviction }));
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utils ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shorten(addr) {
|
||||||
|
if (!addr) return '—';
|
||||||
|
return addr.slice(0, 6) + '…' + addr.slice(-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
// ─── Init ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
updateWalletUI();
|
// Restore wallet from localStorage
|
||||||
await Promise.all([
|
const saved = localStorage.getItem('salior_wallet');
|
||||||
loadSignals(),
|
if (saved) {
|
||||||
loadPortfolio(),
|
try {
|
||||||
loadPerformance(),
|
STATE.wallet = JSON.parse(saved);
|
||||||
loadAgentStatus(),
|
// Verify session still valid
|
||||||
]);
|
const resp = await fetch(`/api/wallet/session?address=${STATE.wallet.address}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.valid) {
|
||||||
|
STATE.wallet = null;
|
||||||
|
localStorage.removeItem('salior_wallet');
|
||||||
|
} else {
|
||||||
|
STATE.hlKeyConfigured = data.hl_private_key_configured || false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
STATE.wallet = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh signals every 30s
|
// Restore settings
|
||||||
setInterval(loadSignals, 30000);
|
const settings = localStorage.getItem('salior_settings');
|
||||||
setInterval(loadAgentStatus, 10000);
|
if (settings) {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(settings);
|
||||||
|
STATE.execMode = s.execMode || 'paper';
|
||||||
|
STATE.minConviction = s.minConviction || 0.7;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWalletUI();
|
||||||
|
|
||||||
|
if (STATE.wallet) {
|
||||||
|
await loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
setInterval(() => {
|
||||||
|
if (STATE.wallet) loadDashboard();
|
||||||
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -23,90 +23,142 @@
|
|||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; }
|
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; }
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* Nav */
|
nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); position: sticky; top: 0; z-index: 10; }
|
||||||
nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); }
|
|
||||||
nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; }
|
nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; }
|
||||||
nav .logo span { color: var(--accent); }
|
nav .logo span { color: var(--accent); }
|
||||||
nav .nav-links { display: flex; gap: 16px; margin-left: auto; }
|
nav .nav-links { display: flex; gap: 16px; margin-left: auto; }
|
||||||
nav .nav-links a { color: var(--text-dim); font-size: 13px; }
|
nav .nav-links a { color: var(--text-dim); font-size: 13px; }
|
||||||
nav .nav-links a:hover { color: var(--text); }
|
nav .nav-links a:hover, nav .nav-links a.active { color: var(--text); }
|
||||||
|
|
||||||
/* Main layout */
|
|
||||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
/* Wallet bar */
|
/* Wallet bar */
|
||||||
.wallet-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; }
|
.wallet-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; }
|
||||||
.wallet-bar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); }
|
.wallet-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.wallet-bar .status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
.wallet-dot.disconnected { background: var(--text-dim); }
|
||||||
.wallet-bar .address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; }
|
.wallet-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
.wallet-bar .btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; }
|
.wallet-dot.trading { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||||
.wallet-bar .btn:hover { opacity: 0.85; }
|
.wallet-address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.wallet-bar .btn-primary { background: var(--accent); color: #fff; }
|
.hl-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(91,138,240,0.2); color: var(--accent); margin-right: 4px; }
|
||||||
.wallet-bar .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
.trading-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(62,207,142,0.2); color: var(--green); margin-left: 8px; display: none; }
|
||||||
.wallet-bar .btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.trading-badge.visible { display: inline; }
|
||||||
|
.btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap; }
|
||||||
|
.btn:hover { opacity: 0.85; }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-danger { background: transparent; border: 1px solid var(--red); color: var(--red); }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.btn-buy { background: var(--green); color: #000; }
|
||||||
|
.btn-sell { background: var(--red); color: #fff; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
|
||||||
.card h2 { font-size: 15px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
.card h2 { font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||||
.card h2 .badge { background: var(--accent); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: none; }
|
.card h2 .badge { background: var(--accent); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: none; }
|
||||||
|
.card h2 .badge.green { background: var(--green); }
|
||||||
|
.card h2 .badge.red { background: var(--red); }
|
||||||
|
.card h2 .badge.yellow { background: var(--yellow); color: #000; }
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||||
|
|
||||||
/* Signal feed */
|
/* Signals */
|
||||||
.signal-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
.signal-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||||
.signal-item:last-child { border-bottom: none; }
|
.signal-item:last-child { border-bottom: none; }
|
||||||
.signal-item .coin { font-weight: 700; font-size: 14px; width: 60px; }
|
.signal-item .coin { font-weight: 700; font-size: 14px; width: 60px; }
|
||||||
.signal-item .regime { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; }
|
.signal-item .regime { font-size: 11px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0; }
|
||||||
.signal-item .regime.trending_up { background: rgba(62,207,142,0.15); color: var(--green); }
|
.signal-item .regime.trending_up { background: rgba(62,207,142,0.15); color: var(--green); }
|
||||||
.signal-item .regime.trending_down { background: rgba(240,91,91,0.15); color: var(--red); }
|
.signal-item .regime.trending_down { background: rgba(240,91,91,0.15); color: var(--red); }
|
||||||
.signal-item .regime.ranging { background: rgba(240,192,91,0.15); color: var(--yellow); }
|
.signal-item .regime.ranging { background: rgba(240,192,91,0.15); color: var(--yellow); }
|
||||||
.signal-item .regime.volatile { background: rgba(91,138,240,0.15); color: var(--accent2); }
|
.signal-item .regime.volatile { background: rgba(91,138,240,0.15); color: var(--accent2); }
|
||||||
.signal-item .conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
|
.conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
|
||||||
.signal-item .conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
.conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||||
.signal-item .conviction-fill.pos { background: var(--green); }
|
.conviction-fill.pos { background: var(--green); }
|
||||||
.signal-item .conviction-fill.neg { background: var(--red); }
|
.conviction-fill.neg { background: var(--red); }
|
||||||
.signal-item .conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; }
|
.conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; }
|
||||||
.signal-item .time { font-size: 11px; color: var(--text-dim); width: 80px; text-align: right; }
|
.signal-time { font-size: 11px; color: var(--text-dim); width: 70px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Portfolio */
|
/* Portfolio table */
|
||||||
.position-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
.pos-table { width: 100%; }
|
||||||
.position-row:last-child { border-bottom: none; }
|
.pos-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||||
.position-row .coin { font-weight: 700; font-size: 14px; width: 80px; }
|
.pos-row:last-child { border-bottom: none; }
|
||||||
.position-row .size { flex: 1; font-size: 13px; }
|
.pos-row .coin { font-weight: 700; width: 70px; }
|
||||||
.position-row .size .val { font-weight: 600; }
|
.pos-row .sz { flex: 1; font-size: 13px; }
|
||||||
.position-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; }
|
.pos-row .sz .val { font-weight: 600; }
|
||||||
.position-row .pnl.pos { color: var(--green); }
|
.pos-row .entry { font-size: 12px; color: var(--text-dim); width: 90px; }
|
||||||
.position-row .pnl.neg { color: var(--red); }
|
.pos-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; }
|
||||||
|
.pos-row .pnl.pos { color: var(--green); }
|
||||||
|
.pos-row .pnl.neg { color: var(--red); }
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.stat { text-align: center; }
|
.stat { text-align: center; }
|
||||||
.stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; }
|
.stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; }
|
||||||
.stat .val.pos { color: var(--green); }
|
.stat .val.pos { color: var(--green); }
|
||||||
.stat .val.neg { color: var(--red); }
|
.stat .val.neg { color: var(--red); }
|
||||||
|
.stat .val.dim { color: var(--text-dim); font-size: 20px; }
|
||||||
.stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
.stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||||
|
|
||||||
/* Order form */
|
/* Order form */
|
||||||
.order-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; }
|
.order-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; }
|
||||||
.order-form label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px; }
|
.order-form label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px; }
|
||||||
.order-form select, .order-form input { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
|
.order-form input, .order-form select { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
|
||||||
.order-form select:focus, .order-form input:focus { outline: none; border-color: var(--accent); }
|
.order-form input:focus, .order-form select:focus { outline: none; border-color: var(--accent); }
|
||||||
.order-form .btn { padding: 8px 20px; }
|
.order-preview { margin-top: 10px; padding: 10px; background: var(--surface2); border-radius: 6px; font-size: 13px; color: var(--text-dim); display: none; }
|
||||||
|
.order-preview.visible { display: block; }
|
||||||
|
.order-preview .total { font-weight: 700; color: var(--text); font-size: 15px; margin-top: 4px; }
|
||||||
|
.order-status { margin-top: 10px; font-size: 13px; padding: 8px 12px; border-radius: 6px; display: none; }
|
||||||
|
.order-status.visible { display: block; }
|
||||||
|
.order-status.success { background: rgba(62,207,142,0.1); color: var(--green); border: 1px solid rgba(62,207,142,0.3); }
|
||||||
|
.order-status.error { background: rgba(240,91,91,0.1); color: var(--red); border: 1px solid rgba(240,91,91,0.3); }
|
||||||
|
.order-status.pending { background: rgba(240,192,91,0.1); color: var(--yellow); border: 1px solid rgba(240,192,91,0.3); }
|
||||||
|
|
||||||
|
/* HL info card */
|
||||||
|
.hl-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.hl-info-item { padding: 10px; background: var(--surface2); border-radius: 6px; }
|
||||||
|
.hl-info-item .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.hl-info-item .value { font-size: 18px; font-weight: 700; margin-top: 2px; }
|
||||||
|
.hl-info-item .value.pos { color: var(--green); }
|
||||||
|
.hl-info-item .value.neg { color: var(--red); }
|
||||||
|
|
||||||
/* Agent status */
|
/* Agent status */
|
||||||
.agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; }
|
.agent-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
|
||||||
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; }
|
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.agent-row .dot.ok { background: var(--green); }
|
.agent-row .dot.ok { background: var(--green); }
|
||||||
.agent-row .dot.warn { background: var(--yellow); }
|
.agent-row .dot.warn { background: var(--yellow); }
|
||||||
.agent-row .dot.err { background: var(--red); }
|
.agent-row .dot.err { background: var(--red); }
|
||||||
.agent-row .name { font-weight: 600; width: 120px; }
|
.agent-row .dot.offline { background: var(--text-dim); }
|
||||||
|
.agent-row .name { font-weight: 600; width: 120px; font-size: 13px; }
|
||||||
.agent-row .status { font-size: 12px; color: var(--text-dim); }
|
.agent-row .status { font-size: 12px; color: var(--text-dim); }
|
||||||
|
.agent-row .badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: 6px; }
|
||||||
|
.agent-row .badge.live { background: rgba(62,207,142,0.2); color: var(--green); }
|
||||||
|
.agent-row .badge.paper { background: rgba(240,192,91,0.2); color: var(--yellow); }
|
||||||
|
|
||||||
|
/* Connect modal */
|
||||||
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; max-width: 440px; width: 90%; }
|
||||||
|
.modal h3 { font-size: 16px; margin-bottom: 6px; }
|
||||||
|
.modal p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.6; }
|
||||||
|
.modal .form-field { margin-bottom: 14px; }
|
||||||
|
.modal label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
|
||||||
|
.modal input { width: 100%; padding: 9px 12px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
|
||||||
|
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
.modal .error-msg { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
|
||||||
|
.modal .error-msg.visible { display: block; }
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.empty { text-align: center; padding: 32px; color: var(--text-dim); font-size: 13px; }
|
.empty { text-align: center; padding: 28px; color: var(--text-dim); font-size: 13px; }
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section { display: none; }
|
||||||
|
.section.active { display: block; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2, .grid-3, .order-form, .hl-info-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -114,90 +166,243 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<div class="logo">sali<span>or</span></div>
|
<div class="logo">sali<span>or</span></div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#signals">Signals</a>
|
<a href="#" class="nav-tab active" data-section="dashboard">Dashboard</a>
|
||||||
<a href="#portfolio">Portfolio</a>
|
<a href="#" class="nav-tab" data-section="trade">Trade</a>
|
||||||
<a href="#performance">Performance</a>
|
<a href="#" class="nav-tab" data-section="agents">Agents</a>
|
||||||
<a href="#agents">Agents</a>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- Wallet Bar -->
|
<!-- Wallet Bar (always visible) -->
|
||||||
<div class="wallet-bar">
|
<div class="wallet-bar">
|
||||||
<div class="status-dot" id="walletDot"></div>
|
<div class="wallet-dot disconnected" id="walletDot"></div>
|
||||||
<div class="address" id="walletAddress">Not connected</div>
|
<div class="wallet-address" id="walletAddress">No wallet connected</div>
|
||||||
<button class="btn btn-primary" id="connectBtn" onclick="connectWallet()">Connect Wallet</button>
|
<span class="hl-badge" id="hlBadge" style="display:none">HL</span>
|
||||||
<button class="btn btn-outline" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
|
<span class="trading-badge" id="tradingBadge">LIVE</span>
|
||||||
|
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="openSettings()" style="display:none">Settings</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="connectBtn" onclick="openConnectModal()">Connect Wallet</button>
|
||||||
|
<button class="btn btn-outline btn-sm" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signals -->
|
<!-- ─── Dashboard section ─── -->
|
||||||
<div class="grid-2">
|
<div class="section active" id="section-dashboard">
|
||||||
<div class="card" id="signals-card">
|
|
||||||
<h2>Conviction Signals <span class="badge">LIVE</span></h2>
|
<!-- HL Account Summary (shown when connected) -->
|
||||||
<div id="signalsFeed"><div class="empty">Loading signals...</div></div>
|
<div class="card" id="hlAccountCard" style="display:none">
|
||||||
|
<h2>Hyperliquid Account</h2>
|
||||||
|
<div class="hl-info-grid">
|
||||||
|
<div class="hl-info-item">
|
||||||
|
<div class="label">Total Collateral</div>
|
||||||
|
<div class="value" id="hlCollateral">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="hl-info-item">
|
||||||
|
<div class="label">Margin Used</div>
|
||||||
|
<div class="value" id="hlMargin">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<div class="hl-info-item">
|
||||||
|
<div class="label">Open Orders</div>
|
||||||
|
<div class="value dim" id="hlOpenOrders">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="hl-info-item">
|
||||||
|
<div class="label">Positions</div>
|
||||||
|
<div class="value dim" id="hlPositions">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<!-- 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>Positions</h2>
|
||||||
|
<div class="pos-table" id="posTable">
|
||||||
|
<div class="empty">No positions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Performance</h2>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="val dim" id="pnlVal">—</div>
|
||||||
|
<div class="lbl">Total PnL</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="val dim" id="sharpeVal">—</div>
|
||||||
|
<div class="lbl">Sharpe Ratio</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="val dim" id="drawdownVal">—</div>
|
||||||
|
<div class="lbl">Max Drawdown</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
<label>Coin</label>
|
||||||
|
<select id="orderCoin">
|
||||||
|
<option value="BTC">BTC</option>
|
||||||
|
<option value="ETH">ETH</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Side</label>
|
||||||
|
<select id="orderSide">
|
||||||
|
<option value="Buy">Buy / Long</option>
|
||||||
|
<option value="Sell">Sell / Short</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Size</label>
|
||||||
|
<input type="number" id="orderSize" placeholder="0.01" step="0.001" min="0.001" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Price</label>
|
||||||
|
<input type="number" id="orderPrice" placeholder="Market" step="0.01" />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Portfolio</h2>
|
<h2>Pipeline</h2>
|
||||||
<div id="portfolioView"><div class="empty">No positions</div></div>
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:12px; text-align:center; font-size:12px; color:var(--text-dim);">
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div style="font-size:20px; margin-bottom:4px;">📡</div>
|
||||||
|
<div style="font-weight:600;">data_agent</div>
|
||||||
<!-- Performance -->
|
<div>HL WS → TimescaleDB</div>
|
||||||
<div class="card" id="performance">
|
</div>
|
||||||
<h2>Performance</h2>
|
<div>
|
||||||
<div class="grid-3">
|
<div style="font-size:20px; margin-bottom:4px;">🧠</div>
|
||||||
<div class="stat">
|
<div style="font-weight:600;">signal_agent</div>
|
||||||
<div class="val" id="pnlVal">—</div>
|
<div>candles → conviction</div>
|
||||||
<div class="lbl">Total PnL</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="stat">
|
<div style="font-size:20px; margin-bottom:4px;">⚡</div>
|
||||||
<div class="val" id="sharpeVal">—</div>
|
<div style="font-weight:600;">exec_agent</div>
|
||||||
<div class="lbl">Sharpe Ratio</div>
|
<div>signals → HL orders</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div>
|
||||||
<div class="val" id="drawdownVal">—</div>
|
<div style="font-size:20px; margin-bottom:4px;">🛡️</div>
|
||||||
<div class="lbl">Max Drawdown</div>
|
<div style="font-weight:600;">risk_agent</div>
|
||||||
|
<div>circuit breakers</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Form -->
|
</div>
|
||||||
<div class="card" id="portfolio">
|
|
||||||
<h2>Place Order</h2>
|
<!-- ─── Connect Modal ─── -->
|
||||||
<div class="order-form">
|
<div class="modal-overlay" id="connectModal">
|
||||||
<div>
|
<div class="modal">
|
||||||
<label>Coin</label>
|
<h3>Connect Hyperliquid Wallet</h3>
|
||||||
<select id="orderCoin">
|
<p>Enter your Hyperliquid wallet address to connect. Your wallet is read-only — orders are signed server-side using your configured API key.</p>
|
||||||
<option value="BTC">BTC</option>
|
|
||||||
<option value="ETH">ETH</option>
|
<div class="form-field">
|
||||||
</select>
|
<label>HL Wallet Address</label>
|
||||||
</div>
|
<input type="text" id="walletInput" placeholder="0x..." maxlength="42" />
|
||||||
<div>
|
<div class="error-msg" id="walletError"></div>
|
||||||
<label>Side</label>
|
|
||||||
<select id="orderSide">
|
|
||||||
<option value="buy">Buy</option>
|
|
||||||
<option value="sell">Sell</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Size</label>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="orderStatus" style="margin-top:12px;font-size:13px;color:var(--text-dim);"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Agent Status -->
|
<div style="font-size:12px; color:var(--text-dim); margin-bottom:4px; display:none" id="detectedWallet">
|
||||||
<div class="card" id="agents">
|
Detected: <span id="detectedAddr"></span>
|
||||||
<h2>Agent Health</h2>
|
</div>
|
||||||
<div id="agentStatus"><div class="empty">Loading...</div></div>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|||||||
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