Compare commits
No commits in common. "d3b625257ad03c5f8fef4eac25e7352899680497" and "40d6f3c0c4acc9c3460394481e26c6864fed9337" have entirely different histories.
d3b625257a
...
40d6f3c0c4
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +0,0 @@
|
||||
__pycache__/
|
||||
41
README.md
41
README.md
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -1,3 +0,0 @@
|
||||
"""Salior core package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@ -1,4 +0,0 @@
|
||||
"""Data agent module."""
|
||||
from salior.agents.data.agent import DataAgent
|
||||
|
||||
__all__ = ["DataAgent"]
|
||||
@ -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))
|
||||
@ -1,4 +0,0 @@
|
||||
"""Exec agent module."""
|
||||
from salior.agents.exec.agent import ExecAgent
|
||||
|
||||
__all__ = ["ExecAgent"]
|
||||
@ -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
|
||||
@ -1,4 +0,0 @@
|
||||
"""Risk module."""
|
||||
from salior.agents.risk.agent import RiskAgent
|
||||
|
||||
__all__ = ["RiskAgent"]
|
||||
@ -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
|
||||
@ -1,4 +0,0 @@
|
||||
"""Signal agent module."""
|
||||
from salior.agents.signal.agent import SignalAgent
|
||||
|
||||
__all__ = ["SignalAgent"]
|
||||
@ -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"],
|
||||
)
|
||||
480
salior/cli.py
480
salior/cli.py
@ -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()
|
||||
@ -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"]
|
||||
@ -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(),
|
||||
}
|
||||
@ -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
|
||||
]
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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"]
|
||||
@ -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(),
|
||||
}
|
||||
@ -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()
|
||||
@ -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")
|
||||
@ -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()
|
||||
@ -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)
|
||||
@ -1,4 +0,0 @@
|
||||
"""Dashboard package."""
|
||||
from salior.dashboard.server import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@ -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
|
||||
@ -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();
|
||||
@ -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 |
@ -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>
|
||||
@ -1,5 +0,0 @@
|
||||
"""Database module."""
|
||||
from salior.db.timescale_client import TimescaleDB
|
||||
from salior.db.supabase_client import SupabaseClient
|
||||
|
||||
__all__ = ["TimescaleDB", "SupabaseClient"]
|
||||
@ -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()
|
||||
);
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -1,4 +0,0 @@
|
||||
"""Hooks module — event system for agent communication."""
|
||||
from salior.hooks.registry import HookRegistry, global_hooks
|
||||
|
||||
__all__ = ["HookRegistry", "global_hooks"]
|
||||
@ -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},
|
||||
))
|
||||
@ -1,5 +0,0 @@
|
||||
"""LLM client module."""
|
||||
|
||||
from salior.llm.client import LLMClient, llm
|
||||
|
||||
__all__ = ["LLMClient", "llm"]
|
||||
@ -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()
|
||||
@ -1,4 +0,0 @@
|
||||
"""MCP server module."""
|
||||
from salior.mcp.server import MCPServer
|
||||
|
||||
__all__ = ["MCPServer"]
|
||||
@ -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())
|
||||
@ -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)
|
||||
@ -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()
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
"""Skills module."""
|
||||
from salior.skills.registry import SkillRegistry
|
||||
|
||||
__all__ = ["SkillRegistry"]
|
||||
@ -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
|
||||
@ -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]
|
||||
```
|
||||
@ -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
|
||||
@ -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]
|
||||
```
|
||||
@ -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
|
||||
```
|
||||
@ -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]
|
||||
```
|
||||
@ -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
|
||||
@ -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},
|
||||
))
|
||||
@ -1,4 +0,0 @@
|
||||
"""Wallet module — wallet connect integration."""
|
||||
from salior.wallet.connect import WalletSession
|
||||
|
||||
__all__ = ["WalletSession"]
|
||||
@ -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")
|
||||
Loading…
Reference in New Issue
Block a user