Add: dashboard (web UI), compute deploy, risk agent, daemon, plan/test skills
This commit is contained in:
parent
1eb2ecc320
commit
a9b1472068
4
salior/agents/risk/__init__.py
Normal file
4
salior/agents/risk/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Risk module."""
|
||||
from salior.agents.risk.agent import RiskAgent
|
||||
|
||||
__all__ = ["RiskAgent"]
|
||||
79
salior/agents/risk/agent.py
Normal file
79
salior/agents/risk/agent.py
Normal 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
|
||||
@ -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...")
|
||||
|
||||
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
|
||||
|
||||
async def run() -> None:
|
||||
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 <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__":
|
||||
main()
|
||||
5
salior/compute/__init__.py
Normal file
5
salior/compute/__init__.py
Normal 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
103
salior/compute/deploy.py
Normal 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(),
|
||||
}
|
||||
148
salior/compute/node_manager.py
Normal file
148
salior/compute/node_manager.py
Normal 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
93
salior/daemon.py
Normal 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)
|
||||
4
salior/dashboard/__init__.py
Normal file
4
salior/dashboard/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Dashboard package."""
|
||||
from salior.dashboard.server import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
171
salior/dashboard/server.py
Normal file
171
salior/dashboard/server.py
Normal 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())
|
||||
259
salior/dashboard/static/app.js
Normal file
259
salior/dashboard/static/app.js
Normal 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();
|
||||
4
salior/dashboard/static/favicon.svg
Normal file
4
salior/dashboard/static/favicon.svg
Normal 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 |
205
salior/dashboard/templates/index.html
Normal file
205
salior/dashboard/templates/index.html
Normal 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> </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
40
salior/skills/plan.md
Normal 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
38
salior/skills/test.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user