diff --git a/salior/agents/risk/__init__.py b/salior/agents/risk/__init__.py new file mode 100644 index 0000000..7726097 --- /dev/null +++ b/salior/agents/risk/__init__.py @@ -0,0 +1,4 @@ +"""Risk module.""" +from salior.agents.risk.agent import RiskAgent + +__all__ = ["RiskAgent"] \ No newline at end of file diff --git a/salior/agents/risk/agent.py b/salior/agents/risk/agent.py new file mode 100644 index 0000000..a676018 --- /dev/null +++ b/salior/agents/risk/agent.py @@ -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 \ No newline at end of file diff --git a/salior/cli.py b/salior/cli.py index 516737c..f3e595a 100644 --- a/salior/cli.py +++ b/salior/cli.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import click +from pathlib import Path + from salior.core.config import config from salior.core.logging import setup_logging @@ -31,11 +33,12 @@ def status() -> None: def db_init() -> None: """Initialize the database schema.""" import asyncpg + click.echo(f"Connecting to {config.timeseries_url}...") async def run() -> None: 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() await conn.execute(sql) await conn.close() @@ -46,19 +49,25 @@ def db_init() -> None: @main.command() def agent_start() -> None: - """Start all agents.""" + """Start all agents (data + signal + exec + risk).""" click.echo("Starting agents...") - from salior.agents.data.agent import DataAgent - from salior.agents.signal.agent import SignalAgent - 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() signal = SignalAgent() + exec_ = ExecAgent() + risk = RiskAgent() await asyncio.gather( data.start(), signal.start(), + exec_.start(), + risk.start(), ) try: @@ -67,10 +76,31 @@ def agent_start() -> None: 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() def mcp_serve() -> None: """Start the MCP server.""" from salior.mcp.server import MCPServer + click.echo(f"Starting MCP server on {config.host}:{config.port}...") async def run() -> None: @@ -85,6 +115,7 @@ def mcp_serve() -> None: def plugin_list() -> None: """List available plugins.""" from salior.plugins import registry + plugins = registry.discover() click.echo(f"=== Plugins ({len(plugins)}) ===") for p in registry.list(): @@ -92,5 +123,33 @@ def plugin_list() -> None: 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 ") + 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__": main() \ No newline at end of file diff --git a/salior/compute/__init__.py b/salior/compute/__init__.py new file mode 100644 index 0000000..0f06a80 --- /dev/null +++ b/salior/compute/__init__.py @@ -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"] \ No newline at end of file diff --git a/salior/compute/deploy.py b/salior/compute/deploy.py new file mode 100644 index 0000000..33fad83 --- /dev/null +++ b/salior/compute/deploy.py @@ -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(), + } diff --git a/salior/compute/node_manager.py b/salior/compute/node_manager.py new file mode 100644 index 0000000..a6d0c60 --- /dev/null +++ b/salior/compute/node_manager.py @@ -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 + ] diff --git a/salior/daemon.py b/salior/daemon.py new file mode 100644 index 0000000..adb1697 --- /dev/null +++ b/salior/daemon.py @@ -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) diff --git a/salior/dashboard/__init__.py b/salior/dashboard/__init__.py new file mode 100644 index 0000000..4f07fc7 --- /dev/null +++ b/salior/dashboard/__init__.py @@ -0,0 +1,4 @@ +"""Dashboard package.""" +from salior.dashboard.server import create_app + +__all__ = ["create_app"] \ No newline at end of file diff --git a/salior/dashboard/server.py b/salior/dashboard/server.py new file mode 100644 index 0000000..36e73d5 --- /dev/null +++ b/salior/dashboard/server.py @@ -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()) \ No newline at end of file diff --git a/salior/dashboard/static/app.js b/salior/dashboard/static/app.js new file mode 100644 index 0000000..e9f8e59 --- /dev/null +++ b/salior/dashboard/static/app.js @@ -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 = '
No signals yet
'; + return; + } + + el.innerHTML = signals.map(sig => { + const absC = Math.abs(sig.conviction); + const fill = sig.conviction >= 0 + ? `
` + : `
`; + const valColor = sig.conviction >= 0 ? "pos" : "neg"; + const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : ""; + return ` +
+
${sig.coin}
+
${sig.regime}
+
${fill}
+
${sig.conviction >= 0 ? "+" : ""}${sig.conviction.toFixed(2)}
+
${time}
+
+ `; + }).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 = '
No positions
'; + return; + } + + el.innerHTML = positions.map(pos => { + const pnl = pos.unrealized_pnl || 0; + const pnlClass = pnl >= 0 ? "pos" : "neg"; + return ` +
+
${pos.coin}
+
${pos.pos_size} @ ${pos.avg_px}
+
${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}
+
+ `; + }).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 => ` +
+
+
${a.agent}
+
${a.status} ยท ${a.ts || ""}
+
+ `).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(); diff --git a/salior/dashboard/static/favicon.svg b/salior/dashboard/static/favicon.svg new file mode 100644 index 0000000..e95e6b4 --- /dev/null +++ b/salior/dashboard/static/favicon.svg @@ -0,0 +1,4 @@ + + + S + \ No newline at end of file diff --git a/salior/dashboard/templates/index.html b/salior/dashboard/templates/index.html new file mode 100644 index 0000000..06a8eea --- /dev/null +++ b/salior/dashboard/templates/index.html @@ -0,0 +1,205 @@ + + + + + + Salior โ€” Trading System + + + + + + + +
+ + +
+
+
Not connected
+ + +
+ + +
+
+

Conviction Signals LIVE

+
Loading signals...
+
+
+

Portfolio

+
No positions
+
+
+ + +
+

Performance

+
+
+
โ€”
+
Total PnL
+
+
+
โ€”
+
Sharpe Ratio
+
+
+
โ€”
+
Max Drawdown
+
+
+
+ + +
+

Place Order

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+

Agent Health

+
Loading...
+
+ +
+ + + + \ No newline at end of file diff --git a/salior/skills/plan.md b/salior/skills/plan.md new file mode 100644 index 0000000..5fdf1e1 --- /dev/null +++ b/salior/skills/plan.md @@ -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] +``` diff --git a/salior/skills/test.md b/salior/skills/test.md new file mode 100644 index 0000000..cb04bad --- /dev/null +++ b/salior/skills/test.md @@ -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 \ No newline at end of file