Salior v0.1.0: core skeleton + 3 agents + skills + MCP
This commit is contained in:
commit
6411a0cfd4
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
40
README.md
Normal file
40
README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Salior — Autonomous Trading System
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[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 = [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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
|
||||||
3
salior/__init__.py
Normal file
3
salior/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Salior core package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
4
salior/agents/data/__init__.py
Normal file
4
salior/agents/data/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Data agent module."""
|
||||||
|
from salior.agents.data.agent import DataAgent
|
||||||
|
|
||||||
|
__all__ = ["DataAgent"]
|
||||||
172
salior/agents/data/agent.py
Normal file
172
salior/agents/data/agent.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""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))
|
||||||
4
salior/agents/exec/__init__.py
Normal file
4
salior/agents/exec/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Exec agent module."""
|
||||||
|
from salior.agents.exec.agent import ExecAgent
|
||||||
|
|
||||||
|
__all__ = ["ExecAgent"]
|
||||||
203
salior/agents/exec/agent.py
Normal file
203
salior/agents/exec/agent.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
"""Hyperliquid execution agent.
|
||||||
|
Reads signals from Supabase → places HL CLOB orders.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
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
|
||||||
|
from salior.db.supabase_client import SupabaseClient
|
||||||
|
|
||||||
|
log = setup_logging()
|
||||||
|
|
||||||
|
# HL API helpers
|
||||||
|
HL_API = "https://api.hyperliquid.xyz"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecAgent(Agent):
|
||||||
|
"""Executes trades based on conviction signals from Supabase."""
|
||||||
|
|
||||||
|
name = "exec_agent"
|
||||||
|
mode: str # paper | live
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coins: list[str] | None = None,
|
||||||
|
min_conviction: float = 0.7,
|
||||||
|
mode: str | None = 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()
|
||||||
|
self._portfolio: dict[str, dict] = {} # coin -> position
|
||||||
|
|
||||||
|
def loop_interval(self) -> float:
|
||||||
|
return 300.0 # Check signals every 5 minutes
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Poll Supabase for high-conviction signals and execute."""
|
||||||
|
if self.mode == "paper":
|
||||||
|
log.info("exec_agent_paper_mode", min_conviction=self.min_conviction)
|
||||||
|
return # Paper mode: log only, don't execute
|
||||||
|
|
||||||
|
await self._db.connect()
|
||||||
|
|
||||||
|
# Get recent signals with high conviction
|
||||||
|
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 self._db.log_health(self.name, "running", mode=self.mode)
|
||||||
|
|
||||||
|
async def _execute_signal(self, sig: dict) -> None:
|
||||||
|
"""Place an order based on a signal."""
|
||||||
|
coin = sig.get("coin", "")
|
||||||
|
regime = sig.get("regime", "")
|
||||||
|
conviction = sig.get("conviction", 0)
|
||||||
|
reasoning = sig.get("reasoning", "")
|
||||||
|
|
||||||
|
# Determine side from conviction
|
||||||
|
side = "buy" if conviction > 0 else "sell"
|
||||||
|
size = self._calculate_size(coin, conviction)
|
||||||
|
price = await self._get_market_price(coin, side)
|
||||||
|
|
||||||
|
if not price or size <= 0:
|
||||||
|
log.warning("exec_skip_no_price", coin=coin)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Place order via HL API
|
||||||
|
order_result = await self._place_order(
|
||||||
|
coin=coin,
|
||||||
|
side=side,
|
||||||
|
size=size,
|
||||||
|
price=price,
|
||||||
|
sig_id=sig.get("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"order_placed",
|
||||||
|
coin=coin,
|
||||||
|
side=side,
|
||||||
|
size=size,
|
||||||
|
price=price,
|
||||||
|
conviction=conviction,
|
||||||
|
result=order_result.get("status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_size(self, coin: str, conviction: float) -> float:
|
||||||
|
"""Calculate position size based on conviction and portfolio."""
|
||||||
|
# Base size: 1% of portfolio per trade
|
||||||
|
base_pct = 0.01
|
||||||
|
# Scale with conviction (0.7 → 1x, 1.0 → 3x)
|
||||||
|
conviction_multiplier = 1 + (conviction - 0.7) * 6
|
||||||
|
size_pct = base_pct * conviction_multiplier
|
||||||
|
|
||||||
|
# Get current portfolio value (from TimescaleDB)
|
||||||
|
portfolio_value = 10_000 # Default paper amount
|
||||||
|
if self._portfolio:
|
||||||
|
pos = self._portfolio.get(coin, {})
|
||||||
|
pos_value = pos.get("size", 0) * pos.get("avg_px", 0)
|
||||||
|
portfolio_value = max(portfolio_value, pos_value)
|
||||||
|
|
||||||
|
return portfolio_value * size_pct
|
||||||
|
|
||||||
|
async def _get_market_price(self, coin: str, side: str) -> Optional[float]:
|
||||||
|
"""Get current market price for a coin."""
|
||||||
|
# Get latest candle from TimescaleDB
|
||||||
|
candle = await self._db.get_latest_candle("candles_1m", coin)
|
||||||
|
if candle:
|
||||||
|
return candle["c"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _place_order(
|
||||||
|
self,
|
||||||
|
coin: str,
|
||||||
|
side: str,
|
||||||
|
size: float,
|
||||||
|
price: float,
|
||||||
|
sig_id: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Place an order via Hyperliquid CLOB API."""
|
||||||
|
if not config.hl_private_key:
|
||||||
|
log.warning("exec_no_private_key")
|
||||||
|
return {"status": "error", "reason": "no private key configured"}
|
||||||
|
|
||||||
|
# Build order payload
|
||||||
|
payload = {
|
||||||
|
"type": "Limit",
|
||||||
|
"symbol": coin,
|
||||||
|
"side": side,
|
||||||
|
"size": str(size),
|
||||||
|
"price": str(price),
|
||||||
|
"reduceOnly": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log execution (paper mode)
|
||||||
|
if self.mode == "paper":
|
||||||
|
log.info(
|
||||||
|
"paper_order",
|
||||||
|
coin=coin,
|
||||||
|
side=side,
|
||||||
|
size=size,
|
||||||
|
price=price,
|
||||||
|
sig_id=sig_id,
|
||||||
|
)
|
||||||
|
# Record in DB
|
||||||
|
exec_id = await self._db.insert_execution(
|
||||||
|
sig_id, coin, side, size, price, self.mode
|
||||||
|
)
|
||||||
|
await self._db.update_execution(exec_id, "filled", price)
|
||||||
|
return {"status": "paper", "exec_id": exec_id}
|
||||||
|
|
||||||
|
# Live execution via HL API
|
||||||
|
try:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{HL_API}/order",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
result = resp.json()
|
||||||
|
status = result.get("status", "unknown")
|
||||||
|
exec_id = await self._db.insert_execution(
|
||||||
|
sig_id, coin, side, size, price, self.mode
|
||||||
|
)
|
||||||
|
await self._db.update_execution(
|
||||||
|
exec_id, status, result.get("filled_px")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
error = resp.text
|
||||||
|
log.error("hl_order_failed", status=resp.status_code, error=error)
|
||||||
|
return {"status": "error", "reason": error}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error("hl_order_error", error=str(e))
|
||||||
|
return {"status": "error", "reason": str(e)}
|
||||||
4
salior/agents/signal/__init__.py
Normal file
4
salior/agents/signal/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Signal agent module."""
|
||||||
|
from salior.agents.signal.agent import SignalAgent
|
||||||
|
|
||||||
|
__all__ = ["SignalAgent"]
|
||||||
185
salior/agents/signal/agent.py
Normal file
185
salior/agents/signal/agent.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""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"],
|
||||||
|
)
|
||||||
96
salior/cli.py
Normal file
96
salior/cli.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""salior CLI entry point."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import click
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
def status() -> 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}")
|
||||||
|
|
||||||
|
|
||||||
|
@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.parent / "db" / "schema.sql"
|
||||||
|
sql = schema.read_text()
|
||||||
|
await conn.execute(sql)
|
||||||
|
await conn.close()
|
||||||
|
click.echo("Schema applied successfully.")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
def agent_start() -> None:
|
||||||
|
"""Start all agents."""
|
||||||
|
click.echo("Starting agents...")
|
||||||
|
|
||||||
|
from salior.agents.data.agent import DataAgent
|
||||||
|
from salior.agents.signal.agent import SignalAgent
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
data = DataAgent()
|
||||||
|
signal = SignalAgent()
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
data.start(),
|
||||||
|
signal.start(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
click.echo("Agents stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
def mcp_serve() -> None:
|
||||||
|
"""Start the MCP server."""
|
||||||
|
from salior.mcp.server import MCPServer
|
||||||
|
click.echo(f"Starting MCP server on {config.host}:{config.port}...")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
server = MCPServer(config.host, config.port)
|
||||||
|
await server.start()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
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']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
7
salior/core/__init__.py
Normal file
7
salior/core/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""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"]
|
||||||
101
salior/core/agent.py
Normal file
101
salior/core/agent.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""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(),
|
||||||
|
}
|
||||||
70
salior/core/config.py
Normal file
70
salior/core/config.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""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()
|
||||||
35
salior/core/logging.py
Normal file
35
salior/core/logging.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""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")
|
||||||
76
salior/core/memory.py
Normal file
76
salior/core/memory.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""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()
|
||||||
5
salior/db/__init__.py
Normal file
5
salior/db/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Database module."""
|
||||||
|
from salior.db.timescale_client import TimescaleDB
|
||||||
|
from salior.db.supabase_client import SupabaseClient
|
||||||
|
|
||||||
|
__all__ = ["TimescaleDB", "SupabaseClient"]
|
||||||
BIN
salior/db/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/db/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/db/__pycache__/supabase_client.cpython-311.pyc
Normal file
BIN
salior/db/__pycache__/supabase_client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/db/__pycache__/timescale_client.cpython-311.pyc
Normal file
BIN
salior/db/__pycache__/timescale_client.cpython-311.pyc
Normal file
Binary file not shown.
175
salior/db/schema.sql
Normal file
175
salior/db/schema.sql
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
-- 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()
|
||||||
|
);
|
||||||
195
salior/db/supabase_client.py
Normal file
195
salior/db/supabase_client.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""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
|
||||||
281
salior/db/timescale_client.py
Normal file
281
salior/db/timescale_client.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
5
salior/llm/__init__.py
Normal file
5
salior/llm/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""LLM client module."""
|
||||||
|
|
||||||
|
from salior.llm.client import LLMClient, llm
|
||||||
|
|
||||||
|
__all__ = ["LLMClient", "llm"]
|
||||||
BIN
salior/llm/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/llm/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/llm/__pycache__/client.cpython-311.pyc
Normal file
BIN
salior/llm/__pycache__/client.cpython-311.pyc
Normal file
Binary file not shown.
130
salior/llm/client.py
Normal file
130
salior/llm/client.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""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()
|
||||||
4
salior/mcp/__init__.py
Normal file
4
salior/mcp/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""MCP server module."""
|
||||||
|
from salior.mcp.server import MCPServer
|
||||||
|
|
||||||
|
__all__ = ["MCPServer"]
|
||||||
BIN
salior/mcp/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/mcp/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/mcp/__pycache__/server.cpython-311.pyc
Normal file
BIN
salior/mcp/__pycache__/server.cpython-311.pyc
Normal file
Binary file not shown.
169
salior/mcp/server.py
Normal file
169
salior/mcp/server.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""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())
|
||||||
113
salior/plugins/__init__.py
Normal file
113
salior/plugins/__init__.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""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)
|
||||||
BIN
salior/plugins/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/plugins/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
4
salior/skills/__init__.py
Normal file
4
salior/skills/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Skills module."""
|
||||||
|
from salior.skills.registry import SkillRegistry
|
||||||
|
|
||||||
|
__all__ = ["SkillRegistry"]
|
||||||
BIN
salior/skills/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/skills/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/skills/__pycache__/registry.cpython-311.pyc
Normal file
BIN
salior/skills/__pycache__/registry.cpython-311.pyc
Normal file
Binary file not shown.
30
salior/skills/build.md
Normal file
30
salior/skills/build.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# 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
|
||||||
76
salior/skills/registry.py
Normal file
76
salior/skills/registry.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""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
|
||||||
33
salior/skills/research.md
Normal file
33
salior/skills/research.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 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]
|
||||||
|
```
|
||||||
48
salior/skills/review.md
Normal file
48
salior/skills/review.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
45
salior/skills/spec.md
Normal file
45
salior/skills/spec.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 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]
|
||||||
|
```
|
||||||
4
salior/wallet/__init__.py
Normal file
4
salior/wallet/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Wallet module — wallet connect integration."""
|
||||||
|
from salior.wallet.connect import WalletSession
|
||||||
|
|
||||||
|
__all__ = ["WalletSession"]
|
||||||
BIN
salior/wallet/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
salior/wallet/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
salior/wallet/__pycache__/connect.cpython-311.pyc
Normal file
BIN
salior/wallet/__pycache__/connect.cpython-311.pyc
Normal file
Binary file not shown.
97
salior/wallet/connect.py
Normal file
97
salior/wallet/connect.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""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