Add: dashboard (web UI), compute deploy, risk agent, daemon, plan/test skills

This commit is contained in:
Hermes 2026-05-11 09:22:59 +00:00
parent 1eb2ecc320
commit a9b1472068
14 changed files with 1217 additions and 5 deletions

View File

@ -0,0 +1,4 @@
"""Risk module."""
from salior.agents.risk.agent import RiskAgent
__all__ = ["RiskAgent"]

View File

@ -0,0 +1,79 @@
"""Risk management agent."""
from __future__ import annotations
from salior.core.agent import Agent
from salior.core.config import config
from salior.core.logging import setup_logging
from salior.db.timescale_client import TimescaleDB
from salior.db.supabase_client import SupabaseClient
log = setup_logging()
# Risk rules
MAX_POSITION_PCT = 0.10 # Max 10% of portfolio per coin
MAX_DRAWDOWN = 0.20 # Stop all trading at -20%
MAX_DAILY_LOSS = 0.05 # Pause at -5% daily loss
MIN_CONVICTION_TO_TRADE = 0.3
class RiskAgent(Agent):
"""Position sizing, drawdown limits, exposure checks."""
name = "risk_agent"
def __init__(self) -> None:
super().__init__()
self._db = TimescaleDB()
self._supabase = SupabaseClient()
self._paused = False
self._pause_reason = ""
def loop_interval(self) -> float:
return 30.0
async def run(self) -> None:
"""Check portfolio against risk rules."""
await self._db.connect()
portfolio = await self._supabase.get_portfolio()
for pos in portfolio:
await self._check_position(pos)
await self._check_drawdown()
await self._db.log_health(self.name, "running", iteration=self._iteration)
async def _check_position(self, pos: dict) -> None:
"""Check if a position exceeds max size."""
coin = pos.get("coin", "")
size = abs(pos.get("pos_size", 0))
if size == 0:
return
# Placeholder: would need total portfolio value
# For now: just log
log.debug("position_check", coin=coin, size=size)
async def _check_drawdown(self) -> None:
"""Check if drawdown exceeds limits."""
# Would read from performance table
pass
def pause(self, reason: str) -> None:
"""Pause trading due to risk breach."""
if not self._paused:
self._paused = True
self._pause_reason = reason
log.warning("risk_pause", reason=reason)
def resume(self) -> None:
"""Resume trading."""
if self._paused:
self._paused = False
self._pause_reason = ""
log.info("risk_resume")
def is_paused(self) -> bool:
return self._paused
def get_pause_reason(self) -> str:
return self._pause_reason

View File

@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import click import click
from pathlib import Path
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
@ -31,11 +33,12 @@ def status() -> None:
def db_init() -> None: def db_init() -> None:
"""Initialize the database schema.""" """Initialize the database schema."""
import asyncpg import asyncpg
click.echo(f"Connecting to {config.timeseries_url}...") click.echo(f"Connecting to {config.timeseries_url}...")
async def run() -> None: async def run() -> None:
conn = await asyncpg.connect(config.timeseries_url) conn = await asyncpg.connect(config.timeseries_url)
schema = Path(__file__).parent.parent / "db" / "schema.sql" schema = Path(__file__).parent / "db" / "schema.sql"
sql = schema.read_text() sql = schema.read_text()
await conn.execute(sql) await conn.execute(sql)
await conn.close() await conn.close()
@ -46,19 +49,25 @@ def db_init() -> None:
@main.command() @main.command()
def agent_start() -> None: def agent_start() -> None:
"""Start all agents.""" """Start all agents (data + signal + exec + risk)."""
click.echo("Starting agents...") click.echo("Starting agents...")
from salior.agents.data.agent import DataAgent
from salior.agents.signal.agent import SignalAgent
async def run() -> None: async def run() -> None:
from salior.agents.data.agent import DataAgent
from salior.agents.signal.agent import SignalAgent
from salior.agents.exec.agent import ExecAgent
from salior.agents.risk.agent import RiskAgent
data = DataAgent() data = DataAgent()
signal = SignalAgent() signal = SignalAgent()
exec_ = ExecAgent()
risk = RiskAgent()
await asyncio.gather( await asyncio.gather(
data.start(), data.start(),
signal.start(), signal.start(),
exec_.start(),
risk.start(),
) )
try: try:
@ -67,10 +76,31 @@ def agent_start() -> None:
click.echo("Agents stopped.") click.echo("Agents stopped.")
@main.command()
def dashboard_serve() -> None:
"""Start the web dashboard."""
from salior.dashboard.server import create_app
from aiohttp import web
click.echo(f"Starting dashboard on http://{config.host}:{config.port}...")
async def run() -> None:
app = create_app()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, config.host, config.port)
await site.start()
click.echo(f"Dashboard running at http://{config.host}:{config.port}")
await asyncio.Event().wait()
asyncio.run(run())
@main.command() @main.command()
def mcp_serve() -> None: def mcp_serve() -> None:
"""Start the MCP server.""" """Start the MCP server."""
from salior.mcp.server import MCPServer from salior.mcp.server import MCPServer
click.echo(f"Starting MCP server on {config.host}:{config.port}...") click.echo(f"Starting MCP server on {config.host}:{config.port}...")
async def run() -> None: async def run() -> None:
@ -85,6 +115,7 @@ def mcp_serve() -> None:
def plugin_list() -> None: def plugin_list() -> None:
"""List available plugins.""" """List available plugins."""
from salior.plugins import registry from salior.plugins import registry
plugins = registry.discover() plugins = registry.discover()
click.echo(f"=== Plugins ({len(plugins)}) ===") click.echo(f"=== Plugins ({len(plugins)}) ===")
for p in registry.list(): for p in registry.list():
@ -92,5 +123,33 @@ def plugin_list() -> None:
click.echo(f"{status} {p['name']}: {p['description']}") click.echo(f"{status} {p['name']}: {p['description']}")
@main.command()
def skill_list() -> None:
"""List available skills."""
from salior.skills import SkillRegistry
reg = SkillRegistry()
skills = reg.discover()
click.echo(f"=== Skills ({len(skills)}) ===")
for name in sorted(skills.keys()):
click.echo(f" {name}")
@main.command()
def compute_status() -> None:
"""List registered compute nodes."""
from salior.compute import NodeManager
mgr = NodeManager()
nodes = mgr.list()
if not nodes:
click.echo("No nodes registered. Add one with: salior compute add <name> <host>")
return
click.echo(f"=== Nodes ({len(nodes)}) ===")
for n in nodes:
gpu = "🖥️" if n["gpu"] else "💻"
click.echo(f"{gpu} {n['name']}: {n['user']}@{n['host']}:{n['port']}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,5 @@
"""Compute module — plugin deployment orchestration."""
from salior.compute.node_manager import NodeManager
from salior.compute.deploy import deploy_plugin
__all__ = ["NodeManager", "deploy_plugin"]

103
salior/compute/deploy.py Normal file
View File

@ -0,0 +1,103 @@
"""Plugin deployment to remote nodes."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
from salior.compute.node_manager import NodeManager
from salior.core.logging import setup_logging
log = setup_logging()
async def deploy_plugin(
plugin_name: str,
target: str,
plugin_dir: Optional[Path] = None,
plugin_dir_path: Optional[str] = None,
) -> dict:
"""Deploy a plugin to a target node.
Args:
plugin_name: Name of the plugin to deploy
target: Node name (from node registry) or 'local'
plugin_dir: Local plugins directory
plugin_dir_path: Remote path on target node (default: /opt/salior/plugins/)
Returns:
{"status": "ok|deployed|error", "message": str}
"""
plugin_dir = plugin_dir or (Path(__file__).parent.parent.parent / "plugins")
remote_path = plugin_dir_path or f"/opt/salior/plugins/{plugin_name}"
mgr = NodeManager()
if target == "local":
log.info("deploy_plugin_local", plugin=plugin_name)
return {"status": "ok", "message": f"Plugin {plugin_name} already on local host"}
node = mgr.get(target)
if not node:
return {"status": "error", "message": f"Unknown node: {target}"}
# Check plugin exists locally
local_path = plugin_dir / plugin_name
if not local_path.exists():
return {"status": "error", "message": f"Plugin {plugin_name} not found at {local_path}"}
log.info("deploy_plugin", plugin=plugin_name, target=target, remote_path=remote_path)
# rsync plugin dir to target
key_arg: list[str] = []
if node.ssh_key_path:
key_arg = ["-e", f"ssh -i {node.ssh_key_path}"]
rsync_cmd = [
"rsync", "-az",
"--compress",
f"--rsync-path=mkdir -p {remote_path} && rsync",
*key_arg,
str(local_path) + "/",
f"{node.user}@{node.host}:{remote_path}/",
]
proc = await asyncio.create_subprocess_exec(
*rsync_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.error("deploy_rsync_failed", stderr=stderr.decode())
return {"status": "error", "message": f"rsync failed: {stderr.decode()}"}
# Restart plugin service on target (if systemd/supervisor available)
restart_cmd = f"systemctl restart salior-plugin-{plugin_name} 2>/dev/null || true"
_, _, _ = await mgr.run(target, restart_cmd)
return {
"status": "deployed",
"plugin": plugin_name,
"target": target,
"remote_path": remote_path,
}
async def status_plugin(plugin_name: str, target: str) -> dict:
"""Check plugin status on a target node."""
mgr = NodeManager()
node = mgr.get(target)
if not node:
return {"status": "error", "message": f"Unknown node: {target}"}
cmd = f"systemctl status salior-plugin-{plugin_name} 2>/dev/null || pgrep -f 'salior.*{plugin_name}' || echo 'not running'"
code, stdout, stderr = await mgr.run(target, cmd)
return {
"plugin": plugin_name,
"target": target,
"running": code == 0,
"info": stdout.strip() or stderr.strip(),
}

View File

@ -0,0 +1,148 @@
"""Node registry and SSH-based deployment."""
from __future__ import annotations
import asyncio
import yaml
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from salior.core.logging import setup_logging
log = setup_logging()
NODES_FILE = Path.home() / ".salior" / "nodes.yaml"
@dataclass
class Node:
"""A known compute node."""
name: str
host: str
port: int = 22
user: str = "root"
gpu: bool = False
gpu_memory_gb: int = 0
labels: list[str] = field(default_factory=list)
ssh_key_path: Optional[str] = None
class NodeManager:
"""Manages known nodes and SSH access."""
def __init__(self, nodes_file: Optional[Path] = None) -> None:
self.nodes_file = nodes_file or NODES_FILE
self._nodes: dict[str, Node] = {}
self._load()
def _load(self) -> None:
"""Load nodes from YAML file."""
if not self.nodes_file.exists():
return
data = yaml.safe_load(self.nodes_file.read_text()) or {}
for name, info in data.items():
self._nodes[name] = Node(
name=name,
host=info["host"],
port=info.get("port", 22),
user=info.get("user", "root"),
gpu=info.get("gpu", False),
gpu_memory_gb=info.get("gpu_memory_gb", 0),
labels=info.get("labels", []),
ssh_key_path=info.get("ssh_key_path"),
)
def save(self) -> None:
"""Save nodes to YAML file."""
self.nodes_file.parent.mkdir(parents=True, exist_ok=True)
data = {
name: {
"host": n.host,
"port": n.port,
"user": n.user,
"gpu": n.gpu,
"gpu_memory_gb": n.gpu_memory_gb,
"labels": n.labels,
"ssh_key_path": n.ssh_key_path,
}
for name, n in self._nodes.items()
}
self.nodes_file.write_text(yaml.dump(data))
def add(self, node: Node) -> None:
"""Register a new node."""
self._nodes[node.name] = node
self.save()
def get(self, name: str) -> Optional[Node]:
"""Get a node by name."""
return self._nodes.get(name)
def list(self) -> list[dict]:
"""List all nodes."""
return [
{
"name": n.name,
"host": n.host,
"port": n.port,
"user": n.user,
"gpu": n.gpu,
"labels": n.labels,
}
for n in self._nodes.values()
]
async def ping(self, name: str) -> bool:
"""Ping a node via SSH."""
node = self.get(name)
if not node:
return False
proc = await asyncio.create_subprocess_exec(
"ssh", "-p", str(node.port),
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=5",
f"{node.user}@{node.host}",
"echo", "ok",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
await asyncio.wait_for(proc.communicate(), timeout=10)
return proc.returncode == 0
except asyncio.TimeoutError:
proc.kill()
return False
async def run(
self,
name: str,
command: str,
capture: bool = True,
) -> tuple[int, str, str]:
"""Run a command on a node via SSH."""
node = self.get(name)
if not node:
raise ValueError(f"Unknown node: {name}")
key_arg: list[str] = []
if node.ssh_key_path:
key_arg = ["-i", node.ssh_key_path]
cmd = ["ssh", "-p", str(node.port), "-o", "StrictHostKeyChecking=no"] + key_arg + [
f"{node.user}@{node.host}", command
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
def nodes_for_plugin(self, requires_gpu: bool = False) -> list[Node]:
"""Find nodes suitable for a plugin's requirements."""
return [
n for n in self._nodes.values()
if not requires_gpu or n.gpu
]

93
salior/daemon.py Normal file
View File

@ -0,0 +1,93 @@
"""Daemon — background process management."""
from __future__ import annotations
import asyncio
import os
import signal
import sys
from pathlib import Path
from typing import Optional
import structlog
from salior.core.logging import setup_logging
log = setup_logging()
PID_DIR = Path.home() / ".salior" / "run"
PID_DIR.mkdir(parents=True, exist_ok=True)
class Daemon:
"""Background daemon with PID file and graceful shutdown."""
def __init__(self, name: str) -> None:
self.name = name
self.pid_file = PID_DIR / f"{name}.pid"
self._task: Optional[asyncio.Task] = None
self._stopping = False
def pid(self) -> Optional[int]:
"""Return current PID if running, else None."""
if not self.pid_file.exists():
return None
try:
return int(self.pid_file.read_text().strip())
except (ValueError, FileNotFoundError):
return None
def is_running(self) -> bool:
"""True if PID file exists and process is alive."""
pid = self.pid()
if pid is None:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
async def start(self, coro) -> None:
"""Start daemon, writing PID file."""
if self.is_running():
log.warning("daemon_already_running", name=self.name, pid=self.pid())
return
pid = os.getpid()
self.pid_file.write_text(str(pid))
log.info("daemon_started", name=self.name, pid=pid)
# Handle signals
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop()))
self._task = asyncio.create_task(coro)
try:
await self._task
except asyncio.CancelledError:
pass
async def stop(self) -> None:
"""Stop daemon gracefully."""
self._stopping = True
log.info("daemon_stopping", name=self.name)
if self._task:
self._task.cancel()
try:
await asyncio.wait_for(self._task, timeout=5.0)
except asyncio.CancelledError:
pass
try:
self.pid_file.unlink()
except FileNotFoundError:
pass
log.info("daemon_stopped", name=self.name)
def make_daemon(name: str) -> Daemon:
return Daemon(name)

View File

@ -0,0 +1,4 @@
"""Dashboard package."""
from salior.dashboard.server import create_app
__all__ = ["create_app"]

171
salior/dashboard/server.py Normal file
View File

@ -0,0 +1,171 @@
"""Dashboard server — FastAPI + vanilla HTML/JS."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from aiohttp import web
from aiohttp.web import Application, Request, Response
from salior.core.config import config
from salior.core.logging import setup_logging
from salior.db.supabase_client import SupabaseClient
from salior.wallet.connect import WalletSession
log = setup_logging()
TEMPLATES = Path(__file__).parent / "templates"
STATIC = Path(__file__).parent / "static"
async def health_handler(request: Request) -> Response:
return web.json_response({"status": "ok", "ts": datetime.utcnow().isoformat()})
async def api_portfolio(request: Request) -> Response:
"""Get current portfolio positions."""
supabase = SupabaseClient()
portfolio = await supabase.get_portfolio()
return web.json_response({"positions": portfolio})
async def api_signals(request: Request) -> Response:
"""Get recent conviction signals."""
supabase = SupabaseClient()
coin = request.query.get("coin")
signals = await supabase.get_recent_signals(coin=coin, limit=20)
return web.json_response({"signals": signals})
async def api_performance(request: Request) -> Response:
"""Get performance metrics."""
# Placeholder — read from performance table
return web.json_response({
"days": 30,
"total_pnl": 0,
"sharpe": 0,
"max_drawdown": 0,
"trades_count": 0,
})
async def api_wallet_connect(request: Request) -> Response:
"""Handle wallet connect callback after user signs auth message."""
body = await request.json()
address = body.get("address", "")
signature = body.get("signature", "")
message = body.get("message", "")
if not all([address, signature, message]):
return web.json_response(
{"error": "missing fields"}, status=400
)
try:
wallet = WalletSession()
session = await wallet.connect(address, signature, message)
return web.json_response({"session": session})
except ValueError as e:
return web.json_response({"error": str(e)}, status=401)
async def api_wallet_session(request: Request) -> Response:
"""Check existing wallet session."""
address = request.query.get("address", "")
if not address:
return web.json_response({"error": "missing address"}, status=400)
wallet = WalletSession()
session = await wallet.get_session(address)
if session:
return web.json_response({"session": session, "valid": True})
return web.json_response({"session": None, "valid": False})
async def api_wallet_auth_message(request: Request) -> Response:
"""Get auth message for a wallet address."""
address = request.query.get("address", "")
if not address:
return web.json_response({"error": "missing address"}, status=400)
wallet = WalletSession()
message = wallet.generate_auth_message(address)
return web.json_response({"message": message})
async def api_order(request: Request) -> Response:
"""Place an order (requires wallet connection)."""
body = await request.json()
address = body.get("address", "")
coin = body.get("coin", "")
side = body.get("side", "")
size = body.get("size", 0)
price = body.get("price", 0)
if not all([address, coin, side, size]):
return web.json_response({"error": "missing fields"}, status=400)
# Check wallet session
wallet = WalletSession()
session = await wallet.get_session(address)
if not session:
return web.json_response(
{"error": "wallet not connected"}, status=401
)
log.info("order_request", address=address, coin=coin, side=side, size=size, price=price)
return web.json_response({
"status": "requires_signature",
"message": f"Sign to place {side} order for {size} {coin}",
"order": {"coin": coin, "side": side, "size": size, "price": price},
})
async def index_handler(request: Request) -> Response:
"""Serve the main dashboard HTML."""
tmpl_path = TEMPLATES / "index.html"
html = tmpl_path.read_text()
return Response(text=html, content_type="text/html")
def create_app() -> Application:
"""Build the aiohttp application."""
app = Application()
# Static files
app.router.add_static("/static/", STATIC, show_index=True)
# Routes
app.router.add_get("/", index_handler)
app.router.add_get("/health", health_handler)
app.router.add_get("/api/portfolio", api_portfolio)
app.router.add_get("/api/signals", api_signals)
app.router.add_get("/api/performance", api_performance)
app.router.add_post("/api/wallet/connect", api_wallet_connect)
app.router.add_get("/api/wallet/session", api_wallet_session)
app.router.add_get("/api/wallet/auth-message", api_wallet_auth_message)
app.router.add_post("/api/order", api_order)
return app
async def main() -> None:
"""Run the dashboard server."""
app = create_app()
host = config.host
port = config.port
log.info("dashboard_starting", host=host, port=port)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
log.info("dashboard_running", url=f"http://{host}:{port}")
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,259 @@
/* Salior Dashboard — app.js */
// State
let walletAddress = localStorage.getItem("salior_wallet") || null;
let sessionToken = localStorage.getItem("salior_session") || null;
// ─── Wallet Connect ───────────────────────────────────────────────────────────
async function connectWallet() {
if (!window.ethereum) {
alert("Please install MetaMask or Rabby wallet");
return;
}
try {
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
const address = accounts[0];
// Get auth message from backend
const msgResp = await fetch(`/api/wallet/auth-message?address=${address}`);
const { message } = await msgResp.json();
// Sign it
const signature = await window.ethereum.request({
method: "personal_sign",
params: [message, address],
});
// Submit to backend
const resp = await fetch("/api/wallet/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, signature, message }),
});
const data = await resp.json();
if (data.error) {
alert("Auth failed: " + data.error);
return;
}
// Store session
walletAddress = address;
sessionToken = data.session.session_token;
localStorage.setItem("salior_wallet", address);
localStorage.setItem("salior_session", sessionToken);
updateWalletUI();
} catch (e) {
console.error("connectWallet error", e);
alert("Connection failed: " + e.message);
}
}
function disconnectWallet() {
walletAddress = null;
sessionToken = null;
localStorage.removeItem("salior_wallet");
localStorage.removeItem("salior_session");
updateWalletUI();
}
function updateWalletUI() {
const dot = document.getElementById("walletDot");
const addr = document.getElementById("walletAddress");
const connBtn = document.getElementById("connectBtn");
const discBtn = document.getElementById("disconnectBtn");
if (walletAddress) {
dot.classList.add("connected");
addr.textContent = shorten(walletAddress);
connBtn.style.display = "none";
discBtn.style.display = "block";
} else {
dot.classList.remove("connected");
addr.textContent = "Not connected";
connBtn.style.display = "block";
discBtn.style.display = "none";
}
}
function shorten(addr) {
return addr ? addr.slice(0, 6) + "..." + addr.slice(-4) : "";
}
// ─── 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() {
try {
const resp = await fetch("/api/portfolio");
const { positions } = await resp.json();
renderPortfolio(positions || []);
} catch (e) {
console.error("loadPortfolio error", e);
}
}
function renderPortfolio(positions) {
const el = document.getElementById("portfolioView");
if (!positions.length) {
el.innerHTML = '<div class="empty">No positions</div>';
return;
}
el.innerHTML = positions.map(pos => {
const pnl = pos.unrealized_pnl || 0;
const pnlClass = pnl >= 0 ? "pos" : "neg";
return `
<div class="position-row">
<div class="coin">${pos.coin}</div>
<div class="size"><span class="val">${pos.pos_size}</span> @ ${pos.avg_px}</div>
<div class="pnl ${pnlClass}">${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}</div>
</div>
`;
}).join("");
}
// ─── Performance ─────────────────────────────────────────────────────────────
async function loadPerformance() {
try {
const resp = await fetch("/api/performance");
const data = await resp.json();
renderPerformance(data);
} catch (e) {
console.error("loadPerformance error", e);
}
}
function renderPerformance(data) {
document.getElementById("pnlVal").textContent = data.total_pnl ? `$${data.total_pnl.toFixed(2)}` : "—";
document.getElementById("pnlVal").className = "val " + (data.total_pnl >= 0 ? "pos" : "neg");
document.getElementById("sharpeVal").textContent = data.sharpe ? data.sharpe.toFixed(2) : "—";
document.getElementById("drawdownVal").textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : "—";
}
// ─── Orders ─────────────────────────────────────────────────────────────────
async function placeOrder() {
const coin = document.getElementById("orderCoin").value;
const side = document.getElementById("orderSide").value;
const size = parseFloat(document.getElementById("orderSize").value);
const statusEl = document.getElementById("orderStatus");
if (!walletAddress) {
statusEl.style.color = "var(--red)";
statusEl.textContent = "Connect wallet first";
return;
}
if (!size || size <= 0) {
statusEl.style.color = "var(--red)";
statusEl.textContent = "Enter a valid size";
return;
}
statusEl.style.color = "var(--text-dim)";
statusEl.textContent = "Signing...";
try {
const resp = await fetch("/api/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: walletAddress, coin, side, size }),
});
const data = await resp.json();
if (data.error) {
statusEl.style.color = "var(--red)";
statusEl.textContent = data.error;
} else {
statusEl.style.color = "var(--green)";
statusEl.textContent = data.message || "Order signed — approve in wallet";
}
} catch (e) {
statusEl.style.color = "var(--red)";
statusEl.textContent = e.message;
}
}
// ─── Agent Status ────────────────────────────────────────────────────────────
async function loadAgentStatus() {
// Poll agent health endpoint
try {
const resp = await fetch("/health");
const data = await resp.json();
renderAgentStatus([{ agent: "dashboard", status: "running", ts: data.ts }]);
} catch (e) {
renderAgentStatus([{ agent: "dashboard", status: "error", ts: "—" }]);
}
}
function renderAgentStatus(agents) {
const el = document.getElementById("agentStatus");
const dotClass = { running: "ok", paused: "warn", error: "err" };
el.innerHTML = agents.map(a => `
<div class="agent-row">
<div class="dot ${dotClass[a.status] || "warn"}"></div>
<div class="name">${a.agent}</div>
<div class="status">${a.status} · ${a.ts || ""}</div>
</div>
`).join("");
}
// ─── Init ────────────────────────────────────────────────────────────────────
async function init() {
updateWalletUI();
await Promise.all([
loadSignals(),
loadPortfolio(),
loadPerformance(),
loadAgentStatus(),
]);
// Auto-refresh signals every 30s
setInterval(loadSignals, 30000);
setInterval(loadAgentStatus, 10000);
}
init();

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#18181d"/>
<text x="16" y="22" font-family="sans-serif" font-size="18" font-weight="bold" fill="#5b8af0" text-anchor="middle">S</text>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salior — Trading System</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<style>
:root {
--bg: #0d0d0f;
--surface: #18181d;
--surface2: #22222a;
--border: #2e2e3a;
--text: #e8e8f0;
--text-dim: #8888a0;
--accent: #5b8af0;
--accent2: #9b6ff0;
--green: #3ecf8e;
--red: #f05b5b;
--yellow: #f0c05b;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); }
nav .logo { font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; }
nav .logo span { color: var(--accent); }
nav .nav-links { display: flex; gap: 16px; margin-left: auto; }
nav .nav-links a { color: var(--text-dim); font-size: 13px; }
nav .nav-links a:hover { color: var(--text); }
/* Main layout */
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
/* Wallet bar */
.wallet-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; }
.wallet-bar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); }
.wallet-bar .status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.wallet-bar .address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; }
.wallet-bar .btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; }
.wallet-bar .btn:hover { opacity: 0.85; }
.wallet-bar .btn-primary { background: var(--accent); color: #fff; }
.wallet-bar .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.wallet-bar .btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 15px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.card h2 .badge { background: var(--accent); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: none; }
/* Grid */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
/* Signal feed */
.signal-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.signal-item:last-child { border-bottom: none; }
.signal-item .coin { font-weight: 700; font-size: 14px; width: 60px; }
.signal-item .regime { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; }
.signal-item .regime.trending_up { background: rgba(62,207,142,0.15); color: var(--green); }
.signal-item .regime.trending_down { background: rgba(240,91,91,0.15); color: var(--red); }
.signal-item .regime.ranging { background: rgba(240,192,91,0.15); color: var(--yellow); }
.signal-item .regime.volatile { background: rgba(91,138,240,0.15); color: var(--accent2); }
.signal-item .conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
.signal-item .conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.signal-item .conviction-fill.pos { background: var(--green); }
.signal-item .conviction-fill.neg { background: var(--red); }
.signal-item .conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; }
.signal-item .time { font-size: 11px; color: var(--text-dim); width: 80px; text-align: right; }
/* Portfolio */
.position-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.position-row:last-child { border-bottom: none; }
.position-row .coin { font-weight: 700; font-size: 14px; width: 80px; }
.position-row .size { flex: 1; font-size: 13px; }
.position-row .size .val { font-weight: 600; }
.position-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; }
.position-row .pnl.pos { color: var(--green); }
.position-row .pnl.neg { color: var(--red); }
/* Stats */
.stat { text-align: center; }
.stat .val { font-size: 28px; font-weight: 700; line-height: 1.1; }
.stat .val.pos { color: var(--green); }
.stat .val.neg { color: var(--red); }
.stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
/* Order form */
.order-form { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; }
.order-form label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px; }
.order-form select, .order-form input { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
.order-form select:focus, .order-form input:focus { outline: none; border-color: var(--accent); }
.order-form .btn { padding: 8px 20px; }
/* Agent status */
.agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; }
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; }
.agent-row .dot.ok { background: var(--green); }
.agent-row .dot.warn { background: var(--yellow); }
.agent-row .dot.err { background: var(--red); }
.agent-row .name { font-weight: 600; width: 120px; }
.agent-row .status { font-size: 12px; color: var(--text-dim); }
/* Empty state */
.empty { text-align: center; padding: 32px; color: var(--text-dim); font-size: 13px; }
</style>
</head>
<body>
<nav>
<div class="logo">sali<span>or</span></div>
<div class="nav-links">
<a href="#signals">Signals</a>
<a href="#portfolio">Portfolio</a>
<a href="#performance">Performance</a>
<a href="#agents">Agents</a>
</div>
</nav>
<div class="container">
<!-- Wallet Bar -->
<div class="wallet-bar">
<div class="status-dot" id="walletDot"></div>
<div class="address" id="walletAddress">Not connected</div>
<button class="btn btn-primary" id="connectBtn" onclick="connectWallet()">Connect Wallet</button>
<button class="btn btn-outline" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
</div>
<!-- Signals -->
<div class="grid-2">
<div class="card" id="signals-card">
<h2>Conviction Signals <span class="badge">LIVE</span></h2>
<div id="signalsFeed"><div class="empty">Loading signals...</div></div>
</div>
<div class="card">
<h2>Portfolio</h2>
<div id="portfolioView"><div class="empty">No positions</div></div>
</div>
</div>
<!-- Performance -->
<div class="card" id="performance">
<h2>Performance</h2>
<div class="grid-3">
<div class="stat">
<div class="val" id="pnlVal"></div>
<div class="lbl">Total PnL</div>
</div>
<div class="stat">
<div class="val" id="sharpeVal"></div>
<div class="lbl">Sharpe Ratio</div>
</div>
<div class="stat">
<div class="val" id="drawdownVal"></div>
<div class="lbl">Max Drawdown</div>
</div>
</div>
</div>
<!-- Order Form -->
<div class="card" id="portfolio">
<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</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>&nbsp;</label>
<button class="btn btn-primary btn" onclick="placeOrder()">Place Order</button>
</div>
</div>
<div id="orderStatus" style="margin-top:12px;font-size:13px;color:var(--text-dim);"></div>
</div>
<!-- Agent Status -->
<div class="card" id="agents">
<h2>Agent Health</h2>
<div id="agentStatus"><div class="empty">Loading...</div></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

40
salior/skills/plan.md Normal file
View File

@ -0,0 +1,40 @@
# Plan Skill
Use this to decompose a complex task into atomic, verifiable steps.
## When to Use
- Task has more than 3 steps
- Steps have dependencies (A must happen before B)
- Uncertainty about how to approach something
## Steps
1. **Goal** — State the end state in one sentence
2. **Breakdown** — List all sub-tasks needed to get there
3. **Order** — Arrange sub-tasks in execution order
4. **Identify blockers** — What's unknown, untested, or needs outside input?
5. **Define done** — How do you know each step is complete?
6. **Estimate** — Roughly how long each step takes
## Output Format
```
# Plan: [task name]
## Goal
[One sentence]
## Steps
1. [step] → [verification]
2. [step] → [verification]
...
## Blockers
- [blocker] → [what's needed to unblock]
## Time estimate
[total]
## Definition of done
[what constitutes completion]
```

38
salior/skills/test.md Normal file
View File

@ -0,0 +1,38 @@
# Test Skill
Use this when writing tests or validating behavior.
## Principles
- Write the simplest test that could fail (not just pass)
- Red → Green → Refactor
- Cover the failure modes, not the happy path
## Test Pyramid
| Layer | What | How |
|-------|------|-----|
| Unit | Single function | pytest |
| Integration | Data flow between modules | pytest + real DB |
| E2E | Full pipeline | script or browser |
## Steps
1. **Arrange** — Set up inputs and state
2. **Act** — Call the thing being tested
3. **Assert** — Check the result
4. **Refactor** — Clean up without changing behavior
## What to Test (Priority Order)
1. Functions that touch external systems (DB, network)
2. Data transformations (regime detection, sizing)
3. Error handling paths
4. Edge cases (empty candles, zero size, negative conviction)
## What NOT to Test
- Framework code (FastAPI route wiring)
- Trivial getters/setters
- Exact output formatting unless contractually specified
## Verification Gate
- At least one assertion per test
- Tests run without network (mock or use test DB)
- All tests pass in < 10s