Compare commits

...

No commits in common. "d3b625257ad03c5f8fef4eac25e7352899680497" and "40d6f3c0c4acc9c3460394481e26c6864fed9337" have entirely different histories.

62 changed files with 2 additions and 5554 deletions

1
.gitignore vendored
View File

@ -1 +0,0 @@
__pycache__/

View File

@ -1,40 +1,3 @@
# Salior — Autonomous Trading System
# salior
See `plans/salior-v1-plan.md` for full architecture.
## Structure
```
salior/ # Project root
├── pyproject.toml
├── README.md
└── salior/ # Python package
├── __init__.py
├── cli.py # salior CLI
├── core/ # Config, logging, memory, base agent
├── agents/
│ ├── data/ # HL WebSocket → TimescaleDB
│ ├── signal/ # Regime + conviction → Supabase signals
│ └── exec/ # HL CLOB API → orders
├── db/ # Schema + clients
├── skills/ # Agent skill definitions
├── mcp/ # MCP server
├── plugins/ # Plugin system
└── wallet/ # Wallet connect
```
## Quick Start
```bash
# Install
pip install -e .
# Initialize database
salior db init
# Start agents
salior agent start
# Check MCP tools
curl http://localhost:8080/mcp/tools
```
Salior Trading System - All-in-one app

View File

@ -1,92 +0,0 @@
# Changelog
All notable changes to Salior are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0] — 2026-05-11
### Added
- **Hyperliquid API client** (`salior/hl_client.py`)
- `HyperliquidClient` — full REST client for HL CLOB
- Order signing via secp256k1 (msgpack → SHA256 → sign, requires `HL_PRIVATE_KEY` env var)
- `place_order()` — signed limit orders via `/exchange` endpoint
- `cancel_order()` — signed cancels
- `get_account()`, `get_fills()`, `get_open_orders()` — account state
- `get_market_price()`, `get_candles()` — market data
- `get_hl_client()` — singleton factory
- Requires: `eth-account`, `eth-keys`, `eth-utils`, `msgpack`
- **Simplified wallet connect** (dashboard)
- User pastes HL wallet address — no message signing required for read-only
- Server verifies address has HL account via API
- Session stored in Supabase `wallet_sessions` (180-day validity)
- Auto-detect via `window.ethereum` if browser has Rabby/MetaMask
- HL key status visible in UI: "✅ Configured — live orders enabled" vs "❌ Not configured — paper trading only"
- **Full trading dashboard** (`salior/dashboard/`)
- Dashboard / Trade / Agents tab navigation
- 3-section layout: signals + portfolio + performance; order form + open orders; agent health
- Order preview: shows market price, total cost, HL key status
- Place Order button: submits to `/api/order` → HL CLOB
- HL account summary card: collateral, margin, open orders, positions
- Settings modal: execution mode (paper/live), min conviction
- Connect modal: paste address or auto-detect from browser wallet
- **`/api/order-preview`** — dry-run an order without placing it
- **`/api/hl-price`** — current mid-price for a coin from HL
### Changed
- `pyproject.toml` — added `eth-account`, `eth-keys`, `eth-utils`, `msgpack` dependencies
- `exec_agent` now uses `HyperliquidClient` for real order placement (was stub)
- `dashboard/server.py` — fully rewritten with simplified wallet connect + HL integration
### Known Limitations
- `HL_PRIVATE_KEY` must be set server-side for live orders (Sailor will add via dashboard after creating HL API wallet)
- Telegram bot requires `TELEGRAM_BOT_TOKEN` env var
## [0.4.0] — 2026-05-11
### Added
- **Hooks event system** (`salior/hooks/`)
- `HookRegistry` with `on()` / `off()` / `emit()` and global instance
- Built-in events: `on_signal`, `on_fill`, `on_execution`, `on_error`, `on_risk_breach`, `on_agent_health`
- `HookEvent` dataclass
- **Scheduler** (`salior/scheduler.py`)
- `Scheduler` — run multiple tasks on independent intervals
- `IntervalTask` — wraps coroutine, runs on fixed `loop_interval`, handles timeouts
- **Telegram bot** (`salior/telegram_bot.py`)
- `/start`, `/status`, `/signals`, `/pnl`, `/help` commands
- Hook integration: alerts on `on_fill`, `on_risk_breach`, `on_error`
- `emit_signal()`, `emit_fill()`, `emit_risk_breach()` helpers
- **CLI groups fully wired**
- `salior agent [list|start]`, `salior daemon [start|stop|status]`
- `salior telegram serve`, `salior compute [list|add|remove|ping|deploy]`
- `salior plugin [list|enable|disable]`, `salior skill [list|show]`, `salior hook [list|fire]`
- `salior status --verbose` — nodes + plugins summary
## [0.3.0] — 2026-05-11
### Added
- Dashboard web UI (aiohttp + vanilla HTML/JS), compute orchestration, risk agent, daemon
## [0.2.0] — 2026-05-11
### Added
- 4 built-in plugins (llm_batcher, backtest_engine, rl_trainer, ml_predictor)
## [0.1.0] — 2026-05-11
### Added
- Core skeleton, 3 agents, database layer, LLM client, skills, MCP server, plugin system, wallet connect, CLI

View File

@ -1,101 +0,0 @@
# Salior Skeleton — Gap Analysis vs. Plan
## Plan v/s Implementation
### ✅ Implemented
| Plan Item | File | Status |
|-----------|------|--------|
| Config from env | `salior/core/config.py` | ✅ |
| structlog logging | `salior/core/logging.py` | ✅ |
| Long-term memory | `salior/core/memory.py` | ✅ |
| Base Agent class | `salior/core/agent.py` | ✅ |
| data_agent (HL WS) | `salior/agents/data/agent.py` | ✅ |
| signal_agent (regime) | `salior/agents/signal/agent.py` | ✅ |
| exec_agent (CLOB) | `salior/agents/exec/agent.py` | ✅ Stub — no real signing |
| risk_agent | `salior/agents/risk/agent.py` | ✅ |
| Schema (8 hypertables) | `salior/db/schema.sql` | ✅ |
| TimescaleDB client | `salior/db/timescale_client.py` | ✅ |
| Supabase client | `salior/db/supabase_client.py` | ✅ |
| LLM client | `salior/llm/client.py` | ✅ |
| 6 skills | `salior/skills/*.md` | ✅ |
| MCP server | `salior/mcp/server.py` | ✅ |
| Plugin registry | `salior/plugins/__init__.py` | ✅ |
| 4 built-in plugins | `plugins/*/` | ✅ |
| Wallet connect | `salior/wallet/connect.py` | ✅ |
| Dashboard | `salior/dashboard/` | ✅ |
| Compute node manager | `salior/compute/node_manager.py` | ✅ |
| Plugin deploy | `salior/compute/deploy.py` | ✅ |
| Daemon class | `salior/daemon.py` | ✅ No CLI integration |
### ❌ Missing / Incomplete
| Item | Priority | Why |
|------|----------|-----|
| **HL real wallet signing** | CRITICAL | exec_agent is a stub — can't place real orders |
| **Hooks event system** | HIGH | No on_signal / on_fill / on_error events |
| **Agent scheduler** | HIGH | No cron-like scheduling per agent |
| **Swarm/coordinator** | MEDIUM | No multi-agent orchestration |
| **Signal planner/validator** | MEDIUM | signal_agent is a single LLM call, no self-validation |
| **Telegram bot** | MEDIUM | No Telegram alerts or commands |
| **Daemon CLI** | MEDIUM | `daemon start/stop/status` not in cli.py |
| **Compute `add/remove` CLI** | MEDIUM | Can't register nodes via CLI |
| **`nodes.yaml` seeded** | LOW | VPS3 not pre-registered in node manager |
| **`compute/status.py`** | LOW | No per-node plugin status |
| **`hooks/` directory** | HIGH | Event hooks system entirely missing |
| **wallet/vault.py** | LOW | Renamed to connect.py (minor) |
| **Dashboard: real PnL data** | LOW | Performance endpoint is placeholder |
| **Dashboard: order signing flow** | LOW | Order requires wallet popup, backend not wired for tx signing |
| **Agent `__pycache__` in gitignore** | LOW | Some leaked into last commit |
---
## Priority Build Order
### 1. Hooks event system (HIGH)
on_signal → trigger other agents or webhooks
on_fill → update portfolio, notify
on_error → alert, pause agent
on_schedule → cron-like scheduling per agent
### 2. Agent scheduler (HIGH)
Decouple loop intervals per agent
data: continuous (WS-based, no loop)
signal: 60s
exec: 300s
risk: 30s
### 3. Telegram bot (MEDIUM)
`salior telegram serve` — bot commands + alerts
### 4. Daemon CLI integration (MEDIUM)
`salior daemon start/stop/status`
### 5. Compute add/remove node CLI (MEDIUM)
### 6. VPS3 pre-seeded in nodes.yaml (LOW)
Auto-discover local node on install
### 7. Signal planner + validator (MEDIUM)
Decompose regime detection, confidence scoring
---
## Skeleton Completeness Score
| Area | Score | Notes |
|------|-------|-------|
| Core | 8/10 | Missing scheduler, swarm, hooks |
| Agents | 7/10 | 4/5 built; exec signing stub |
| Database | 10/10 | Full schema + both clients |
| LLM | 10/10 | Routing + batch |
| Skills | 10/10 | 6/6 built |
| MCP | 10/10 | 5 tools |
| Dashboard | 8/10 | UI complete; order signing not wired |
| Compute | 8/10 | Node manager + deploy; no status.py |
| Plugins | 10/10 | 4/4 built |
| Wallet | 9/10 | EIP-4361 done; tx signing not wired |
| Daemon | 6/10 | Class done; no CLI integration |
| **Total** | **~85%** | |
**Remaining ~15%**: hooks system, scheduler, telegram bot, daemon CLI, node add/remove CLI, signal planner.

View File

@ -1,11 +0,0 @@
name: backtest_engine
version: 0.1.0
description: Vectorized historical backtesting on TimescaleDB candles
compute:
gpu: false
location: local
requires_llm: false
env:
DEFAULT_SKIP_DAYS: "30"

View File

@ -1,27 +0,0 @@
"""Backtest engine plugin — historical strategy testing."""
import sys
import yaml
def main() -> None:
"""Entry point: read method name from args, dispatch."""
if len(sys.argv) < 2:
print(yaml.dump({"error": "no method specified"}))
return
method = sys.argv[1]
params = yaml.safe_load(sys.stdin.read()) if not sys.stdin.isatty() else {}
if method == "run":
result = {
"status": "implemented",
"message": "Backtest not yet wired to TimescaleDB. Configure TIMESERIES_* env vars.",
"params": params,
}
print(yaml.dump(result))
else:
print(yaml.dump({"error": f"unknown method: {method}"}))
if __name__ == "__main__":
main()

View File

@ -1,13 +0,0 @@
name: llm_batcher
version: 0.1.0
description: Batch multiple LLM calls into one request for cost + speed savings
llm_batching: true
compute:
gpu: false
location: local
requires_llm: true
env:
BATCH_SIZE: "20"
BATCH_TIMEOUT_MS: "500"

View File

@ -1,25 +0,0 @@
"""LLM batcher plugin — aggregate multiple LLM calls into one request."""
import sys
import yaml
import json
def main() -> None:
"""Entry point: read method name from args, dispatch."""
if len(sys.argv) < 2:
print(yaml.dump({"error": "no method specified"}))
return
method = sys.argv[1]
params = yaml.safe_load(sys.stdin.read()) if not sys.stdin.isatty() else {}
if method == "batch":
calls = params.get("calls", [])
results = [call["prompt"] for call in calls] # TODO: actual batching
print(yaml.dump({"results": results, "count": len(results)}))
else:
print(yaml.dump({"error": f"unknown method: {method}"}))
if __name__ == "__main__":
main()

View File

@ -1,12 +0,0 @@
name: ml_predictor
version: 0.1.0
description: Scikit-learn signal enhancement model trained on historical data
compute:
gpu: false
location: local
requires_llm: false
env:
MODEL_TYPE: "random_forest"
FEATURES: "trend,momentum,volume,regime"

View File

@ -1,27 +0,0 @@
"""ML predictor plugin — sklearn signal enhancement."""
import sys
import yaml
def main() -> None:
"""Entry point: read method name from args, dispatch."""
if len(sys.argv) < 2:
print(yaml.dump({"error": "no method specified"}))
return
method = sys.argv[1]
params = yaml.safe_load(sys.stdin.read()) if not sys.stdin.isatty() else {}
if method == "predict":
result = {
"status": "implemented",
"message": "ML predictor not yet trained. Collect signal data first.",
"features": params,
}
print(yaml.dump(result))
else:
print(yaml.dump({"error": f"unknown method: {method}"}))
if __name__ == "__main__":
main()

View File

@ -1,14 +0,0 @@
name: rl_trainer
version: 0.1.0
description: Reinforcement learning agent training (PPO on GPU node)
compute:
gpu: true
gpu_memory_gb: 8
location: remote
preferred_hosts: []
requires_llm: false
env:
RL_MODEL: "ppo_trader"
TRAINING_STEPS: "100000"

View File

@ -1,27 +0,0 @@
"""RL trainer plugin — PPO agent training (requires GPU node)."""
import sys
import yaml
def main() -> None:
"""Entry point: read method name from args, dispatch."""
if len(sys.argv) < 2:
print(yaml.dump({"error": "no method specified"}))
return
method = sys.argv[1]
params = yaml.safe_load(sys.stdin.read()) if not sys.stdin.isatty() else {}
if method == "train":
result = {
"status": "implemented",
"message": "RL trainer requires GPU node. Deploy with `salior compute deploy rl_trainer --host gpu-node`.",
"params": params,
}
print(yaml.dump(result))
else:
print(yaml.dump({"error": f"unknown method: {method}"}))
if __name__ == "__main__":
main()

View File

@ -1,38 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "salior"
version = "0.1.0"
description = "All-in-one autonomous trading system"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
# Core
"aiohttp>=3.9.0",
"asyncpg>=0.29.0",
"structlog>=24.0.0",
"psycopg>=3.1.0",
"httpx>=0.27.0",
"click>=8.1.0",
"pyyaml>=6.0.0",
"websockets>=12.0",
# HL signing
"eth-account>=0.9.0",
"eth-keys>=0.4.0",
"eth-utils>=2.0.0",
"msgpack>=1.0.0",
]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "ruff"]
[project.scripts]
salior = "salior.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["salior"]
[tool.ruff]
line-length = 100

View File

@ -1,3 +0,0 @@
"""Salior core package."""
__version__ = "0.1.0"

View File

@ -1,4 +0,0 @@
"""Data agent module."""
from salior.agents.data.agent import DataAgent
__all__ = ["DataAgent"]

View File

@ -1,172 +0,0 @@
"""Hyperliquid WebSocket data collector.
Streams candles + trades + orderbook TimescaleDB.
"""
from __future__ import annotations
import asyncio
import json
import zlib
from datetime import datetime, timezone
from typing import Any, Optional
import websockets
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
log = setup_logging()
class DataAgent(Agent):
"""Collects HL market data via WebSocket, writes to TimescaleDB."""
name = "data_agent"
coins: list[str] # set by __init__
def __init__(self, coins: Optional[list[str]] = None) -> None:
super().__init__()
self.coins = coins or config.coins
self._db = TimescaleDB()
self._ws: Optional[websockets.WebSocketApp] = None
self._last_candle_time: dict[str, datetime] = {}
def loop_interval(self) -> float:
return 0 # WebSocket-based, no polling
async def run(self) -> None:
"""Connect to HL WebSocket, handle messages forever."""
await self._db.connect()
await self._subscribe()
self._running = True
while self._running:
try:
async with websockets.connect(
config.hl_ws_url,
compression=None,
) as ws:
self._ws = ws
log.info("hl_ws_connected", coins=self.coins)
# Subscribe to channels
await self._send_subscribe()
# Handle messages
async for msg in ws:
await self._handle_message(msg)
except websockets.exceptions.ConnectionClosed as e:
log.warning("hl_ws_disconnected", reason=str(e))
await asyncio.sleep(3)
except Exception as e:
log.error("hl_ws_error", error=str(e))
await asyncio.sleep(5)
async def _subscribe(self) -> None:
"""Send subscription messages for candles + trades + orderbook."""
pass # subscriptions sent after connect
async def _send_subscribe(self) -> None:
"""Send HL WebSocket subscription payload."""
if not self._ws:
return
# Candle candles
candle_sub = {
"method": "subscribe",
"params": {
"channels": [
{"type": "candle_1m", "coin": coin}
for coin in self.coins
] + [
{"type": "candle_5m", "coin": coin}
for coin in self.coins
] + [
{"type": "trades", "coin": coin}
for coin in self.coins
]
},
"id": 1,
}
await self._ws.send(json.dumps(candle_sub))
log.info("hl_subscriptions_sent", coins=self.coins)
async def _handle_message(self, raw: str | bytes) -> None:
"""Parse and dispatch a HL WebSocket message."""
try:
# Decompress if compressed
if isinstance(raw, bytes):
raw = zlib.decompress(raw).decode()
msg = json.loads(raw)
# Handle channel data
if "channel" in msg and "data" in msg:
channel = msg["channel"]
data = msg["data"]
if channel == "candle_1m" or channel == "candle_5m":
await self._handle_candle(data)
elif channel == "trades":
await self._handle_trade(data)
elif channel == "batched":
for item in data:
if "candle" in item:
await self._handle_candle(item["candle"])
elif "trade" in item:
await self._handle_trade(item["trade"])
# Handle subscription confirmations
if msg.get("id") == 1 and msg.get("code") == 0:
log.info("hl_subscription_confirmed")
except Exception as e:
log.error("hl_parse_error", error=str(e), raw=str(raw)[:100])
async def _handle_candle(self, data: dict) -> None:
"""Handle a candle update."""
coin = data.get("coin", "")
t_str = data.get("t", "") # Unix timestamp in ms
o = float(data.get("o", 0) or 0)
h = float(data.get("h", 0) or 0)
l = float(data.get("l", 0) or 0)
c = float(data.get("c", 0) or 0)
v = float(data.get("v", 0) or 0)
if not t_str:
return
t = datetime.fromtimestamp(int(t_str) / 1000, tz=timezone.utc)
# Determine table from channel
interval = data.get("interval", "1m")
table = f"candles_1m" if interval == "1m" else f"candles_5m"
await self._db.upsert_candle(table, coin, t, o, h, l, c, v)
log.debug("candle_written", coin=coin, table=table, t=t.isoformat())
async def _handle_trade(self, data: dict) -> None:
"""Handle a trade message."""
coin = data.get("coin", "")
px = float(data.get("px", 0) or 0)
sz = float(data.get("sz", 0) or 0)
side = data.get("side", "") # B = bid, A = ask
hash_ = data.get("hash", "")
if not hash_:
return
t_str = data.get("time", "")
t = datetime.fromtimestamp(int(t_str) / 1000, tz=timezone.utc) if t_str else datetime.now(timezone.utc)
# Deduplicate by hash (handled by schema)
await self._db.insert_trade(
coin, t, px, sz, side.lower(), hash_
)
# Log heartbeat periodically
if len(self._last_candle_time) % 100 == 0:
await self._db.log_health(self.name, "running", iteration=len(self._last_candle_time))

View File

@ -1,4 +0,0 @@
"""Exec agent module."""
from salior.agents.exec.agent import ExecAgent
__all__ = ["ExecAgent"]

View File

@ -1,145 +0,0 @@
"""Hyperliquid execution agent.
Reads signals from Supabase places HL CLOB orders via hl_client.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
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
from salior.hl_client import get_hl_client
from salior.hooks import global_hooks
from salior.hooks.registry import HookEvent
log = setup_logging()
class ExecAgent(Agent):
"""Executes trades based on conviction signals from Supabase.
Modes:
- paper: log orders only, don't submit to HL
- live: sign + submit real orders via hl_client
"""
name = "exec_agent"
def __init__(
self,
coins: Optional[list[str]] = None,
min_conviction: float = 0.7,
mode: Optional[str] = None,
) -> None:
super().__init__()
self.coins = coins or config.coins
self.min_conviction = min_conviction
self.mode = mode or config.execution_mode
self._db = TimescaleDB()
self._supabase = SupabaseClient()
def loop_interval(self) -> float:
return 300.0 # Poll every 5 minutes
async def run(self) -> None:
"""Poll Supabase for high-conviction signals and execute."""
await self._db.connect()
if self.mode == "paper":
log.info("exec_paper_mode", min_conviction=self.min_conviction)
return # Signal agent emits; exec just logs in paper mode
# Live mode: check HL key
if not config.hl_private_key:
log.warning("exec_no_private_key")
return
signals = await self._supabase.get_recent_signals(limit=10)
for sig in signals:
conviction = abs(sig.get("conviction", 0))
if conviction < self.min_conviction:
continue
try:
await self._execute_signal(sig)
except Exception as e:
log.error("exec_error", signal_id=sig.get("id"), error=str(e))
await global_hooks.emit(HookEvent(
name="on_error",
source=self.name,
data={"agent": self.name, "error": str(e), "signal_id": sig.get("id")},
))
await self._db.log_health(self.name, "running", iteration=self._iteration)
async def _execute_signal(self, sig: dict) -> None:
"""Place a real order based on a signal."""
coin = sig.get("coin", "")
conviction = sig.get("conviction", 0)
side = "Buy" if conviction > 0 else "Sell"
size = self._calculate_size(coin, conviction)
price = await self._get_market_price(coin)
if not price or size <= 0:
log.warning("exec_skip", coin=coin, reason="no price or size")
return
# Get API wallet address from config (the HL wallet address, not the PK)
# In production this would come from config or a wallet registry
api_wallet = config.hl_private_key[:42] if len(config.hl_private_key) > 42 else "0xd4Deb02E7A74d1d3317BFa44Ba9a5D755991293E"
client = get_hl_client()
result = await client.place_order(
address=api_wallet,
coin=coin,
side=side,
size=size,
price=price,
)
# Log to DB
exec_id = await self._db.insert_execution(
sig.get("id"), coin, side.lower(), size, price, self.mode
)
is_success = not result.get("error") and result.get("status") != "failed"
if is_success:
await self._db.update_execution(exec_id, "filled", price)
await global_hooks.emit(HookEvent(
name="on_fill",
source=self.name,
data={
"coin": coin,
"side": side,
"size": size,
"price": price,
"exec_id": exec_id,
"mode": self.mode,
},
))
else:
await self._db.update_execution(exec_id, "failed", error_msg=str(result.get("error")))
log.info("exec_order", coin=coin, side=side, size=size, price=price,
result=result.get("status") or result.get("error"))
def _calculate_size(self, coin: str, conviction: float) -> float:
"""Calculate position size based on conviction."""
# Base: 1% of portfolio per trade
base_pct = 0.01
conviction_multiplier = 1 + (conviction - 0.7) * 6
size_pct = base_pct * conviction_multiplier
# Default $10k portfolio equivalent
portfolio_value = 10_000.0
return round(portfolio_value * size_pct, 4)
async def _get_market_price(self, coin: str) -> Optional[float]:
"""Get current mid-price from HL."""
client = get_hl_client()
try:
return await client.get_market_price(coin)
except Exception as e:
log.error("hl_price_error", coin=coin, error=str(e))
return None

View File

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

View File

@ -1,79 +0,0 @@
"""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

@ -1,4 +0,0 @@
"""Signal agent module."""
from salior.agents.signal.agent import SignalAgent
__all__ = ["SignalAgent"]

View File

@ -1,185 +0,0 @@
"""Signal generation agent.
Reads candles from TimescaleDB regime + conviction Supabase signals.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
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()
REGIME_PROMPT = """You are a market regime detector. Analyze BTC/USD price data and classify the current regime.
Data:
- Current price: ${price}
- 24h high: ${high}, 24h low: ${low}
- Price range (high-low)/low: {range_pct:.2%}
- 1h trend: {trend_1h:.2%}
- Volume (last 1h): {volume_1h}
- Volume (prev 1h): {volume_prev}
- Volume ratio: {vol_ratio:.2f}x
Classify regime (choose one):
- trending_up: Strong directional move, clear trend
- trending_down: Strong downward move
- ranging: No clear direction, oscillating
- volatile: High range but unclear direction
Return JSON only:
{{"regime": "trending_up|trending_down|ranging|volatile", "reasoning": "brief explanation"}}
"""
CONVICTION_PROMPT = """You are a trading conviction scorer. Score how confident you are in the current direction.
Current regime: {regime}
Price: ${price}
1h candles:
{candles_text}
Score conviction from -1.0 (strong sell) to +1.0 (strong buy).
Return JSON only:
{{"conviction": -0.85, "reasoning": "why", "momentum": "strong_bull|weak_bull|neutral|weak_bear|strong_bear"}}
"""
class SignalAgent(Agent):
"""Analyzes candles → writes conviction signals to Supabase."""
name = "signal_agent"
def __init__(
self,
coins: list[str] | None = None,
min_conviction: float | None = None,
) -> None:
super().__init__()
self.coins = coins or config.coins
self.min_conviction = min_conviction or config.min_conviction
self._db = TimescaleDB()
self._supabase = SupabaseClient()
def loop_interval(self) -> float:
return 60.0 # Run every 60 seconds
async def run(self) -> None:
"""Analyze each coin, emit signals if conviction is high enough."""
await self._db.connect()
for coin in self.coins:
try:
signal = await self._analyze_coin(coin)
if signal and abs(signal["conviction"]) >= self.min_conviction:
await self._emit_signal(signal)
log.info(
"signal_emitted",
coin=coin,
regime=signal["regime"],
conviction=signal["conviction"],
)
except Exception as e:
log.error("signal_error", coin=coin, error=str(e))
await self._db.log_health(self.name, "running", iteration=self._iteration)
async def _analyze_coin(self, coin: str) -> dict | None:
"""Analyze a coin's candles and return a signal dict."""
# Fetch recent candles
candles_1m = await self._db.get_candles("candles_1m", coin, hours=24)
candles_5m = await self._db.get_candles("candles_5m", coin, hours=24)
if len(candles_1m) < 5:
log.debug("not_enough_candles", coin=coin, count=len(candles_1m))
return None
# Get latest values
latest = candles_1m[-1]
price = latest["c"]
high = max(c["h"] for c in candles_1m[-24:])
low = min(c["l"] for c in candles_1m[-24:])
range_pct = (high - low) / low if low > 0 else 0
# Calculate trend (1h change)
if len(candles_1m) >= 60:
old_price = candles_1m[-60]["c"]
trend_1h = (price - old_price) / old_price if old_price > 0 else 0
else:
trend_1h = 0
# Volume analysis
volume_1h = sum(c["v"] for c in candles_1m[-60:])
volume_prev = sum(c["v"] for c in candles_1m[-120:-60]) if len(candles_1m) >= 120 else volume_1h
vol_ratio = volume_1h / volume_prev if volume_prev > 0 else 1.0
# Detect regime
from salior.llm import llm
regime_text = await llm.chat(
REGIME_PROMPT.format(
price=price,
high=high,
low=low,
range_pct=range_pct,
trend_1h=trend_1h,
volume_1h=volume_1h,
volume_prev=volume_prev,
vol_ratio=vol_ratio,
),
system="You are a JSON-only assistant. Return valid JSON with regime and reasoning keys.",
)
import json as json_mod
try:
regime_data = json_mod.loads(regime_text)
except Exception:
regime_data = {"regime": "ranging", "reasoning": regime_text[:100]}
# Score conviction
candles_text = "\n".join(
f" {c['t'].isoformat()}: O={c['o']:.1f} H={c['h']:.1f} L={c['l']:.1f} C={c['c']:.1f} V={c['v']:.0f}"
for c in candles_1m[-20:]
)
conviction_text = await llm.chat(
CONVICTION_PROMPT.format(
regime=regime_data["regime"],
price=price,
candles_text=candles_text,
),
system="You are a JSON-only assistant. Return valid JSON with conviction (-1 to 1), reasoning, and momentum keys.",
)
try:
conviction_data = json_mod.loads(conviction_text)
except Exception:
conviction_data = {"conviction": 0.0, "reasoning": conviction_text[:100], "momentum": "neutral"}
return {
"coin": coin,
"regime": regime_data["regime"],
"conviction": float(conviction_data.get("conviction", 0.0)),
"reasoning": f"{regime_data.get('reasoning', '')} | {conviction_data.get('reasoning', '')}",
"price": price,
"momentum": conviction_data.get("momentum", "neutral"),
}
async def _emit_signal(self, signal: dict) -> None:
"""Write signal to both TimescaleDB and Supabase."""
# Write to TimescaleDB
await self._db.insert_signal(
signal["coin"],
signal["regime"],
signal["conviction"],
signal["reasoning"],
)
# Write to Supabase (primary for external access)
await self._supabase.insert_signal(
signal["coin"],
signal["regime"],
signal["conviction"],
signal["reasoning"],
)

View File

@ -1,480 +0,0 @@
"""salior CLI entry point."""
from __future__ import annotations
import asyncio
import os
import sys
import click
from pathlib import Path
from salior.core.config import config
from salior.core.logging import setup_logging
log = setup_logging()
@click.group()
def main() -> None:
"""Salior — autonomous trading system."""
pass
# ─── Status ───────────────────────────────────────────────────────────────────
@main.command()
@click.option("--verbose", is_flag=True, help="Show nodes + plugins too")
def status(verbose: bool) -> None:
"""Check system status."""
click.echo("=== Salior Status ===")
click.echo(f"Host: {config.host}:{config.port}")
click.echo(f"Execution mode: {config.execution_mode}")
click.echo(f"Coins: {', '.join(config.coins)}")
click.echo(f"Min conviction: {config.min_conviction}")
click.echo(f"TimescaleDB: {config.timeseries_host}:{config.timeseries_port}/{config.timeseries_db}")
click.echo(f"Supabase: {config.supabase_url}")
if verbose:
from salior.compute import NodeManager, full_status
mgr = NodeManager()
nodes = mgr.list()
click.echo(f"\n=== Nodes ({len(nodes)}) ===")
for n in nodes:
gpu = "🖥️" if n["gpu"] else "💻"
click.echo(f"{gpu} {n['name']}: {n['user']}@{n['host']}:{n['port']}")
st = full_status()
click.echo(f"\n=== Plugins ({len(st['plugins'])}) ===")
for p in st["plugins"]:
click.echo(f" {'' if p['enabled'] else ''} {p['name']}")
# ─── Database ─────────────────────────────────────────────────────────────────
@main.command()
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 / "db" / "schema.sql"
sql = schema.read_text()
await conn.execute(sql)
await conn.close()
click.echo("Schema applied successfully.")
asyncio.run(run())
# ─── Agents ───────────────────────────────────────────────────────────────────
@main.group()
def agent() -> None:
"""Agent management commands."""
pass
@agent.command()
def start() -> None:
"""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
data = DataAgent()
signal = SignalAgent()
exec_ = ExecAgent()
risk = RiskAgent()
await asyncio.gather(
data.start(),
signal.start(),
exec_.start(),
risk.start(),
)
try:
asyncio.run(run())
except KeyboardInterrupt:
click.echo("Agents stopped.")
@agent.command()
def list() -> None:
"""List all known agents."""
agents = ["data_agent", "signal_agent", "exec_agent", "risk_agent"]
click.echo("=== Agents ===")
for a in agents:
click.echo(f" {a}")
# ─── Dashboard ────────────────────────────────────────────────────────────────
@main.group()
def dashboard() -> None:
"""Dashboard commands."""
pass
@dashboard.command()
def 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())
# ─── MCP ──────────────────────────────────────────────────────────────────────
@main.group()
def mcp() -> None:
"""MCP server commands."""
pass
@mcp.command()
def 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:
server = MCPServer(config.host, config.port)
await server.start()
await asyncio.Event().wait()
asyncio.run(run())
# ─── Daemon ───────────────────────────────────────────────────────────────────
@main.group()
def daemon() -> None:
"""Daemon management (PID file + background)."""
pass
@daemon.command()
@click.option("--detach", is_flag=True, help="Fork and run in background")
def start(detach: bool) -> None:
"""Start the salior daemon in background."""
from salior.daemon import Daemon
d = Daemon("salior")
if d.is_running():
click.echo(f"Daemon already running (PID {d.pid()})")
return
if detach:
pid = os.fork()
if pid == 0:
# Child — become session leader, redirect output
os.setsid()
sys.stdout.flush()
sys.stderr.flush()
with open("/dev/null", "r") as devnull:
os.dup2(devnull.fileno(), 0)
with open("/tmp/salior_daemon.log", "a") as logfile:
os.dup2(logfile.fileno(), 1)
os.dup2(logfile.fileno(), 2)
async def run() -> None:
from salior.daemon import Daemon
d = Daemon("salior")
await d.start(asyncio.sleep(1e9)) # Sleep forever until stopped
click.echo(f"Daemon started (PID {os.getpid()})")
asyncio.run(run())
@daemon.command()
def stop() -> None:
"""Stop the running daemon."""
from salior.daemon import Daemon
d = Daemon("salior")
pid = d.pid()
if not pid or not d.is_running():
click.echo("Daemon not running")
return
os.kill(pid, 15) # SIGTERM
click.echo(f"Daemon {pid} stopped")
@daemon.command()
def status() -> None:
"""Show daemon PID and running state."""
from salior.daemon import Daemon
d = Daemon("salior")
if d.is_running():
click.echo(f"Daemon running — PID {d.pid()}")
else:
click.echo("Daemon not running")
# ─── Telegram ─────────────────────────────────────────────────────────────────
@main.group()
def telegram() -> None:
"""Telegram bot commands."""
pass
@telegram.command()
def serve() -> None:
"""Start the Telegram bot."""
from salior.telegram_bot import TelegramBot
token = os.getenv("TELEGRAM_BOT_TOKEN", "")
if not token:
click.echo("Error: TELEGRAM_BOT_TOKEN not set")
return
bot = TelegramBot(token)
async def run() -> None:
await bot.start()
await asyncio.Event().wait()
click.echo("Telegram bot started")
asyncio.run(run())
# ─── Compute ──────────────────────────────────────────────────────────────────
@main.group()
def compute() -> None:
"""Compute node management."""
pass
@compute.command("list")
def compute_list() -> 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: salior compute add <name> <host> [options]")
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']}")
@compute.command("add")
@click.argument("name")
@click.argument("host")
@click.option("--port", "-p", default=22, help="SSH port")
@click.option("--user", "-u", default="root", help="SSH user")
@click.option("--gpu", is_flag=True, help="Node has GPU")
@click.option("--gpu-mem", default=0, help="GPU memory in GB")
@click.option("--labels", default="", help="Comma-separated labels")
@click.option("--ssh-key", help="Path to SSH private key")
def compute_add(name: str, host: str, port: int, user: str, gpu: bool, gpu_mem: int, labels: str, ssh_key: str) -> None:
"""Register a new compute node."""
from salior.compute import NodeManager, Node
mgr = NodeManager()
node = Node(
name=name,
host=host,
port=port,
user=user,
gpu=gpu,
gpu_memory_gb=gpu_mem,
labels=[l for l in labels.split(",") if l],
ssh_key_path=ssh_key,
)
mgr.add(node)
click.echo(f"Node '{name}' added: {user}@{host}:{port} (gpu={gpu})")
@compute.command("remove")
@click.argument("name")
def compute_remove(name: str) -> None:
"""Remove a compute node."""
from salior.compute import NodeManager
mgr = NodeManager()
node = mgr.get(name)
if not node:
click.echo(f"Node '{name}' not found")
return
mgr._nodes.pop(name)
mgr.save()
click.echo(f"Node '{name}' removed")
@compute.command("ping")
@click.argument("name")
def compute_ping(name: str) -> None:
"""Ping a compute node."""
from salior.compute import NodeManager
async def run() -> None:
mgr = NodeManager()
ok = await mgr.ping(name)
if ok:
click.echo(f"Node '{name}': ✅ reachable")
else:
click.echo(f"Node '{name}': ❌ unreachable")
asyncio.run(run())
@compute.command("deploy")
@click.argument("plugin")
@click.argument("target")
def compute_deploy(plugin: str, target: str) -> None:
"""Deploy a plugin to a target node."""
from salior.compute import deploy_plugin
async def run() -> None:
result = await deploy_plugin(plugin, target)
if result["status"] == "deployed":
click.echo(f"✅ Plugin '{plugin}' deployed to '{target}'")
elif result["status"] == "ok":
click.echo(f" Plugin '{plugin}' already local")
else:
click.echo(f"❌ Deploy failed: {result['message']}")
asyncio.run(run())
# ─── Plugins ───────────────────────────────────────────────────────────────────
@main.group()
def plugin() -> None:
"""Plugin management commands."""
pass
@plugin.command("list")
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():
status = "" if p["enabled"] else ""
click.echo(f"{status} {p['name']}: {p['description']}")
@plugin.command("enable")
@click.argument("name")
def plugin_enable(name: str) -> None:
"""Enable a plugin."""
from salior.plugins import registry
registry.discover()
registry.enable(name)
click.echo(f"Plugin '{name}' enabled")
@plugin.command("disable")
@click.argument("name")
def plugin_disable(name: str) -> None:
"""Disable a plugin."""
from salior.plugins import registry
registry.discover()
registry.disable(name)
click.echo(f"Plugin '{name}' disabled")
# ─── Skills ────────────────────────────────────────────────────────────────────
@main.group()
def skill() -> None:
"""Skill management commands."""
pass
@skill.command("list")
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}")
@skill.command("show")
@click.argument("name")
def skill_show(name: str) -> None:
"""Show the content of a skill."""
from salior.skills import SkillRegistry
reg = SkillRegistry()
content = reg.render(name)
click.echo(content)
# ─── Hooks ─────────────────────────────────────────────────────────────────────
@main.group()
def hook() -> None:
"""Hook event management."""
pass
@hook.command("list")
def hook_list() -> None:
"""List registered hooks."""
from salior.hooks import global_hooks
hooks = global_hooks.list()
if not hooks:
click.echo("No hooks registered")
return
click.echo(f"=== Hooks ({len(hooks)}) ===")
for name, count in hooks.items():
click.echo(f" {name}: {count} handler(s)")
@hook.command("fire")
@click.argument("event_name")
def hook_fire(event_name: str) -> None:
"""Manually fire a hook event (for testing)."""
from salior.hooks import global_hooks
from salior.hooks.registry import HookEvent
async def run() -> None:
await global_hooks.emit(HookEvent(
name=event_name,
source="cli",
data={"triggered_by": "salior hook fire"},
))
click.echo(f"Hook '{event_name}' fired")
asyncio.run(run())
if __name__ == "__main__":
main()

View File

@ -1,6 +0,0 @@
"""Compute module — plugin deployment orchestration."""
from salior.compute.node_manager import Node, NodeManager
from salior.compute.deploy import deploy_plugin, status_plugin
from salior.compute.status import full_status, node_status
__all__ = ["Node", "NodeManager", "deploy_plugin", "status_plugin", "full_status", "node_status"]

View File

@ -1,103 +0,0 @@
"""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

@ -1,148 +0,0 @@
"""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
]

View File

@ -1,44 +0,0 @@
"""Compute node and plugin status."""
from __future__ import annotations
from salior.compute.node_manager import NodeManager
from salior.plugins import registry
from salior.core.logging import setup_logging
log = setup_logging()
def full_status() -> dict:
"""Return full system status for `salior status --verbose`."""
mgr = NodeManager()
nodes = mgr.list()
plugins = registry.list()
return {
"nodes": nodes,
"plugins": plugins,
"total_nodes": len(nodes),
"total_plugins": len(plugins),
}
def node_status(name: str) -> dict:
"""Return detailed status for a specific node."""
mgr = NodeManager()
node = mgr.get(name)
if not node:
return {"error": f"Node '{name}' not found"}
return {
"node": {
"name": node.name,
"host": node.host,
"port": node.port,
"user": node.user,
"gpu": node.gpu,
"gpu_memory_gb": node.gpu_memory_gb,
"labels": node.labels,
},
}

View File

@ -1,7 +0,0 @@
"""Core modules."""
from salior.core.config import config
from salior.core.logging import setup_logging
from salior.core.memory import memory
from salior.core.agent import Agent
__all__ = ["config", "setup_logging", "memory", "Agent"]

View File

@ -1,101 +0,0 @@
"""Base agent class."""
from __future__ import annotations
import asyncio
import signal
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
import structlog
logger = structlog.get_logger("salior.agent")
class Agent(ABC):
"""Base class for all Salior agents.
Provides:
- Lifecycle (start/stop/health)
- Self-validation hooks
- Loop detection (max iterations)
- Graceful shutdown
"""
name: str = "agent"
def __init__(self) -> None:
self._running = False
self._stopping = False
self._task: Optional[asyncio.Task] = None
self._last_heartbeat: Optional[datetime] = None
self._iteration = 0
self._log = logger.bind(agent=self.name)
async def start(self) -> None:
"""Start the agent. Override in subclass."""
self._running = True
self._last_heartbeat = datetime.utcnow()
self._task = asyncio.create_task(self._run())
self._log.info("agent_started")
async def stop(self) -> None:
"""Stop the agent gracefully."""
self._stopping = True
self._running = False
self._log.info("agent_stopping")
if self._task:
try:
self._task.cancel()
await asyncio.wait_for(self._task, timeout=5.0)
except asyncio.CancelledError:
pass
except Exception as e:
self._log.error("agent_stop_error", error=str(e))
self._log.info("agent_stopped")
async def _run(self) -> None:
"""Main agent loop. Calls run() repeatedly until stopped."""
try:
while self._running and not self._stopping:
try:
await asyncio.wait_for(self.run(), timeout=self.loop_interval())
except asyncio.TimeoutError:
# run() took too long — continue to next iteration
self._log.warning("agent_loop_timeout", iteration=self._iteration)
self._iteration += 1
self._last_heartbeat = datetime.utcnow()
except Exception as e:
self._log.error("agent_loop_error", error=str(e))
self._running = False
@abstractmethod
async def run(self) -> None:
"""One iteration of the agent loop. Implement in subclass."""
...
def loop_interval(self) -> float:
"""Seconds between run() calls. Override in subclass."""
return 60.0
def is_healthy(self) -> bool:
"""Return True if agent is running and heartbeat is recent."""
if not self._running:
return False
if self._last_heartbeat is None:
return True
# Unhealthy if no heartbeat in 5x the loop interval
elapsed = (datetime.utcnow() - self._last_heartbeat).total_seconds()
return elapsed < self.loop_interval() * 5
def health_status(self) -> dict:
"""Return health status dict."""
return {
"agent": self.name,
"running": self._running,
"iteration": self._iteration,
"last_heartbeat": (
self._last_heartbeat.isoformat() if self._last_heartbeat else None
),
"healthy": self.is_healthy(),
}

View File

@ -1,70 +0,0 @@
"""Configuration from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Config:
"""All configuration from environment variables."""
# Host
host: str = os.getenv("SALIOR_HOST", "localhost")
port: int = int(os.getenv("SALIOR_PORT", "8080"))
# TimescaleDB (VPS3 local)
timeseries_host: str = os.getenv("TIMESERIES_HOST", "localhost")
timeseries_port: int = int(os.getenv("TIMESERIES_PORT", "5432"))
timeseries_user: str = os.getenv("TIMESERIES_USER", "salior")
timeseries_password: str = os.getenv("TIMESERIES_PASSWORD", "SaliorDB123!")
timeseries_db: str = os.getenv("TIMESERIES_DB", "salior")
timeseries_dsn: str = field(
default=lambda self: f"postgresql://{self.timeseries_user}:{self.timeseries_password}@{self.timeseries_host}:{self.timeseries_port}/{self.timeseries_db}",
init=False,
)
# Supabase (Salior_DATA)
supabase_url: str = os.getenv("SUPABASE_URL", "https://ridjlobkeolorkcunisl.supabase.co")
supabase_key: str = os.getenv("SUPABASE_KEY", "")
# LLM
minimax_api_key: str = os.getenv("MINIMAX_API_KEY", "")
minimax_model: str = os.getenv("MINIMAX_MODEL", "MiniMax-Text-01")
local_llm_url: str = os.getenv("LOCAL_LLM_URL", "")
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
# Hyperliquid
hl_ws_url: str = os.getenv(
"HYPERLIQUID_WS_URL", "wss://api.hyperliquid.xyz/ws"
)
hl_api_url: str = os.getenv(
"HYPERLIQUID_API_URL", "https://api.hyperliquid.xyz"
)
# Wallet session
wallet_session_days: int = int(os.getenv("WALLET_SESSION_DAYS", "180"))
# Execution
hl_private_key: str = os.getenv("HL_PRIVATE_KEY", "")
execution_mode: str = os.getenv("EXECUTION_MODE", "paper") # paper | live
# Plugins
plugin_dir: str = os.getenv("SALIOR_PLUGIN_DIR", "~/.salior/plugins")
# Coins to trade (v1: BTC + ETH)
coins: list[str] = field(
default_factory=lambda: os.getenv("COINS", "BTC,ETH").split(",")
)
# Signal thresholds
min_conviction: float = float(os.getenv("MIN_CONVICTION", "0.3"))
@property
def timeseries_url(self) -> str:
return f"postgresql://{self.timeseries_user}:{self.timeseries_password}@{self.timeseries_host}:{self.timeseries_port}/{self.timeseries_db}"
config = Config()

View File

@ -1,35 +0,0 @@
"""Structured logging setup."""
from __future__ import annotations
import structlog
import logging
import sys
def setup_logging(level: str = "INFO") -> structlog.BoundLogger:
"""Configure structlog with console output."""
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, level.upper(), logging.INFO),
)
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer(colors=True),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
return structlog.get_logger("salior")

View File

@ -1,76 +0,0 @@
"""Long-term memory across sessions."""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
MEMORY_DIR = Path.home() / ".salior" / "memory"
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
class Memory:
"""Persistent memory backed by files in ~/.salior/memory/."""
def __init__(self) -> None:
self._cache: dict[str, Any] = {}
def save(self, key: str, value: str | dict, tags: list[str] | None = None) -> None:
"""Save a memory entry."""
entry = {
"key": key,
"value": value,
"tags": tags or [],
"saved_at": datetime.utcnow().isoformat(),
}
# Store in cache
self._cache[key] = entry
# Write to file
safe_key = key.replace("/", "_").replace(" ", "_")
path = MEMORY_DIR / f"{safe_key}.json"
with open(path, "w") as f:
json.dump(entry, f, indent=2)
def get(self, key: str) -> Optional[Any]:
"""Get a memory entry."""
if key in self._cache:
return self._cache[key]["value"]
safe_key = key.replace("/", "_").replace(" ", "_")
path = MEMORY_DIR / f"{safe_key}.json"
if path.exists():
with open(path) as f:
entry = json.load(f)
self._cache[key] = entry
return entry["value"]
return None
def search(self, query: str, tags: list[str] | None = None) -> list[dict]:
"""Search memories by content or tags."""
results = []
for path in MEMORY_DIR.glob("*.json"):
with open(path) as f:
entry = json.load(f)
if query.lower() in str(entry.get("value", "")).lower():
if tags is None or any(t in entry.get("tags", []) for t in tags):
results.append(entry)
return results
def list_keys(self) -> list[str]:
"""List all memory keys."""
keys = []
for path in MEMORY_DIR.glob("*.json"):
keys.append(path.stem.replace("_", " "))
return sorted(keys)
def forget(self, key: str) -> None:
"""Delete a memory entry."""
safe_key = key.replace("/", "_").replace(" ", "_")
path = MEMORY_DIR / f"{safe_key}.json"
if path.exists():
path.unlink()
self._cache.pop(key, None)
memory = Memory()

View File

@ -1,93 +0,0 @@
"""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

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

View File

@ -1,329 +0,0 @@
"""Dashboard server — aiohttp + vanilla HTML/JS.
Simplified wallet connect:
1. User pastes HL wallet address (or auto-detect via window.ethereum)
2. Server verifies it has a HL account
3. Session stored in Supabase wallet_sessions (no message signing needed)
4. HL API private key stored server-side (env var HL_PRIVATE_KEY) not in browser
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from aiohttp import web
from aiohttp.web import Application, Request, Response
from salior.core.config import config
from salior.core.logging import setup_logging
from salior.db.supabase_client import SupabaseClient
from salior.hl_client import HyperliquidClient
log = setup_logging()
TEMPLATES = Path(__file__).parent / "templates"
STATIC = Path(__file__).parent / "static"
# ─── Helpers ─────────────────────────────────────────────────────────────────
def render_html(path: Path, **vars) -> str:
"""Simple variable substitution in HTML templates."""
html = path.read_text()
for key, val in vars.items():
html = html.replace(f"{{{{{key}}}}}", str(val))
return html
# ─── Handlers ─────────────────────────────────────────────────────────────────
async def health_handler(request: Request) -> Response:
return web.json_response({
"status": "ok",
"ts": datetime.utcnow().isoformat(),
"hl_private_key_set": bool(config.hl_private_key),
})
async def api_portfolio(request: Request) -> Response:
"""Get current portfolio from HL (or cache)."""
address = request.query.get("address", "")
if not address:
return web.json_response({"positions": []})
client = HyperliquidClient()
try:
account = await client.get_account(address)
positions = account.get("account", {}).get("positions", [])
balance = account.get("account", {}).get("totalCollateral", 0)
return web.json_response({
"positions": [
{
"coin": p.get("coin", ""),
"size": float(p.get("size", 0) or 0),
"avg_px": float(p.get("entryPx", 0) or 0),
"unrealized_pnl": float(p.get("unrealizedPnl", 0) or 0),
}
for p in positions
if float(p.get("size", 0) or 0) != 0
],
"balance": balance,
})
finally:
await client.close()
async def api_signals(request: Request) -> Response:
"""Get recent conviction signals from Supabase."""
supabase = SupabaseClient()
coin = request.query.get("coin")
signals = await supabase.get_recent_signals(coin=coin, limit=20)
return web.json_response({"signals": signals})
async def api_performance(request: Request) -> Response:
"""Get performance metrics from Supabase performance table."""
supabase = SupabaseClient()
rows = await supabase.select("performance", order="date.desc", limit=30)
if not rows:
return web.json_response({"days": 0, "total_pnl": 0, "sharpe": 0, "max_drawdown": 0})
total_pnl = sum(r.get("daily_pnl", 0) for r in rows)
sharpe = rows[0].get("sharpe", 0) or 0
max_dd = rows[0].get("max_drawdown", 0) or 0
trades = sum(r.get("trades_count", 0) for r in rows)
return web.json_response({
"days": len(rows),
"total_pnl": total_pnl,
"sharpe": sharpe,
"max_drawdown": max_dd,
"trades_count": trades,
})
async def api_hl_price(request: Request) -> Response:
"""Get current price for a coin from HL."""
coin = request.query.get("coin", "BTC")
client = HyperliquidClient()
try:
price = await client.get_market_price(coin)
return web.json_response({"coin": coin, "price": price})
finally:
await client.close()
# ─── Wallet Connect (simplified) ───────────────────────────────────────────────
async def api_wallet_connect(request: Request) -> Response:
"""Connect a HL wallet address (no signing needed for read-only).
POST body: { "address": "0x..." }
"""
body = await request.json()
address = body.get("address", "").strip()
if not address or len(address) != 42 or not address.startswith("0x"):
return web.json_response({"error": "Invalid Ethereum address"}, status=400)
# Verify address exists on HL
client = HyperliquidClient()
try:
hl_info = await client.verify_address(address)
if not hl_info.get("connected"):
return web.json_response({
"error": "This address has no Hyperliquid account. Bridge funds first."
}, status=400)
finally:
await client.close()
# Create session
session_token = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days)
supabase = SupabaseClient()
await supabase.upsert_wallet_session(
wallet_address=address,
session_token=session_token,
signature="hl_wallet_connect", # placeholder — no message signing
expires_at=expires_at.isoformat(),
)
log.info("wallet_connected", address=address)
return web.json_response({
"session": {
"address": address,
"session_token": session_token,
"expires_at": expires_at.isoformat(),
"days": config.wallet_session_days,
},
"hl_info": hl_info,
})
async def api_wallet_session(request: Request) -> Response:
"""Check existing wallet session."""
address = request.query.get("address", "")
if not address:
return web.json_response({"error": "missing address"}, status=400)
supabase = SupabaseClient()
session = await supabase.get_wallet_session(address)
if session:
return web.json_response({
"session": session,
"valid": True,
"hl_private_key_configured": bool(config.hl_private_key),
})
return web.json_response({"session": None, "valid": False})
async def api_wallet_balances(request: Request) -> Response:
"""Get full HL account balances for a wallet."""
address = request.query.get("address", "")
if not address:
return web.json_response({"error": "missing address"}, status=400)
client = HyperliquidClient()
try:
account = await client.get_account(address)
info = account.get("account", {})
return web.json_response({
"address": address,
"total_collateral": info.get("totalCollateral", 0),
"margin_used": info.get("marginUsed", 0),
"positions": info.get("positions", []),
"open_orders": info.get("openOrders", []),
})
finally:
await client.close()
# ─── Orders ─────────────────────────────────────────────────────────────────
async def api_order(request: Request) -> Response:
"""Place a real HL order (requires wallet session + server-side HL private key).
POST body: { "address": "0x...", "coin": "BTC", "side": "Buy", "size": 0.01, "price": 95000 }
"""
body = await request.json()
address = body.get("address", "")
coin = body.get("coin", "")
side = body.get("side", "")
size = float(body.get("size", 0))
price = float(body.get("price", 0))
if not all([address, coin, side, size, price]):
return web.json_response({"error": "missing fields"}, status=400)
# Check wallet session
supabase = SupabaseClient()
session = await supabase.get_wallet_session(address)
if not session:
return web.json_response({"error": "wallet not connected"}, status=401)
# Check if HL private key is configured
if not config.hl_private_key:
return web.json_response({
"error": "HL API private key not configured on server",
"hint": "Set HL_PRIVATE_KEY env var on the server to enable live trading",
}, status=503)
# Place the order
client = HyperliquidClient()
try:
result = await client.place_order(
address=address,
coin=coin,
side=side,
size=size,
price=price,
)
log.info("order_placed", address=address, coin=coin, side=side, size=size, price=price)
return web.json_response({
"status": "success",
"result": result,
"order": {"coin": coin, "side": side, "size": size, "price": price},
})
except Exception as e:
log.error("order_failed", error=str(e))
return web.json_response({"error": str(e)}, status=500)
finally:
await client.close()
async def api_order_preview(request: Request) -> Response:
"""Preview an order without placing it (requires wallet connected)."""
body = await request.json()
address = body.get("address", "")
coin = body.get("coin", "")
side = body.get("side", "")
size = float(body.get("size", 0))
if not all([address, coin, side, size]):
return web.json_response({"error": "missing fields"}, status=400)
supabase = SupabaseClient()
session = await supabase.get_wallet_session(address)
if not session:
return web.json_response({"error": "wallet not connected"}, status=401)
# Get current market price
client = HyperliquidClient()
try:
price = await client.get_market_price(coin)
finally:
await client.close()
return web.json_response({
"preview": True,
"coin": coin,
"side": side,
"size": size,
"price": price,
"hl_private_key_configured": bool(config.hl_private_key),
"total": size * price if price else 0,
})
# ─── Index ───────────────────────────────────────────────────────────────────────
async def index_handler(request: Request) -> Response:
"""Serve the main dashboard HTML."""
tmpl_path = TEMPLATES / "index.html"
html = tmpl_path.read_text()
return Response(text=html, content_type="text/html")
# ─── App factory ────────────────────────────────────────────────────────────────
def create_app() -> Application:
app = Application()
app.router.add_static("/static/", STATIC, show_index=True)
app.router.add_get("/", index_handler)
app.router.add_get("/health", health_handler)
# Wallet
app.router.add_post("/api/wallet/connect", api_wallet_connect)
app.router.add_get("/api/wallet/session", api_wallet_session)
app.router.add_get("/api/wallet/balances", api_wallet_balances)
# Market
app.router.add_get("/api/portfolio", api_portfolio)
app.router.add_get("/api/signals", api_signals)
app.router.add_get("/api/performance", api_performance)
app.router.add_get("/api/hl-price", api_hl_price)
# Orders
app.router.add_post("/api/order", api_order)
app.router.add_post("/api/order-preview", api_order_preview)
return app

View File

@ -1,429 +0,0 @@
/* Salior Dashboard — app.js */
// ─── State ───────────────────────────────────────────────────────────────────
const STATE = {
wallet: null, // { address, session_token, expires_at }
hlKeyConfigured: false,
execMode: 'paper',
minConviction: 0.7,
};
// ─── Nav ──────────────────────────────────────────────────────────────────────
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', e => {
e.preventDefault();
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`section-${tab.dataset.section}`).classList.add('active');
});
});
// ─── Wallet Connect ───────────────────────────────────────────────────────────
function openConnectModal() {
document.getElementById('connectModal').classList.add('open');
document.getElementById('walletInput').value = '';
document.getElementById('walletError').classList.remove('visible');
// Try auto-detect via window.ethereum
if (window.ethereum) {
window.ethereum.request({ method: 'eth_requestAccounts' })
.then(accounts => {
if (accounts[0]) {
document.getElementById('walletInput').value = accounts[0];
document.getElementById('detectedWallet').style.display = 'block';
document.getElementById('detectedAddr').textContent = shorten(accounts[0]);
}
})
.catch(() => {});
}
}
function closeConnectModal() {
document.getElementById('connectModal').classList.remove('open');
}
async function connectWallet() {
const input = document.getElementById('walletInput').value.trim();
const errorEl = document.getElementById('walletError');
if (!input) {
errorEl.textContent = 'Enter a wallet address';
errorEl.classList.add('visible');
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(input)) {
errorEl.textContent = 'Invalid Ethereum address';
errorEl.classList.add('visible');
return;
}
try {
const resp = await fetch('/api/wallet/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: input }),
});
const data = await resp.json();
if (data.error) {
errorEl.textContent = data.error;
errorEl.classList.add('visible');
return;
}
STATE.wallet = data.session;
STATE.hlKeyConfigured = data.hl_info ? true : false;
localStorage.setItem('salior_wallet', JSON.stringify(data.session));
closeConnectModal();
updateWalletUI();
loadDashboard();
} catch (e) {
errorEl.textContent = 'Connection failed: ' + e.message;
errorEl.classList.add('visible');
}
}
async function disconnectWallet() {
STATE.wallet = null;
localStorage.removeItem('salior_wallet');
updateWalletUI();
// Reset views
document.getElementById('hlAccountCard').style.display = 'none';
document.getElementById('signalsFeed').innerHTML = '<div class="empty">Connect wallet to view</div>';
document.getElementById('posTable').innerHTML = '<div class="empty">No positions</div>';
}
function updateWalletUI() {
const dot = document.getElementById('walletDot');
const addr = document.getElementById('walletAddress');
const badge = document.getElementById('hlBadge');
const tBadge = document.getElementById('tradingBadge');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const settingsBtn = document.getElementById('settingsBtn');
const tradeNotice = document.getElementById('orderNotConnected');
if (STATE.wallet) {
dot.className = 'wallet-dot ' + (STATE.hlKeyConfigured ? 'trading' : 'connected');
addr.textContent = shorten(STATE.wallet.address);
badge.style.display = 'inline';
tBadge.classList.toggle('visible', STATE.hlKeyConfigured);
connectBtn.style.display = 'none';
disconnectBtn.style.display = 'block';
settingsBtn.style.display = 'block';
if (tradeNotice) tradeNotice.style.display = 'none';
} else {
dot.className = 'wallet-dot disconnected';
addr.textContent = 'No wallet connected';
badge.style.display = 'none';
tBadge.classList.remove('visible');
connectBtn.style.display = 'block';
disconnectBtn.style.display = 'none';
settingsBtn.style.display = 'none';
if (tradeNotice) tradeNotice.style.display = 'block';
}
}
// ─── Dashboard load ──────────────────────────────────────────────────────────
async function loadDashboard() {
await Promise.all([
loadPortfolio(),
loadSignals(),
loadPerformance(),
loadHLAccount(),
loadOpenOrders(),
]);
}
async function loadPortfolio() {
if (!STATE.wallet) return;
try {
const resp = await fetch(`/api/portfolio?address=${STATE.wallet.address}`);
const { positions } = await resp.json();
renderPortfolio(positions || []);
} catch (e) { console.error(e); }
}
function renderPortfolio(positions) {
const el = document.getElementById('posTable');
if (!positions.length) {
el.innerHTML = '<div class="empty">No positions</div>';
return;
}
el.innerHTML = positions.map(p => {
const pnl = p.unrealized_pnl || 0;
const pnlClass = pnl >= 0 ? 'pos' : 'neg';
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
return `
<div class="pos-row">
<div class="coin">${p.coin}</div>
<div class="sz"><span class="val">${p.size}</span></div>
<div class="entry">@ $${p.avg_px?.toFixed(2) || '—'}</div>
<div class="pnl ${pnlClass}">${pnlStr}</div>
</div>`;
}).join('');
}
async function loadSignals() {
try {
const resp = await fetch('/api/signals');
const { signals } = await resp.json();
renderSignals(signals || []);
} catch (e) { console.error(e); }
}
function renderSignals(signals) {
const el = document.getElementById('signalsFeed');
if (!signals.length) {
el.innerHTML = '<div class="empty">No signals yet</div>';
return;
}
el.innerHTML = signals.slice(0, 15).map(sig => {
const absC = Math.abs(sig.conviction || 0);
const fill = sig.conviction >= 0
? `<div class="conviction-fill pos" style="width:${absC*100}%"></div>`
: `<div class="conviction-fill neg" style="width:${absC*100}%"></div>`;
const valColor = sig.conviction >= 0 ? 'pos' : 'neg';
const c = sig.conviction || 0;
const time = sig.created_at ? new Date(sig.created_at).toLocaleTimeString() : '';
return `
<div class="signal-item">
<div class="coin">${sig.coin}</div>
<div class="regime ${sig.regime || 'ranging'}">${sig.regime || '?'}</div>
<div class="conviction-bar">${fill}</div>
<div class="conviction-val ${valColor}">${c >= 0 ? '+' : ''}${c.toFixed(2)}</div>
<div class="signal-time">${time}</div>
</div>`;
}).join('');
}
async function loadPerformance() {
try {
const resp = await fetch('/api/performance');
const data = await resp.json();
renderPerformance(data);
} catch (e) { console.error(e); }
}
function renderPerformance(data) {
const pnlEl = document.getElementById('pnlVal');
const sharpeEl = document.getElementById('sharpeVal');
const ddEl = document.getElementById('drawdownVal');
const pnl = data.total_pnl || 0;
pnlEl.textContent = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
pnlEl.className = 'val ' + (pnl >= 0 ? 'pos' : 'neg');
sharpeEl.textContent = data.sharpe ? data.sharpe.toFixed(2) : '—';
ddEl.textContent = data.max_drawdown ? `${(data.max_drawdown*100).toFixed(1)}%` : '—';
}
async function loadHLAccount() {
if (!STATE.wallet) return;
try {
const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`);
const data = await resp.json();
const card = document.getElementById('hlAccountCard');
card.style.display = 'block';
const coll = parseFloat(data.total_collateral || 0);
document.getElementById('hlCollateral').textContent = coll >= 0 ? `$${coll.toFixed(2)}` : `$${coll.toFixed(2)}`;
document.getElementById('hlCollateral').className = 'value ' + (coll >= 0 ? 'pos' : 'neg');
document.getElementById('hlMargin').textContent = `$${parseFloat(data.margin_used || 0).toFixed(2)}`;
document.getElementById('hlOpenOrders').textContent = (data.open_orders || []).length;
document.getElementById('hlPositions').textContent = (data.positions || []).length;
// Account page
const usdc = coll; // simplified
document.getElementById('usdcAvailable').textContent = `$${usdc.toFixed(2)}`;
document.getElementById('totalPnl').textContent = `$${(data.unrealized_pnl || 0).toFixed(2)}`;
} catch (e) { console.error(e); }
}
async function loadOpenOrders() {
if (!STATE.wallet) return;
try {
const resp = await fetch(`/api/wallet/balances?address=${STATE.wallet.address}`);
const data = await resp.json();
renderOpenOrders(data.open_orders || []);
} catch (e) { console.error(e); }
}
function renderOpenOrders(orders) {
const el = document.getElementById('openOrdersView');
if (!orders.length) {
el.innerHTML = '<div class="empty">No open orders</div>';
return;
}
el.innerHTML = orders.map(o => {
const sideColor = o.side === 'Buy' ? 'pos' : 'neg';
return `
<div class="pos-row">
<div class="coin" style="color:var(--${sideColor === 'pos' ? 'green' : 'red'})">${o.side} ${o.coin || o.asset}</div>
<div class="sz">sz: <span class="val">${o.sz || o.size}</span></div>
<div class="entry">@ $${o.price}</div>
</div>`;
}).join('');
}
// ─── Order placement ─────────────────────────────────────────────────────────
async function previewOrder() {
if (!STATE.wallet) { alert('Connect wallet first'); return; }
const coin = document.getElementById('orderCoin').value;
const side = document.getElementById('orderSide').value;
const size = parseFloat(document.getElementById('orderSize').value);
const price = parseFloat(document.getElementById('orderPrice').value) || undefined;
if (!size || size <= 0) { showOrderStatus('Enter a valid size', 'error'); return; }
try {
const resp = await fetch('/api/order-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: STATE.wallet.address, coin, side, size }),
});
const data = await resp.json();
const preview = document.getElementById('orderPreview');
const total = (data.price || 0) * size;
const hlNote = data.hl_private_key_configured ? '✅ HL key ready — order will be placed' : '⚠️ No HL key — order will NOT be placed';
preview.innerHTML = `
<div>${side} <strong>${size}</strong> ${coin} @ <strong>$${data.price || 'market'}</strong></div>
<div class="total">Total: $${total.toFixed(2)}</div>
<div style="font-size:11px; margin-top:4px; color:var(--text-dim);">${hlNote}</div>
`;
preview.classList.add('visible');
document.getElementById('placeOrderBtn').disabled = false;
if (data.price) document.getElementById('orderPrice').value = data.price;
} catch (e) {
showOrderStatus('Preview failed: ' + e.message, 'error');
}
}
async function placeOrder() {
if (!STATE.wallet) { alert('Connect wallet first'); return; }
const coin = document.getElementById('orderCoin').value;
const side = document.getElementById('orderSide').value;
const size = parseFloat(document.getElementById('orderSize').value);
const price = parseFloat(document.getElementById('orderPrice').value);
showOrderStatus('Placing order...', 'pending');
try {
const resp = await fetch('/api/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: STATE.wallet.address, coin, side, size, price }),
});
const data = await resp.json();
if (data.error) {
showOrderStatus(data.error, 'error');
return;
}
showOrderStatus(`✅ Order placed: ${side} ${size} ${coin} @ $${price}`, 'success');
document.getElementById('orderPreview').classList.remove('visible');
document.getElementById('orderSize').value = '';
document.getElementById('orderPrice').value = '';
setTimeout(() => loadOpenOrders(), 2000);
} catch (e) {
showOrderStatus('Order failed: ' + e.message, 'error');
}
}
function showOrderStatus(msg, type) {
const el = document.getElementById('orderStatus');
el.textContent = msg;
el.className = 'order-status visible ' + type;
setTimeout(() => el.classList.remove('visible'), 6000);
}
// ─── Settings ─────────────────────────────────────────────────────────────────
function openSettings() {
document.getElementById('settingsModal').classList.add('open');
document.getElementById('execMode').value = STATE.execMode;
document.getElementById('minConviction').value = STATE.minConviction;
document.getElementById('hlKeyStatus').textContent = STATE.hlKeyConfigured ? '✅ Configured — live orders enabled' : '❌ Not configured — paper trading only';
document.getElementById('hlKeyStatus').style.color = STATE.hlKeyConfigured ? 'var(--green)' : 'var(--yellow)';
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('open');
}
function saveSettings() {
STATE.execMode = document.getElementById('execMode').value;
STATE.minConviction = parseFloat(document.getElementById('minConviction').value);
localStorage.setItem('salior_settings', JSON.stringify({ execMode: STATE.execMode, minConviction: STATE.minConviction }));
closeSettings();
}
// ─── Utils ────────────────────────────────────────────────────────────────────
function shorten(addr) {
if (!addr) return '—';
return addr.slice(0, 6) + '…' + addr.slice(-4);
}
// ─── Init ────────────────────────────────────────────────────────────────────
async function init() {
// Restore wallet from localStorage
const saved = localStorage.getItem('salior_wallet');
if (saved) {
try {
STATE.wallet = JSON.parse(saved);
// Verify session still valid
const resp = await fetch(`/api/wallet/session?address=${STATE.wallet.address}`);
const data = await resp.json();
if (!data.valid) {
STATE.wallet = null;
localStorage.removeItem('salior_wallet');
} else {
STATE.hlKeyConfigured = data.hl_private_key_configured || false;
}
} catch (e) {
STATE.wallet = null;
}
}
// Restore settings
const settings = localStorage.getItem('salior_settings');
if (settings) {
try {
const s = JSON.parse(settings);
STATE.execMode = s.execMode || 'paper';
STATE.minConviction = s.minConviction || 0.7;
} catch (e) {}
}
updateWalletUI();
if (STATE.wallet) {
await loadDashboard();
}
// Auto-refresh
setInterval(() => {
if (STATE.wallet) loadDashboard();
}, 30000);
}
init();

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 248 B

View File

@ -1,410 +0,0 @@
<!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; }
nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--surface); position: sticky; top: 0; z-index: 10; }
nav .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, nav .nav-links a.active { color: var(--text); }
.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-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.wallet-dot.disconnected { background: var(--text-dim); }
.wallet-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.wallet-dot.trading { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.wallet-address { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-dim); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hl-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(91,138,240,0.2); color: var(--accent); margin-right: 4px; }
.trading-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(62,207,142,0.2); color: var(--green); margin-left: 8px; display: none; }
.trading-badge.visible { display: inline; }
.btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap; }
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 1px solid var(--red); color: var(--red); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-buy { background: var(--green); color: #000; }
.btn-sell { background: var(--red); color: #fff; }
/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.card h2 .badge { background: var(--accent); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: none; }
.card h2 .badge.green { background: var(--green); }
.card h2 .badge.red { background: var(--red); }
.card h2 .badge.yellow { background: var(--yellow); color: #000; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
/* Signals */
.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; text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0; }
.signal-item .regime.trending_up { background: rgba(62,207,142,0.15); color: var(--green); }
.signal-item .regime.trending_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); }
.conviction-bar { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
.conviction-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.conviction-fill.pos { background: var(--green); }
.conviction-fill.neg { background: var(--red); }
.conviction-val { font-size: 13px; font-weight: 600; width: 50px; text-align: right; }
.signal-time { font-size: 11px; color: var(--text-dim); width: 70px; text-align: right; flex-shrink: 0; }
/* Portfolio table */
.pos-table { width: 100%; }
.pos-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.pos-row:last-child { border-bottom: none; }
.pos-row .coin { font-weight: 700; width: 70px; }
.pos-row .sz { flex: 1; font-size: 13px; }
.pos-row .sz .val { font-weight: 600; }
.pos-row .entry { font-size: 12px; color: var(--text-dim); width: 90px; }
.pos-row .pnl { font-size: 13px; font-weight: 600; width: 100px; text-align: right; }
.pos-row .pnl.pos { color: var(--green); }
.pos-row .pnl.neg { color: var(--red); }
/* Stats */
.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 .val.dim { color: var(--text-dim); font-size: 20px; }
.stat .lbl { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
/* 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 input, .order-form select { width: 100%; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
.order-form input:focus, .order-form select:focus { outline: none; border-color: var(--accent); }
.order-preview { margin-top: 10px; padding: 10px; background: var(--surface2); border-radius: 6px; font-size: 13px; color: var(--text-dim); display: none; }
.order-preview.visible { display: block; }
.order-preview .total { font-weight: 700; color: var(--text); font-size: 15px; margin-top: 4px; }
.order-status { margin-top: 10px; font-size: 13px; padding: 8px 12px; border-radius: 6px; display: none; }
.order-status.visible { display: block; }
.order-status.success { background: rgba(62,207,142,0.1); color: var(--green); border: 1px solid rgba(62,207,142,0.3); }
.order-status.error { background: rgba(240,91,91,0.1); color: var(--red); border: 1px solid rgba(240,91,91,0.3); }
.order-status.pending { background: rgba(240,192,91,0.1); color: var(--yellow); border: 1px solid rgba(240,192,91,0.3); }
/* HL info card */
.hl-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.hl-info-item { padding: 10px; background: var(--surface2); border-radius: 6px; }
.hl-info-item .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.3px; }
.hl-info-item .value { font-size: 18px; font-weight: 700; margin-top: 2px; }
.hl-info-item .value.pos { color: var(--green); }
.hl-info-item .value.neg { color: var(--red); }
/* Agent status */
.agent-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
.agent-row .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.agent-row .dot.ok { background: var(--green); }
.agent-row .dot.warn { background: var(--yellow); }
.agent-row .dot.err { background: var(--red); }
.agent-row .dot.offline { background: var(--text-dim); }
.agent-row .name { font-weight: 600; width: 120px; font-size: 13px; }
.agent-row .status { font-size: 12px; color: var(--text-dim); }
.agent-row .badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: 6px; }
.agent-row .badge.live { background: rgba(62,207,142,0.2); color: var(--green); }
.agent-row .badge.paper { background: rgba(240,192,91,0.2); color: var(--yellow); }
/* Connect modal */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; max-width: 440px; width: 90%; }
.modal h3 { font-size: 16px; margin-bottom: 6px; }
.modal p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; line-height: 1.6; }
.modal .form-field { margin-bottom: 14px; }
.modal label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
.modal input { width: 100%; padding: 9px 12px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
.modal input:focus { outline: none; border-color: var(--accent); }
.modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal .error-msg { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
.modal .error-msg.visible { display: block; }
/* Empty state */
.empty { text-align: center; padding: 28px; color: var(--text-dim); font-size: 13px; }
/* Sections */
.section { display: none; }
.section.active { display: block; }
@media (max-width: 768px) {
.grid-2, .grid-3, .order-form, .hl-info-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav>
<div class="logo">sali<span>or</span></div>
<div class="nav-links">
<a href="#" class="nav-tab active" data-section="dashboard">Dashboard</a>
<a href="#" class="nav-tab" data-section="trade">Trade</a>
<a href="#" class="nav-tab" data-section="agents">Agents</a>
</div>
</nav>
<div class="container">
<!-- Wallet Bar (always visible) -->
<div class="wallet-bar">
<div class="wallet-dot disconnected" id="walletDot"></div>
<div class="wallet-address" id="walletAddress">No wallet connected</div>
<span class="hl-badge" id="hlBadge" style="display:none">HL</span>
<span class="trading-badge" id="tradingBadge">LIVE</span>
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="openSettings()" style="display:none">Settings</button>
<button class="btn btn-primary btn-sm" id="connectBtn" onclick="openConnectModal()">Connect Wallet</button>
<button class="btn btn-outline btn-sm" id="disconnectBtn" onclick="disconnectWallet()" style="display:none">Disconnect</button>
</div>
<!-- ─── Dashboard section ─── -->
<div class="section active" id="section-dashboard">
<!-- HL Account Summary (shown when connected) -->
<div class="card" id="hlAccountCard" style="display:none">
<h2>Hyperliquid Account</h2>
<div class="hl-info-grid">
<div class="hl-info-item">
<div class="label">Total Collateral</div>
<div class="value" id="hlCollateral"></div>
</div>
<div class="hl-info-item">
<div class="label">Margin Used</div>
<div class="value" id="hlMargin"></div>
</div>
</div>
<div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div class="hl-info-item">
<div class="label">Open Orders</div>
<div class="value dim" id="hlOpenOrders"></div>
</div>
<div class="hl-info-item">
<div class="label">Positions</div>
<div class="value dim" id="hlPositions"></div>
</div>
</div>
</div>
<div class="grid-2">
<!-- Signals -->
<div class="card">
<h2>Conviction Signals <span class="badge" id="signalBadge">LIVE</span></h2>
<div id="signalsFeed"><div class="empty">Loading signals...</div></div>
</div>
<!-- Portfolio -->
<div class="card">
<h2>Positions</h2>
<div class="pos-table" id="posTable">
<div class="empty">No positions</div>
</div>
</div>
</div>
<!-- Performance -->
<div class="card">
<h2>Performance</h2>
<div class="grid-3">
<div class="stat">
<div class="val dim" id="pnlVal"></div>
<div class="lbl">Total PnL</div>
</div>
<div class="stat">
<div class="val dim" id="sharpeVal"></div>
<div class="lbl">Sharpe Ratio</div>
</div>
<div class="stat">
<div class="val dim" id="drawdownVal"></div>
<div class="lbl">Max Drawdown</div>
</div>
</div>
</div>
</div>
<!-- ─── Trade section ─── -->
<div class="section" id="section-trade">
<div class="grid-2">
<!-- Place Order -->
<div class="card">
<h2>Place Order</h2>
<div class="order-form">
<div>
<label>Coin</label>
<select id="orderCoin">
<option value="BTC">BTC</option>
<option value="ETH">ETH</option>
</select>
</div>
<div>
<label>Side</label>
<select id="orderSide">
<option value="Buy">Buy / Long</option>
<option value="Sell">Sell / Short</option>
</select>
</div>
<div>
<label>Size</label>
<input type="number" id="orderSize" placeholder="0.01" step="0.001" min="0.001" />
</div>
<div>
<label>Price</label>
<input type="number" id="orderPrice" placeholder="Market" step="0.01" />
</div>
</div>
<div class="order-preview" id="orderPreview"></div>
<div style="margin-top:12px; display:flex; gap:8px;">
<button class="btn btn-primary" id="previewBtn" onclick="previewOrder()">Preview</button>
<button class="btn btn-primary" id="placeOrderBtn" onclick="placeOrder()" disabled>Place Order</button>
</div>
<div class="order-status" id="orderStatus"></div>
<div id="orderNotConnected" class="empty" style="display:none; padding:16px;">
Connect your HL wallet above to trade.
</div>
</div>
<!-- Open Orders -->
<div class="card">
<h2>Open Orders</h2>
<div id="openOrdersView"><div class="empty">No open orders</div></div>
</div>
</div>
<!-- Balance -->
<div class="card">
<h2>Account</h2>
<div class="hl-info-grid">
<div class="hl-info-item">
<div class="label">USDC Available</div>
<div class="value" id="usdcAvailable"></div>
</div>
<div class="hl-info-item">
<div class="label">Total PnL</div>
<div class="value" id="totalPnl"></div>
</div>
</div>
</div>
</div>
<!-- ─── Agents section ─── -->
<div class="section" id="section-agents">
<div class="card">
<h2>Agent Health</h2>
<div id="agentStatus"><div class="empty">Loading...</div></div>
</div>
<div class="card">
<h2>Pipeline</h2>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:12px; text-align:center; font-size:12px; color:var(--text-dim);">
<div>
<div style="font-size:20px; margin-bottom:4px;">📡</div>
<div style="font-weight:600;">data_agent</div>
<div>HL WS → TimescaleDB</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;">🧠</div>
<div style="font-weight:600;">signal_agent</div>
<div>candles → conviction</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;"></div>
<div style="font-weight:600;">exec_agent</div>
<div>signals → HL orders</div>
</div>
<div>
<div style="font-size:20px; margin-bottom:4px;">🛡️</div>
<div style="font-weight:600;">risk_agent</div>
<div>circuit breakers</div>
</div>
</div>
</div>
</div>
</div>
<!-- ─── Connect Modal ─── -->
<div class="modal-overlay" id="connectModal">
<div class="modal">
<h3>Connect Hyperliquid Wallet</h3>
<p>Enter your Hyperliquid wallet address to connect. Your wallet is read-only — orders are signed server-side using your configured API key.</p>
<div class="form-field">
<label>HL Wallet Address</label>
<input type="text" id="walletInput" placeholder="0x..." maxlength="42" />
<div class="error-msg" id="walletError"></div>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-bottom:4px; display:none" id="detectedWallet">
Detected: <span id="detectedAddr"></span>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeConnectModal()">Cancel</button>
<button class="btn btn-primary" onclick="connectWallet()">Connect</button>
</div>
</div>
</div>
<!-- ─── Settings Modal ─── -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<h3>Settings</h3>
<p>Trading configuration. The HL API key is set server-side by your administrator.</p>
<div class="form-field">
<label>Execution Mode</label>
<select id="execMode">
<option value="paper">Paper Trading</option>
<option value="live">Live Trading</option>
</select>
</div>
<div class="form-field">
<label>Min Conviction to Trade</label>
<input type="number" id="minConviction" value="0.7" step="0.05" min="0.3" max="1.0" />
</div>
<div class="form-field">
<label>HL API Key Configured</label>
<div id="hlKeyStatus" style="font-size:13px; color:var(--green);">✅ Configured</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeSettings()">Close</button>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -1,5 +0,0 @@
"""Database module."""
from salior.db.timescale_client import TimescaleDB
from salior.db.supabase_client import SupabaseClient
__all__ = ["TimescaleDB", "SupabaseClient"]

View File

@ -1,175 +0,0 @@
-- Salior Database Schema
-- PostgreSQL 17 + TimescaleDB 2.26
-- Run: psql $DATABASE_URL -f schema.sql
-- === TimescaleDB hypertables (market data) ===
-- 1m candles per coin
CREATE TABLE IF NOT EXISTS candles_1m (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
o REAL NOT NULL,
h REAL NOT NULL,
l REAL NOT NULL,
c REAL NOT NULL,
v REAL NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('candles_1m', 't', if_not_exists => TRUE);
-- 5m candles
CREATE TABLE IF NOT EXISTS candles_5m (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
o REAL NOT NULL,
h REAL NOT NULL,
l REAL NOT NULL,
c REAL NOT NULL,
v REAL NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('candles_5m', 't', if_not_exists => TRUE);
-- 15m candles
CREATE TABLE IF NOT EXISTS candles_15m (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
o REAL NOT NULL,
h REAL NOT NULL,
l REAL NOT NULL,
c REAL NOT NULL,
v REAL NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('candles_15m', 't', if_not_exists => TRUE);
-- 1h candles
CREATE TABLE IF NOT EXISTS candles_1h (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
o REAL NOT NULL,
h REAL NOT NULL,
l REAL NOT NULL,
c REAL NOT NULL,
v REAL NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('candles_1h', 't', if_not_exists => TRUE);
-- Orderbook snapshots
CREATE TABLE IF NOT EXISTS orderbook (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
bids JSONB NOT NULL, -- [{px, sz}, ...]
asks JSONB NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('orderbook', 't', if_not_exists => TRUE);
-- Individual trades
CREATE TABLE IF NOT EXISTS trades (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
px REAL NOT NULL,
sz REAL NOT NULL,
side TEXT NOT NULL, -- 'bid' | 'ask'
hash TEXT UNIQUE NOT NULL,
PRIMARY KEY (hash)
);
CREATE INDEX IF NOT EXISTS idx_trades_coin_t ON trades (coin, t DESC);
SELECT create_hypertable('trades', 't', if_not_exists => TRUE);
-- Funding rates
CREATE TABLE IF NOT EXISTS funding (
coin TEXT NOT NULL,
t TIMESTAMPTZ NOT NULL,
rate REAL NOT NULL,
PRIMARY KEY (coin, t)
);
SELECT create_hypertable('funding', 't', if_not_exists => TRUE);
-- === PostgreSQL tables (application data) ===
-- Signals from signal_agent
CREATE TABLE IF NOT EXISTS signals (
id UUID DEFAULT gen_random_uuid(),
coin TEXT NOT NULL,
regime TEXT NOT NULL, -- trending_up | trending_down | ranging | volatile
conviction REAL NOT NULL, -- -1.0 to 1.0
reasoning TEXT NOT NULL,
t TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(coin, t)
);
-- Execution log
CREATE TABLE IF NOT EXISTS executions (
id UUID DEFAULT gen_random_uuid(),
signal_id UUID REFERENCES signals(id),
coin TEXT NOT NULL,
side TEXT NOT NULL, -- buy | sell
sz REAL NOT NULL,
px REAL NOT NULL,
filled_px REAL,
status TEXT NOT NULL, -- pending | filled | cancelled | failed
mode TEXT NOT NULL, -- paper | live
error_msg TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Portfolio positions
CREATE TABLE IF NOT EXISTS portfolio (
coin TEXT PRIMARY KEY,
pos_size REAL NOT NULL DEFAULT 0,
avg_px REAL NOT NULL DEFAULT 0,
unrealized_pnl REAL NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Performance log
CREATE TABLE IF NOT EXISTS performance (
date DATE PRIMARY KEY,
daily_pnl REAL NOT NULL DEFAULT 0,
cumulative_pnl REAL NOT NULL DEFAULT 0,
sharpe REAL,
max_drawdown REAL,
trades_count INTEGER NOT NULL DEFAULT 0
);
-- Risk events (circuit breakers triggered)
CREATE TABLE IF NOT EXISTS risk_events (
id UUID DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL, -- max_drawdown | daily_loss | position_limit
details JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Agent heartbeat log
CREATE TABLE IF NOT EXISTS agent_health (
agent TEXT NOT NULL,
heartbeat TIMESTAMPTZ DEFAULT NOW(),
iteration INTEGER,
status TEXT NOT NULL, -- running | paused | error
details JSONB
);
CREATE INDEX IF NOT EXISTS idx_agent_health_agent ON agent_health (agent, heartbeat DESC);
-- Wallet sessions (180-day auth)
CREATE TABLE IF NOT EXISTS wallet_sessions (
id UUID DEFAULT gen_random_uuid(),
wallet_address VARCHAR(42) UNIQUE NOT NULL,
session_token TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
last_active TIMESTAMPTZ DEFAULT NOW(),
signature VARCHAR(256) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Wallet addresses (known wallets)
CREATE TABLE IF NOT EXISTS wallets (
address VARCHAR(42) PRIMARY KEY,
label TEXT,
wallet_type TEXT NOT NULL, -- main | api | receiving
created_at TIMESTAMPTZ DEFAULT NOW()
);

View File

@ -1,195 +0,0 @@
"""Supabase client for application data."""
from __future__ import annotations
from typing import Any, Optional
import httpx
from salior.core.config import config
class SupabaseClient:
"""REST-based Supabase client for Salior_DATA."""
def __init__(self, url: Optional[str] = None, key: Optional[str] = None) -> None:
self.url = url or config.supabase_url
self.key = key or config.supabase_key
self._headers = {
"apikey": self.key,
"Authorization": f"Bearer {self.key}",
"Content-Type": "application/json",
}
def _table_url(self, table: str) -> str:
return f"{self.url}/rest/v1/{table}"
async def insert(
self,
table: str,
data: dict,
params: Optional[dict] = None,
) -> dict | None:
"""Insert a row into a table."""
async with httpx.AsyncClient() as client:
resp = await client.post(
self._table_url(table),
json=data,
headers=self._headers,
params=params or {"select": "*, id"},
)
if resp.status_code in (200, 201):
return resp.json()
return None
async def update(
self,
table: str,
data: dict,
filters: dict,
) -> dict | None:
"""Update rows matching filters."""
query = " AND ".join(f"{k}=eq.{v}" for k, v in filters.items())
async with httpx.AsyncClient() as client:
resp = await client.patch(
f"{self._table_url(table)}?{query}",
json=data,
headers=self._headers,
)
if resp.status_code in (200, 204):
return resp.json()
return None
async def select(
self,
table: str,
filters: Optional[dict] = None,
order: Optional[str] = None,
limit: int = 100,
) -> list[dict]:
"""Select rows from a table."""
params = {"select": "*", "limit": str(limit)}
if filters:
for k, v in filters.items():
params[f"where"] = f"{k}=eq.{v}"
if order:
params["order"] = order
async with httpx.AsyncClient() as client:
resp = await client.get(
self._table_url(table),
headers=self._headers,
params=params,
)
if resp.status_code == 200:
return resp.json()
return []
async def rpc(
self,
func: str,
params: Optional[dict] = None,
) -> Any:
"""Call a Supabase stored procedure."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.url}/rest/v1/rpc/{func}",
json=params or {},
headers=self._headers,
)
if resp.status_code in (200, 201):
return resp.json()
return None
# === Signals ===
async def insert_signal(
self,
coin: str,
regime: str,
conviction: float,
reasoning: str,
) -> Optional[dict]:
"""Insert a conviction signal."""
return await self.insert(
"signals",
{
"coin": coin,
"regime": regime,
"conviction": conviction,
"reasoning": reasoning,
},
params={"select": "id"},
)
async def get_recent_signals(
self,
coin: Optional[str] = None,
limit: int = 10,
) -> list[dict]:
"""Get recent conviction signals."""
filters = {"coin": f"eq.{coin}"} if coin else None
return await self.select(
"signals",
filters=filters,
order="created_at.desc",
limit=limit,
)
# === Executions ===
async def insert_execution(self, data: dict) -> Optional[dict]:
"""Log an execution."""
return await self.insert("executions", data)
async def get_open_executions(self) -> list[dict]:
"""Get pending/filled executions."""
return await self.select(
"executions",
filters={"status": "in.(pending,filled)"},
order="created_at.desc",
limit=50,
)
# === Portfolio ===
async def get_portfolio(self) -> list[dict]:
"""Get current portfolio."""
return await self.select("portfolio", limit=100)
# === Wallet sessions ===
async def upsert_wallet_session(
self,
wallet_address: str,
session_token: str,
signature: str,
expires_at: str,
) -> Optional[dict]:
"""Insert or update a wallet session (180-day)."""
data = {
"wallet_address": wallet_address,
"session_token": session_token,
"signature": signature,
"issued_at": "now",
"expires_at": expires_at,
}
return await self.insert(
"wallet_sessions",
data,
params={"on_conflict": "wallet_address", "select": "*"},
)
async def get_wallet_session(
self,
wallet_address: str,
) -> Optional[dict]:
"""Get active session for a wallet."""
rows = await self.select(
"wallet_sessions",
filters={
"wallet_address": f"eq.{wallet_address}",
"expires_at": "gt.now",
},
limit=1,
)
return rows[0] if rows else None

View File

@ -1,281 +0,0 @@
"""TimescaleDB client for market data."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
from typing import Any, Optional
import asyncpg
from salior.core.config import config
class TimescaleDB:
"""Async TimescaleDB client for market data."""
def __init__(self, dsn: Optional[str] = None) -> None:
self.dsn = dsn or config.timeseries_url
self._pool: Optional[asyncpg.Pool] = None
async def connect(self) -> None:
"""Create connection pool."""
self._pool = await asyncpg.create_pool(
self.dsn,
min_size=2,
max_size=10,
command_timeout=60,
)
async def close(self) -> None:
"""Close connection pool."""
if self._pool:
await self._pool.close()
self._pool = None
async def execute(self, query: str, *args: Any) -> None:
"""Execute a query (INSERT/UPDATE)."""
if not self._pool:
await self.connect()
async with self._pool.acquire() as conn:
await conn.execute(query, *args)
async def fetch(self, query: str, *args: Any) -> list[dict]:
"""Fetch rows as list of dicts."""
if not self._pool:
await self.connect()
async with self._pool.acquire() as conn:
rows = await conn.fetch(query, *args)
return [dict(r) for r in rows]
# === Candles ===
async def upsert_candle(
self,
table: str,
coin: str,
t: datetime,
o: float,
h: float,
l: float,
c: float,
v: float,
) -> None:
"""Insert or update a candle row."""
await self.execute(
f"""
INSERT INTO {table} (coin, t, o, h, l, c, v)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (coin, t) DO UPDATE SET
o = EXCLUDED.o,
h = EXCLUDED.h,
l = EXCLUDED.l,
c = EXCLUDED.c,
v = EXCLUDED.v
""",
coin, t, o, h, l, c, v,
)
async def get_latest_candle(self, table: str, coin: str) -> Optional[dict]:
"""Get the most recent candle for a coin."""
rows = await self.fetch(
f"""
SELECT * FROM {table}
WHERE coin = $1
ORDER BY t DESC
LIMIT 1
""",
coin,
)
return rows[0] if rows else None
async def get_candles(
self,
table: str,
coin: str,
hours: int = 24,
) -> list[dict]:
"""Get candles for a coin from the last N hours."""
since = datetime.utcnow() - timedelta(hours=hours)
return await self.fetch(
f"""
SELECT * FROM {table}
WHERE coin = $1 AND t >= $2
ORDER BY t ASC
""",
coin, since,
)
# === Trades ===
async def insert_trade(
self,
coin: str,
t: datetime,
px: float,
sz: float,
side: str,
hash: str,
) -> None:
"""Insert a trade (ignore duplicates by hash)."""
await self.execute(
"""
INSERT INTO trades (coin, t, px, sz, side, hash)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
""",
coin, t, px, sz, side, hash,
)
# === Orderbook ===
async def insert_orderbook(
self,
coin: str,
t: datetime,
bids: list[dict],
asks: list[dict],
) -> None:
"""Insert orderbook snapshot."""
await self.execute(
"""
INSERT INTO orderbook (coin, t, bids, asks)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
""",
coin, t, bids, asks,
)
# === Signals ===
async def insert_signal(
self,
coin: str,
regime: str,
conviction: float,
reasoning: str,
) -> Optional[str]:
"""Insert a signal, return its ID."""
row = await self.fetch(
"""
INSERT INTO signals (coin, regime, conviction, reasoning)
VALUES ($1, $2, $3, $4)
ON CONFLICT (coin, t) DO UPDATE SET
regime = EXCLUDED.regime,
conviction = EXCLUDED.conviction,
reasoning = EXCLUDED.reasoning
RETURNING id::text
""",
coin, regime, conviction, reasoning,
)
return row[0]["id"] if row else None
async def get_latest_signals(self, limit: int = 10) -> list[dict]:
"""Get most recent signals for all coins."""
return await self.fetch(
"""
SELECT * FROM signals
ORDER BY t DESC
LIMIT $1
""",
limit,
)
# === Portfolio ===
async def upsert_portfolio(
self,
coin: str,
pos_size: float,
avg_px: float,
unrealized_pnl: float,
) -> None:
"""Update portfolio position."""
await self.execute(
"""
INSERT INTO portfolio (coin, pos_size, avg_px, unrealized_pnl)
VALUES ($1, $2, $3, $4)
ON CONFLICT (coin) DO UPDATE SET
pos_size = EXCLUDED.pos_size,
avg_px = EXCLUDED.avg_px,
unrealized_pnl = EXCLUDED.unrealized_pnl,
updated_at = NOW()
""",
coin, pos_size, avg_px, unrealized_pnl,
)
async def get_portfolio(self) -> list[dict]:
"""Get all portfolio positions."""
return await self.fetch("SELECT * FROM portfolio")
# === Executions ===
async def insert_execution(
self,
signal_id: Optional[str],
coin: str,
side: str,
sz: float,
px: float,
mode: str,
) -> str:
"""Insert an execution record, return its ID."""
rows = await self.fetch(
"""
INSERT INTO executions (signal_id, coin, side, sz, px, mode, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
RETURNING id::text
""",
signal_id, coin, side, sz, px, mode,
)
return rows[0]["id"]
async def update_execution(
self,
exec_id: str,
status: str,
filled_px: Optional[float] = None,
error_msg: Optional[str] = None,
) -> None:
"""Update execution status."""
await self.execute(
"""
UPDATE executions
SET status = $2,
filled_px = COALESCE($3, filled_px),
error_msg = $4,
updated_at = NOW()
WHERE id = $1::uuid
""",
exec_id, status, filled_px, error_msg,
)
# === Health ===
async def log_health(
self,
agent: str,
status: str,
iteration: Optional[int] = None,
details: Optional[dict] = None,
) -> None:
"""Log agent health heartbeat."""
await self.execute(
"""
INSERT INTO agent_health (agent, status, iteration, details)
VALUES ($1, $2, $3, $4)
""",
agent, status, iteration, details,
)
async def get_agent_health(self, agent: str, minutes: int = 10) -> list[dict]:
"""Get recent health entries for an agent."""
since = datetime.utcnow() - timedelta(minutes=minutes)
return await self.fetch(
"""
SELECT * FROM agent_health
WHERE agent = $1 AND heartbeat >= $2
ORDER BY heartbeat DESC
LIMIT 10
""",
agent, since,
)

View File

@ -1,222 +0,0 @@
"""Hyperliquid API client — REST + WebSocket + order signing.
Requires: pip install eth-account eth-keys msgpack websockets aiohttp
"""
from __future__ import annotations
import asyncio
import hashlib
import time as time_module
from datetime import datetime, timezone
from typing import Any, Optional
import httpx
import msgpack
import websockets
from salior.core.config import config
from salior.core.logging import setup_logging
log = setup_logging()
HL_API = "https://api.hyperliquid.xyz"
class HyperliquidClient:
"""Client for Hyperliquid REST API.
Handles:
- Account state (balances, positions, open orders)
- Order signing + submission (requires private key)
- Candle + trade data via WS
"""
def __init__(self, private_key: Optional[str] = None) -> None:
self.private_key = private_key or config.hl_private_key
self._http = httpx.AsyncClient(timeout=15.0)
async def close(self) -> None:
await self._http.aclose()
# ─── Signing ────────────────────────────────────────────────────────────
def _sign_action(self, payload: dict) -> dict:
"""Sign a HL order/cancel payload using secp256k1.
HL CLOB signing: msgpack(action) SHA256 secp256k1 sign.
Returns payload with added signature {r, s, v}.
"""
if not self.private_key:
raise ValueError("HL private key not configured. Set HL_PRIVATE_KEY env var.")
try:
from eth_account import Account
from eth_keys import keys
from eth_utils import decode_hex
except ImportError as e:
raise RuntimeError(
"Missing dependencies for HL signing. Install: pip install eth-account eth-keys eth-utils"
) from e
# msgpack encode (binary mode)
encoded = msgpack.packb(payload, use_bin_type=True)
digest = hashlib.sha256(encoded).digest()
# secp256k1 sign
pk_bytes = decode_hex(self.private_key) if self.private_key.startswith("0x") else bytes.fromhex(self.private_key)
private_key = keys.PrivateKey(pk_bytes)
signature = private_key.sign_msg_hash(digest)
return {
**payload,
"signature": {
"r": hex(signature.r),
"s": hex(signature.s),
"v": signature.v,
},
}
# ─── Account ───────────────────────────────────────────────────────────
async def get_account(self, address: str) -> dict:
"""Get account info: balances, positions, open orders."""
payload = {"type": "account", "user": address}
resp = await self._http.post(f"{HL_API}/exchange", json=payload)
if resp.status_code == 200:
return resp.json()
return {"account": {"positions": [], "totalCollateral": 0}}
async def get_fills(self, address: str, start_time: int = 0) -> list[dict]:
"""Get user fill history."""
payload = {"type": "fills", "user": address, "startTime": start_time}
resp = await self._http.post(f"{HL_API}/exchange", json=payload)
if resp.status_code == 200:
return resp.json().get("fills", [])
return []
async def get_open_orders(self, address: str) -> list[dict]:
"""Get user's open orders for all coins."""
payload = {"type": "openOrders", "user": address}
resp = await self._http.post(f"{HL_API}/exchange", json=payload)
if resp.status_code == 200:
orders = resp.json()
# HL returns {orders: [...], successful: true}
return orders.get("orders", [])
return []
# ─── Orders ────────────────────────────────────────────────────────────
async def place_order(
self,
address: str,
coin: str,
side: str, # "Buy" | "Sell"
size: float,
price: float,
order_type: str = "Limit",
) -> dict:
"""Place a signed limit order via HL CLOB API."""
if not self.private_key:
raise ValueError("HL private key not configured")
ts = str(int(time_module.time() * 1000))
# Build order request payload
order_req = {
"asset": coin,
"user": address,
"side": side,
"type": order_type,
"size": str(size),
"price": str(price),
"fillOrKill": False,
"time": ts,
}
# Sign it
signed_order = self._sign_action(order_req)
# Submit
resp = await self._http.post(
f"{HL_API}/exchange",
json={"type": "order", **signed_order},
)
if resp.status_code == 200:
result = resp.json()
log.info("hl_order_result", coin=coin, side=side, result=result)
return result
return {"error": resp.text, "status_code": resp.status_code}
async def cancel_order(self, address: str, coin: str, order_id: str) -> dict:
"""Cancel an open order."""
ts = str(int(time_module.time() * 1000))
cancel_req = {
"asset": coin,
"user": address,
"orderId": order_id,
"time": ts,
}
if self.private_key:
signed = self._sign_action(cancel_req)
else:
signed = cancel_req
resp = await self._http.post(
f"{HL_API}/exchange",
json={"type": "cancel", **signed},
)
if resp.status_code == 200:
return resp.json()
return {"error": resp.text}
# ─── Market data ────────────────────────────────────────────────────────
async def get_market_price(self, coin: str) -> Optional[float]:
"""Get current mid-price for a coin."""
payload = {"type": "midPrice", "coin": coin}
resp = await self._http.post(f"{HL_API}/exchange", json=payload)
if resp.status_code == 200:
data = resp.json()
price = data.get("midPrice")
if price is not None:
return float(price)
return None
async def get_candles(self, coin: str, interval: str = "1m", limit: int = 100) -> list[dict]:
"""Get recent candles."""
payload = {"type": "candleRecent", "coin": coin, "interval": interval}
resp = await self._http.post(f"{HL_API}/exchange", json=payload)
if resp.status_code == 200:
return resp.json().get("candle", {}).get("data", [])
return []
# ─── Wallet connect ──────────────────────────────────────────────────────
async def verify_address(self, address: str) -> dict:
"""Check if an address has a HL account."""
try:
account = await self.get_account(address)
info = account.get("account", {})
has_balance = float(info.get("totalCollateral", 0) or 0) > 0 or len(info.get("positions", [])) > 0
return {
"address": address,
"connected": True, # HL is public-read, all addresses valid
"has_activity": has_balance,
"balance": info.get("totalCollateral", 0),
}
except Exception as e:
log.warning("hl_verify_failed", address=address, error=str(e))
return {"address": address, "connected": False}
# Singleton for exec_agent use
_client: Optional[HyperliquidClient] = None
def get_hl_client() -> HyperliquidClient:
global _client
if _client is None:
_client = HyperliquidClient()
return _client

View File

@ -1,4 +0,0 @@
"""Hooks module — event system for agent communication."""
from salior.hooks.registry import HookRegistry, global_hooks
__all__ = ["HookRegistry", "global_hooks"]

View File

@ -1,144 +0,0 @@
"""Hook event system — emit events, register handlers, dispatch callbacks."""
from __future__ import annotations
import asyncio
import fnmatch
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
from salior.core.logging import setup_logging
log = setup_logging()
@dataclass
class HookEvent:
"""A hook event with metadata."""
name: str # e.g. "on_signal", "on_fill", "on_error"
data: dict # Event payload
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
source: str = "" # Which agent emitted this
HookHandler = Callable[[HookEvent], Awaitable[None]] | Callable[[HookEvent], None]
class HookRegistry:
"""Register and dispatch hook handlers."""
def __init__(self) -> None:
self._handlers: dict[str, list[HookHandler]] = {}
def on(self, event_name: str, handler: HookHandler) -> None:
"""Register a handler for an event."""
if event_name not in self._handlers:
self._handlers[event_name] = []
# Prevent duplicate registration
if handler not in self._handlers[event_name]:
self._handlers[event_name].append(handler)
log.debug("hook_registered", event=event_name, handler=handler.__name__)
def off(self, event_name: str, handler: HookHandler) -> None:
"""Unregister a handler."""
if event_name in self._handlers:
try:
self._handlers[event_name].remove(handler)
log.debug("hook_unregistered", event=event_name, handler=handler.__name__)
except ValueError:
pass
async def emit(self, event: HookEvent) -> None:
"""Fire all handlers for an event, async-safe."""
handlers = self._handlers.get(event.name, [])
if not handlers:
return
log.debug("hook_fired", event=event.name, count=len(handlers), data=event.data)
for handler in handlers:
try:
if asyncio.iscoroutinefunction(handler):
await handler(event)
else:
handler(event)
except Exception as e:
log.error("hook_handler_error", event=event.name, handler=handler.__name__, error=str(e))
def list(self) -> dict[str, int]:
"""List registered hooks with handler counts."""
return {name: len(handlers) for name, handlers in self._handlers.items()}
# Global registry — shared across all agents
global_hooks = HookRegistry()
# ─── Built-in hook events ─────────────────────────────────────────────────────
async def on_signal(coin: str, regime: str, conviction: float, reasoning: str) -> None:
"""Emit a signal event."""
await global_hooks.emit(HookEvent(
name="on_signal",
source="signal_agent",
data={"coin": coin, "regime": regime, "conviction": conviction, "reasoning": reasoning},
))
async def on_fill(
coin: str,
side: str,
size: float,
price: float,
exec_id: str,
mode: str,
) -> None:
"""Emit a fill event when an order fills."""
await global_hooks.emit(HookEvent(
name="on_fill",
source="exec_agent",
data={"coin": coin, "side": side, "size": size, "price": price, "exec_id": exec_id, "mode": mode},
))
async def on_execution(
coin: str,
side: str,
size: float,
price: float,
status: str,
error: str | None = None,
) -> None:
"""Emit an execution event (placed, filled, cancelled, failed)."""
await global_hooks.emit(HookEvent(
name="on_execution",
source="exec_agent",
data={"coin": coin, "side": side, "size": size, "price": price, "status": status, "error": error},
))
async def on_error(agent: str, error: str, details: dict | None = None) -> None:
"""Emit an error event."""
await global_hooks.emit(HookEvent(
name="on_error",
source=agent,
data={"agent": agent, "error": error, "details": details or {}},
))
async def on_risk_breach(reason: str, details: dict) -> None:
"""Emit a risk breach event."""
await global_hooks.emit(HookEvent(
name="on_risk_breach",
source="risk_agent",
data={"reason": reason, "details": details},
))
async def on_agent_health(agent: str, status: str, iteration: int) -> None:
"""Emit a health heartbeat."""
await global_hooks.emit(HookEvent(
name="on_agent_health",
source=agent,
data={"agent": agent, "status": status, "iteration": iteration},
))

View File

@ -1,5 +0,0 @@
"""LLM client module."""
from salior.llm.client import LLMClient, llm
__all__ = ["LLMClient", "llm"]

View File

@ -1,130 +0,0 @@
"""Unified LLM client with MiniMax → OpenRouter → Local routing."""
from __future__ import annotations
import json
from typing import Any, Optional
import httpx
from salior.core.config import config
class LLMClient:
"""Unified LLM client with automatic provider fallback."""
def __init__(self) -> None:
self._providers = self._build_providers()
def _build_providers(self) -> dict[str, dict]:
"""Build provider config from environment."""
return {
"minimax": {
"url": "https://api.minimax.chat/v1/text/chatcompletion_v2",
"model": config.minimax_model,
"api_key": config.minimax_api_key,
"enabled": bool(config.minimax_api_key),
},
"openrouter": {
"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "anthropic/claude-3.5-haiku",
"api_key": config.openrouter_api_key,
"enabled": bool(config.openrouter_api_key),
},
"local": {
"url": f"{config.local_llm_url}/v1/chat/completions",
"model": "local",
"api_key": "not-needed",
"enabled": bool(config.local_llm_url),
},
}
async def chat(
self,
prompt: str,
system: Optional[str] = None,
model_override: Optional[str] = None,
**kwargs,
) -> str:
"""Send a chat completion request.
Args:
prompt: The user prompt
system: Optional system message
model_override: Force a specific provider/model (e.g. "openrouter/claude-3")
Returns:
The model's text response
"""
# Parse model_override if provided (e.g. "openrouter/anthropic/claude-3")
if model_override and "/" in model_override:
parts = model_override.split("/", 1)
provider = parts[0]
model = parts[1]
selected = self._providers.get(provider)
if not selected or not selected["enabled"]:
raise ValueError(f"Provider {provider} not configured or not enabled")
else:
# Try providers in priority order
selected = None
model = None
for name in ["minimax", "openrouter", "local"]:
p = self._providers[name]
if p["enabled"]:
selected = p
model = model_override or p["model"]
break
if not selected:
raise RuntimeError("No LLM provider configured. Set MINIMAX_API_KEY, OPENROUTER_API_KEY, or LOCAL_LLM_URL")
# Build messages
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
# Build request
headers = {
"Authorization": f"Bearer {selected['api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": messages,
**{k: v for k, v in kwargs.items() if k not in ["model", "messages"]},
}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(selected["url"], headers=headers, json=payload)
if resp.status_code != 200:
raise RuntimeError(f"LLM request failed: {resp.status_code} {resp.text}")
data = resp.json()
return data["choices"][0]["message"]["content"]
async def batch(
self,
calls: list[dict],
system: Optional[str] = None,
) -> list[str]:
"""Batch multiple prompts into one request (if provider supports).
Args:
calls: List of {"prompt": str} dicts
system: Optional system message
Returns:
List of responses in same order as calls
"""
# For now, just call chat() for each (llm_batcher plugin does the batching)
results = []
for call in calls:
result = await self.chat(
prompt=call.get("prompt", ""),
system=system,
)
results.append(result)
return results
llm = LLMClient()

View File

@ -1,4 +0,0 @@
"""MCP server module."""
from salior.mcp.server import MCPServer
__all__ = ["MCPServer"]

View File

@ -1,169 +0,0 @@
"""MCP server — external AI control of Salior via JSON-RPC."""
from __future__ import annotations
import asyncio
import json
from typing import Any, Callable
from salior.core.logging import setup_logging
from salior.db.supabase_client import SupabaseClient
log = setup_logging()
# MCP tool definitions
TOOLS = {
"get_portfolio": {
"description": "Get current positions and PnL",
"params": {},
},
"get_signals": {
"description": "Get recent conviction signals",
"params": {"coin": {"type": "string", "optional": True}, "limit": {"type": "int", "optional": True}},
},
"get_market_state": {
"description": "Get regime + conviction for a coin",
"params": {"coin": {"type": "string", "required": True}},
},
"place_order": {
"description": "Execute a trade (with confirmation gate)",
"params": {
"coin": {"type": "string", "required": True},
"side": {"type": "string", "required": True},
"size": {"type": "float", "required": True},
"price": {"type": "float", "optional": True},
},
},
"get_performance": {
"description": "Historical PnL, Sharpe, drawdown",
"params": {"days": {"type": "int", "optional": True}},
},
}
class MCPServer:
"""JSON-RPC MCP server running on localhost:8080/mcp."""
def __init__(self, host: str = "localhost", port: int = 8080) -> None:
self.host = host
self.port = port
self._supabase = SupabaseClient()
self._server: Any = None
async def start(self) -> None:
"""Start the MCP server."""
from aiohttp import web
async def handle(request: web.Request) -> web.Response:
body = await request.json()
result = await self._handle_jsonrpc(body)
return web.json_response(result)
app = web.Application()
app.router.add_post("/mcp", handle)
app.router.add_get("/mcp/tools", self._handle_tools_list)
app.router.add_get("/mcp/health", self._handle_health)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, self.host, self.port)
await site.start()
log.info("mcp_server_started", host=self.host, port=self.port)
async def _handle_jsonrpc(self, body: dict) -> dict:
"""Handle a JSON-RPC request."""
method = body.get("method", "")
params = body.get("params", {})
msg_id = body.get("id")
try:
if method == "tools/list":
return {"id": msg_id, "result": self._list_tools()}
elif method == "tools/call":
tool = params.get("name", "")
args = params.get("arguments", {})
result = await self._call_tool(tool, args)
return {"id": msg_id, "result": result}
else:
return {"id": msg_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}}
except Exception as e:
log.error("mcp_error", method=method, error=str(e))
return {"id": msg_id, "error": {"code": -32603, "message": str(e)}}
def _list_tools(self) -> list[dict]:
"""List available MCP tools."""
return [
{"name": name, "description": info["description"], "inputSchema": {"type": "object", "properties": info["params"]}}
for name, info in TOOLS.items()
]
async def _call_tool(self, name: str, args: dict) -> Any:
"""Dispatch to the appropriate tool handler."""
handler = getattr(self, f"_tool_{name}", None)
if not handler:
raise ValueError(f"Unknown tool: {name}")
return await handler(args)
async def _tool_get_portfolio(self, args: dict) -> dict:
"""Get current portfolio positions."""
portfolio = await self._supabase.get_portfolio()
return {"positions": portfolio, "count": len(portfolio)}
async def _tool_get_signals(self, args: dict) -> dict:
"""Get recent conviction signals."""
coin = args.get("coin")
limit = args.get("limit", 10)
signals = await self._supabase.get_recent_signals(coin=coin, limit=limit)
return {"signals": signals, "count": len(signals)}
async def _tool_get_market_state(self, args: dict) -> dict:
"""Get regime + conviction for a specific coin."""
from salior.db.timescale_client import TimescaleDB
db = TimescaleDB()
await db.connect()
coin = args["coin"]
candles = await db.get_candles("candles_1m", coin, hours=24)
latest = candles[-1] if candles else None
signals = await self._supabase.get_recent_signals(coin=coin, limit=1)
signal = signals[0] if signals else None
return {
"coin": coin,
"price": latest["c"] if latest else None,
"regime": signal["regime"] if signal else None,
"conviction": signal["conviction"] if signal else None,
"candles_count": len(candles),
}
async def _tool_place_order(self, args: dict) -> dict:
"""Place an order (requires confirmation)."""
# For now, just confirm via log — full wallet flow requires user sign-in
log.info("mcp_order_request", **args)
return {
"status": "requires_confirmation",
"message": "Order requires wallet approval. Connect wallet at https://salior.ai",
"requested": args,
}
async def _tool_get_performance(self, args: dict) -> dict:
"""Get historical performance metrics."""
# Placeholder — would read from performance table
return {"days": args.get("days", 30), "message": "Connect to TimescaleDB for full data"}
async def _handle_tools_list(self, request: web.Request) -> web.Response:
return web.json_response({"tools": self._list_tools()})
async def _handle_health(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
async def main() -> None:
"""Run the MCP server."""
server = MCPServer()
await server.start()
await asyncio.Event().wait() # Keep running
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,113 +0,0 @@
"""Plugin system — discovery, loading, and dispatch."""
from __future__ import annotations
import os
import subprocess
import yaml
from pathlib import Path
from typing import Any, Optional
PLUGIN_DIR = Path(__file__).parent.parent.parent / "plugins"
class Plugin:
"""A discovered plugin with manifest and run entry point."""
def __init__(self, name: str, manifest: dict, dir: Path) -> None:
self.name = name
self.manifest = manifest
self.dir = dir
self.enabled = False
@property
def requires_gpu(self) -> bool:
return self.manifest.get("compute", {}).get("gpu", False)
@property
def description(self) -> str:
return self.manifest.get("description", "")
def run(self, method: str, params: dict) -> Any:
"""Run a plugin method as a subprocess."""
result = subprocess.run(
["python3", str(self.dir / "run.py"), method],
input=yaml.dump(params),
capture_output=True,
text=True,
)
if result.returncode == 0:
return yaml.safe_load(result.stdout)
raise RuntimeError(f"Plugin error: {result.stderr}")
class PluginRegistry:
"""Discovers and manages plugins."""
def __init__(self, plugin_dir: Optional[Path] = None) -> None:
self.plugin_dir = plugin_dir or PLUGIN_DIR
self._plugins: dict[str, Plugin] = {}
def discover(self) -> dict[str, Plugin]:
"""Find all plugins in the plugin directory."""
if self._plugins:
return self._plugins
if not self.plugin_dir.exists():
return self._plugins
for entry in self.plugin_dir.iterdir():
if not entry.is_dir() or entry.name.startswith("_"):
continue
manifest_path = entry / "manifest.yaml"
if manifest_path.exists():
manifest = yaml.safe_load(manifest_path.read_text())
self._plugins[entry.name] = Plugin(entry.name, manifest, entry)
return self._plugins
def enable(self, name: str) -> None:
"""Enable a plugin."""
plugin = self._plugins.get(name)
if plugin:
plugin.enabled = True
def disable(self, name: str) -> None:
"""Disable a plugin."""
plugin = self._plugins.get(name)
if plugin:
plugin.enabled = False
def dispatch(
self,
plugin_name: str,
method: str,
params: Optional[dict] = None,
) -> Any:
"""Dispatch a call to a plugin."""
plugin = self._plugins.get(plugin_name)
if not plugin:
raise ValueError(f"Plugin '{plugin_name}' not found")
if not plugin.enabled:
raise RuntimeError(f"Plugin '{plugin_name}' is not enabled")
return plugin.run(method, params or {})
def list(self) -> list[dict]:
"""List all plugins with status."""
return [
{
"name": p.name,
"enabled": p.enabled,
"description": p.description,
"gpu": p.requires_gpu,
}
for p in self._plugins.values()
]
# Global registry
registry = PluginRegistry()
def dispatch(plugin_name: str, method: str, params: Optional[dict] = None) -> Any:
"""Global plugin dispatch."""
return registry.dispatch(plugin_name, method, params)

View File

@ -1,87 +0,0 @@
"""Scheduler — decoupled per-agent loop timing."""
from __future__ import annotations
import asyncio
from typing import Callable, Awaitable
from salior.core.logging import setup_logging
log = setup_logging()
class IntervalTask:
"""A task that runs on a fixed interval."""
def __init__(
self,
name: str,
coro: Callable[[], Awaitable[None]],
interval: float,
) -> None:
self.name = name
self._coro = coro
self.interval = interval
self._task: asyncio.Task | None = None
self._stopping = False
self._log = log.bind(task=name)
async def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._run())
self._log.info("scheduler_task_started", interval=self.interval)
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
try:
await asyncio.wait_for(self._task, timeout=5.0)
except asyncio.CancelledError:
pass
self._log.info("scheduler_task_stopped")
async def _run(self) -> None:
"""Run the task on the given interval, with initial delay."""
while not self._stopping:
try:
await asyncio.wait_for(self._coro(), timeout=self.interval * 2)
except asyncio.TimeoutError:
self._log.warning("task_timeout", interval=self.interval)
except Exception as e:
self._log.error("task_error", error=str(e))
if not self._stopping:
await asyncio.sleep(self.interval)
class Scheduler:
"""Run multiple tasks on independent intervals."""
def __init__(self) -> None:
self._tasks: dict[str, IntervalTask] = {}
self._running = False
def schedule(self, name: str, coro: Callable[[], Awaitable[None]], interval: float) -> None:
"""Register a task to run on an interval."""
self._tasks[name] = IntervalTask(name, coro, interval)
self._log = log.bind(scheduler=True)
async def start(self) -> None:
"""Start all scheduled tasks."""
self._running = True
self._log.info("scheduler_started", tasks=list(self._tasks.keys()))
await asyncio.gather(*[t.start() for t in self._tasks.values()])
async def stop(self) -> None:
"""Stop all scheduled tasks gracefully."""
self._running = False
self._log.info("scheduler_stopping", tasks=list(self._tasks.keys()))
await asyncio.gather(*[t.stop() for t in self._tasks.values()])
self._log.info("scheduler_stopped")
def task_status(self) -> dict[str, dict]:
"""Return status of all tasks."""
return {
name: {"interval": task.interval, "running": not task._stopping}
for name, task in self._tasks.items()
}

View File

@ -1,4 +0,0 @@
"""Skills module."""
from salior.skills.registry import SkillRegistry
__all__ = ["SkillRegistry"]

View File

@ -1,30 +0,0 @@
# Build Skill
Use this when writing code or building a feature.
## Steps
1. **Spec first** — Write the PRD before touching code (use `/spec`)
2. **Vertical slice** — Build the smallest end-to-end path first
3. **Verify each layer** — Check data flows before adding logic
4. **Iterate** — Add features one at a time, test each
## Verification Gate
- Code runs without import/syntax errors
- Basic happy path works (e.g., for an agent: starts, loops, writes data)
- No hardcoded secrets or placeholder values in final code
## Principles
- Thin vertical slices over big upfront architecture
- Verify before adding complexity
- Delete dead code immediately
- Name things clearly the first time
## Notes
- Follow the project conventions (check existing files for patterns)
- Use type hints on all function signatures
- Structured logging with structlog
- All file paths relative to project root

View File

@ -1,40 +0,0 @@
# 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]
```

View File

@ -1,76 +0,0 @@
"""Agent skill definitions — markdown files with steps + verification gates."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
SKILLS_DIR = Path(__file__).parent
class Skill:
"""A skill is a markdown file with structured steps and verification gates."""
def __init__(self, name: str, path: Path) -> None:
self.name = name
self.path = path
self._content: Optional[str] = None
@property
def content(self) -> str:
if self._content is None:
self._content = self.path.read_text()
return self._content
def steps(self) -> list[str]:
"""Parse steps from markdown (lines starting with numbers or -)."""
lines = self.content.split("\n")
steps = []
for line in lines:
line = line.strip()
if line and (line[0].isdigit() or line.startswith("-")):
steps.append(line)
return steps
def verify(self, step: int, result: str) -> bool:
"""Check if verification gate passes for a step."""
# Simple verification: look for pass/fail markers in the content
return True # Override in subclass
class SkillRegistry:
"""Discovers and manages skills from the skills/ directory."""
def __init__(self, skills_dir: Optional[Path] = None) -> None:
self.skills_dir = skills_dir or SKILLS_DIR
self._skills: dict[str, Skill] = {}
def discover(self) -> dict[str, Skill]:
"""Find all .md skill files."""
if self._skills:
return self._skills
for path in self.skills_dir.glob("*.md"):
if path.stem.startswith("_"):
continue
skill = Skill(path.stem, path)
self._skills[skill.name] = skill
return self._skills
def get(self, name: str) -> Optional[Skill]:
"""Get a skill by name."""
if not self._skills:
self.discover()
return self._skills.get(name)
def list(self) -> list[str]:
"""List all available skill names."""
return list(self.discover().keys())
def render(self, name: str) -> str:
"""Get the full skill content for an agent to follow."""
skill = self.get(name)
if not skill:
return f"Skill '{name}' not found. Available: {', '.join(self.list())}"
return skill.content

View File

@ -1,33 +0,0 @@
# Research Skill
Use this when investigating a topic, market condition, or technical problem.
## Steps
1. **Decompose** — Break the question into specific sub-questions
2. **Gather** — Fetch data from multiple sources (web, database, APIs)
3. **Analyze** — Apply reasoning, compare viewpoints
4. **Validate** — Check for contradictions, confidence level
5. **Synthesize** — Write a clear conclusion with supporting evidence
## Verification Gate
- Each claim must have a source or data point
- If confidence < 60%, say so explicitly
- List remaining unknowns at the end
## Output Format
```
# Research: [topic]
## Findings
[Key facts with citations]
## Analysis
[Reasoning chain]
## Confidence: [HIGH/MEDIUM/LOW]
## Remaining unknowns: [list]
## Recommended actions: [list]
```

View File

@ -1,48 +0,0 @@
# Review Skill
Use this when reviewing code, decisions, or architecture.
## Steps
1. **Understand** — Read the full context before judging
2. **Pressure test** — What's the weakest part? The hidden assumption?
3. **Check against requirements** — Does this actually solve the stated problem?
4. **Consider alternatives** — Is there a simpler approach?
5. **Flag clearly** — Distinguish bugs from style preferences
## Verification Gate
- Bug: Must be fixed before merge
- Style: Suggest only if it impacts readability
- Architecture: Propose an alternative, don't just criticize
## Review Categories
| Category | Priority | Action |
|----------|----------|--------|
| Correctness bug | CRITICAL | Block merge |
| Security issue | CRITICAL | Block merge |
| Performance regression | HIGH | Discuss |
| Missing test | MEDIUM | Suggest |
| Style/naming | LOW | Optional |
| Architecture | MEDIUM | Discuss |
## Output Format
```
# Review: [item]
## Summary
[One paragraph overview]
## Issues (blocking)
- [list]
## Issues (non-blocking)
- [list]
## Recommendations
- [list]
## Verdict: APPROVE / REQUEST_CHANGES / BLOCK
```

View File

@ -1,45 +0,0 @@
# Spec Skill
Use this before writing any significant code or architectural decisions.
## Steps
1. **What** — Define the feature clearly in one sentence
2. **Why** — Why is this needed? What problem does it solve?
3. **Scope** — What's in scope? What's explicitly out of scope?
4. **Interface** — What does the API / interface look like?
5. **Data flow** — How does data move through the system?
6. **Failure modes** — What can go wrong? How does it recover?
7. **Verify** — Does this spec solve the original problem?
## Output Format
```markdown
# Feature: [name]
## What
[One sentence]
## Why
[Problem being solved]
## Scope
- In: [list]
- Out: [list]
## Interface
```
[API/function signatures]
```
## Data Flow
[How data moves]
## Failure Modes
| Scenario | Response |
|----------|----------|
| ... | ... |
## Verification
[How to verify this works]
```

View File

@ -1,38 +0,0 @@
# 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

View File

@ -1,239 +0,0 @@
"""Telegram bot — alerts + commands."""
from __future__ import annotations
import asyncio
import os
from typing import Optional
import httpx
from aiohttp import web
from salior.core.config import config
from salior.core.logging import setup_logging
from salior.hooks import global_hooks
from salior.hooks.registry import HookEvent
log = setup_logging()
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
class TelegramBot:
"""Telegram bot for alerts and commands.
Commands:
/start Welcome message
/status Agent health + portfolio summary
/signals Recent conviction signals
/pnl Performance summary
/help Show commands
"""
def __init__(self, token: Optional[str] = None) -> None:
self.token = token or BOT_TOKEN
self.api_url = f"https://api.telegram.org/bot{self.token}"
self._offset = 0
self._running = False
async def send(self, chat_id: int, text: str, parse_mode: str = "HTML") -> None:
"""Send a message to a chat."""
if not self.token:
return
async with httpx.AsyncClient() as client:
await client.post(
f"{self.api_url}/sendMessage",
json={"chat_id": chat_id, "text": text, "parse_mode": parse_mode},
timeout=10.0,
)
async def send_alert(self, text: str) -> None:
"""Send an alert to configured chat (broadcasts to all known chats)."""
for chat_id in self._get_broadcast_chats():
try:
await self.send(chat_id, text)
except Exception as e:
log.error("telegram_send_error", error=str(e))
def _get_broadcast_chats(self) -> list[int]:
"""Return list of chat IDs to broadcast to. Stored in ~/.salior/telegram_chats.txt."""
path = os.path.expanduser("~/.salior/telegram_chats.txt")
if not os.path.exists(path):
return []
return [int(line.strip()) for line in open(path).readlines() if line.strip()]
async def handle_update(self, update: dict) -> Optional[str]:
"""Handle an incoming Telegram update, return command response or None."""
if "message" not in update:
return None
msg = update["message"]
chat_id = msg["chat"]["id"]
text = msg.get("text", "")
user = msg["from"].get("first_name", "trader")
if text == "/start":
return f"👋 Welcome to Salior, {user}.\n\nI send alerts when signals fire and orders fill.\n\nCommands:\n/status — Agent health\n/signals — Recent signals\n/pnl — Performance\n/help — This message"
if text == "/help":
return (
"📋 <b>Salior Commands</b>\n\n"
"/start — Welcome\n"
"/status — Agent health + portfolio\n"
"/signals — Recent conviction signals\n"
"/pnl — Performance summary\n"
"/help — This message"
)
if text == "/status":
return await self._cmd_status()
if text == "/signals":
return await self._cmd_signals()
if text == "/pnl":
return await self._cmd_pnl()
return None
async def _cmd_status(self) -> str:
"""Return agent + portfolio status."""
from salior.db.supabase_client import SupabaseClient
from salior.db.timescale_client import TimescaleDB
supabase = SupabaseClient()
db = TimescaleDB()
await db.connect()
# Agent health
health = await db.fetch(
"SELECT agent, status, heartbeat FROM agent_health ORDER BY heartbeat DESC LIMIT 5"
)
# Portfolio
portfolio = await supabase.get_portfolio()
lines = ["🤖 <b>Agent Status</b>"]
for h in (health or [])[:4]:
dot = "🟢" if h["status"] == "running" else "🔴"
lines.append(f"{dot} {h['agent']}: {h['status']}")
lines.append("\n💼 <b>Portfolio</b>")
if portfolio:
for pos in portfolio:
pnl = pos.get("unrealized_pnl", 0)
pnl_str = f"+${pnl:.2f}" if pnl >= 0 else f"-${abs(pnl):.2f}"
lines.append(f"{pos['coin']}: {pos['pos_size']} pos | {pnl_str}")
else:
lines.append("No positions")
return "\n".join(lines)
async def _cmd_signals(self) -> str:
"""Return recent signals."""
from salior.db.supabase_client import SupabaseClient
supabase = SupabaseClient()
signals = await supabase.get_recent_signals(limit=5)
if not signals:
return "📊 No recent signals"
lines = ["📊 <b>Recent Signals</b>"]
for sig in signals:
c = sig["conviction"]
arrow = "🟢" if c >= 0 else "🔴"
lines.append(f"{arrow} {sig['coin']} | {sig['regime']} | {c:+.2f}")
return "\n".join(lines)
async def _cmd_pnl(self) -> str:
"""Return performance summary."""
return "📈 PnL: Connect TimescaleDB performance table for full data.\n\nCurrently placeholder."
async def poll_loop(self) -> None:
"""Poll Telegram for updates."""
if not self.token:
log.warning("telegram_bot_no_token")
return
self._running = True
log.info("telegram_bot_started")
while self._running:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_url}/getUpdates",
params={"offset": self._offset, "timeout": 30},
timeout=35.0,
)
data = resp.json()
if not data.get("ok"):
await asyncio.sleep(5)
continue
for update in data.get("result", []):
self._offset = update["update_id"] + 1
response = await self.handle_update(update)
if response and "message" in update:
chat_id = update["message"]["chat"]["id"]
await self.send(chat_id, response)
except asyncio.TimeoutError:
pass
except Exception as e:
log.error("telegram_poll_error", error=str(e))
await asyncio.sleep(5)
async def start(self) -> None:
"""Start the bot poll loop."""
asyncio.create_task(self.poll_loop())
# Register hook listeners
from salior.hooks import global_hooks
async def on_fill_handler(event: HookEvent) -> None:
d = event.data
await self.send_alert(
f"✅ <b>Order Filled</b>\n"
f"{d['side'].upper()} {d['size']} {d['coin']} @ ${d['price']}\n"
f"Mode: {d['mode']}"
)
async def on_risk_handler(event: HookEvent) -> None:
await self.send_alert(f"⚠️ <b>Risk Breach</b>\n{event.data['reason']}")
async def on_error_handler(event: HookEvent) -> None:
await self.send_alert(f"🔴 <b>Agent Error</b>\n{event.data['agent']}: {event.data['error']}")
global_hooks.on("on_fill", on_fill_handler)
global_hooks.on("on_risk_breach", on_risk_handler)
global_hooks.on("on_error", on_error_handler)
# Hook emitter helpers — call these from agents
async def emit_signal(coin: str, regime: str, conviction: float, reasoning: str) -> None:
from salior.hooks import global_hooks
await global_hooks.emit(HookEvent(
name="on_signal",
source="signal_agent",
data={"coin": coin, "regime": regime, "conviction": conviction, "reasoning": reasoning},
))
async def emit_fill(coin: str, side: str, size: float, price: float, exec_id: str, mode: str) -> None:
from salior.hooks import global_hooks
await global_hooks.emit(HookEvent(
name="on_fill",
source="exec_agent",
data={"coin": coin, "side": side, "size": size, "price": price, "exec_id": exec_id, "mode": mode},
))
async def emit_risk_breach(reason: str, details: dict) -> None:
from salior.hooks import global_hooks
await global_hooks.emit(HookEvent(
name="on_risk_breach",
source="risk_agent",
data={"reason": reason, "details": details},
))

View File

@ -1,4 +0,0 @@
"""Wallet module — wallet connect integration."""
from salior.wallet.connect import WalletSession
__all__ = ["WalletSession"]

View File

@ -1,97 +0,0 @@
"""Wallet Connect — EIP-4361 sign-in with 180-day sessions."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from salior.core.config import config
from salior.db.supabase_client import SupabaseClient
class WalletSession:
"""Handle wallet connection + 180-day session management.
Works with Rabby or MetaMask (both inject window.ethereum).
Frontend calls connect() to trigger wallet popup.
Backend verifies signature and stores session.
"""
def __init__(self) -> None:
self._supabase = SupabaseClient()
self._address: Optional[str] = None
self._session_token: Optional[str] = None
self._expires_at: Optional[datetime] = None
@property
def address(self) -> Optional[str]:
"""Connected wallet address."""
return self._address
@property
def is_connected(self) -> bool:
"""True if wallet is connected and session is valid."""
if not self._address or not self._expires_at:
return False
return datetime.now(timezone.utc) < self._expires_at
def generate_auth_message(self, address: str) -> str:
"""Generate the EIP-4361 auth message for the wallet to sign."""
nonce = str(uuid.uuid4())
issued_at = datetime.now(timezone.utc).isoformat()
expires_at = (datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days)).isoformat()
return f"salior.ai wants you to sign in.\n\nAddress: {address}\nNonce: {nonce}\nIssued At: {issued_at}\nExpiration Time: {expires_at}\n\nSign in to Salior Trading System."
def verify_signature(
self,
address: str,
signature: str,
message: str,
) -> bool:
"""Verify a wallet signature against the auth message.
In production: use eth_account or web3.py to verify ECDSA signature.
For now: accept any non-empty signature (frontend should verify).
"""
return bool(signature and len(signature) > 20)
async def connect(self, address: str, signature: str, message: str) -> dict:
"""Complete wallet connection after user signs.
Called by backend after frontend submits the signed auth message.
Returns session info.
"""
if not self.verify_signature(address, signature, message):
raise ValueError("Invalid signature")
session_token = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=config.wallet_session_days)
await self._supabase.upsert_wallet_session(
wallet_address=address,
session_token=session_token,
signature=signature,
expires_at=expires_at.isoformat(),
)
self._address = address
self._session_token = session_token
self._expires_at = expires_at
return {
"address": address,
"session_token": session_token,
"expires_at": expires_at.isoformat(),
"days": config.wallet_session_days,
}
async def get_session(self, address: str) -> Optional[dict]:
"""Check for existing valid session for an address."""
return await self._supabase.get_wallet_session(address)
def sign_transaction(self, tx_params: dict) -> str:
"""Request user signature for a specific transaction.
In production: return tx_params for frontend to call wallet popup.
"""
raise NotImplementedError("Use frontend wallet popup for tx signing")