Compare commits
147 Commits
fix/issue-
...
29b7634c34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b7634c34 | ||
|
|
fd5c2b56d7 | ||
|
|
632e1bf524 | ||
|
|
5ae1165ad9 | ||
|
|
283573f5a8 | ||
|
|
90fa66bd39 | ||
|
|
84d8a6f4a6 | ||
|
|
a8e0baf0c0 | ||
|
|
6c39e4e89d | ||
|
|
bba773251a | ||
|
|
3013326ded | ||
|
|
a82185de60 | ||
|
|
cadea23e40 | ||
|
|
984656c83c | ||
|
|
1505bc9913 | ||
|
|
dd61c32ea7 | ||
|
|
01ec8bc539 | ||
|
|
a253aae766 | ||
|
|
13e899c851 | ||
|
|
384f84e772 | ||
|
|
cd1a41d1d7 | ||
|
|
6a20cc174f | ||
|
|
ce8a29c0a4 | ||
|
|
f425ae08d7 | ||
|
|
d4400f5dcd | ||
|
|
1591fcb1ca | ||
|
|
b0131aa566 | ||
|
|
52adc93b25 | ||
|
|
79c3ec7d16 | ||
|
|
3505cf4ade | ||
|
|
1b1358353f | ||
|
|
726e579f5f | ||
|
|
b111e4d79f | ||
|
|
0d63a10ac8 | ||
|
|
19f28fc599 | ||
|
|
5f7667992e | ||
|
|
cd4583ca90 | ||
|
|
6cadb7a67b | ||
|
|
02e0b0ccab | ||
|
|
29ec67cced | ||
|
|
c86e71c3a3 | ||
|
|
44fb840731 | ||
|
|
6a5694f74b | ||
|
|
680a9322e3 | ||
|
|
9973b8f6e2 | ||
|
|
30476e782b | ||
|
|
02ca452655 | ||
|
|
cb9558d54f | ||
|
|
638e17eb73 | ||
|
|
69a8b06462 | ||
|
|
15e72b009c | ||
|
|
19ba0c7cc6 | ||
|
|
847890b634 | ||
|
|
6658a418cc | ||
|
|
5c9e46e693 | ||
|
|
194c4f8a62 | ||
|
|
7afcb983e8 | ||
|
|
caef4b36ed | ||
|
|
3bf2877df2 | ||
|
|
145c6710d1 | ||
|
|
3c8c85aefc | ||
|
|
39b2b558a5 | ||
|
|
7795753aaa | ||
|
|
36dcfdb6e2 | ||
|
|
48fc323dac | ||
|
|
0af2de7209 | ||
|
|
e82b8b3549 | ||
|
|
6f23b322d3 | ||
|
|
297a185215 | ||
|
|
f86ff75525 | ||
|
|
6f9564790f | ||
|
|
f43eb11f6f | ||
|
|
446da96ce4 | ||
|
|
922ef89c1e | ||
|
|
a601ebb08b | ||
|
|
bb40193fc3 | ||
|
|
3a7d3a3732 | ||
|
|
0f558a5e8e | ||
|
|
9e9ff6fa7f | ||
|
|
4c48932ece | ||
|
|
bfc85648db | ||
|
|
925920eee1 | ||
|
|
299e74cffa | ||
|
|
2b875cfa27 | ||
|
|
ae612ad725 | ||
|
|
08912019c2 | ||
|
|
44453877b3 | ||
|
|
ad4a1e89d5 | ||
|
|
57fa200ba9 | ||
|
|
db4fb83243 | ||
|
|
560b61c431 | ||
|
|
c6baadf8b8 | ||
|
|
937cc2da60 | ||
| 32cd7184ea | |||
|
|
765e390b9b | ||
| 21ce282cae | |||
|
|
4fa9b0456a | ||
| af9900d0ba | |||
|
|
b3ab004447 | ||
| d394bc0857 | |||
|
|
dfa806ab53 | ||
| 3493775b7f | |||
|
|
82645dfb3b | ||
| c17fa243a1 | |||
|
|
a55ed9cc04 | ||
| d1408b74b4 | |||
|
|
4197475eed | ||
| 87bac8894a | |||
|
|
bef4479675 | ||
| 75970c57e3 | |||
|
|
f23044465a | ||
| a6e4d28aa7 | |||
|
|
8693946cb8 | ||
| a2f549c056 | |||
|
|
ad6e57655d | ||
| ac5e9d8b81 | |||
|
|
81f3342365 | ||
| 6adad0701d | |||
|
|
405b35c3ba | ||
| dd25d38e7e | |||
|
|
da8327c0e0 | ||
| 8d33ea9a44 | |||
|
|
d81464b869 | ||
| 55b008d4e8 | |||
|
|
04e4c1a487 | ||
| feb65131fa | |||
|
|
50af4e0722 | ||
|
|
786e964e32 | ||
| 41b699f9ee | |||
|
|
ccc0404cd1 | ||
|
|
0a2e347fdb | ||
| 2561759b78 | |||
| b6f99aa8fe | |||
|
|
3806af3e23 | ||
| a892a403fb | |||
|
|
0bb5d9a5d6 | ||
| 875427a0c1 | |||
|
|
5eb623f022 | ||
| a59a1ccd97 | |||
|
|
965efa122b | ||
| 0fb16f06e4 | |||
|
|
a461005015 | ||
| b0311bc96f | |||
|
|
a280217254 | ||
| 0cc3327991 | |||
|
|
429d46c6d0 | ||
| a2f0c9a0e9 |
223
deployment/DEPLOYMENT.md
Normal file
223
deployment/DEPLOYMENT.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This document describes how to deploy the Randebu Trading Bot application to a production server.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Debian server with 8GB RAM, 4 cores
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 18+
|
||||||
|
- Nginx
|
||||||
|
- SSL certificate (Let's Encrypt)
|
||||||
|
- SSH access to server
|
||||||
|
|
||||||
|
## Server Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/
|
||||||
|
└── bot/
|
||||||
|
├── backend/ # Backend application (FastAPI)
|
||||||
|
├── frontend/ # Frontend static files (SvelteKit build)
|
||||||
|
└── data/ # SQLite database and app data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step-by-Step Deployment
|
||||||
|
|
||||||
|
### 1. Clone Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@your-server
|
||||||
|
sudo mkdir -p /var/www/bot
|
||||||
|
sudo chown -R $USER:$USER /var/www/bot
|
||||||
|
cd /var/www/bot
|
||||||
|
git clone https://git.example.com/shoko/randebu.git .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Setup Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/bot/src/backend
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
mkdir -p /var/www/bot/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
Copy and configure the environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp src/backend/.env.example /var/www/bot/data/.env
|
||||||
|
nano /var/www/bot/data/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Update these values:
|
||||||
|
- `SECRET_KEY` - Generate a secure key
|
||||||
|
- `DATABASE_URL` - Update path to `/var/www/bot/data/app.db`
|
||||||
|
- `MINIMAX_API_KEY` - Your API key
|
||||||
|
- `AVE_API_KEY` - Your API key
|
||||||
|
|
||||||
|
### 4. Build Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/bot/src/frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Move build to expected location
|
||||||
|
mkdir -p /var/www/bot/frontend
|
||||||
|
cp -r build/* /var/www/bot/frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configure Nginx
|
||||||
|
|
||||||
|
Copy the nginx template and modify as needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /var/www/bot/deployment/scripts/nginx-template.conf /etc/nginx/sites-available/bot.yourdomain.com
|
||||||
|
sudo nano /etc/nginx/sites-available/bot.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `bot.yourdomain.com` with your actual domain.
|
||||||
|
|
||||||
|
Enable the site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/bot.yourdomain.com /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Setup SSL Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d bot.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Configure Systemd Service
|
||||||
|
|
||||||
|
Copy and configure the systemd service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /var/www/bot/deployment/scripts/systemd-template.service /etc/systemd/system/ave-backend.service
|
||||||
|
sudo nano /etc/systemd/system/ave-backend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `your-user` and `/var/www/bot` paths as needed.
|
||||||
|
|
||||||
|
### 8. Start Backend Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable ave-backend
|
||||||
|
sudo systemctl start ave-backend
|
||||||
|
sudo systemctl status ave-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Configure Firewall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 22/tcp
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Verify Deployment
|
||||||
|
|
||||||
|
1. Visit `https://bot.yourdomain.com` - should show frontend
|
||||||
|
2. Visit `https://bot.yourdomain.com/api/...` - should hit backend API
|
||||||
|
3. Check backend logs: `sudo journalctl -u ave-backend -f`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/bot/
|
||||||
|
├── deployment/ # Deployment scripts and templates
|
||||||
|
│ ├── DEPLOYMENT.md # This file
|
||||||
|
│ └── scripts/
|
||||||
|
│ ├── nginx-template.conf
|
||||||
|
│ ├── systemd-template.service
|
||||||
|
│ └── deploy.sh # Automated deployment script
|
||||||
|
├── src/
|
||||||
|
│ ├── backend/ # FastAPI application
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── api/ # API routes
|
||||||
|
│ │ │ ├── core/ # Core functionality
|
||||||
|
│ │ │ ├── db/ # Database models
|
||||||
|
│ │ │ └── services/ # Business logic
|
||||||
|
│ │ ├── run.py
|
||||||
|
│ │ └── requirements.txt
|
||||||
|
│ └── frontend/ # SvelteKit application
|
||||||
|
│ ├── src/
|
||||||
|
│ └── package.json
|
||||||
|
├── data/ # Runtime data (gitignored)
|
||||||
|
│ ├── app.db # SQLite database
|
||||||
|
│ └── .env # Environment variables
|
||||||
|
└── frontend/ # Built frontend static files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't start
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u ave-backend -n 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Missing environment variables - check `.env` file
|
||||||
|
- Port 8000 already in use - check configuration
|
||||||
|
- Database path incorrect - verify paths
|
||||||
|
|
||||||
|
### Nginx errors
|
||||||
|
|
||||||
|
Test configuration:
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Check error logs:
|
||||||
|
```bash
|
||||||
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL certificate issues
|
||||||
|
|
||||||
|
Renew certificate:
|
||||||
|
```bash
|
||||||
|
sudo certbot renew
|
||||||
|
```
|
||||||
|
|
||||||
|
Check certificate status:
|
||||||
|
```bash
|
||||||
|
sudo certbot certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Restart backend | `sudo systemctl restart ave-backend` |
|
||||||
|
| View backend logs | `sudo journalctl -u ave-backend -f` |
|
||||||
|
| Check nginx status | `sudo systemctl status nginx` |
|
||||||
|
| Reload nginx | `sudo systemctl reload nginx` |
|
||||||
|
| Check port 8000 | `curl http://localhost:8000/health` |
|
||||||
|
|
||||||
|
## Rolling Updates
|
||||||
|
|
||||||
|
To update the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/bot
|
||||||
|
git pull
|
||||||
|
cd src/backend && source venv/bin/activate && pip install -r requirements.txt
|
||||||
|
sudo systemctl restart ave-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
For frontend updates, rebuild and copy static files to `/var/www/bot/frontend`.
|
||||||
47
deployment/scripts/deploy.sh
Executable file
47
deployment/scripts/deploy.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DEPLOY_DIR="/var/www/bot"
|
||||||
|
DOMAIN="bot.yourdomain.com"
|
||||||
|
GIT_REPO="https://git.example.com/shoko/randebu.git"
|
||||||
|
BRANCH="main"
|
||||||
|
|
||||||
|
echo "=== Randebu Deployment Script ==="
|
||||||
|
echo "Deploy directory: $DEPLOY_DIR"
|
||||||
|
echo "Domain: $DOMAIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
echo "[1/6] Pulling latest code..."
|
||||||
|
git pull origin "$BRANCH"
|
||||||
|
|
||||||
|
echo "[2/6] Updating backend dependencies..."
|
||||||
|
cd "$DEPLOY_DIR/src/backend"
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "[3/6] Rebuilding frontend..."
|
||||||
|
cd "$DEPLOY_DIR/src/frontend"
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
mkdir -p "$DEPLOY_DIR/frontend"
|
||||||
|
cp -r build/* "$DEPLOY_DIR/frontend/"
|
||||||
|
|
||||||
|
echo "[4/6] Restarting backend service..."
|
||||||
|
sudo systemctl restart ave-backend
|
||||||
|
sleep 2
|
||||||
|
sudo systemctl status ave-backend --no-pager
|
||||||
|
|
||||||
|
echo "[5/6] Testing endpoints..."
|
||||||
|
curl -s "http://localhost:8000/health" && echo ""
|
||||||
|
curl -s -o /dev/null -w "Frontend: %{http_code}\n" "https://$DOMAIN/" || true
|
||||||
|
|
||||||
|
echo "[6/6] Verifying SSL..."
|
||||||
|
sudo certbot certificates 2>/dev/null | grep -A2 "$DOMAIN" || echo "No certificate found for $DOMAIN"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment Complete ==="
|
||||||
|
echo "Backend: https://$DOMAIN/api/"
|
||||||
|
echo "Frontend: https://$DOMAIN/"
|
||||||
|
echo "Backend logs: sudo journalctl -u ave-backend -f"
|
||||||
62
deployment/scripts/nginx-template.conf
Normal file
62
deployment/scripts/nginx-template.conf
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name bot.yourdomain.com;
|
||||||
|
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name bot.yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/bot.yourdomain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/bot.yourdomain.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
root /var/www/bot/frontend;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/ws/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||||
|
}
|
||||||
23
deployment/scripts/systemd-template.service
Normal file
23
deployment/scripts/systemd-template.service
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Randebu Trading Bot Backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=your-user
|
||||||
|
WorkingDirectory=/var/www/bot/src/backend
|
||||||
|
Environment="PATH=/var/www/bot/src/backend/venv/bin"
|
||||||
|
ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStartSec=300
|
||||||
|
TimeoutStopSec=300
|
||||||
|
|
||||||
|
EnvironmentFile=/var/www/bot/data/.env
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ave-backend
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
521
docs/AUDIT_REPORT.md
Normal file
521
docs/AUDIT_REPORT.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# Randebu Trading Bot - Product & Technical Audit Report
|
||||||
|
|
||||||
|
> **Date:** 2026-04-09
|
||||||
|
> **Phase:** Phase 1 Implementation Complete - Pre-Testing Review
|
||||||
|
> **Purpose:** Document current state, issues found, and recommendations for next steps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Overview
|
||||||
|
|
||||||
|
### 1.1 What is Randebu?
|
||||||
|
|
||||||
|
Randebu is an AI-powered trading bot platform where users create and manage automated trading strategies through natural language chat—similar to ChatGPT, but specialized for creating trading bots.
|
||||||
|
|
||||||
|
### 1.2 Core User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Registration → Create Bot → Chat with AI to Define Strategy
|
||||||
|
→ Backtest Strategy → Simulate Trading → (Future) Live Trading
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Phase 1 Scope
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| BNB Chain only | ✅ Intended (not yet enforced) |
|
||||||
|
| Backtest engine | ✅ Implemented |
|
||||||
|
| Simulation engine | ✅ Implemented |
|
||||||
|
| Natural language strategy parsing | ✅ Implemented |
|
||||||
|
| User authentication | ✅ Implemented |
|
||||||
|
| Multi-bot support (max 3) | ✅ Implemented |
|
||||||
|
| Dummy wallet (database record) | ✅ Implemented |
|
||||||
|
|
||||||
|
### 1.4 Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Frontend | Svelte 5 + TypeScript |
|
||||||
|
| Backend | Python FastAPI |
|
||||||
|
| AI Agent | CrewAI + MiniMax LLM |
|
||||||
|
| Database | SQLite |
|
||||||
|
| Trading Data | AVE Cloud API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical Issues (Must Fix Before Testing)
|
||||||
|
|
||||||
|
These issues will cause complete pipeline failure if not addressed.
|
||||||
|
|
||||||
|
### 2.1 Database Tables Never Created
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`, `src/backend/run.py`
|
||||||
|
|
||||||
|
**Problem:** The application starts but never creates the database tables. There is no:
|
||||||
|
- Alembic migration setup
|
||||||
|
- `Base.metadata.create_all()` call on startup
|
||||||
|
- Database initialization script
|
||||||
|
|
||||||
|
**Impact:** First database operation will fail with "table not found" error.
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
```python
|
||||||
|
# core/database.py defines Base, but nothing calls:
|
||||||
|
# Base.metadata.create_all(engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Add database initialization on application startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Strategy Config Schema Mismatch
|
||||||
|
|
||||||
|
**Location:** Multiple files - see mapping below
|
||||||
|
|
||||||
|
**Problem:** The LLM outputs one schema format, but the backtest and simulation engines expect a completely different format. This is a **complete pipeline break** - strategies parsed by AI will never trigger any trades in backtesting.
|
||||||
|
|
||||||
|
#### Schema Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ LLM OUTPUT (llm_connector.py) - What AI actually produces │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ { │
|
||||||
|
│ "type": "price_drop", │
|
||||||
|
│ "params": { │
|
||||||
|
│ "token": "PEPE", │
|
||||||
|
│ "threshold_percent": 5 │
|
||||||
|
│ } │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND VALIDATOR (crew.py - StrategyValidator.validate()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # Validator expects params.threshold_percent - THIS WORKS │
|
||||||
|
│ if "threshold_percent" not in params: │
|
||||||
|
│ errors.append(f"Condition {i}: missing 'threshold_percent'") │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ (But engines look for flat fields)
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKTEST ENGINE (services/backtest/engine.py - _check_condition()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # What engine actually looks for: │
|
||||||
|
│ threshold = condition.get("threshold", 0) # ❌ Returns 0! │
|
||||||
|
│ token = condition.get("token") # ❌ Wrong path! │
|
||||||
|
│ timeframe = condition.get("timeframe") # ❌ Not in params! │
|
||||||
|
│ │
|
||||||
|
│ # Result: Conditions NEVER trigger because field names don't match │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SIMULATE ENGINE (services/simulate/engine.py - _check_condition()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # Same issue as backtest engine │
|
||||||
|
│ threshold = condition.get("threshold", 0) # ❌ Returns 0 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND TYPES (src/frontend/src/lib/api/types.ts) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ interface Condition { │
|
||||||
|
│ type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';│
|
||||||
|
│ token: string; # Flat - no params wrapper │
|
||||||
|
│ threshold?: number; # Not threshold_percent! │
|
||||||
|
│ timeframe?: string; # Exists here │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field Mapping Table
|
||||||
|
|
||||||
|
| Component | Token Field | Threshold Field | Timeframe Field |
|
||||||
|
|-----------|-----------|-----------------|-----------------|
|
||||||
|
| LLM Output | `params.token` | `params.threshold_percent` | N/A |
|
||||||
|
| Validator | `params.token` | `params.threshold_percent` | N/A |
|
||||||
|
| Backtest Engine | `token` | `threshold` | `timeframe` |
|
||||||
|
| Simulate Engine | `token` | `threshold` | `timeframe` |
|
||||||
|
| Frontend Types | `token` | `threshold` | `timeframe` |
|
||||||
|
|
||||||
|
**Fix Required:** Normalize to ONE consistent schema across the entire pipeline. Recommended: Use the flat structure (token, threshold, timeframe) as it's simpler and already used by engines and frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Bot Creation Will Fail
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/db/schemas.py` (BotCreate)
|
||||||
|
- `src/frontend/src/lib/api/client.ts` (bots.create)
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
|
||||||
|
| Issue | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| Backend requires | `strategy_config: dict` (REQUIRED) |
|
||||||
|
| Backend requires | `llm_config: dict` (REQUIRED) |
|
||||||
|
| Frontend sends | Only `name` and optional `description` |
|
||||||
|
|
||||||
|
**Impact:** Users cannot create bots through the frontend - API will return validation error.
|
||||||
|
|
||||||
|
**Fix Required:** Either:
|
||||||
|
1. Make `strategy_config` and `llm_config` optional in backend with default values
|
||||||
|
2. OR update frontend to send default config values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Config Endpoints Return Empty Data
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/config.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/chains")
|
||||||
|
def get_chains():
|
||||||
|
return {"chains": []} # ❌ Always empty
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
def get_tokens():
|
||||||
|
return {"tokens": []} # ❌ Always empty
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Frontend cannot populate dropdowns for chain/token selection.
|
||||||
|
|
||||||
|
**Fix Required:** Return BSC (BNB Chain) as the only supported chain in Phase 1, and query AVE API for available tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Major Issues
|
||||||
|
|
||||||
|
### 3.1 Risk Management Not Implemented
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/db/models.py` (schema supports it)
|
||||||
|
- `src/backend/app/services/backtest/engine.py`
|
||||||
|
- `src/backend/app/services/simulate/engine.py`
|
||||||
|
|
||||||
|
**Problem:** The database schema and frontend UI support `risk_management` configuration:
|
||||||
|
```typescript
|
||||||
|
interface RiskManagement {
|
||||||
|
stop_loss_percent?: number;
|
||||||
|
take_profit_percent?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
However, neither the backtest nor simulation engines actually check or use stop-loss/take-profit logic during trade execution. The config is saved but ignored.
|
||||||
|
|
||||||
|
**Fix Required:** Implement actual stop-loss and take-profit checks in both engines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Duplicate AveCloudClient Implementations
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/services/ave/client.py`
|
||||||
|
- `src/backend/app/services/backtest/ave_client.py`
|
||||||
|
|
||||||
|
**Problem:** Two different AveCloudClient classes with different methods:
|
||||||
|
|
||||||
|
| `services/ave/client.py` | `services/backtest/ave_client.py` |
|
||||||
|
|--------------------------|-----------------------------------|
|
||||||
|
| `get_tokens()` | ❌ Missing |
|
||||||
|
| `get_batch_prices()` | ✅ `get_batch_prices()` |
|
||||||
|
| `get_token_details()` | ❌ Missing |
|
||||||
|
| `get_klines()` | ✅ `get_klines()` |
|
||||||
|
| `get_trending_tokens()` | ❌ Missing |
|
||||||
|
| `get_token_risk()` | ❌ Missing |
|
||||||
|
| `get_chain_quote()` | ❌ Missing |
|
||||||
|
| `get_chain_swap()` | ❌ Missing |
|
||||||
|
| ❌ Missing | `get_token_price()` |
|
||||||
|
|
||||||
|
Additionally, the simulate engine imports from the wrong location:
|
||||||
|
```python
|
||||||
|
# services/simulate/engine.py
|
||||||
|
from ..backtest.ave_client import AveCloudClient # ❌ Wrong import
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Consolidate into ONE AveCloudClient class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Silent Error Handling in Simulation
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/services/simulate/engine.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
# ... API calls ...
|
||||||
|
except Exception as e:
|
||||||
|
pass # ❌ Silently swallows ALL errors!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** If AVE API fails or returns bad data, the simulation continues silently with no logging or user feedback.
|
||||||
|
|
||||||
|
**Fix Required:** Add proper error logging and user-facing error messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 No Chain Validation for Phase 1
|
||||||
|
|
||||||
|
**Problem:** You mentioned limiting to BNB Chain only for Phase 1, but:
|
||||||
|
- No backend validation enforces this
|
||||||
|
- Users can specify any chain in backtest/simulate config
|
||||||
|
- The config endpoints return empty arrays
|
||||||
|
|
||||||
|
**Fix Required:** Add chain validation that only allows "bsc" for Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 In-Memory Token Blacklist
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/auth.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
TOKEN_BLACKLIST = set() # ❌ In-memory only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems:**
|
||||||
|
- Resets when server restarts
|
||||||
|
- Doesn't work with multiple workers/processes
|
||||||
|
- Logout doesn't truly invalidate tokens in production
|
||||||
|
|
||||||
|
**Fix Required:** Use Redis or database-backed token blacklist for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 Conversation History Not Passed to Crew
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/bots.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
history_for_crew = conversation_history[-10:] # Gets history
|
||||||
|
crew = get_trading_crew() # ❌ Doesn't pass history!
|
||||||
|
result = crew.chat(user_message, history_for_crew)
|
||||||
|
```
|
||||||
|
|
||||||
|
The history is fetched but not actually used by the agent - each chat starts fresh.
|
||||||
|
|
||||||
|
**Fix Required:** Pass conversation history to the crew agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 No Rate Limiting Applied
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.state.limiter = limiter # Set up but not used on most endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
The rate limiter is initialized but only applied to the login endpoint. Other endpoints have no protection.
|
||||||
|
|
||||||
|
**Fix Required:** Apply rate limiting to sensitive endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.8 CORS Wide Open
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
allow_origins=["*"] # ❌ Should be restricted to frontend domain
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Limit CORS to the frontend domain in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.9 No WebSocket for Real-Time Updates
|
||||||
|
|
||||||
|
**Problem:** Users must poll the API to see:
|
||||||
|
- Backtest progress
|
||||||
|
- Simulation signals (new signals only appear on refresh)
|
||||||
|
|
||||||
|
**Impact:** Poor UX during long-running operations.
|
||||||
|
|
||||||
|
**Fix Required:** Add WebSocket support for real-time updates (Phase 2 or later).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Minor Issues
|
||||||
|
|
||||||
|
### 4.1 Unused Dependencies
|
||||||
|
|
||||||
|
**Location:** `src/backend/requirements.txt`
|
||||||
|
|
||||||
|
```python
|
||||||
|
anthropic>=0.18.0 # Included but project uses MiniMax
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Remove unused dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Missing .env Example
|
||||||
|
|
||||||
|
**Problem:** No `.env.example` file to guide deployment.
|
||||||
|
|
||||||
|
**Fix Required:** Create `.env.example` with all required variables documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 No Input Sanitization
|
||||||
|
|
||||||
|
User-provided data (bot names, chat messages) isn't sanitized before storage or display.
|
||||||
|
|
||||||
|
**Fix Required:** Add input validation and sanitization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Inconsistent Error Responses
|
||||||
|
|
||||||
|
Some endpoints return `{"detail": "..."}` (FastAPI default), others return custom error shapes.
|
||||||
|
|
||||||
|
**Fix Required:** Standardize error response format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 No Integration Tests
|
||||||
|
|
||||||
|
No tests that verify the full pipeline (chat → config → backtest).
|
||||||
|
|
||||||
|
**Fix Required:** Add integration tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Missing Documentation Files
|
||||||
|
|
||||||
|
The following should be created:
|
||||||
|
|
||||||
|
1. **`.env.example`** - All environment variables with descriptions
|
||||||
|
2. **`docs/STRATEGY_SCHEMA.md`** - Single source of truth for strategy config schema
|
||||||
|
3. **`docs/API_SCHEMA.md`** - API contract documentation
|
||||||
|
4. **`init_db.py`** - Database initialization script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommendations Summary
|
||||||
|
|
||||||
|
### Priority Matrix
|
||||||
|
|
||||||
|
| Priority | Issue | Effort | Impact |
|
||||||
|
|----------|-------|--------|--------|
|
||||||
|
| **P0** | Database tables not created | Small | App crashes on startup |
|
||||||
|
| **P0** | Bot creation fails | Small | Users can't create bots |
|
||||||
|
| **P0** | Strategy schema mismatch | Medium | Backtesting completely broken |
|
||||||
|
| **P0** | Config endpoints empty | Small | No chain/token selection |
|
||||||
|
| **P1** | Risk management not implemented | Medium | No stop-loss/take-profit |
|
||||||
|
| **P1** | Chain validation missing | Small | Can use non-BSC chains |
|
||||||
|
| **P1** | Silent error handling | Small | Hard to debug issues |
|
||||||
|
| **P2** | Duplicate AveCloudClient | Medium | Maintenance burden |
|
||||||
|
| **P2** | CORS restricted | Small | Security hardening |
|
||||||
|
| **P2** | Token blacklist (production) | Medium | Security |
|
||||||
|
| **P2** | Rate limiting | Medium | DoS protection |
|
||||||
|
| **P3** | WebSocket support | Large | UX improvement |
|
||||||
|
| **P3** | Integration tests | Medium | Code quality |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AVE Cloud Integration Notes
|
||||||
|
|
||||||
|
### Rate Limit Strategy
|
||||||
|
|
||||||
|
| Tier | TPS | Recommended Approach |
|
||||||
|
|------|-----|---------------------|
|
||||||
|
| Free | 1 | Aggressive caching, batch requests |
|
||||||
|
| Normal | 5 | Moderate caching |
|
||||||
|
| Pro | 20 | Minimal caching |
|
||||||
|
|
||||||
|
### Caching Recommendations
|
||||||
|
|
||||||
|
1. **Token prices:** Cache for 30-60 seconds
|
||||||
|
2. **Trending tokens:** Cache for 5-10 minutes
|
||||||
|
3. **Token details:** Cache for 5-10 minutes
|
||||||
|
4. **Risk assessments:** Cache for 15-30 minutes
|
||||||
|
|
||||||
|
### No Testnet Warning
|
||||||
|
|
||||||
|
AVE Cloud has **no testnet**. All API calls use real money:
|
||||||
|
- Use quote/dry-run mode for testing
|
||||||
|
- Start with minimal amounts ($1-10)
|
||||||
|
- Contact AVE support about sandbox options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Next Steps
|
||||||
|
|
||||||
|
### Immediate (Before Testing)
|
||||||
|
|
||||||
|
1. Add database initialization to startup
|
||||||
|
2. Fix bot creation (frontend or backend)
|
||||||
|
3. **Normalize strategy schema** - Choose flat structure, update all components
|
||||||
|
4. Populate config endpoints with BSC + default tokens
|
||||||
|
5. Add BSC-only chain validation
|
||||||
|
|
||||||
|
### Short Term
|
||||||
|
|
||||||
|
6. Implement risk management (stop-loss/take-profit)
|
||||||
|
7. Consolidate AveCloudClient
|
||||||
|
8. Add proper error handling
|
||||||
|
9. Create .env.example
|
||||||
|
10. Add input sanitization
|
||||||
|
|
||||||
|
### Medium Term
|
||||||
|
|
||||||
|
11. Add WebSocket for real-time updates
|
||||||
|
12. Implement production token blacklist (Redis)
|
||||||
|
13. Apply rate limiting
|
||||||
|
14. Restrict CORS
|
||||||
|
15. Add integration tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Files Reference
|
||||||
|
|
||||||
|
### Key Backend Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/backend/app/main.py` | FastAPI app initialization |
|
||||||
|
| `src/backend/app/api/bots.py` | Bot CRUD + chat endpoint |
|
||||||
|
| `src/backend/app/api/backtest.py` | Backtest API |
|
||||||
|
| `src/backend/app/api/simulate.py` | Simulation API |
|
||||||
|
| `src/backend/app/api/ave.py` | AVE Cloud proxy endpoints |
|
||||||
|
| `src/backend/app/api/config.py` | Config endpoints |
|
||||||
|
| `src/backend/app/db/schemas.py` | Pydantic schemas |
|
||||||
|
| `src/backend/app/db/models.py` | SQLAlchemy models |
|
||||||
|
| `src/backend/app/services/ai_agent/crew.py` | CrewAI agents |
|
||||||
|
| `src/backend/app/services/ai_agent/llm_connector.py` | MiniMax LLM |
|
||||||
|
| `src/backend/app/services/backtest/engine.py` | Backtest logic |
|
||||||
|
| `src/backend/app/services/simulate/engine.py` | Simulation logic |
|
||||||
|
| `src/backend/app/services/ave/client.py` | AVE Cloud client |
|
||||||
|
|
||||||
|
### Key Frontend Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/frontend/src/lib/api/client.ts` | API client |
|
||||||
|
| `src/frontend/src/lib/api/types.ts` | TypeScript types |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/+page.svelte` | Bot chat page |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/backtest/+page.svelte` | Backtest page |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/simulate/+page.svelte` | Simulation page |
|
||||||
|
| `src/frontend/src/lib/components/ChatInterface.svelte` | Chat UI |
|
||||||
|
| `src/frontend/src/lib/components/StrategyPreview.svelte` | Strategy display |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Audit Complete
|
||||||
|
|
||||||
|
This audit was conducted by reviewing:
|
||||||
|
- All source code in `src/backend/` and `src/frontend/`
|
||||||
|
- Documentation in `docs/`
|
||||||
|
- Database models and schemas
|
||||||
|
- API endpoints and their implementations
|
||||||
|
|
||||||
|
The product has a **solid architectural foundation** and addresses a real market need. The core issues are manageable - primarily schema standardization and missing initialization code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Audit Report*
|
||||||
27
docs/ISSUES.md
Normal file
27
docs/ISSUES.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Open Issues
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Token Address Confirmation Dialog
|
||||||
|
- **Priority**: High
|
||||||
|
- **Status**: Open
|
||||||
|
- **Description**: When user configures a trading strategy via chat and mentions a token (e.g., "buy PEPE"), the AI asks for the token contract address. The frontend should show a confirmation dialog allowing user to:
|
||||||
|
1. See the token the AI detected (PEPE)
|
||||||
|
2. Enter/confirm the BSC contract address
|
||||||
|
3. Save the strategy with the confirmed address
|
||||||
|
|
||||||
|
**Related Files**:
|
||||||
|
- Frontend: `src/frontend/src/routes/bot/[id]/+page.svelte`
|
||||||
|
- Backend: `src/backend/app/services/ai_agent/conversational.py`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Modal/dialog appears when AI detects a token without address
|
||||||
|
- [ ] User can enter the contract address (0x...)
|
||||||
|
- [ ] Strategy is saved only after user confirmation
|
||||||
|
- [ ] Clear error handling if address is invalid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
*No open backend issues*
|
||||||
279
docs/STRATEGY_SCHEMA.md
Normal file
279
docs/STRATEGY_SCHEMA.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Strategy Config Schema
|
||||||
|
|
||||||
|
> **Status:** DRAFT - Needs to be normalized with implementation
|
||||||
|
> **Purpose:** Single source of truth for strategy configuration format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document defines the structure of the `strategy_config` JSON object that represents a trading bot's strategy. This config is:
|
||||||
|
- Generated by the AI from natural language input
|
||||||
|
- Validated by the backend
|
||||||
|
- Used by backtest and simulation engines
|
||||||
|
- Displayed in the frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schema Version
|
||||||
|
|
||||||
|
**Current Version:** 1.0
|
||||||
|
**Status:** Flat structure (NOT nested in `params`)
|
||||||
|
|
||||||
|
> **IMPORTANT:** The current implementation has a mismatch where the LLM outputs a nested `params` structure but the engines expect flat fields. This document defines the **TARGET** schema to normalize all components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Full Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_drop",
|
||||||
|
"token": "PEPE",
|
||||||
|
"chain": "bsc",
|
||||||
|
"threshold": 5,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 10,
|
||||||
|
"token": "PEPE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 3,
|
||||||
|
"take_profit_percent": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Field Definitions
|
||||||
|
|
||||||
|
### 4.1 Root Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `version` | string | No | Schema version (for future compatibility) |
|
||||||
|
| `conditions` | array | Yes | List of trigger conditions |
|
||||||
|
| `actions` | array | Yes | List of actions to execute when conditions are met |
|
||||||
|
| `risk_management` | object | No | Risk management settings |
|
||||||
|
|
||||||
|
### 4.2 Condition Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | Yes | Condition type (see supported types below) |
|
||||||
|
| `token` | string | Yes | Token symbol or address (e.g., "PEPE" or "0x123...-bsc") |
|
||||||
|
| `chain` | string | No | Blockchain chain (default: "bsc") |
|
||||||
|
| `threshold` | number | For price_drop/rise/volume_spike | Percentage threshold (e.g., 5 = 5%) |
|
||||||
|
| `price` | number | For price_level | Price level to trigger on |
|
||||||
|
| `direction` | string | For price_level | "above" or "below" |
|
||||||
|
| `timeframe` | string | No | Time window for calculation (e.g., "1h", "15m") |
|
||||||
|
|
||||||
|
#### Supported Condition Types
|
||||||
|
|
||||||
|
| Type | Description | Required Fields |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `price_drop` | Triggers when token price drops by threshold % | token, threshold |
|
||||||
|
| `price_rise` | Triggers when token price rises by threshold % | token, threshold |
|
||||||
|
| `volume_spike` | Triggers when trading volume increases by threshold % | token, threshold |
|
||||||
|
| `price_level` | Triggers when price crosses a specific level | token, price, direction |
|
||||||
|
|
||||||
|
### 4.3 Action Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | Yes | Action type (buy, sell, hold, notify) |
|
||||||
|
| `amount_percent` | number | For buy/sell | Percentage of portfolio to trade |
|
||||||
|
| `token` | string | No | Token to trade (defaults to condition token) |
|
||||||
|
|
||||||
|
#### Supported Action Types
|
||||||
|
|
||||||
|
| Type | Description | Required Fields |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `buy` | Purchase tokens | amount_percent |
|
||||||
|
| `sell` | Sell tokens | amount_percent |
|
||||||
|
| `hold` | Do nothing (log only) | - |
|
||||||
|
| `notify` | Send notification to user | - |
|
||||||
|
|
||||||
|
### 4.4 Risk Management Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `stop_loss_percent` | number | No | Exit trade if loss exceeds this % |
|
||||||
|
| `take_profit_percent` | number | No | Exit trade if profit reaches this % |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Examples
|
||||||
|
|
||||||
|
### 5.1 Simple Buy on Price Drop
|
||||||
|
|
||||||
|
> "Buy PEPE when it drops 5% in 1 hour"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_drop",
|
||||||
|
"token": "PEPE",
|
||||||
|
"chain": "bsc",
|
||||||
|
"threshold": 5,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Buy on Price Rise with Stop Loss
|
||||||
|
|
||||||
|
> "Buy when PEPE rises 10%, but stop loss at 3%"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_rise",
|
||||||
|
"token": "PEPE",
|
||||||
|
"threshold": 10,
|
||||||
|
"timeframe": "4h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Sell on Price Level
|
||||||
|
|
||||||
|
> "Sell when PEPE reaches $0.0001"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_level",
|
||||||
|
"token": "PEPE",
|
||||||
|
"price": 0.0001,
|
||||||
|
"direction": "above"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "sell",
|
||||||
|
"amount_percent": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Volume Spike Alert
|
||||||
|
|
||||||
|
> "Notify me when PEPE volume spikes 50%"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "volume_spike",
|
||||||
|
"token": "PEPE",
|
||||||
|
"threshold": 50,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation Rules
|
||||||
|
|
||||||
|
### 6.1 Conditions
|
||||||
|
|
||||||
|
- At least one condition is required
|
||||||
|
- Each condition must have a valid `type`
|
||||||
|
- Token must be specified
|
||||||
|
- Threshold must be positive number (for applicable types)
|
||||||
|
- Price level must be specified for `price_level` type
|
||||||
|
- Direction must be "above" or "below" for `price_level` type
|
||||||
|
|
||||||
|
### 6.2 Actions
|
||||||
|
|
||||||
|
- At least one action is required
|
||||||
|
- Each action must have a valid `type`
|
||||||
|
- `amount_percent` must be between 0 and 100
|
||||||
|
|
||||||
|
### 6.3 Risk Management
|
||||||
|
|
||||||
|
- `stop_loss_percent` must be positive
|
||||||
|
- `take_profit_percent` must be positive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation Status
|
||||||
|
|
||||||
|
### Components Using This Schema
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Backend Validator (crew.py) | ❌ Mismatch | Uses nested `params` structure |
|
||||||
|
| Backtest Engine | ❌ Mismatch | Uses flat structure (correct) |
|
||||||
|
| Simulate Engine | ❌ Mismatch | Uses flat structure (correct) |
|
||||||
|
| Frontend Types | ✅ Match | Uses flat structure |
|
||||||
|
| Frontend StrategyPreview | ✅ Match | Uses flat structure |
|
||||||
|
|
||||||
|
### Normalization Required
|
||||||
|
|
||||||
|
The LLM output parser should be updated to output flat structure (not nested in `params`) to match what the engines and frontend expect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Future Extensions
|
||||||
|
|
||||||
|
### Potential Condition Types (Phase 2+)
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rsi_oversold` | RSI indicator below threshold |
|
||||||
|
| `rsi_overbought` | RSI indicator above threshold |
|
||||||
|
| `ma_crossover` | Moving average crossover |
|
||||||
|
| `bollinger_breakout` | Bollinger Band breakout |
|
||||||
|
| `news_sentiment` | Based on news sentiment analysis |
|
||||||
|
|
||||||
|
### Potential Action Types (Phase 2+)
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `dca_buy` | Dollar cost averaging buy |
|
||||||
|
| `trailing_stop` | Trailing stop loss |
|
||||||
|
| `smart_rebalance` | Portfolio rebalancing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 1.0*
|
||||||
|
*Last Updated: 2026-04-09*
|
||||||
@@ -1,11 +1,68 @@
|
|||||||
|
# Randebu Trading Bot - Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SQLite database path (relative or absolute)
|
||||||
|
# Example: sqlite:///./data/app.db
|
||||||
DATABASE_URL=sqlite:///./data/app.db
|
DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTHENTICATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Secret key for JWT token signing
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
SECRET_KEY=your-super-secret-key-change-in-production
|
SECRET_KEY=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
|
# JWT algorithm (HS256 is recommended)
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
|
# Token expiration time in minutes (1440 = 24 hours)
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MINIMAX LLM
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# MiniMax API key (get from https://platform.minimax.chat/)
|
||||||
MINIMAX_API_KEY=your-minimax-api-key
|
MINIMAX_API_KEY=your-minimax-api-key
|
||||||
MINIMAX_MODEL=MiniMax-Text-01
|
|
||||||
AVE_API_KEY=your-ave-cloud-api-key
|
# MiniMax model to use
|
||||||
|
# Common options: MiniMax-Text-01, MiniMax-M2.1
|
||||||
|
MINIMAX_MODEL=MiniMax-M2.7
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AVE CLOUD API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# AVE Cloud API key (get from https://cloud.ave.ai/)
|
||||||
|
AVE_API_KEY=your-ave-api-key
|
||||||
|
|
||||||
|
# AVE Cloud plan tier
|
||||||
|
# Options: free, normal, pro
|
||||||
|
# Note: Free tier has 1 TPS limit, Pro required for WebSocket
|
||||||
AVE_API_PLAN=free
|
AVE_API_PLAN=free
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SERVER CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Server host (0.0.0.0 for all interfaces)
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Server port
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
|
# Debug mode (set to false in production)
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FRONTEND CONFIGURATION (for reference)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Frontend environment variables (set in frontend .env file):
|
||||||
|
# VITE_API_URL=https://bot.yourdomain.com/api
|
||||||
|
# VITE_WS_URL=wss://bot.yourdomain.com/ws
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from ..core.config import get_settings
|
|||||||
from ..core.limiter import limiter
|
from ..core.limiter import limiter
|
||||||
from ..db.schemas import (
|
from ..db.schemas import (
|
||||||
UserCreate,
|
UserCreate,
|
||||||
|
LoginRequest,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
Token,
|
Token,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
@@ -58,7 +59,7 @@ def get_current_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
"/register", response_model=Token, status_code=status.HTTP_201_CREATED
|
||||||
)
|
)
|
||||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
existing_user = db.query(User).filter(User.email == user.email).first()
|
||||||
@@ -75,18 +76,21 @@ def register(user: UserCreate, db: Session = Depends(get_db)):
|
|||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
db.refresh(db_user)
|
||||||
return db_user
|
|
||||||
|
# Generate and return access token so frontend can proceed immediately
|
||||||
|
access_token = create_access_token(data={"sub": db_user.id})
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
def login(
|
def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
login_data: LoginRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
user = db.query(User).filter(User.email == form_data.username).first()
|
user = db.query(User).filter(User.email == login_data.username).first()
|
||||||
if not user or not verify_password(form_data.password, user.password_hash):
|
if not user or not verify_password(login_data.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
|
|||||||
265
src/backend/app/api/ave.py
Normal file
265
src/backend/app/api/ave.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..db.models import User
|
||||||
|
from ..services.ave import AveCloudClient, check_tier_access
|
||||||
|
from ..db.schemas import (
|
||||||
|
AveBatchPricesRequest,
|
||||||
|
AveKlinesRequest,
|
||||||
|
AveChainQuoteRequest,
|
||||||
|
AveChainSwapRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ave_client() -> AveCloudClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
async def search_tokens(
|
||||||
|
query: Optional[str] = None,
|
||||||
|
chain: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
tokens = await client.get_tokens(query=query, chain=chain, limit=limit)
|
||||||
|
return {"tokens": tokens}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch tokens: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/price")
|
||||||
|
async def get_batch_prices(
|
||||||
|
request: AveBatchPricesRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
prices = await client.get_batch_prices(request.token_ids)
|
||||||
|
return {"prices": prices}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch batch prices: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens/{token_id}")
|
||||||
|
async def get_token_details(
|
||||||
|
token_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
token = await client.get_token_details(token_id)
|
||||||
|
if token is None:
|
||||||
|
return {"token": None, "upsell_message": None}
|
||||||
|
return {"token": token}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch token details: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/klines/{token_id}")
|
||||||
|
async def get_klines(
|
||||||
|
token_id: str,
|
||||||
|
interval: str = "1h",
|
||||||
|
limit: int = 100,
|
||||||
|
start_time: Optional[int] = None,
|
||||||
|
end_time: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
klines = await client.get_klines(
|
||||||
|
token_id=token_id,
|
||||||
|
interval=interval,
|
||||||
|
limit=limit,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
return {"klines": klines}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch klines: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens/trending")
|
||||||
|
async def get_trending_tokens(
|
||||||
|
chain: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
tokens = await client.get_trending_tokens(chain=chain, limit=limit)
|
||||||
|
return {"tokens": tokens}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch trending tokens: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts/{contract_id}")
|
||||||
|
async def get_token_risk(
|
||||||
|
contract_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
risk = await client.get_token_risk(contract_id)
|
||||||
|
if risk is None:
|
||||||
|
return {"risk": None, "upsell_message": None}
|
||||||
|
return {"risk": risk}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch token risk: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chain/quote")
|
||||||
|
async def get_chain_quote(
|
||||||
|
request: AveChainQuoteRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
quote = await client.get_chain_quote(
|
||||||
|
chain=request.chain,
|
||||||
|
from_token=request.from_token,
|
||||||
|
to_token=request.to_token,
|
||||||
|
amount=request.amount,
|
||||||
|
slippage=request.slippage,
|
||||||
|
)
|
||||||
|
if quote is None:
|
||||||
|
return {"quote": None, "upsell_message": None}
|
||||||
|
return {"quote": quote}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch chain quote: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chain/swap")
|
||||||
|
async def get_chain_swap(
|
||||||
|
request: AveChainSwapRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
swap = await client.get_chain_swap(
|
||||||
|
chain=request.chain,
|
||||||
|
from_token=request.from_token,
|
||||||
|
to_token=request.to_token,
|
||||||
|
amount=request.amount,
|
||||||
|
slippage=request.slippage,
|
||||||
|
wallet_address=request.wallet_address,
|
||||||
|
)
|
||||||
|
if swap is None:
|
||||||
|
return {"swap": None, "upsell_message": None}
|
||||||
|
return {"swap": swap}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch chain swap: {str(e)}",
|
||||||
|
)
|
||||||
@@ -1,36 +1,312 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List, Dict, Any, Optional
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
from ..core.config import get_settings
|
||||||
from ..db.schemas import BacktestCreate, BacktestResponse
|
from ..db.schemas import BacktestCreate, BacktestResponse
|
||||||
|
from ..db.models import Bot, Backtest, Signal, User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
running_backtests: Dict[str, Any] = {}
|
||||||
|
executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
@router.post("/bots/{bot_id}/backtest", response_model=BacktestResponse)
|
|
||||||
def start_backtest(bot_id: str, config: BacktestCreate, db: Session = Depends(get_db)):
|
def run_backtest_sync(
|
||||||
raise HTTPException(
|
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
):
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from ..services.backtest.engine import BacktestEngine
|
||||||
|
from ..core.database import SessionLocal
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
engine = BacktestEngine(config)
|
||||||
|
engine.run_id = backtest_id
|
||||||
|
running_backtests[backtest_id] = engine
|
||||||
|
try:
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
# Convert datetime objects to ISO strings for JSON serialization
|
||||||
|
def convert_datetime(obj):
|
||||||
|
if isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {k: convert_datetime(v) for k, v in obj.items()}
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [convert_datetime(i) for i in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
results = convert_datetime(results)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
||||||
|
if backtest:
|
||||||
|
backtest.status = engine.status
|
||||||
|
backtest.ended_at = datetime.utcnow()
|
||||||
|
backtest.result = results
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for signal in engine.signals:
|
||||||
|
signal_data = convert_datetime(signal)
|
||||||
|
db_signal = Signal(
|
||||||
|
id=signal_data["id"],
|
||||||
|
bot_id=signal_data["bot_id"],
|
||||||
|
run_id=signal_data["run_id"],
|
||||||
|
signal_type=signal_data["signal_type"],
|
||||||
|
token=signal_data["token"],
|
||||||
|
price=signal_data["price"],
|
||||||
|
confidence=signal_data.get("confidence"),
|
||||||
|
reasoning=signal_data.get("reasoning"),
|
||||||
|
executed=signal_data.get("executed", False),
|
||||||
|
created_at=signal["created_at"], # Use original datetime, not converted string
|
||||||
|
)
|
||||||
|
db.add(db_signal)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
finally:
|
||||||
|
if backtest_id in running_backtests:
|
||||||
|
del running_backtests[backtest_id]
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bots/{bot_id}/backtest",
|
||||||
|
response_model=BacktestResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def start_backtest(
|
||||||
|
bot_id: str,
|
||||||
|
config: BacktestCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
backtest_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
backtest_config = {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"token": config.token,
|
||||||
|
"chain": config.chain,
|
||||||
|
"timeframe": config.timeframe,
|
||||||
|
"start_date": config.start_date,
|
||||||
|
"end_date": config.end_date,
|
||||||
|
"strategy_config": bot.strategy_config,
|
||||||
|
"ave_api_key": settings.AVE_API_KEY,
|
||||||
|
"ave_api_plan": settings.AVE_API_PLAN,
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
backtest = Backtest(
|
||||||
|
id=backtest_id,
|
||||||
|
bot_id=bot_id,
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
status="running",
|
||||||
|
config={
|
||||||
|
"token": config.token,
|
||||||
|
"chain": config.chain,
|
||||||
|
"timeframe": config.timeframe,
|
||||||
|
"start_date": config.start_date,
|
||||||
|
"end_date": config.end_date,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
db.add(backtest)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(backtest)
|
||||||
|
|
||||||
|
db_url = str(settings.DATABASE_URL)
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config
|
||||||
|
)
|
||||||
|
|
||||||
|
return backtest
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bots/{bot_id}/backtest/{run_id}", response_model=BacktestResponse)
|
@router.get("/bots/{bot_id}/backtest/{run_id}", response_model=BacktestResponse)
|
||||||
def get_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
def get_backtest(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
run_id: str,
|
||||||
)
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
backtest = (
|
||||||
|
db.query(Backtest)
|
||||||
|
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not backtest:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add progress from running engine if available
|
||||||
|
if backtest.status == "running" and run_id in running_backtests:
|
||||||
|
engine = running_backtests[run_id]
|
||||||
|
backtest.progress = engine.progress
|
||||||
|
|
||||||
|
return backtest
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bots/{bot_id}/backtest/{run_id}/trades")
|
||||||
|
def get_backtest_trades(
|
||||||
|
bot_id: str,
|
||||||
|
run_id: str,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 5,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get paginated trade history for a specific backtest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Number of trades per page (default 5, max 20)
|
||||||
|
"""
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
backtest = (
|
||||||
|
db.query(Backtest)
|
||||||
|
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not backtest:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get trades from result
|
||||||
|
result = backtest.result or {}
|
||||||
|
# Handle case where result might be a JSON string
|
||||||
|
if isinstance(result, str):
|
||||||
|
import json
|
||||||
|
result = json.loads(result)
|
||||||
|
all_trades = result.get("trades", []) or []
|
||||||
|
total_trades = len(all_trades)
|
||||||
|
|
||||||
|
# Validate pagination params
|
||||||
|
per_page = min(max(per_page, 1), 20) # Clamp between 1 and 20
|
||||||
|
page = max(page, 1)
|
||||||
|
|
||||||
|
# Calculate pagination
|
||||||
|
total_pages = max(1, (total_trades + per_page - 1) // per_page) if total_trades > 0 else 1
|
||||||
|
start_idx = (page - 1) * per_page
|
||||||
|
end_idx = start_idx + per_page
|
||||||
|
|
||||||
|
# Get page of trades (return empty list if start_idx >= total_trades)
|
||||||
|
paginated_trades = all_trades[start_idx:end_idx] if start_idx < total_trades else []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backtest_id": run_id,
|
||||||
|
"trades": paginated_trades,
|
||||||
|
"total_trades": total_trades,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"has_next": page < total_pages,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
|
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
|
||||||
def list_backtests(bot_id: str, db: Session = Depends(get_db)):
|
def list_backtests(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
backtests = (
|
||||||
|
db.query(Backtest)
|
||||||
|
.filter(Backtest.bot_id == bot_id)
|
||||||
|
.order_by(Backtest.started_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
return backtests
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bots/{bot_id}/backtest/{run_id}/stop")
|
@router.post("/bots/{bot_id}/backtest/{run_id}/stop")
|
||||||
def stop_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
def stop_backtest(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
run_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
backtest = (
|
||||||
|
db.query(Backtest)
|
||||||
|
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if not backtest:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_id in running_backtests:
|
||||||
|
engine = running_backtests[run_id]
|
||||||
|
engine.running = False # Direct sync access to running flag
|
||||||
|
backtest.status = "stopped"
|
||||||
|
backtest.ended_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
elif backtest.status == "running":
|
||||||
|
# Engine already finished but status not updated
|
||||||
|
backtest.status = "stopped"
|
||||||
|
backtest.ended_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "stopping", "run_id": run_id}
|
||||||
|
|||||||
@@ -1,57 +1,256 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List, Annotated
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..db.schemas import BotCreate, BotUpdate, BotResponse
|
from ..core.config import get_settings
|
||||||
|
from ..db.schemas import (
|
||||||
|
BotCreate,
|
||||||
|
BotUpdate,
|
||||||
|
BotResponse,
|
||||||
|
BotConversationCreate,
|
||||||
|
BotConversationResponse,
|
||||||
|
BotChatRequest,
|
||||||
|
BotChatResponse,
|
||||||
|
)
|
||||||
|
from ..db.models import Bot, BotConversation, User
|
||||||
|
from ..services.ai_agent.crew import get_trading_crew
|
||||||
|
from ..services.ai_agent.conversational import get_conversational_agent
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
MAX_BOTS_PER_USER = 3
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[BotResponse])
|
@router.get("", response_model=List[BotResponse])
|
||||||
def list_bots(db: Session = Depends(get_db)):
|
def list_bots(
|
||||||
raise HTTPException(
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
db: Session = Depends(get_db),
|
||||||
)
|
):
|
||||||
|
bots = db.query(Bot).filter(Bot.user_id == current_user.id).all()
|
||||||
|
return bots
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BotResponse)
|
@router.post("", response_model=BotResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_bot(bot: BotCreate, db: Session = Depends(get_db)):
|
def create_bot(
|
||||||
raise HTTPException(
|
bot_data: BotCreate,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_bot_count = db.query(Bot).filter(Bot.user_id == current_user.id).count()
|
||||||
|
if user_bot_count >= MAX_BOTS_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Maximum of {MAX_BOTS_PER_USER} bots per user exceeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_bot = (
|
||||||
|
db.query(Bot)
|
||||||
|
.filter(Bot.user_id == current_user.id, Bot.name == bot_data.name)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if existing_bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Bot name must be unique per user",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_bot = Bot(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=bot_data.name,
|
||||||
|
description=bot_data.description,
|
||||||
|
strategy_config=bot_data.strategy_config,
|
||||||
|
llm_config=bot_data.llm_config,
|
||||||
|
)
|
||||||
|
db.add(db_bot)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_bot)
|
||||||
|
return db_bot
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bot_id}", response_model=BotResponse)
|
@router.get("/{bot_id}", response_model=BotResponse)
|
||||||
def get_bot(bot_id: str, db: Session = Depends(get_db)):
|
def get_bot(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
)
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Bot not found",
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to access this bot",
|
||||||
|
)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{bot_id}", response_model=BotResponse)
|
@router.put("/{bot_id}", response_model=BotResponse)
|
||||||
def update_bot(bot_id: str, bot: BotUpdate, db: Session = Depends(get_db)):
|
def update_bot(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
bot_data: BotUpdate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Bot not found",
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to update this bot",
|
||||||
|
)
|
||||||
|
|
||||||
|
if bot_data.name is not None:
|
||||||
|
existing_bot = (
|
||||||
|
db.query(Bot)
|
||||||
|
.filter(
|
||||||
|
Bot.user_id == current_user.id,
|
||||||
|
Bot.name == bot_data.name,
|
||||||
|
Bot.id != bot_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Bot name must be unique per user",
|
||||||
|
)
|
||||||
|
bot.name = bot_data.name
|
||||||
|
|
||||||
|
if bot_data.description is not None:
|
||||||
|
bot.description = bot_data.description
|
||||||
|
if bot_data.strategy_config is not None:
|
||||||
|
bot.strategy_config = bot_data.strategy_config
|
||||||
|
if bot_data.llm_config is not None:
|
||||||
|
bot.llm_config = bot_data.llm_config
|
||||||
|
if bot_data.status is not None:
|
||||||
|
bot.status = bot_data.status
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(bot)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_bot(
|
||||||
|
bot_id: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Bot not found",
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to delete this bot",
|
||||||
|
)
|
||||||
|
db.delete(bot)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{bot_id}/chat", response_model=BotChatResponse)
|
||||||
|
def chat(
|
||||||
|
bot_id: str,
|
||||||
|
request: BotChatRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Bot not found",
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to chat with this bot",
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation_history = (
|
||||||
|
db.query(BotConversation)
|
||||||
|
.filter(BotConversation.bot_id == bot_id)
|
||||||
|
.order_by(BotConversation.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
history_for_agent = [
|
||||||
|
{"role": conv.role, "content": conv.content}
|
||||||
|
for conv in conversation_history[-10:]
|
||||||
|
]
|
||||||
|
|
||||||
|
user_message = request.message
|
||||||
|
|
||||||
|
# Use ConversationalAgent for natural chat with tool-calling
|
||||||
|
agent = get_conversational_agent(bot_id=bot_id)
|
||||||
|
result = agent.chat(user_message, history_for_agent)
|
||||||
|
|
||||||
|
assistant_content = result.get("response", "I couldn't process your request.")
|
||||||
|
|
||||||
|
# Save conversation
|
||||||
|
db_conversation = BotConversation(
|
||||||
|
bot_id=bot_id,
|
||||||
|
role="user",
|
||||||
|
content=user_message,
|
||||||
|
)
|
||||||
|
db.add(db_conversation)
|
||||||
|
|
||||||
|
db_assistant = BotConversation(
|
||||||
|
bot_id=bot_id,
|
||||||
|
role="assistant",
|
||||||
|
content=assistant_content,
|
||||||
|
)
|
||||||
|
db.add(db_assistant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_assistant)
|
||||||
|
|
||||||
|
# If strategy was updated via tool, refresh bot data
|
||||||
|
if result.get("strategy_updated"):
|
||||||
|
db.refresh(bot)
|
||||||
|
|
||||||
|
return BotChatResponse(
|
||||||
|
response=assistant_content,
|
||||||
|
thinking=result.get("thinking"),
|
||||||
|
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
|
||||||
|
success=result.get("success", False),
|
||||||
|
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
|
||||||
|
strategy_data=result.get("strategy_data") if result.get("strategy_needs_confirmation") else None,
|
||||||
|
token_search_results=result.get("token_search_results"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{bot_id}")
|
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
|
||||||
def delete_bot(bot_id: str, db: Session = Depends(get_db)):
|
def get_history(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
)
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
@router.post("/{bot_id}/chat")
|
if not bot:
|
||||||
def chat(bot_id: str, message: dict, db: Session = Depends(get_db)):
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
detail="Bot not found",
|
||||||
)
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
@router.get("/{bot_id}/history")
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
def get_history(bot_id: str, db: Session = Depends(get_db)):
|
detail="Not authorized to access this bot's history",
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
|
||||||
|
conversations = (
|
||||||
|
db.query(BotConversation)
|
||||||
|
.filter(BotConversation.bot_id == bot_id)
|
||||||
|
.order_by(BotConversation.created_at)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
return conversations
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..services.ave import AveCloudClient
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chains")
|
@router.get("/chains")
|
||||||
def get_chains():
|
def get_chains():
|
||||||
return {"chains": []}
|
return {"chains": ["bsc"]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tokens")
|
@router.get("/tokens")
|
||||||
def get_tokens():
|
async def get_tokens():
|
||||||
return {"tokens": []}
|
settings = get_settings()
|
||||||
|
client = AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
|
||||||
|
tokens = await client.get_tokens(chain="bsc", limit=20)
|
||||||
|
return {"tokens": tokens}
|
||||||
|
|||||||
@@ -1,38 +1,322 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List, Dict, Any, Optional
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
|
from ..core.config import get_settings
|
||||||
from ..db.schemas import SimulationCreate, SimulationResponse
|
from ..db.schemas import SimulationCreate, SimulationResponse
|
||||||
|
from ..db.models import Bot, Simulation, Signal, User
|
||||||
|
from ..services.ave.client import AveCloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
running_simulations: Dict[str, Any] = {}
|
||||||
|
executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
@router.post("/bots/{bot_id}/simulate", response_model=SimulationResponse)
|
|
||||||
def start_simulation(
|
def run_simulation_sync(
|
||||||
bot_id: str, config: SimulationCreate, db: Session = Depends(get_db)
|
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
import asyncio
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
import time
|
||||||
|
from ..services.simulate.engine import SimulateEngine
|
||||||
|
from ..core.database import SessionLocal
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
engine = SimulateEngine(config)
|
||||||
|
engine.run_id = simulation_id
|
||||||
|
running_simulations[simulation_id] = engine
|
||||||
|
|
||||||
|
# Serialize signals for JSON storage (convert datetime to string)
|
||||||
|
def serialize_signal(s):
|
||||||
|
created = s.get("created_at")
|
||||||
|
if hasattr(created, "isoformat"):
|
||||||
|
created = created.isoformat()
|
||||||
|
return {
|
||||||
|
**s,
|
||||||
|
"created_at": created
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_progress():
|
||||||
|
"""Save current progress to database."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
simulation = (
|
||||||
|
db.query(Simulation).filter(Simulation.id == simulation_id).first()
|
||||||
|
)
|
||||||
|
if simulation:
|
||||||
|
simulation.status = engine.status
|
||||||
|
simulation.signals = [serialize_signal(s) for s in engine.signals]
|
||||||
|
simulation.klines = [
|
||||||
|
{"time": k.get("time"), "close": k.get("close")}
|
||||||
|
for k in engine.klines
|
||||||
|
]
|
||||||
|
simulation.trade_log = engine.trade_log
|
||||||
|
# Save portfolio data
|
||||||
|
simulation.portfolio = {
|
||||||
|
"initial_balance": engine.config.get("initial_balance", 10000),
|
||||||
|
"current_balance": engine.current_balance,
|
||||||
|
"position": engine.position,
|
||||||
|
"position_token": engine.position_token,
|
||||||
|
"entry_price": engine.entry_price,
|
||||||
|
"current_price": engine.last_close,
|
||||||
|
}
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def run_with_progress_save():
|
||||||
|
"""Run simulation and save progress periodically."""
|
||||||
|
last_save_time = time.time()
|
||||||
|
save_interval = 5 # Save every 5 seconds
|
||||||
|
|
||||||
|
while engine.running and engine.status == "running":
|
||||||
|
await asyncio.sleep(1) # Check every second
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_save_time >= save_interval:
|
||||||
|
save_progress()
|
||||||
|
last_save_time = current_time
|
||||||
|
|
||||||
|
# Final save when done
|
||||||
|
save_progress()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run both simulation and progress saving concurrently
|
||||||
|
await asyncio.gather(
|
||||||
|
engine.run(),
|
||||||
|
run_with_progress_save()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Save final state
|
||||||
|
save_progress()
|
||||||
|
if simulation_id in running_simulations:
|
||||||
|
del running_simulations[simulation_id]
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bots/{bot_id}/simulate",
|
||||||
|
response_model=SimulationResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def start_simulation(
|
||||||
|
bot_id: str,
|
||||||
|
config: SimulationCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if there's already a running simulation for this bot
|
||||||
|
existing_simulation = (
|
||||||
|
db.query(Simulation)
|
||||||
|
.filter(Simulation.bot_id == bot_id, Simulation.status == "running")
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if existing_simulation:
|
||||||
|
# Stop the existing simulation first
|
||||||
|
if existing_simulation.id in running_simulations:
|
||||||
|
running_simulations[existing_simulation.id].stop()
|
||||||
|
del running_simulations[existing_simulation.id]
|
||||||
|
existing_simulation.status = "stopped"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
simulation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create AVE client for klines fetching
|
||||||
|
ave_client = AveCloudClient(
|
||||||
|
api_key=settings.AVE_API_KEY,
|
||||||
|
plan=settings.AVE_API_PLAN,
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation_config = {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"token": config.token,
|
||||||
|
"chain": config.chain,
|
||||||
|
"kline_interval": config.kline_interval,
|
||||||
|
"auto_execute": False, # Always paper trade
|
||||||
|
"strategy_config": bot.strategy_config,
|
||||||
|
"ave_api_key": settings.AVE_API_KEY,
|
||||||
|
"ave_api_plan": settings.AVE_API_PLAN,
|
||||||
|
}
|
||||||
|
|
||||||
|
simulation = Simulation(
|
||||||
|
id=simulation_id,
|
||||||
|
bot_id=bot_id,
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
status="running",
|
||||||
|
config={
|
||||||
|
"token": config.token,
|
||||||
|
"chain": config.chain,
|
||||||
|
"kline_interval": config.kline_interval,
|
||||||
|
},
|
||||||
|
signals=[],
|
||||||
|
klines=[],
|
||||||
|
)
|
||||||
|
db.add(simulation)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(simulation)
|
||||||
|
|
||||||
|
# Fetch klines SYNCHRONOUSLY so user can see chart immediately
|
||||||
|
try:
|
||||||
|
token_id = f"{config.token}-{config.chain}"
|
||||||
|
|
||||||
|
# Calculate time range (last 1 hour)
|
||||||
|
import time
|
||||||
|
end_time = int(time.time() * 1000)
|
||||||
|
start_time = end_time - (60 * 60 * 1000) # 1 hour ago
|
||||||
|
|
||||||
|
klines_data = await ave_client.get_klines(
|
||||||
|
token_id,
|
||||||
|
interval=config.kline_interval,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=500
|
||||||
|
)
|
||||||
|
klines_for_chart = [
|
||||||
|
{"time": k.get("time"), "close": k.get("close")}
|
||||||
|
for k in sorted(klines_data, key=lambda x: x.get("time", 0))
|
||||||
|
]
|
||||||
|
# Update simulation with klines
|
||||||
|
simulation.klines = klines_for_chart
|
||||||
|
db.commit()
|
||||||
|
db.refresh(simulation)
|
||||||
|
logger.info(f"Fetched {len(klines_for_chart)} klines for simulation {simulation_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch klines: {e}")
|
||||||
|
|
||||||
|
# Run simulation in background for signal processing
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
|
||||||
|
)
|
||||||
|
|
||||||
|
return simulation
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bots/{bot_id}/simulate/{run_id}", response_model=SimulationResponse)
|
@router.get("/bots/{bot_id}/simulate/{run_id}", response_model=SimulationResponse)
|
||||||
def get_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
def get_simulation(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
run_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation = (
|
||||||
|
db.query(Simulation)
|
||||||
|
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if not simulation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_id in running_simulations:
|
||||||
|
engine = running_simulations[run_id]
|
||||||
|
simulation.signals = engine.get_signals()
|
||||||
|
|
||||||
|
return simulation
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bots/{bot_id}/simulations", response_model=List[SimulationResponse])
|
@router.get("/bots/{bot_id}/simulations", response_model=List[SimulationResponse])
|
||||||
def list_simulations(bot_id: str, db: Session = Depends(get_db)):
|
def list_simulations(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
simulations = (
|
||||||
|
db.query(Simulation)
|
||||||
|
.filter(Simulation.bot_id == bot_id)
|
||||||
|
.order_by(Simulation.started_at.desc())
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for sim in simulations:
|
||||||
|
if sim.id in running_simulations:
|
||||||
|
engine = running_simulations[sim.id]
|
||||||
|
sim.signals = engine.get_signals()
|
||||||
|
# Include klines from running engine for chart display
|
||||||
|
if hasattr(engine, 'klines'):
|
||||||
|
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
|
||||||
|
|
||||||
|
return simulations
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bots/{bot_id}/simulate/{run_id}/stop")
|
@router.post("/bots/{bot_id}/simulate/{run_id}/stop")
|
||||||
def stop_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
def stop_simulation(
|
||||||
raise HTTPException(
|
bot_id: str,
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
run_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||||
|
)
|
||||||
|
if bot.user_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation = (
|
||||||
|
db.query(Simulation)
|
||||||
|
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if not simulation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always update status to stopped, even if engine is not in memory
|
||||||
|
simulation.status = "stopped"
|
||||||
|
|
||||||
|
# Try to stop the engine if it's still in memory
|
||||||
|
if run_id in running_simulations:
|
||||||
|
engine = running_simulations[run_id]
|
||||||
|
engine.stop()
|
||||||
|
del running_simulations[run_id]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "stopped", "run_id": run_id}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class User(Base):
|
|||||||
id = Column(String, primary_key=True, default=generate_uuid)
|
id = Column(String, primary_key=True, default=generate_uuid)
|
||||||
email = Column(String, unique=True, nullable=False)
|
email = Column(String, unique=True, nullable=False)
|
||||||
password_hash = Column(String, nullable=False)
|
password_hash = Column(String, nullable=False)
|
||||||
|
tier = Column(String, default="free")
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ class Simulation(Base):
|
|||||||
status = Column(String, nullable=False)
|
status = Column(String, nullable=False)
|
||||||
config = Column(JSON, nullable=False)
|
config = Column(JSON, nullable=False)
|
||||||
signals = Column(JSON)
|
signals = Column(JSON)
|
||||||
|
klines = Column(JSON) # Price data for chart display
|
||||||
|
trade_log = Column(JSON) # Trade activity log
|
||||||
|
portfolio = Column(JSON) # Portfolio data
|
||||||
|
|
||||||
bot = relationship("Bot", back_populates="simulations")
|
bot = relationship("Bot", back_populates="simulations")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ class UserCreate(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
@@ -35,8 +40,8 @@ class UserSettingsUpdate(BaseModel):
|
|||||||
class BotCreate(BaseModel):
|
class BotCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
strategy_config: dict
|
strategy_config: Optional[dict] = {}
|
||||||
llm_config: dict
|
llm_config: Optional[dict] = {}
|
||||||
|
|
||||||
|
|
||||||
class BotUpdate(BaseModel):
|
class BotUpdate(BaseModel):
|
||||||
@@ -64,11 +69,19 @@ class BotResponse(BaseModel):
|
|||||||
|
|
||||||
class BacktestCreate(BaseModel):
|
class BacktestCreate(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
token_name: Optional[str] = None
|
||||||
chain: str
|
chain: str
|
||||||
timeframe: str
|
timeframe: str
|
||||||
start_date: str
|
start_date: str
|
||||||
end_date: str
|
end_date: str
|
||||||
|
|
||||||
|
@field_validator("chain")
|
||||||
|
@classmethod
|
||||||
|
def chain_must_be_bsc(cls, v: str) -> str:
|
||||||
|
if v != "bsc":
|
||||||
|
raise ValueError("Phase 1 only supports BSC (bnb chain)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class BacktestResponse(BaseModel):
|
class BacktestResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -78,6 +91,7 @@ class BacktestResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
config: dict
|
config: dict
|
||||||
result: Optional[dict]
|
result: Optional[dict]
|
||||||
|
progress: Optional[int] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -86,8 +100,14 @@ class BacktestResponse(BaseModel):
|
|||||||
class SimulationCreate(BaseModel):
|
class SimulationCreate(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
chain: str
|
chain: str
|
||||||
duration_seconds: int = 3600
|
kline_interval: str = "1m"
|
||||||
auto_execute: bool = False
|
|
||||||
|
@field_validator("chain")
|
||||||
|
@classmethod
|
||||||
|
def chain_must_be_bsc(cls, v: str) -> str:
|
||||||
|
if v != "bsc":
|
||||||
|
raise ValueError("Phase 1 only supports BSC (bnb chain)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class SimulationResponse(BaseModel):
|
class SimulationResponse(BaseModel):
|
||||||
@@ -97,6 +117,12 @@ class SimulationResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
config: dict
|
config: dict
|
||||||
signals: Optional[List[dict]]
|
signals: Optional[List[dict]]
|
||||||
|
klines: Optional[List[dict]] = None # Price data for chart
|
||||||
|
trade_log: Optional[List[dict]] = None # Trade activity log
|
||||||
|
portfolio: Optional[dict] = None # Portfolio data
|
||||||
|
current_candle_index: Optional[int] = None # Progress: current candle
|
||||||
|
total_candles: Optional[int] = None # Progress: total candles
|
||||||
|
candles_processed: Optional[int] = None # Progress: candles processed
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -118,6 +144,21 @@ class BotConversationResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BotChatRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
strategy_config: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class BotChatResponse(BaseModel):
|
||||||
|
response: str
|
||||||
|
thinking: Optional[str] = None
|
||||||
|
strategy_config: Optional[dict] = None
|
||||||
|
success: bool = False
|
||||||
|
strategy_needs_confirmation: Optional[bool] = False
|
||||||
|
strategy_data: Optional[dict] = None
|
||||||
|
token_search_results: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class SignalResponse(BaseModel):
|
class SignalResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
bot_id: str
|
bot_id: str
|
||||||
@@ -132,3 +173,72 @@ class SignalResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AveTokenSearchResponse(BaseModel):
|
||||||
|
tokens: List[dict]
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveBatchPricesRequest(BaseModel):
|
||||||
|
token_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AveBatchPricesResponse(BaseModel):
|
||||||
|
prices: Dict[str, dict]
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveTokenDetailsResponse(BaseModel):
|
||||||
|
token: Optional[dict] = None
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveKlinesRequest(BaseModel):
|
||||||
|
token_id: str
|
||||||
|
interval: str = "1h"
|
||||||
|
limit: int = 100
|
||||||
|
start_time: Optional[int] = None
|
||||||
|
end_time: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveKlinesResponse(BaseModel):
|
||||||
|
klines: List[dict]
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveTrendingTokensResponse(BaseModel):
|
||||||
|
tokens: List[dict]
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveTokenRiskResponse(BaseModel):
|
||||||
|
risk: Optional[dict] = None
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveChainQuoteRequest(BaseModel):
|
||||||
|
chain: str
|
||||||
|
from_token: str
|
||||||
|
to_token: str
|
||||||
|
amount: str
|
||||||
|
slippage: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class AveChainQuoteResponse(BaseModel):
|
||||||
|
quote: Optional[dict] = None
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveChainSwapRequest(BaseModel):
|
||||||
|
chain: str
|
||||||
|
from_token: str
|
||||||
|
to_token: str
|
||||||
|
amount: str
|
||||||
|
slippage: float = 0.5
|
||||||
|
wallet_address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AveChainSwapResponse(BaseModel):
|
||||||
|
swap: Optional[dict] = None
|
||||||
|
upsell_message: Optional[str] = None
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from .api import auth, bots, backtest, simulate, config
|
from .api import auth, bots, backtest, simulate, config, ave
|
||||||
from .core.limiter import limiter
|
from .core.limiter import limiter
|
||||||
|
from .core.database import engine, Base
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Initialize database on startup."""
|
||||||
|
# Import all models to ensure they're registered
|
||||||
|
from .db.models import User, Bot, BotConversation, Backtest, Simulation, Signal
|
||||||
|
|
||||||
|
# Create tables if they don't exist
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
yield
|
||||||
|
# Cleanup on shutdown if needed
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Randebu Trading Bot API",
|
title="Randebu Trading Bot API",
|
||||||
description="AI-powered trading bot platform API",
|
description="AI-powered trading bot platform API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
@@ -26,6 +47,7 @@ app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
|
|||||||
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
||||||
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
||||||
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
||||||
|
app.include_router(ave.router, prefix="/api/ave", tags=["ave"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .crew import CrewAgent
|
from .crew import TradingCrew, get_trading_crew
|
||||||
from .llm_connector import LLMConnector
|
from .llm_connector import MiniMaxLLM, MiniMaxConnector
|
||||||
|
|
||||||
__all__ = ["CrewAgent", "LLMConnector"]
|
__all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"]
|
||||||
|
|||||||
800
src/backend/app/services/ai_agent/conversational.py
Normal file
800
src/backend/app/services/ai_agent/conversational.py
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
"""
|
||||||
|
Conversational Trading Agent
|
||||||
|
|
||||||
|
This agent can:
|
||||||
|
1. Have normal conversations with users
|
||||||
|
2. Update trading strategies when user provides specific instructions
|
||||||
|
|
||||||
|
Uses MiniMax extended thinking API for proper thinking/reasoning separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from ...core.config import get_settings
|
||||||
|
from ...db.models import Bot, Simulation
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are a helpful AI trading assistant named Randebu. You help users manage their trading bots.
|
||||||
|
|
||||||
|
IMPORTANT CHAIN LIMITATION:
|
||||||
|
- We ONLY support BSC (Binance Smart Chain) blockchain
|
||||||
|
- If user asks about any other chain (Solana, ETH, Base, etc.), respond with: "Currently we only support BSC (Binance Smart Chain). All trading strategies and token searches are performed on BSC."
|
||||||
|
- Never search or recommend tokens on other chains
|
||||||
|
- The search_tokens tool defaults to BSC, never change this
|
||||||
|
|
||||||
|
Your response must be valid JSON with exactly this structure:
|
||||||
|
{
|
||||||
|
"thinking": "Your internal reasoning and analysis (what you're thinking about)",
|
||||||
|
"response": "Your actual response to the user (be concise and helpful)",
|
||||||
|
"strategy_update": null or {
|
||||||
|
"conditions": [{"type": "price_drop" | "price_rise" | "volume_spike" | "price_level", "token": "TOKEN_SYMBOL", "token_address": null, "threshold": number, ...}],
|
||||||
|
"actions": [{"type": "buy" | "sell" | "hold", "amount_percent": number, ...}],
|
||||||
|
"risk_management": {"stop_loss_percent": number, "take_profit_percent": number}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- "thinking" should be detailed reasoning about the user's request
|
||||||
|
- "response" should be conversational and clear
|
||||||
|
- "strategy_update" should be populated ONLY when the user provides specific trading parameters (percentages, tokens, conditions, etc.)
|
||||||
|
- IMPORTANT: When a token is mentioned, set "token_address": null and ask user to confirm the token address before saving. Your response should say something like: "I need to confirm the token address. Could you provide the contract address for [TOKEN]?"
|
||||||
|
- If no strategy parameters are provided, set "strategy_update" to null
|
||||||
|
- Be friendly, concise, and helpful in your response
|
||||||
|
|
||||||
|
Example 1 (no strategy update):
|
||||||
|
User: "What can this bot do?"
|
||||||
|
{
|
||||||
|
"thinking": "The user is asking about the bot's capabilities. I should explain the main features.",
|
||||||
|
"response": "Randebu is your AI trading assistant! It can monitor cryptocurrency prices and execute trades based on your configured strategies. Tell me your trading parameters and I'll set them up for you.",
|
||||||
|
"strategy_update": null
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 2 (token needs confirmation):
|
||||||
|
User: "I want to buy PEPE when it drops 10%"
|
||||||
|
{
|
||||||
|
"thinking": "User wants to buy PEPE. I need the token contract address to proceed. I should ask for confirmation.",
|
||||||
|
"response": "I'd be happy to set up a buy order for PEPE! However, I need to confirm the token contract address. Could you provide the BSC contract address for PEPE? (It usually starts with 0x...)",
|
||||||
|
"strategy_update": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "PEPE", "token_address": null, "threshold": 10}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 3 (with token address provided by user):
|
||||||
|
User: "Buy 0x6982508145454Ce125dDE157d8d64a26D53f60a2 when it drops 10%"
|
||||||
|
{
|
||||||
|
"thinking": "User provided a contract address, I can use it directly.",
|
||||||
|
"response": "Perfect! I've configured your strategy to buy the token when it drops 10%.",
|
||||||
|
"strategy_update": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TOKEN", "token_address": "0x6982508145454Ce125dDE157d8d64a26D53f60a2", "threshold": 10}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": null
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
# Tool definitions for the agent
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_tokens",
|
||||||
|
"description": "Search for trending tokens on BSC blockchain. Use this when user asks for token recommendations, trending tokens, or wants to discover new tokens to trade. ALWAYS uses BSC chain.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of tokens to return (default: 10)",
|
||||||
|
"default": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run_backtest",
|
||||||
|
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically. Returns key metrics like ROI, win rate, max drawdown, etc. Use this when user asks to backtest, test strategy, or check historical performance.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token_address": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The BSC contract address of the token to backtest (required)"
|
||||||
|
},
|
||||||
|
"timeframe": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Timeframe for klines: '1d' (1 day), '4h' (4 hours), '1h' (1 hour), '15m' (15 minutes)",
|
||||||
|
"default": "1d"
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Start date for backtest in YYYY-MM-DD format (e.g., '2024-01-01')"
|
||||||
|
},
|
||||||
|
"end_date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "End date for backtest in YYYY-MM-DD format (e.g., '2024-12-01')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["token_address"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "manage_simulation",
|
||||||
|
"description": "Manage trading simulations: start, stop, or check status. Simulations run on real-time klines and show live portfolio updates. Use when user asks to run simulation, check simulation status, or stop simulation.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["start", "stop", "status", "results"],
|
||||||
|
"description": "Action to perform: 'start' (begin new simulation), 'stop' (stop running simulation), 'status' (check if simulation is running), 'results' (get results from current or latest simulation)"
|
||||||
|
},
|
||||||
|
"token_address": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Token contract address for simulation (required for 'start' action)"
|
||||||
|
},
|
||||||
|
"kline_interval": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Kline interval: '1m', '5m', '15m', '1h' (default: '1m')",
|
||||||
|
"default": "1m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_WITH_TOOLS = SYSTEM_PROMPT + """
|
||||||
|
|
||||||
|
You have access to tools:
|
||||||
|
- search_tokens(chain, limit): Search for trending tokens on a blockchain. Use it when user asks for token recommendations or trending tokens.
|
||||||
|
- run_backtest(token_address, timeframe, start_date, end_date): Run a backtest on historical data. Returns performance metrics. Use when user asks to backtest or check historical performance.
|
||||||
|
- manage_simulation(action, token_address, kline_interval): Manage trading simulations. Actions: 'start' (begin new), 'stop' (stop running), 'status' (check if running), 'results' (get current/latest results).
|
||||||
|
|
||||||
|
When you want to use a tool, respond with:
|
||||||
|
{
|
||||||
|
"thinking": "...",
|
||||||
|
"response": "Running backtest...",
|
||||||
|
"tool_call": {"name": "run_backtest", "arguments": {"token_address": "0x...", "timeframe": "1d", "start_date": "2024-01-01", "end_date": "2024-12-01"}}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationalAgent:
|
||||||
|
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", bot_id: str = None):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.bot_id = bot_id
|
||||||
|
|
||||||
|
# Extended thinking endpoint
|
||||||
|
self.thinking_endpoint = "https://api.minimax.io/v1/text/chatcompletion_v2"
|
||||||
|
|
||||||
|
def chat(self, user_message: str, conversation_history: List[Dict] = None) -> Dict[str, Any]:
|
||||||
|
"""Process a user message and return a structured response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_message: The user's message
|
||||||
|
conversation_history: Optional list of previous messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'response', 'thinking', and 'strategy_updated'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build messages array with system prompt and conversation history
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT_WITH_TOOLS}]
|
||||||
|
|
||||||
|
# Add conversation history (last 10 messages)
|
||||||
|
if conversation_history:
|
||||||
|
for msg in conversation_history[-10:]:
|
||||||
|
role = "assistant" if msg.get("role") == "assistant" else "user"
|
||||||
|
messages.append({"role": role, "content": msg.get("content", "")})
|
||||||
|
|
||||||
|
# Add current user message
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
# Make API call to extended thinking endpoint
|
||||||
|
resp = requests.post(
|
||||||
|
self.thinking_endpoint,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"thinking": {
|
||||||
|
"type": "human",
|
||||||
|
"budget_tokens": 1500
|
||||||
|
},
|
||||||
|
"tools": TOOLS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
# Extract thinking from reasoning_content
|
||||||
|
thinking = None
|
||||||
|
if "choices" in result and len(result["choices"]) > 0:
|
||||||
|
choice = result["choices"][0]
|
||||||
|
if "message" in choice:
|
||||||
|
message = choice["message"]
|
||||||
|
thinking = message.get("reasoning_content")
|
||||||
|
|
||||||
|
# Check for native function calls (tool_calls)
|
||||||
|
tool_calls = message.get("tool_calls", [])
|
||||||
|
if tool_calls:
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
func = tool_call.get("function", {})
|
||||||
|
func_name = func.get("name", "")
|
||||||
|
args = json.loads(func.get("arguments", "{}"))
|
||||||
|
|
||||||
|
if func_name == "search_tokens":
|
||||||
|
chain = "bsc" # Always BSC
|
||||||
|
limit = args.get("limit", 10)
|
||||||
|
|
||||||
|
# Execute the tool
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
from ...core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
ave_client = AveCloudClient(
|
||||||
|
api_key=settings.AVE_API_KEY,
|
||||||
|
plan=settings.AVE_API_PLAN
|
||||||
|
)
|
||||||
|
import asyncio
|
||||||
|
tokens = asyncio.run(ave_client.get_tokens(chain=chain, limit=limit))
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
# Format tokens for response
|
||||||
|
token_list = ""
|
||||||
|
for t in tokens[:limit]:
|
||||||
|
addr = t.get("token", "")
|
||||||
|
symbol = t.get("symbol", "")
|
||||||
|
name = t.get("name", "")
|
||||||
|
price_change = t.get("token_price_change_24h", "N/A")
|
||||||
|
token_list += f"- **{symbol}** ({name}): `{addr}` - 24h change: {price_change}%\n"
|
||||||
|
|
||||||
|
response_text = f"Here are the trending tokens on {chain.upper()}:\n\n{token_list}\nWould you like me to set up a strategy for any of these?"
|
||||||
|
else:
|
||||||
|
response_text = f"I couldn't find any trending tokens on {chain.upper()}. Try again later."
|
||||||
|
|
||||||
|
# Return the tool result directly
|
||||||
|
return {
|
||||||
|
"response": response_text,
|
||||||
|
"thinking": thinking,
|
||||||
|
"strategy_updated": False,
|
||||||
|
"strategy_needs_confirmation": False,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
elif func_name == "run_backtest":
|
||||||
|
token_address = args.get("token_address")
|
||||||
|
timeframe = args.get("timeframe", "1d")
|
||||||
|
start_date = args.get("start_date")
|
||||||
|
end_date = args.get("end_date")
|
||||||
|
|
||||||
|
# Execute backtest
|
||||||
|
backtest_result = self._execute_backtest(
|
||||||
|
token_address=token_address,
|
||||||
|
timeframe=timeframe,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": backtest_result,
|
||||||
|
"thinking": thinking,
|
||||||
|
"strategy_updated": False,
|
||||||
|
"strategy_needs_confirmation": False,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
elif func_name == "manage_simulation":
|
||||||
|
action = args.get("action")
|
||||||
|
token_address = args.get("token_address")
|
||||||
|
kline_interval = args.get("kline_interval", "1m")
|
||||||
|
|
||||||
|
# Execute simulation management
|
||||||
|
sim_result = self._manage_simulation(
|
||||||
|
action=action,
|
||||||
|
token_address=token_address,
|
||||||
|
kline_interval=kline_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": sim_result,
|
||||||
|
"thinking": thinking,
|
||||||
|
"strategy_updated": False,
|
||||||
|
"strategy_needs_confirmation": False,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the main response content
|
||||||
|
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
# Parse JSON from the content
|
||||||
|
thinking_field = None
|
||||||
|
response_text = content
|
||||||
|
strategy_update = None
|
||||||
|
|
||||||
|
# Try to extract JSON from the content
|
||||||
|
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group(1)
|
||||||
|
else:
|
||||||
|
# Try to find JSON object directly
|
||||||
|
json_match = re.search(r'\{.*\}', content, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group(0)
|
||||||
|
else:
|
||||||
|
json_str = None
|
||||||
|
|
||||||
|
if json_str:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
thinking_field = parsed.get("thinking", "")
|
||||||
|
response_text = parsed.get("response", content)
|
||||||
|
strategy_update = parsed.get("strategy_update")
|
||||||
|
|
||||||
|
# Handle tool call
|
||||||
|
tool_call = parsed.get("tool_call")
|
||||||
|
if tool_call and tool_call.get("name") == "search_tokens":
|
||||||
|
args = tool_call.get("arguments", {})
|
||||||
|
chain = args.get("chain", "bsc")
|
||||||
|
limit = args.get("limit", 10)
|
||||||
|
|
||||||
|
# Execute the tool
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
from ...core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
ave_client = AveCloudClient(
|
||||||
|
api_key=settings.AVE_API_KEY,
|
||||||
|
plan=settings.AVE_API_PLAN
|
||||||
|
)
|
||||||
|
import asyncio
|
||||||
|
tokens = asyncio.run(ave_client.get_tokens(chain=chain, limit=limit))
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
# Format tokens for response
|
||||||
|
token_list = ""
|
||||||
|
for t in tokens[:limit]:
|
||||||
|
addr = t.get("token", "")
|
||||||
|
symbol = t.get("symbol", "")
|
||||||
|
name = t.get("name", "")
|
||||||
|
price_change = t.get("token_price_change_24h", "N/A")
|
||||||
|
token_list += f"- **{symbol}** ({name}): `{addr}` - 24h change: {price_change}%\n"
|
||||||
|
|
||||||
|
response_text = f"Here are the trending tokens on {chain.upper()}:\n\n{token_list}\nWould you like me to set up a strategy for any of these?"
|
||||||
|
else:
|
||||||
|
response_text = f"I couldn't find any trending tokens on {chain.upper()}. Try again later."
|
||||||
|
|
||||||
|
strategy_update = None
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # Use defaults
|
||||||
|
|
||||||
|
# Use the native thinking from API if available, otherwise use parsed thinking
|
||||||
|
final_thinking = thinking or thinking_field
|
||||||
|
|
||||||
|
# Check if token_address is missing in strategy_update
|
||||||
|
strategy_needs_confirmation = False
|
||||||
|
token_search_results = None
|
||||||
|
|
||||||
|
if strategy_update:
|
||||||
|
# Extract token name from conditions
|
||||||
|
token_name = None
|
||||||
|
for cond in strategy_update.get("conditions", []):
|
||||||
|
if not cond.get("token_address") and cond.get("token"):
|
||||||
|
token_name = cond.get("token")
|
||||||
|
strategy_needs_confirmation = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Search for token if name is found
|
||||||
|
if strategy_needs_confirmation and token_name:
|
||||||
|
try:
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
from ...core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
ave_client = AveCloudClient(
|
||||||
|
api_key=settings.AVE_API_KEY,
|
||||||
|
plan=settings.AVE_API_PLAN
|
||||||
|
)
|
||||||
|
# Run async search in sync context
|
||||||
|
import asyncio
|
||||||
|
tokens = asyncio.run(ave_client.get_tokens(query=token_name, chain="bsc", limit=5))
|
||||||
|
if tokens:
|
||||||
|
token_search_results = [
|
||||||
|
{
|
||||||
|
"symbol": t.get("symbol", ""),
|
||||||
|
"name": t.get("name", ""),
|
||||||
|
"address": t.get("token", ""), # trending API uses "token" for contract address
|
||||||
|
"chain": t.get("chain", "bsc")
|
||||||
|
}
|
||||||
|
for t in tokens
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Token search error: {e}")
|
||||||
|
|
||||||
|
# Only update strategy if token_address is provided
|
||||||
|
if strategy_update and strategy_needs_confirmation:
|
||||||
|
# Don't auto-save - user needs to confirm token address
|
||||||
|
# Return response but with strategy_update as None
|
||||||
|
return {
|
||||||
|
"response": response_text,
|
||||||
|
"thinking": final_thinking,
|
||||||
|
"strategy_updated": False,
|
||||||
|
"strategy_needs_confirmation": True,
|
||||||
|
"strategy_data": strategy_update,
|
||||||
|
"token_search_results": token_search_results,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update strategy in database if provided
|
||||||
|
if strategy_update and self.bot_id:
|
||||||
|
self._update_strategy(strategy_update)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": response_text,
|
||||||
|
"thinking": final_thinking,
|
||||||
|
"strategy_updated": strategy_update is not None,
|
||||||
|
"strategy_needs_confirmation": False,
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"response": f"I encountered an error: {str(e)}. Please try again.",
|
||||||
|
"thinking": None,
|
||||||
|
"strategy_updated": False,
|
||||||
|
"success": False
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_backtest(
|
||||||
|
self,
|
||||||
|
token_address: str,
|
||||||
|
timeframe: str = "1d",
|
||||||
|
start_date: str = None,
|
||||||
|
end_date: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Execute a backtest using the bot's current strategy."""
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
from ...core.database import get_db
|
||||||
|
from ...db.models import Backtest
|
||||||
|
from ...services.backtest.engine import BacktestEngine
|
||||||
|
from ...core.config import get_settings
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
# Get the bot
|
||||||
|
bot = db.query(Bot).filter(Bot.id == self.bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
return "I couldn't find the bot. Please try again."
|
||||||
|
|
||||||
|
# Default dates if not provided (last 30 days)
|
||||||
|
if not end_date:
|
||||||
|
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
if not start_date:
|
||||||
|
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Create backtest engine
|
||||||
|
backtest_config = {
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"token": token_address,
|
||||||
|
"chain": "bsc",
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"strategy_config": bot.strategy_config,
|
||||||
|
"ave_api_key": settings.AVE_API_KEY,
|
||||||
|
"ave_api_plan": settings.AVE_API_PLAN,
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = BacktestEngine(backtest_config)
|
||||||
|
results = asyncio.run(engine.run())
|
||||||
|
|
||||||
|
# Format results for display
|
||||||
|
if "error" in results:
|
||||||
|
return f"Backtest failed: {results['error']}"
|
||||||
|
|
||||||
|
total_return = results.get("total_return", 0)
|
||||||
|
win_rate = results.get("win_rate", 0)
|
||||||
|
total_trades = results.get("total_trades", 0)
|
||||||
|
max_drawdown = results.get("max_drawdown", 0)
|
||||||
|
sharpe_ratio = results.get("sharpe_ratio", 0)
|
||||||
|
final_balance = results.get("final_balance", 10000)
|
||||||
|
|
||||||
|
# Format return with emoji indicators
|
||||||
|
return_emoji = "📈" if total_return >= 0 else "📉"
|
||||||
|
return_str = f"+{total_return:.2f}%" if total_return >= 0 else f"{total_return:.2f}%"
|
||||||
|
|
||||||
|
drawdown_emoji = "⚠️" if abs(max_drawdown) > 10 else "✅"
|
||||||
|
|
||||||
|
response = f"""Here's the backtest result for {token_address}:
|
||||||
|
|
||||||
|
**Performance Summary**
|
||||||
|
{return_emoji} Total Return: {return_str}
|
||||||
|
💰 Final Balance: ${final_balance:,.2f}
|
||||||
|
📊 Total Trades: {total_trades}
|
||||||
|
🎯 Win Rate: {win_rate:.1f}%
|
||||||
|
|
||||||
|
**Risk Metrics**
|
||||||
|
{drawdown_emoji} Max Drawdown: {max_drawdown:.2f}%
|
||||||
|
📉 Sharpe Ratio: {sharpe_ratio:.2f}
|
||||||
|
|
||||||
|
**Period**: {start_date} to {end_date} ({timeframe})
|
||||||
|
|
||||||
|
Would you like me to adjust the strategy parameters based on these results?"""
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"I encountered an error running the backtest: {str(e)}"
|
||||||
|
|
||||||
|
def _manage_simulation(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
token_address: str = None,
|
||||||
|
kline_interval: str = "1m"
|
||||||
|
) -> str:
|
||||||
|
"""Manage trading simulations: start, stop, status, or results."""
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from ...core.database import SessionLocal
|
||||||
|
from ...services.simulate.engine import SimulateEngine
|
||||||
|
from ...core.config import get_settings
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the bot
|
||||||
|
bot = db.query(Bot).filter(Bot.id == self.bot_id).first()
|
||||||
|
if not bot:
|
||||||
|
return "I couldn't find the bot. Please try again."
|
||||||
|
|
||||||
|
if action == "start":
|
||||||
|
if not token_address:
|
||||||
|
return "I need a token address to start a simulation. Which token would you like to simulate?"
|
||||||
|
|
||||||
|
# Check if there's already a running simulation
|
||||||
|
running_sim = db.query(Simulation).filter(
|
||||||
|
Simulation.bot_id == self.bot_id,
|
||||||
|
Simulation.status == "running"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if running_sim:
|
||||||
|
# Stop the existing one first
|
||||||
|
self._stop_simulation_db(running_sim.id)
|
||||||
|
|
||||||
|
# Create new simulation
|
||||||
|
sim_id = str(uuid.uuid4())
|
||||||
|
simulation = Simulation(
|
||||||
|
id=sim_id,
|
||||||
|
bot_id=self.bot_id,
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
status="running",
|
||||||
|
config={
|
||||||
|
"token": token_address,
|
||||||
|
"chain": "bsc",
|
||||||
|
"kline_interval": kline_interval
|
||||||
|
},
|
||||||
|
signals=[],
|
||||||
|
klines=[],
|
||||||
|
trade_log=[]
|
||||||
|
)
|
||||||
|
db.add(simulation)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Start the simulation in background
|
||||||
|
sim_config = {
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"token": token_address,
|
||||||
|
"chain": "bsc",
|
||||||
|
"kline_interval": kline_interval,
|
||||||
|
"max_candles": 100,
|
||||||
|
"candle_delay": 30 if kline_interval == "1m" else 60,
|
||||||
|
"strategy_config": bot.strategy_config,
|
||||||
|
"ave_api_key": settings.AVE_API_KEY,
|
||||||
|
"ave_api_plan": settings.AVE_API_PLAN,
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run simulation in background thread
|
||||||
|
def run_sim():
|
||||||
|
asyncio.run(self._run_simulation_sync(sim_id, settings.DATABASE_URL, sim_config))
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run_sim)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return f"Started simulation on {token_address} using {kline_interval} klines. The simulation is running and will process up to 100 candles. Ask me for status or results anytime!"
|
||||||
|
|
||||||
|
elif action == "stop":
|
||||||
|
# Find running simulation
|
||||||
|
running_sim = db.query(Simulation).filter(
|
||||||
|
Simulation.bot_id == self.bot_id,
|
||||||
|
Simulation.status == "running"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not running_sim:
|
||||||
|
return "No simulation is currently running."
|
||||||
|
|
||||||
|
self._stop_simulation_db(running_sim.id)
|
||||||
|
|
||||||
|
# Get final results
|
||||||
|
portfolio = running_sim.portfolio or {}
|
||||||
|
current_balance = portfolio.get("current_balance", 10000)
|
||||||
|
initial_balance = portfolio.get("initial_balance", 10000)
|
||||||
|
pnl = current_balance - initial_balance
|
||||||
|
pnl_pct = (pnl / initial_balance) * 100 if initial_balance > 0 else 0
|
||||||
|
|
||||||
|
return f"Simulation stopped!\n\nFinal Results:\n💰 Final Balance: ${current_balance:,.2f}\n📈 P&L: {'+' if pnl >= 0 else ''}${pnl:,.2f} ({'+' if pnl_pct >= 0 else ''}{pnl_pct:.2f}%)\n📊 Trades: {len(running_sim.trade_log or [])}"
|
||||||
|
|
||||||
|
elif action == "status":
|
||||||
|
# Find running simulation
|
||||||
|
running_sim = db.query(Simulation).filter(
|
||||||
|
Simulation.bot_id == self.bot_id,
|
||||||
|
Simulation.status == "running"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not running_sim:
|
||||||
|
return "No simulation is currently running."
|
||||||
|
|
||||||
|
portfolio = running_sim.portfolio or {}
|
||||||
|
klines_count = len(running_sim.klines or [])
|
||||||
|
trade_count = len(running_sim.trade_log or [])
|
||||||
|
|
||||||
|
status = f"**Simulation Status: Running**\n\n"
|
||||||
|
status += f"📊 Candles processed: ~{klines_count}\n"
|
||||||
|
status += f"📈 Trades executed: {trade_count}\n"
|
||||||
|
|
||||||
|
if portfolio.get("position", 0) > 0:
|
||||||
|
status += f"💰 Position: {portfolio['position']:.4f} {portfolio.get('position_token', 'TOKEN')}\n"
|
||||||
|
status += f"💰 Cash: ${portfolio.get('current_balance', 0):,.2f}\n"
|
||||||
|
else:
|
||||||
|
status += f"💰 Cash: ${portfolio.get('current_balance', 10000):,.2f}\n"
|
||||||
|
|
||||||
|
status += "\nAsk me to stop or get full results anytime!"
|
||||||
|
return status
|
||||||
|
|
||||||
|
elif action == "results":
|
||||||
|
# Find running or most recent simulation
|
||||||
|
simulation = db.query(Simulation).filter(
|
||||||
|
Simulation.bot_id == self.bot_id
|
||||||
|
).order_by(Simulation.started_at.desc()).first()
|
||||||
|
|
||||||
|
if not simulation:
|
||||||
|
return "No simulation found. Start a simulation first!"
|
||||||
|
|
||||||
|
portfolio = simulation.portfolio or {}
|
||||||
|
current_balance = portfolio.get("current_balance", 10000)
|
||||||
|
initial_balance = portfolio.get("initial_balance", 10000)
|
||||||
|
pnl = current_balance - initial_balance
|
||||||
|
pnl_pct = (pnl / initial_balance) * 100 if initial_balance > 0 else 0
|
||||||
|
trade_log = simulation.trade_log or []
|
||||||
|
|
||||||
|
status_emoji = "🟢" if simulation.status == "running" else "⚪"
|
||||||
|
status_text = "Running" if simulation.status == "running" else "Completed/Stopped"
|
||||||
|
|
||||||
|
results = f"**Simulation Results** {status_emoji} ({status_text})\n\n"
|
||||||
|
results += f"💰 Final Balance: ${current_balance:,.2f}\n"
|
||||||
|
results += f"📈 P&L: {'+' if pnl >= 0 else ''}${pnl:,.2f} ({'+' if pnl_pct >= 0 else ''}{pnl_pct:.2f}%)\n"
|
||||||
|
results += f"📊 Total Trades: {len(trade_log)}\n"
|
||||||
|
|
||||||
|
if simulation.status == "running":
|
||||||
|
results += f"\n⏳ Simulation still running... (refresh for latest)"
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
else:
|
||||||
|
return f"Unknown action: {action}. Use 'start', 'stop', 'status', or 'results'."
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"I encountered an error managing the simulation: {str(e)}"
|
||||||
|
|
||||||
|
def _stop_simulation_db(self, simulation_id: str):
|
||||||
|
"""Stop a simulation in the database."""
|
||||||
|
from ...core.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
simulation = db.query(Simulation).filter(Simulation.id == simulation_id).first()
|
||||||
|
if simulation:
|
||||||
|
simulation.status = "stopped"
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def _run_simulation_sync(self, simulation_id: str, db_url: str, config: dict):
|
||||||
|
"""Run simulation synchronously in background."""
|
||||||
|
from ...services.simulate.engine import SimulateEngine
|
||||||
|
from ...core.database import SessionLocal
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
engine = SimulateEngine(config)
|
||||||
|
engine.run_id = simulation_id
|
||||||
|
|
||||||
|
def serialize_signal(s):
|
||||||
|
created = s.get("created_at")
|
||||||
|
if hasattr(created, "isoformat"):
|
||||||
|
created = created.isoformat()
|
||||||
|
return {**s, "created_at": created}
|
||||||
|
|
||||||
|
def save_progress():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
sim = db.query(Simulation).filter(Simulation.id == simulation_id).first()
|
||||||
|
if sim:
|
||||||
|
sim.status = engine.status
|
||||||
|
sim.signals = [serialize_signal(s) for s in engine.signals]
|
||||||
|
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
|
||||||
|
sim.trade_log = engine.trade_log
|
||||||
|
sim.portfolio = {
|
||||||
|
"initial_balance": config.get("initial_balance", 10000),
|
||||||
|
"current_balance": engine.current_balance,
|
||||||
|
"position": engine.position,
|
||||||
|
"position_token": engine.position_token,
|
||||||
|
"entry_price": engine.entry_price,
|
||||||
|
"current_price": engine.last_close,
|
||||||
|
}
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await engine.run()
|
||||||
|
finally:
|
||||||
|
save_progress()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
def _update_strategy(self, strategy_update: Dict) -> bool:
|
||||||
|
"""Update the bot's strategy in the database."""
|
||||||
|
try:
|
||||||
|
from ...core.database import get_db
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
bot = db.query(Bot).filter(Bot.id == self.bot_id).first()
|
||||||
|
if bot:
|
||||||
|
bot.strategy_config = strategy_update
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating strategy: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversational_agent(api_key: str = None, model: str = None, bot_id: str = None) -> ConversationalAgent:
|
||||||
|
"""Get or create a ConversationalAgent instance."""
|
||||||
|
if api_key is None:
|
||||||
|
settings = get_settings()
|
||||||
|
api_key = settings.MINIMAX_API_KEY
|
||||||
|
if model is None:
|
||||||
|
settings = get_settings()
|
||||||
|
model = settings.MINIMAX_MODEL
|
||||||
|
|
||||||
|
return ConversationalAgent(api_key=api_key, model=model, bot_id=bot_id)
|
||||||
@@ -1,15 +1,241 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional, Dict, Any
|
||||||
|
from crewai import Agent, Task, Crew, LLM
|
||||||
|
from .llm_connector import MiniMaxConnector
|
||||||
|
from ...core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
class CrewAgent:
|
class StrategyValidator:
|
||||||
def __init__(self, role: str, goal: str, backstory: str):
|
SUPPORTED_CONDITIONS = ["price_drop", "price_rise", "volume_spike", "price_level"]
|
||||||
self.role = role
|
SUPPORTED_ACTIONS = ["buy", "sell", "notify"]
|
||||||
self.goal = goal
|
|
||||||
self.backstory = backstory
|
|
||||||
|
|
||||||
def execute_task(self, task: str) -> str:
|
def validate(self, strategy_config: dict) -> tuple[bool, list[str]]:
|
||||||
raise NotImplementedError("CrewAI agent not yet implemented")
|
errors = []
|
||||||
|
|
||||||
|
if "conditions" not in strategy_config:
|
||||||
|
errors.append("Missing 'conditions' in strategy config")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
if not isinstance(strategy_config["conditions"], list):
|
||||||
|
errors.append("'conditions' must be a list")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
if len(strategy_config["conditions"]) == 0:
|
||||||
|
errors.append("At least one condition is required")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
for i, condition in enumerate(strategy_config["conditions"]):
|
||||||
|
if "type" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'type'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cond_type = condition.get("type")
|
||||||
|
if cond_type not in self.SUPPORTED_CONDITIONS:
|
||||||
|
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
|
||||||
|
if "token" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'token'")
|
||||||
|
if "threshold" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'threshold'")
|
||||||
|
elif not isinstance(condition["threshold"], (int, float)):
|
||||||
|
errors.append(f"Condition {i}: 'threshold' must be a number")
|
||||||
|
elif condition["threshold"] <= 0:
|
||||||
|
errors.append(f"Condition {i}: 'threshold' must be positive")
|
||||||
|
|
||||||
|
elif cond_type == "price_level":
|
||||||
|
if "token" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'token'")
|
||||||
|
if "price" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'price'")
|
||||||
|
if "direction" not in condition:
|
||||||
|
errors.append(f"Condition {i}: missing 'direction'")
|
||||||
|
elif condition["direction"] not in ["above", "below"]:
|
||||||
|
errors.append(
|
||||||
|
f"Condition {i}: direction must be 'above' or 'below'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "actions" in strategy_config:
|
||||||
|
if not isinstance(strategy_config["actions"], list):
|
||||||
|
errors.append("'actions' must be a list")
|
||||||
|
else:
|
||||||
|
for i, action in enumerate(strategy_config["actions"]):
|
||||||
|
if "type" not in action:
|
||||||
|
errors.append(f"Action {i}: missing 'type'")
|
||||||
|
elif action["type"] not in self.SUPPORTED_ACTIONS:
|
||||||
|
errors.append(
|
||||||
|
f"Action {i}: unsupported type '{action['type']}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
|
||||||
|
|
||||||
def get_trading_crew():
|
class StrategyExplainer:
|
||||||
raise NotImplementedError("Trading crew not yet implemented")
|
def explain(self, strategy_config: dict) -> str:
|
||||||
|
explanations = []
|
||||||
|
|
||||||
|
if "conditions" in strategy_config:
|
||||||
|
cond_list = strategy_config["conditions"]
|
||||||
|
if cond_list:
|
||||||
|
explanations.append("This strategy will trigger when:")
|
||||||
|
for cond in cond_list:
|
||||||
|
cond_type = cond.get("type")
|
||||||
|
token = cond.get("token", "the token")
|
||||||
|
|
||||||
|
if cond_type == "price_drop":
|
||||||
|
pct = cond.get("threshold", 0)
|
||||||
|
explanations.append(f" - {token} price drops by {pct}%")
|
||||||
|
elif cond_type == "price_rise":
|
||||||
|
pct = cond.get("threshold", 0)
|
||||||
|
explanations.append(f" - {token} price rises by {pct}%")
|
||||||
|
elif cond_type == "volume_spike":
|
||||||
|
pct = cond.get("threshold", 0)
|
||||||
|
explanations.append(
|
||||||
|
f" - {token} trading volume increases by {pct}%"
|
||||||
|
)
|
||||||
|
elif cond_type == "price_level":
|
||||||
|
price = cond.get("price", 0)
|
||||||
|
direction = cond.get("direction", "unknown")
|
||||||
|
explanations.append(
|
||||||
|
f" - {token} price crosses {direction} ${price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "actions" in strategy_config:
|
||||||
|
actions = strategy_config.get("actions", [])
|
||||||
|
if actions:
|
||||||
|
explanations.append("\nWhen triggered, the strategy will:")
|
||||||
|
for action in actions:
|
||||||
|
action_type = action.get("type")
|
||||||
|
if action_type == "buy":
|
||||||
|
explanations.append(" - Buy the token")
|
||||||
|
elif action_type == "sell":
|
||||||
|
explanations.append(" - Sell the token")
|
||||||
|
elif action_type == "notify":
|
||||||
|
explanations.append(" - Send a notification")
|
||||||
|
|
||||||
|
if not explanations:
|
||||||
|
explanations.append("Strategy configuration is empty or invalid.")
|
||||||
|
|
||||||
|
return "\n".join(explanations)
|
||||||
|
|
||||||
|
|
||||||
|
def create_trading_designer_agent(
|
||||||
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
|
) -> Agent:
|
||||||
|
connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||||
|
|
||||||
|
system_prompt = """You are a Trading Strategy Designer AI. Your role is to parse user requests
|
||||||
|
for trading strategies into structured JSON configuration.
|
||||||
|
|
||||||
|
Supported conditions (MVP):
|
||||||
|
- price_drop: Triggers when a token's price drops by a specified percentage
|
||||||
|
- price_rise: Triggers when a token's price rises by a specified percentage
|
||||||
|
- volume_spike: Triggers when trading volume increases by a specified percentage
|
||||||
|
- price_level: Triggers when price crosses above or below a specified level
|
||||||
|
|
||||||
|
Always ask clarifying questions if the user's request is ambiguous.
|
||||||
|
Output strategy_config in valid JSON format only when you have all required information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Agent(
|
||||||
|
role="Trading Strategy Designer",
|
||||||
|
goal="Convert natural language trading requests into precise strategy configurations",
|
||||||
|
backstory=system_prompt,
|
||||||
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_strategy_validator_agent(
|
||||||
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
|
) -> Agent:
|
||||||
|
return Agent(
|
||||||
|
role="Strategy Validator",
|
||||||
|
goal="Validate trading strategy configurations for feasibility and identify potential issues",
|
||||||
|
backstory="""You are a meticulous strategy validator with expertise in trading systems.
|
||||||
|
You check that all required parameters are present, values are reasonable, and the
|
||||||
|
strategy makes logical sense. You never approve strategies with missing or invalid data.""",
|
||||||
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_strategy_explainer_agent(
|
||||||
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
|
) -> Agent:
|
||||||
|
return Agent(
|
||||||
|
role="Strategy Explainer",
|
||||||
|
goal="Generate clear, user-friendly explanations of trading strategies",
|
||||||
|
backstory="""You are a patient trading strategy explainer. You translate complex
|
||||||
|
strategy configurations into easy-to-understand language. You help users understand
|
||||||
|
exactly what their strategies will do when triggered.""",
|
||||||
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradingCrew:
|
||||||
|
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.validator = StrategyValidator()
|
||||||
|
self.explainer = StrategyExplainer()
|
||||||
|
self.connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||||
|
|
||||||
|
def parse_strategy(
|
||||||
|
self, user_message: str, conversation_history: list[dict] = None
|
||||||
|
) -> dict:
|
||||||
|
strategy_config = self.connector.parse_strategy(
|
||||||
|
user_message, conversation_history
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in strategy_config:
|
||||||
|
return strategy_config
|
||||||
|
|
||||||
|
is_valid, errors = self.validator.validate(strategy_config)
|
||||||
|
if not is_valid:
|
||||||
|
return {
|
||||||
|
"error": "Strategy validation failed",
|
||||||
|
"validation_errors": errors,
|
||||||
|
"partial_config": strategy_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
return strategy_config
|
||||||
|
|
||||||
|
def explain_strategy(self, strategy_config: dict) -> str:
|
||||||
|
return self.explainer.explain(strategy_config)
|
||||||
|
|
||||||
|
def chat(self, user_message: str, conversation_history: list[dict] = None) -> dict:
|
||||||
|
strategy_config = self.parse_strategy(user_message, conversation_history)
|
||||||
|
|
||||||
|
if "error" in strategy_config:
|
||||||
|
explanation = f"I had trouble understanding your strategy: {strategy_config.get('error', 'Unknown error')}"
|
||||||
|
if "validation_errors" in strategy_config:
|
||||||
|
explanation += "\n\nValidation issues:"
|
||||||
|
for err in strategy_config["validation_errors"]:
|
||||||
|
explanation += f"\n - {err}"
|
||||||
|
return {
|
||||||
|
"response": explanation,
|
||||||
|
"strategy_config": strategy_config.get("partial_config"),
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
explanation = self.explain_strategy(strategy_config)
|
||||||
|
return {
|
||||||
|
"response": f"I've configured your strategy:\n\n{explanation}",
|
||||||
|
"strategy_config": strategy_config,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_trading_crew(
|
||||||
|
api_key: Optional[str] = None, model: Optional[str] = None
|
||||||
|
) -> TradingCrew:
|
||||||
|
if api_key is None:
|
||||||
|
settings = get_settings()
|
||||||
|
api_key = settings.MINIMAX_API_KEY
|
||||||
|
if model is None:
|
||||||
|
settings = get_settings()
|
||||||
|
model = settings.MINIMAX_MODEL
|
||||||
|
|
||||||
|
return TradingCrew(api_key=api_key, model=model)
|
||||||
|
|||||||
@@ -1,13 +1,105 @@
|
|||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
class LLMConnector:
|
class MiniMaxLLM:
|
||||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.base_url = "https://api.minimax.io/v1"
|
||||||
|
|
||||||
|
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": kwargs.get("temperature", 0.7),
|
||||||
|
"max_tokens": kwargs.get("max_tokens", 2048),
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"{self.base_url}/text/chatcompletion_v2",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
def call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||||
|
return self._call(messages, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniMaxConnector:
|
||||||
|
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
def chat(self, messages: list[dict], **kwargs):
|
def chat(self, messages: list[dict], **kwargs) -> str:
|
||||||
raise NotImplementedError("LLM integration not yet implemented")
|
formatted_messages = []
|
||||||
|
for msg in messages:
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
formatted_messages.append(
|
||||||
|
{
|
||||||
|
"role": msg.get("role", "user"),
|
||||||
|
"content": msg.get("content", str(msg)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
formatted_messages.append({"role": "user", "content": str(msg)})
|
||||||
|
|
||||||
def parse_strategy(self, user_message: str) -> dict:
|
llm = MiniMaxLLM(api_key=self.api_key, model=self.model)
|
||||||
raise NotImplementedError("Strategy parsing not yet implemented")
|
return llm.call(formatted_messages, **kwargs)
|
||||||
|
|
||||||
|
def parse_strategy(
|
||||||
|
self, user_message: str, conversation_history: list[dict] = None
|
||||||
|
) -> dict:
|
||||||
|
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
|
||||||
|
|
||||||
|
Supported conditions (MVP):
|
||||||
|
- price_drop: Token price drops by X% (requires: token, threshold)
|
||||||
|
- price_rise: Token price rises by X% (requires: token, threshold)
|
||||||
|
- volume_spike: Trading volume increases X% (requires: token, threshold)
|
||||||
|
- price_level: Price crosses above/below X (requires: token, price, direction)
|
||||||
|
|
||||||
|
Output ONLY valid JSON with this schema:
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_drop|price_rise|volume_spike|price_level",
|
||||||
|
"token": "TOKEN_SYMBOL",
|
||||||
|
"chain": "bsc",
|
||||||
|
"threshold": number, // for price_drop, price_rise, volume_spike
|
||||||
|
"price": number, // for price_level
|
||||||
|
"direction": "above|below", // for price_level
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy|sell|notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the user wants a condition not in the supported list, ask for clarification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
if conversation_history:
|
||||||
|
for msg in conversation_history:
|
||||||
|
messages.append(
|
||||||
|
{"role": msg.get("role", "user"), "content": msg.get("content", "")}
|
||||||
|
)
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
response = self.chat(messages)
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
result = json.loads(response)
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"error": "Failed to parse strategy", "raw_response": response}
|
||||||
|
|||||||
3
src/backend/app/services/ave/__init__.py
Normal file
3
src/backend/app/services/ave/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .client import AveCloudClient, check_tier_access
|
||||||
|
|
||||||
|
__all__ = ["AveCloudClient", "check_tier_access"]
|
||||||
243
src/backend/app/services/ave/client.py
Normal file
243
src/backend/app/services/ave/client.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AveCloudClient:
|
||||||
|
DATA_API_URL = "https://prod.ave-api.com"
|
||||||
|
TRADING_API_URL = "https://bot-api.ave.ai"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, plan: str = "free"):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.plan = plan
|
||||||
|
|
||||||
|
def _data_headers(self) -> Dict[str, str]:
|
||||||
|
return {"X-API-KEY": self.api_key}
|
||||||
|
|
||||||
|
def _trading_headers(self) -> Dict[str, str]:
|
||||||
|
return {"X-API-KEY": self.api_key, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
async def get_tokens(
|
||||||
|
self,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
chain: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
# Use trending endpoint which supports chain filter
|
||||||
|
url = f"{self.DATA_API_URL}/v2/tokens/trending"
|
||||||
|
params = {"limit": min(limit, 100)} # API returns max 100
|
||||||
|
if chain:
|
||||||
|
params["chain"] = chain
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 1: # 1 = SUCCESS
|
||||||
|
tokens = data.get("data", {}).get("tokens", [])
|
||||||
|
# Filter by query if provided
|
||||||
|
if query:
|
||||||
|
query_lower = query.lower()
|
||||||
|
tokens = [
|
||||||
|
t for t in tokens
|
||||||
|
if query_lower in t.get("symbol", "").lower()
|
||||||
|
or query_lower in t.get("name", "").lower()
|
||||||
|
]
|
||||||
|
return tokens[:limit]
|
||||||
|
return []
|
||||||
|
raise Exception(f"Failed to fetch tokens: {data}")
|
||||||
|
|
||||||
|
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
url = f"{self.DATA_API_URL}/v2/tokens/price"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=self._data_headers(),
|
||||||
|
json={"token_ids": token_ids},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data", {})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_token_details(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
url = f"{self.DATA_API_URL}/v2/tokens/{token_id}"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_klines(
|
||||||
|
self,
|
||||||
|
token_id: str,
|
||||||
|
interval: str = "1h",
|
||||||
|
limit: int = 100,
|
||||||
|
start_time: Optional[int] = None,
|
||||||
|
end_time: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
# Token ID must be in format "{contract_address}-bsc" for the AVE API
|
||||||
|
if not token_id.endswith("-bsc") and token_id.startswith("0x"):
|
||||||
|
token_id = f"{token_id}-bsc"
|
||||||
|
|
||||||
|
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
|
||||||
|
params = {"interval": interval, "limit": limit}
|
||||||
|
if start_time:
|
||||||
|
params["start_time"] = start_time
|
||||||
|
if end_time:
|
||||||
|
params["end_time"] = end_time
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
# AVE API returns status: 1 for success, not 200
|
||||||
|
if data.get("status") == 1:
|
||||||
|
return data.get("data", {}).get("points", [])
|
||||||
|
raise Exception(f"Failed to fetch klines: {data}")
|
||||||
|
|
||||||
|
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
url = f"{self.DATA_API_URL}/v2/tokens/price"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=self._data_headers(),
|
||||||
|
json={"token_ids": [token_id]},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 1:
|
||||||
|
prices = data.get("data", {})
|
||||||
|
return prices.get(token_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_trending_tokens(
|
||||||
|
self, chain: Optional[str] = None, limit: int = 20
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
url = f"{self.DATA_API_URL}/v2/tokens/trending"
|
||||||
|
params = {"limit": limit}
|
||||||
|
if chain:
|
||||||
|
params["chain"] = chain
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data", [])
|
||||||
|
raise Exception(f"Failed to fetch trending tokens: {data}")
|
||||||
|
|
||||||
|
async def get_token_risk(self, contract_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
url = f"{self.DATA_API_URL}/v2/contracts/{contract_id}"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_chain_quote(
|
||||||
|
self,
|
||||||
|
chain: str,
|
||||||
|
from_token: str,
|
||||||
|
to_token: str,
|
||||||
|
amount: str,
|
||||||
|
slippage: float = 0.5,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
url = f"{self.TRADING_API_URL}/v1/chain/quote"
|
||||||
|
payload = {
|
||||||
|
"chain": chain,
|
||||||
|
"from_token": from_token,
|
||||||
|
"to_token": to_token,
|
||||||
|
"amount": amount,
|
||||||
|
"slippage": slippage,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url, headers=self._trading_headers(), json=payload, timeout=30.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_chain_swap(
|
||||||
|
self,
|
||||||
|
chain: str,
|
||||||
|
from_token: str,
|
||||||
|
to_token: str,
|
||||||
|
amount: str,
|
||||||
|
slippage: float = 0.5,
|
||||||
|
wallet_address: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
url = f"{self.TRADING_API_URL}/v1/chain/swap"
|
||||||
|
payload = {
|
||||||
|
"chain": chain,
|
||||||
|
"from_token": from_token,
|
||||||
|
"to_token": to_token,
|
||||||
|
"amount": amount,
|
||||||
|
"slippage": slippage,
|
||||||
|
}
|
||||||
|
if wallet_address:
|
||||||
|
payload["wallet_address"] = wallet_address
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url, headers=self._trading_headers(), json=payload, timeout=60.0
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == 200:
|
||||||
|
return data.get("data")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_tier_access(user_tier: str, feature: str) -> tuple[bool, Optional[str]]:
|
||||||
|
tier_access = {
|
||||||
|
"free": {
|
||||||
|
"data_rest": True,
|
||||||
|
"websocket": False,
|
||||||
|
"chain_wallet": True,
|
||||||
|
"proxy_wallet": False,
|
||||||
|
},
|
||||||
|
"normal": {
|
||||||
|
"data_rest": True,
|
||||||
|
"websocket": False,
|
||||||
|
"chain_wallet": True,
|
||||||
|
"proxy_wallet": True,
|
||||||
|
},
|
||||||
|
"pro": {
|
||||||
|
"data_rest": True,
|
||||||
|
"websocket": True,
|
||||||
|
"chain_wallet": True,
|
||||||
|
"proxy_wallet": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_tier not in tier_access:
|
||||||
|
user_tier = "free"
|
||||||
|
|
||||||
|
access = tier_access[user_tier]
|
||||||
|
if access.get(feature):
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
upsell_messages = {
|
||||||
|
"websocket": "Upgrade to Pro plan to access WebSocket streaming data. Visit your account settings.",
|
||||||
|
"proxy_wallet": "Upgrade to Normal or Pro plan to access Proxy Wallet functionality. Visit your account settings.",
|
||||||
|
}
|
||||||
|
|
||||||
|
return False, upsell_messages.get(
|
||||||
|
feature, "Upgrade your plan to access this feature."
|
||||||
|
)
|
||||||
@@ -1,15 +1,475 @@
|
|||||||
from typing import Optional, Dict, Any
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
|
||||||
|
|
||||||
class BacktestEngine:
|
class BacktestEngine:
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.run_id = str(uuid.uuid4())
|
||||||
|
self.status = "pending"
|
||||||
|
self.results: Optional[Dict[str, Any]] = None
|
||||||
|
self.signals: List[Dict[str, Any]] = []
|
||||||
|
self.ave_client = AveCloudClient(
|
||||||
|
api_key=config.get("ave_api_key", ""),
|
||||||
|
plan=config.get("ave_api_plan", "free"),
|
||||||
|
)
|
||||||
|
self.bot_id = config.get("bot_id")
|
||||||
|
self.strategy_config = config.get("strategy_config", {})
|
||||||
|
self.conditions = self.strategy_config.get("conditions", [])
|
||||||
|
self.actions = self.strategy_config.get("actions", [])
|
||||||
|
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||||
|
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
|
||||||
|
self.take_profit_percent = self.risk_management.get("take_profit_percent")
|
||||||
|
self.initial_balance = config.get("initial_balance", 10000.0)
|
||||||
|
self.current_balance = self.initial_balance
|
||||||
|
self.position = 0.0
|
||||||
|
self.position_token = ""
|
||||||
|
self.entry_price: Optional[float] = None
|
||||||
|
self.cost_basis = 0.0 # Track total amount spent on current position for average price calc
|
||||||
|
self.entry_time: Optional[int] = None
|
||||||
|
self.trades: List[Dict[str, Any]] = []
|
||||||
|
self.running = False
|
||||||
|
self.progress = 0
|
||||||
|
self.total_klines = 0
|
||||||
|
self.last_kline_price: Optional[float] = None # Track last price for open position valuation
|
||||||
|
|
||||||
async def run(self) -> Dict[str, Any]:
|
async def run(self) -> Dict[str, Any]:
|
||||||
raise NotImplementedError("Backtest engine not yet implemented")
|
self.running = True
|
||||||
|
self.status = "running"
|
||||||
|
started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
chain = self.config.get("chain", "bsc")
|
||||||
|
timeframe = self.config.get("timeframe", "1h")
|
||||||
|
start_date = self.config.get("start_date", "")
|
||||||
|
end_date = self.config.get("end_date", "")
|
||||||
|
|
||||||
|
# Get token address from strategy config (saved when user confirmed token)
|
||||||
|
token_address = None
|
||||||
|
token_symbol = None
|
||||||
|
|
||||||
|
# Try to get from conditions first
|
||||||
|
if self.conditions:
|
||||||
|
token_address = self.conditions[0].get("token_address")
|
||||||
|
token_symbol = self.conditions[0].get("token")
|
||||||
|
# Fallback to actions
|
||||||
|
if not token_address and self.actions:
|
||||||
|
token_address = self.actions[0].get("token_address")
|
||||||
|
token_symbol = self.actions[0].get("token") or token_symbol
|
||||||
|
|
||||||
|
if not token_address:
|
||||||
|
raise ValueError("Token address not found in strategy. Please update your strategy with a valid token.")
|
||||||
|
|
||||||
|
token_id = token_address
|
||||||
|
|
||||||
|
start_ts = None
|
||||||
|
end_ts = None
|
||||||
|
if start_date:
|
||||||
|
start_ts = int(
|
||||||
|
datetime.fromisoformat(
|
||||||
|
start_date.replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
* 1000
|
||||||
|
)
|
||||||
|
if end_date:
|
||||||
|
end_ts = int(
|
||||||
|
datetime.fromisoformat(end_date.replace("Z", "+00:00")).timestamp()
|
||||||
|
* 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
klines = await self.ave_client.get_klines(
|
||||||
|
token_id=token_id,
|
||||||
|
interval=timeframe,
|
||||||
|
limit=1000,
|
||||||
|
start_time=start_ts,
|
||||||
|
end_time=end_ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not klines:
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": "No kline data available"}
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
await self._process_klines(klines)
|
||||||
|
self._calculate_metrics()
|
||||||
|
self.status = "completed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": str(e)}
|
||||||
|
|
||||||
|
ended_at = datetime.utcnow()
|
||||||
|
self.results = self.results or {}
|
||||||
|
self.results["started_at"] = started_at
|
||||||
|
self.results["ended_at"] = ended_at
|
||||||
|
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
async def run_with_klines(self, klines: List[Dict[str, Any]]):
|
||||||
|
"""Test helper method that runs backtest with provided klines (bypasses API call)."""
|
||||||
|
self.running = True
|
||||||
|
self.status = "running"
|
||||||
|
started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not klines:
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": "No kline data available"}
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
await self._process_klines(klines)
|
||||||
|
self._calculate_metrics()
|
||||||
|
self.status = "completed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": str(e)}
|
||||||
|
|
||||||
|
ended_at = datetime.utcnow()
|
||||||
|
self.results = self.results or {}
|
||||||
|
self.results["started_at"] = started_at
|
||||||
|
self.results["ended_at"] = ended_at
|
||||||
|
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
async def _process_klines(self, klines: List[Dict[str, Any]]):
|
||||||
|
self.total_klines = len(klines)
|
||||||
|
for i, kline in enumerate(klines):
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
|
||||||
|
|
||||||
|
price = float(kline.get("close", 0))
|
||||||
|
if price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.last_kline_price = price # Track last price for open position valuation
|
||||||
|
|
||||||
|
timestamp = kline.get("timestamp", 0)
|
||||||
|
|
||||||
|
if self.position > 0 and self.entry_price is not None:
|
||||||
|
exit_info = self._check_risk_management(price, timestamp)
|
||||||
|
if exit_info:
|
||||||
|
await self._execute_risk_exit(price, timestamp, exit_info)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for condition in self.conditions:
|
||||||
|
if self._check_condition(condition, klines, i, price):
|
||||||
|
await self._execute_actions(price, timestamp, condition)
|
||||||
|
break
|
||||||
|
|
||||||
|
@property
|
||||||
|
def average_entry_price(self) -> Optional[float]:
|
||||||
|
"""Calculate weighted average entry price based on cost basis."""
|
||||||
|
if self.position <= 0 or self.cost_basis <= 0:
|
||||||
|
return None
|
||||||
|
return self.cost_basis / self.position
|
||||||
|
|
||||||
|
def _check_risk_management(
|
||||||
|
self, current_price: float, timestamp: int
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if self.position <= 0 or self.average_entry_price is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.stop_loss_percent is not None:
|
||||||
|
stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100)
|
||||||
|
if current_price <= stop_loss_price:
|
||||||
|
return {"reason": "stop_loss", "price": stop_loss_price}
|
||||||
|
|
||||||
|
if self.take_profit_percent is not None:
|
||||||
|
take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100)
|
||||||
|
# Use small epsilon to handle floating point precision
|
||||||
|
if current_price >= take_profit_price - 0.001:
|
||||||
|
return {"reason": "take_profit", "price": take_profit_price}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _execute_risk_exit(
|
||||||
|
self, price: float, timestamp: int, exit_info: Dict[str, Any]
|
||||||
|
):
|
||||||
|
if self.position <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
reason = exit_info["reason"]
|
||||||
|
sell_amount = self.position * price
|
||||||
|
self.current_balance += sell_amount
|
||||||
|
self.trades.append(
|
||||||
|
{
|
||||||
|
"type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"amount": sell_amount,
|
||||||
|
"quantity": self.position,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"exit_reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.signals.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"reasoning": f"Risk management triggered {reason}",
|
||||||
|
"executed": False,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.position = 0
|
||||||
|
self.entry_price = None
|
||||||
|
self.cost_basis = 0.0
|
||||||
|
self.entry_time = None
|
||||||
|
|
||||||
|
def _check_condition(
|
||||||
|
self,
|
||||||
|
condition: Dict[str, Any],
|
||||||
|
klines: List[Dict[str, Any]],
|
||||||
|
current_idx: int,
|
||||||
|
current_price: float,
|
||||||
|
) -> bool:
|
||||||
|
cond_type = condition.get("type", "")
|
||||||
|
threshold = condition.get("threshold", 0)
|
||||||
|
timeframe = condition.get("timeframe", "1h")
|
||||||
|
price_level = condition.get("price")
|
||||||
|
direction = condition.get("direction", "above")
|
||||||
|
|
||||||
|
if cond_type == "price_drop":
|
||||||
|
if current_idx == 0:
|
||||||
|
return False
|
||||||
|
prev_price = float(klines[current_idx - 1].get("close", 0))
|
||||||
|
if prev_price <= 0:
|
||||||
|
return False
|
||||||
|
drop_pct = ((prev_price - current_price) / prev_price) * 100
|
||||||
|
return drop_pct >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "price_rise":
|
||||||
|
if current_idx == 0:
|
||||||
|
return False
|
||||||
|
prev_price = float(klines[current_idx - 1].get("close", 0))
|
||||||
|
if prev_price <= 0:
|
||||||
|
return False
|
||||||
|
rise_pct = ((current_price - prev_price) / prev_price) * 100
|
||||||
|
return rise_pct >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "volume_spike":
|
||||||
|
if current_idx == 0:
|
||||||
|
return False
|
||||||
|
prev_volume = float(klines[current_idx - 1].get("volume", 0))
|
||||||
|
current_volume = float(kline.get("volume", 0))
|
||||||
|
if prev_volume <= 0:
|
||||||
|
return False
|
||||||
|
volume_increase = ((current_volume - prev_volume) / prev_volume) * 100
|
||||||
|
return volume_increase >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "price_level":
|
||||||
|
if price_level is None:
|
||||||
|
return False
|
||||||
|
if direction == "above":
|
||||||
|
return current_price > price_level
|
||||||
|
else:
|
||||||
|
return current_price < price_level
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _execute_actions(
|
||||||
|
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||||
|
):
|
||||||
|
token = matched_condition.get("token", self.config.get("token", ""))
|
||||||
|
|
||||||
|
for action in self.actions:
|
||||||
|
action_type = action.get("type", "")
|
||||||
|
amount_percent = action.get("amount_percent", 10)
|
||||||
|
amount = self.current_balance * (amount_percent / 100)
|
||||||
|
|
||||||
|
if action_type == "buy" and self.current_balance >= amount:
|
||||||
|
quantity = amount / price
|
||||||
|
self.position += quantity
|
||||||
|
self.current_balance -= amount
|
||||||
|
self.cost_basis += amount # Track total cost for average price
|
||||||
|
self.position_token = token
|
||||||
|
self.entry_price = price # Keep last entry price for reference
|
||||||
|
self.entry_time = timestamp
|
||||||
|
self.trades.append(
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"amount": amount,
|
||||||
|
"quantity": quantity,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.signals.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": "buy",
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 0.8,
|
||||||
|
"reasoning": f"Condition {matched_condition.get('type')} triggered buy",
|
||||||
|
"executed": False,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action_type == "sell" and self.position > 0:
|
||||||
|
sell_amount = self.position * price
|
||||||
|
self.current_balance += sell_amount
|
||||||
|
self.trades.append(
|
||||||
|
{
|
||||||
|
"type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"amount": sell_amount,
|
||||||
|
"quantity": self.position,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"exit_reason": "manual",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.position = 0
|
||||||
|
self.entry_price = None
|
||||||
|
self.entry_time = None
|
||||||
|
self.signals.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 0.8,
|
||||||
|
"reasoning": f"Condition {matched_condition.get('type')} triggered sell",
|
||||||
|
"executed": False,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_metrics(self):
|
||||||
|
# For open positions, use the last kline price to mark to market
|
||||||
|
# If no last kline price, fall back to entry price
|
||||||
|
position_price = self.last_kline_price
|
||||||
|
if position_price is None and self.trades and self.position > 0:
|
||||||
|
position_price = self.trades[-1]["price"] # Fall back to entry price
|
||||||
|
|
||||||
|
# Calculate final balance: use marked-to-market value if position open, otherwise current balance
|
||||||
|
if self.position > 0 and position_price:
|
||||||
|
final_balance = self.current_balance + self.position * position_price
|
||||||
|
else:
|
||||||
|
final_balance = self.current_balance
|
||||||
|
total_return = (
|
||||||
|
(final_balance - self.initial_balance) / self.initial_balance
|
||||||
|
) * 100
|
||||||
|
|
||||||
|
buy_trades = [t for t in self.trades if t["type"] == "buy"]
|
||||||
|
sell_trades = [t for t in self.trades if t["type"] == "sell"]
|
||||||
|
total_trades = len(buy_trades) + len(sell_trades)
|
||||||
|
|
||||||
|
winning_trades = 0
|
||||||
|
for i, trade in enumerate(sell_trades):
|
||||||
|
if i < len(buy_trades):
|
||||||
|
buy_price = buy_trades[i]["price"]
|
||||||
|
sell_price = trade["price"]
|
||||||
|
if sell_price > buy_price:
|
||||||
|
winning_trades += 1
|
||||||
|
|
||||||
|
win_rate = (winning_trades / len(sell_trades) * 100) if sell_trades else 0
|
||||||
|
|
||||||
|
portfolio_values = []
|
||||||
|
running_balance = self.initial_balance
|
||||||
|
running_position = 0.0
|
||||||
|
current_token = ""
|
||||||
|
last_price = 0.0
|
||||||
|
|
||||||
|
for trade in self.trades:
|
||||||
|
if trade["type"] == "buy":
|
||||||
|
running_position += trade["quantity"] # Add to existing position (DCA)
|
||||||
|
running_balance -= trade["amount"] # Subtract amount spent
|
||||||
|
current_token = trade["token"]
|
||||||
|
last_price = trade["price"]
|
||||||
|
else: # sell
|
||||||
|
running_balance += trade["amount"] # Add amount received
|
||||||
|
running_position = 0 # Close entire position
|
||||||
|
last_price = trade["price"]
|
||||||
|
|
||||||
|
portfolio_value = running_balance + (running_position * last_price)
|
||||||
|
portfolio_values.append(portfolio_value)
|
||||||
|
|
||||||
|
# If there's an open position, add final marked-to-market value
|
||||||
|
if self.position > 0 and self.last_kline_price:
|
||||||
|
final_portfolio_value = self.current_balance + (self.position * self.last_kline_price)
|
||||||
|
portfolio_values.append(final_portfolio_value)
|
||||||
|
|
||||||
|
max_value = self.initial_balance
|
||||||
|
max_drawdown = 0.0
|
||||||
|
for value in portfolio_values:
|
||||||
|
if value > max_value:
|
||||||
|
max_value = value
|
||||||
|
drawdown = ((max_value - value) / max_value) * 100
|
||||||
|
if drawdown > max_drawdown:
|
||||||
|
max_drawdown = drawdown
|
||||||
|
|
||||||
|
sharpe_ratio = 0.0
|
||||||
|
if len(portfolio_values) > 1:
|
||||||
|
returns = []
|
||||||
|
for i in range(1, len(portfolio_values)):
|
||||||
|
ret = (
|
||||||
|
portfolio_values[i] - portfolio_values[i - 1]
|
||||||
|
) / portfolio_values[i - 1]
|
||||||
|
returns.append(ret)
|
||||||
|
if returns:
|
||||||
|
avg_return = sum(returns) / len(returns)
|
||||||
|
variance = sum((r - avg_return) ** 2 for r in returns) / len(returns)
|
||||||
|
std_dev = variance**0.5
|
||||||
|
if std_dev > 0:
|
||||||
|
sharpe_ratio = avg_return / std_dev
|
||||||
|
|
||||||
|
buy_signals = len(buy_trades)
|
||||||
|
sell_signals = len(sell_trades)
|
||||||
|
|
||||||
|
self.results = {
|
||||||
|
"total_return": round(total_return, 2),
|
||||||
|
"win_rate": round(win_rate, 2),
|
||||||
|
"total_trades": total_trades,
|
||||||
|
"buy_signals": buy_signals,
|
||||||
|
"sell_signals": sell_signals,
|
||||||
|
"max_drawdown": round(max_drawdown, 2),
|
||||||
|
"sharpe_ratio": round(sharpe_ratio, 2),
|
||||||
|
"final_balance": round(final_balance, 2),
|
||||||
|
"signals": self.signals,
|
||||||
|
"trades": self.trades, # Include trades in results for storage
|
||||||
|
}
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
raise NotImplementedError("Backtest stop not yet implemented")
|
self.running = False
|
||||||
|
self.progress = 0
|
||||||
|
self.total_klines = 0
|
||||||
|
self.status = "stopped"
|
||||||
|
self._calculate_metrics()
|
||||||
|
|
||||||
def get_results(self) -> Dict[str, Any]:
|
def get_results(self) -> Dict[str, Any]:
|
||||||
raise NotImplementedError("Backtest results not yet implemented")
|
return {
|
||||||
|
"id": self.run_id,
|
||||||
|
"status": self.status,
|
||||||
|
"results": self.results,
|
||||||
|
"signals": self.signals,
|
||||||
|
"progress": self.progress,
|
||||||
|
"total_klines": self.total_klines,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"progress": self.progress,
|
||||||
|
"total_klines": self.total_klines,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,437 @@
|
|||||||
from typing import Optional, Dict, Any, List
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SimulateEngine:
|
class SimulateEngine:
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.run_id = str(uuid.uuid4())
|
||||||
|
self.status = "pending"
|
||||||
|
self.results: Optional[Dict[str, Any]] = None
|
||||||
|
self.signals: List[Dict[str, Any]] = []
|
||||||
|
self.ave_client = AveCloudClient(
|
||||||
|
api_key=config.get("ave_api_key", ""),
|
||||||
|
plan=config.get("ave_api_plan", "free"),
|
||||||
|
)
|
||||||
|
self.bot_id = config.get("bot_id")
|
||||||
|
self.strategy_config = config.get("strategy_config", {})
|
||||||
|
self.conditions = self.strategy_config.get("conditions", [])
|
||||||
|
self.actions = self.strategy_config.get("actions", [])
|
||||||
|
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||||
|
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
|
||||||
|
self.take_profit_percent = self.risk_management.get("take_profit_percent")
|
||||||
|
|
||||||
|
# Kline-based settings
|
||||||
|
self.kline_interval = config.get("kline_interval", "1m")
|
||||||
|
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
|
||||||
|
|
||||||
|
# Delay between candles (in seconds) to simulate real-time
|
||||||
|
# e.g., 1m interval -> 30s delay between candles
|
||||||
|
# Use config value if provided, otherwise calculate
|
||||||
|
if "candle_delay" in config and config["candle_delay"] is not None:
|
||||||
|
self.candle_delay = config["candle_delay"]
|
||||||
|
else:
|
||||||
|
self.candle_delay = self._get_interval_seconds(self.kline_interval) / 2
|
||||||
|
|
||||||
|
self.auto_execute = config.get("auto_execute", False)
|
||||||
|
self.token = config.get("token", "")
|
||||||
|
self.chain = config.get("chain", "bsc")
|
||||||
|
self.running = False
|
||||||
|
self.started_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Price tracking (for conditions)
|
||||||
|
self.last_close: Optional[float] = None
|
||||||
|
self.last_volume: Optional[float] = None
|
||||||
|
|
||||||
|
# Position tracking (for risk management)
|
||||||
|
self.position: float = 0.0
|
||||||
|
self.position_token: str = ""
|
||||||
|
self.entry_price: Optional[float] = None
|
||||||
|
self.entry_time: Optional[int] = None
|
||||||
|
|
||||||
|
# Portfolio
|
||||||
|
self.current_balance: float = config.get("initial_balance", 10000.0)
|
||||||
|
self.trades: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Error tracking
|
||||||
|
self.errors: List[str] = []
|
||||||
|
|
||||||
|
# Kline data
|
||||||
|
self.klines: List[Dict[str, Any]] = []
|
||||||
|
self.last_processed_time: Optional[int] = None
|
||||||
|
|
||||||
|
# Trade log - tracks what happened at each candle
|
||||||
|
self.trade_log: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Current candle being processed (for frontend to show progress)
|
||||||
|
self.current_candle_index = 0
|
||||||
|
self.total_candles = 0
|
||||||
|
|
||||||
async def run(self) -> List[Dict[str, Any]]:
|
def _get_interval_seconds(self, interval: str) -> int:
|
||||||
raise NotImplementedError("Simulation engine not yet implemented")
|
"""Convert kline interval to seconds."""
|
||||||
|
mapping = {
|
||||||
|
"1m": 60,
|
||||||
|
"5m": 300,
|
||||||
|
"15m": 900,
|
||||||
|
"30m": 1800,
|
||||||
|
"1h": 3600,
|
||||||
|
"4h": 14400,
|
||||||
|
"1d": 86400,
|
||||||
|
}
|
||||||
|
return mapping.get(interval, 60)
|
||||||
|
|
||||||
|
async def run(self) -> Dict[str, Any]:
|
||||||
|
self.running = True
|
||||||
|
self.status = "running"
|
||||||
|
self.started_at = datetime.utcnow()
|
||||||
|
|
||||||
async def stop(self):
|
token_id = (
|
||||||
raise NotImplementedError("Simulation stop not yet implemented")
|
f"{self.token}-{self.chain}"
|
||||||
|
if self.token and not self.token.endswith(f"-{self.chain}")
|
||||||
|
else self.token
|
||||||
|
)
|
||||||
|
|
||||||
|
if not token_id or token_id == f"-{self.chain}":
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": "Token ID is required"}
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Fetch klines (only once for simulation)
|
||||||
|
self.klines = await self._fetch_klines(token_id)
|
||||||
|
|
||||||
|
if not self.klines:
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": "No kline data available"}
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
|
||||||
|
|
||||||
|
# Step 2: Process candles (with limit)
|
||||||
|
candles_processed = 0
|
||||||
|
self.total_candles = min(len(self.klines), self.max_candles)
|
||||||
|
self.current_candle_index = 0
|
||||||
|
|
||||||
|
for i, candle in enumerate(self.klines):
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
if candles_processed >= self.max_candles:
|
||||||
|
logger.info(f"Reached max candles limit ({self.max_candles})")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.current_candle_index = candles_processed
|
||||||
|
candle_time = int(candle.get("time", 0))
|
||||||
|
|
||||||
|
# Get OHLCV data from candle
|
||||||
|
close_price = float(candle.get("close", 0))
|
||||||
|
volume = float(candle.get("volume", 0))
|
||||||
|
|
||||||
|
if close_price > 0:
|
||||||
|
# Process candle
|
||||||
|
await self._process_candle(close_price, volume, candle_time)
|
||||||
|
|
||||||
|
# Update last close for next iteration
|
||||||
|
self.last_close = close_price
|
||||||
|
self.last_volume = volume
|
||||||
|
|
||||||
|
# Track last processed time
|
||||||
|
self.last_processed_time = candle_time
|
||||||
|
|
||||||
|
candles_processed += 1
|
||||||
|
|
||||||
|
# Delay to simulate real-time (only for visible candles, not initial batch)
|
||||||
|
if candles_processed > 1 and self.candle_delay > 0:
|
||||||
|
await asyncio.sleep(self.candle_delay)
|
||||||
|
|
||||||
|
self.status = "completed"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Simulation error: {e}")
|
||||||
|
self.status = "failed"
|
||||||
|
self.results = {"error": str(e)}
|
||||||
|
self.errors.append(str(e))
|
||||||
|
|
||||||
|
self.results = self.results or {}
|
||||||
|
self.results["total_signals"] = len(self.signals)
|
||||||
|
self.results["total_trades"] = len(self.trades)
|
||||||
|
self.results["total_errors"] = len(self.errors)
|
||||||
|
self.results["errors"] = self.errors
|
||||||
|
self.results["signals"] = self.signals
|
||||||
|
self.results["candles_processed"] = candles_processed
|
||||||
|
self.results["current_candle_index"] = self.current_candle_index
|
||||||
|
self.results["total_candles"] = self.total_candles
|
||||||
|
self.results["klines"] = self.klines # Include klines for chart display
|
||||||
|
self.results["trade_log"] = self.trade_log # Include trade log for dashboard
|
||||||
|
self.results["portfolio"] = {
|
||||||
|
"initial_balance": self.config.get("initial_balance", 10000),
|
||||||
|
"current_balance": self.current_balance,
|
||||||
|
"position": self.position,
|
||||||
|
"position_token": self.position_token,
|
||||||
|
"entry_price": self.entry_price,
|
||||||
|
"current_price": self.last_close,
|
||||||
|
}
|
||||||
|
self.results["started_at"] = self.started_at
|
||||||
|
self.results["ended_at"] = datetime.utcnow()
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
async def _fetch_klines(
|
||||||
|
self,
|
||||||
|
token_id: str,
|
||||||
|
limit: int = 500
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch klines from AVE API."""
|
||||||
|
try:
|
||||||
|
klines = await self.ave_client.get_klines(
|
||||||
|
token_id,
|
||||||
|
interval=self.kline_interval,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by time ascending (oldest first)
|
||||||
|
klines = sorted(klines, key=lambda x: x.get("time", 0))
|
||||||
|
return klines
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch klines for {token_id}: {e}")
|
||||||
|
self.errors.append(f"Kline fetch failed: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _process_candle(
|
||||||
|
self,
|
||||||
|
close_price: float,
|
||||||
|
volume: float,
|
||||||
|
timestamp: int
|
||||||
|
):
|
||||||
|
"""Process a single candle - check conditions and risk management."""
|
||||||
|
|
||||||
|
action = "hold" # Default action
|
||||||
|
reason = ""
|
||||||
|
|
||||||
|
# Check risk management first (for open positions)
|
||||||
|
if self.position > 0 and self.entry_price is not None:
|
||||||
|
exit_info = self._check_risk_management(close_price, timestamp)
|
||||||
|
if exit_info:
|
||||||
|
await self._execute_risk_exit(close_price, timestamp, exit_info)
|
||||||
|
action = "sell"
|
||||||
|
reason = exit_info["reason"]
|
||||||
|
# Log the action
|
||||||
|
self.trade_log.append({
|
||||||
|
"time": timestamp,
|
||||||
|
"price": close_price,
|
||||||
|
"action": action,
|
||||||
|
"reason": reason,
|
||||||
|
"position": self.position,
|
||||||
|
"entry_price": self.entry_price,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check conditions (only if no open position)
|
||||||
|
if self.position == 0:
|
||||||
|
for condition in self.conditions:
|
||||||
|
if self._check_condition(condition, close_price, volume):
|
||||||
|
await self._execute_actions(close_price, timestamp, condition)
|
||||||
|
action = "buy"
|
||||||
|
reason = f"{condition.get('type')} {condition.get('threshold')}%".format(
|
||||||
|
type=condition.get('type'),
|
||||||
|
threshold=condition.get('threshold')
|
||||||
|
)
|
||||||
|
# Log the action
|
||||||
|
self.trade_log.append({
|
||||||
|
"time": timestamp,
|
||||||
|
"price": close_price,
|
||||||
|
"action": action,
|
||||||
|
"reason": reason,
|
||||||
|
"position": self.position,
|
||||||
|
"entry_price": self.entry_price,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
# Log hold action (no signal)
|
||||||
|
if action == "hold":
|
||||||
|
# Only log every 10th candle to reduce data
|
||||||
|
if len(self.trade_log) == 0 or (len(self.klines) - len(self.trade_log) > 10):
|
||||||
|
self.trade_log.append({
|
||||||
|
"time": timestamp,
|
||||||
|
"price": close_price,
|
||||||
|
"action": "hold",
|
||||||
|
"reason": "no_signal",
|
||||||
|
"position": self.position,
|
||||||
|
"entry_price": self.entry_price,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _check_risk_management(
|
||||||
|
self, current_price: float, timestamp: int
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Check if stop loss or take profit is triggered."""
|
||||||
|
if self.position <= 0 or self.entry_price is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.stop_loss_percent is not None:
|
||||||
|
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
|
||||||
|
if current_price <= stop_loss_price:
|
||||||
|
return {"reason": "stop_loss", "price": stop_loss_price}
|
||||||
|
|
||||||
|
if self.take_profit_percent is not None:
|
||||||
|
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
|
||||||
|
if current_price >= take_profit_price:
|
||||||
|
return {"reason": "take_profit", "price": take_profit_price}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _execute_risk_exit(
|
||||||
|
self, price: float, timestamp: int, exit_info: Dict[str, Any]
|
||||||
|
):
|
||||||
|
"""Execute stop loss or take profit."""
|
||||||
|
if self.position <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
reason = exit_info["reason"]
|
||||||
|
quantity = self.position
|
||||||
|
sale_proceeds = quantity * price
|
||||||
|
|
||||||
|
# Add sale proceeds to cash balance
|
||||||
|
self.current_balance += sale_proceeds
|
||||||
|
|
||||||
|
self.trades.append(
|
||||||
|
{
|
||||||
|
"type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"quantity": quantity,
|
||||||
|
"amount": sale_proceeds,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"exit_reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.signals.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": "sell",
|
||||||
|
"token": self.position_token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"reasoning": f"Risk management triggered {reason}",
|
||||||
|
"executed": self.auto_execute,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.position = 0
|
||||||
|
self.entry_price = None
|
||||||
|
self.entry_time = None
|
||||||
|
|
||||||
|
def _check_condition(
|
||||||
|
self,
|
||||||
|
condition: Dict[str, Any],
|
||||||
|
current_price: float,
|
||||||
|
current_volume: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a condition is met based on price movement."""
|
||||||
|
cond_type = condition.get("type", "")
|
||||||
|
threshold = condition.get("threshold", 0)
|
||||||
|
|
||||||
|
if cond_type == "price_drop":
|
||||||
|
# Price dropped by threshold % from last close
|
||||||
|
if self.last_close is None or self.last_close <= 0:
|
||||||
|
return False
|
||||||
|
drop_pct = ((self.last_close - current_price) / self.last_close) * 100
|
||||||
|
return drop_pct >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "price_rise":
|
||||||
|
# Price rose by threshold % from last close
|
||||||
|
if self.last_close is None or self.last_close <= 0:
|
||||||
|
return False
|
||||||
|
rise_pct = ((current_price - self.last_close) / self.last_close) * 100
|
||||||
|
return rise_pct >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "volume_spike":
|
||||||
|
# Volume increased significantly
|
||||||
|
if self.last_volume is None or self.last_volume <= 0:
|
||||||
|
return False
|
||||||
|
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
|
||||||
|
return volume_increase >= threshold
|
||||||
|
|
||||||
|
elif cond_type == "price_level":
|
||||||
|
price_level = condition.get("price")
|
||||||
|
direction = condition.get("direction", "above")
|
||||||
|
if price_level is None:
|
||||||
|
return False
|
||||||
|
if direction == "above":
|
||||||
|
return current_price > price_level
|
||||||
|
else:
|
||||||
|
return current_price < price_level
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _execute_actions(
|
||||||
|
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||||
|
):
|
||||||
|
"""Execute buy/sell actions based on matched condition."""
|
||||||
|
token = matched_condition.get("token", self.token)
|
||||||
|
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
||||||
|
|
||||||
|
for action in self.actions:
|
||||||
|
action_type = action.get("type", "")
|
||||||
|
if action_type == "buy":
|
||||||
|
amount_percent = action.get("amount_percent", 10)
|
||||||
|
amount = self.current_balance * (amount_percent / 100)
|
||||||
|
quantity = amount / price
|
||||||
|
|
||||||
|
self.position += quantity
|
||||||
|
self.position_token = token
|
||||||
|
self.entry_price = price
|
||||||
|
self.entry_time = timestamp
|
||||||
|
self.current_balance -= amount
|
||||||
|
|
||||||
|
self.trades.append(
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"amount": amount,
|
||||||
|
"quantity": quantity,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
signal = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": action_type,
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 0.8,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"executed": self.auto_execute,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.signals.append(signal)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the simulation."""
|
||||||
|
self.running = False
|
||||||
|
self.status = "stopped"
|
||||||
|
|
||||||
|
def get_results(self) -> Dict[str, Any]:
|
||||||
|
"""Get simulation results."""
|
||||||
|
return {
|
||||||
|
"id": self.run_id,
|
||||||
|
"status": self.status,
|
||||||
|
"results": self.results,
|
||||||
|
"signals": self.signals,
|
||||||
|
}
|
||||||
|
|
||||||
def get_signals(self) -> List[Dict[str, Any]]:
|
def get_signals(self) -> List[Dict[str, Any]]:
|
||||||
raise NotImplementedError("Simulation signals not yet implemented")
|
"""Get current signals."""
|
||||||
|
return self.signals
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pydantic-settings>=2.1.0
|
|||||||
email-validator>=2.0.0
|
email-validator>=2.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]>=1.7.4
|
||||||
|
bcrypt>=4.0,<5.0 # Required for passlib compatibility
|
||||||
crewai>=0.1.0
|
crewai>=0.1.0
|
||||||
anthropic>=0.18.0
|
anthropic>=0.18.0
|
||||||
httpx>=0.26.0
|
httpx>=0.26.0
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ if __name__ == "__main__":
|
|||||||
host=settings.HOST,
|
host=settings.HOST,
|
||||||
port=settings.PORT,
|
port=settings.PORT,
|
||||||
reload=settings.DEBUG,
|
reload=settings.DEBUG,
|
||||||
|
timeout_keep_alive=300,
|
||||||
)
|
)
|
||||||
|
|||||||
457
src/backend/tests/test_backtest_engine.py
Normal file
457
src/backend/tests/test_backtest_engine.py
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for BacktestEngine
|
||||||
|
Tests stop loss, take profit, and max drawdown calculations
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from app.services.backtest.engine import BacktestEngine
|
||||||
|
|
||||||
|
|
||||||
|
class TestBacktestEngine:
|
||||||
|
"""Test suite for BacktestEngine"""
|
||||||
|
|
||||||
|
def _run_backtest(self, config, klines):
|
||||||
|
"""Helper to run backtest with given klines"""
|
||||||
|
engine = BacktestEngine(config)
|
||||||
|
result = asyncio.run(engine.run_with_klines(klines))
|
||||||
|
return engine, result
|
||||||
|
|
||||||
|
def _trace_portfolio(self, engine, initial_balance):
|
||||||
|
"""Print portfolio trace for debugging"""
|
||||||
|
running_balance = initial_balance
|
||||||
|
running_position = 0.0
|
||||||
|
|
||||||
|
print("\nPortfolio Trace:")
|
||||||
|
for i, trade in enumerate(engine.trades):
|
||||||
|
if trade["type"] == "buy":
|
||||||
|
running_position = trade["quantity"]
|
||||||
|
running_balance -= trade["amount"]
|
||||||
|
portfolio = running_balance + (running_position * trade["price"])
|
||||||
|
print(f" BUY #{i+1}: @${trade['price']} - portfolio=${portfolio:.2f}")
|
||||||
|
else:
|
||||||
|
running_balance += trade["amount"]
|
||||||
|
running_position = 0
|
||||||
|
portfolio = running_balance
|
||||||
|
print(f" SELL #{i+1}: @${trade['price']} ({trade.get('exit_reason', '')}) - portfolio=${portfolio:.2f}")
|
||||||
|
|
||||||
|
if engine.position > 0 and engine.last_kline_price:
|
||||||
|
final = running_balance + (engine.position * engine.last_kline_price)
|
||||||
|
print(f" FINAL: position={engine.position:.2f} @ ${engine.last_kline_price} = ${final:.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def test_stop_loss_triggers_correctly(self):
|
||||||
|
"""Test stop loss triggers at configured percentage"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Price sequence that triggers buy then stop loss:
|
||||||
|
# $110 -> $100 (9% drop, BUY)
|
||||||
|
# $100 -> $95 (5% drop, STOP LOSS at 5% from $100 = $95)
|
||||||
|
klines = [
|
||||||
|
{"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
|
||||||
|
{"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)} (expected 2)")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
assert len(engine.trades) == 2
|
||||||
|
assert engine.trades[0]["type"] == "buy"
|
||||||
|
assert engine.trades[1]["type"] == "sell"
|
||||||
|
assert engine.trades[1]["exit_reason"] == "stop_loss"
|
||||||
|
# Max drawdown should be ~5% (stop loss percentage)
|
||||||
|
assert 3 < result['max_drawdown'] < 8
|
||||||
|
# Total return should be ~-5%
|
||||||
|
assert -8 < result['total_return'] < -3
|
||||||
|
|
||||||
|
def test_take_profit_triggers(self):
|
||||||
|
"""Test take profit triggers at configured percentage"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# $100 -> $95 (5% drop, BUY) -> $104.5 (10% rise, TAKE PROFIT)
|
||||||
|
klines = [
|
||||||
|
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
|
||||||
|
{"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)} (expected 2)")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
assert len(engine.trades) == 2
|
||||||
|
assert engine.trades[1]["exit_reason"] == "take_profit"
|
||||||
|
assert result['total_return'] > 0
|
||||||
|
|
||||||
|
def test_max_drawdown_bounded_by_stop_loss(self):
|
||||||
|
"""Test that max drawdown is bounded by stop loss when position is properly closed"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# $110 -> $100 -> $95 (BUY) -> $90 (STOP LOSS)
|
||||||
|
klines = [
|
||||||
|
{"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
|
||||||
|
{"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
|
||||||
|
{"close": "90.0", "timestamp": 4000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)}")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
# With 5% stop loss, max drawdown should be around 5%
|
||||||
|
assert 3 < result['max_drawdown'] < 8
|
||||||
|
|
||||||
|
def test_open_position_not_closed(self):
|
||||||
|
"""Test scenario where last kline has an open position"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# $100 -> $90 (10% drop, BUY) - and backtest ends here
|
||||||
|
# Position is open, marked to market at $90
|
||||||
|
klines = [
|
||||||
|
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)}")
|
||||||
|
print(f" Position open: {engine.position > 0}")
|
||||||
|
print(f" Entry price: ${engine.entry_price}")
|
||||||
|
print(f" Last kline price: ${engine.last_kline_price}")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
# Position should be open
|
||||||
|
assert engine.position > 0
|
||||||
|
# Entry should be $90
|
||||||
|
assert engine.entry_price == 90.0
|
||||||
|
# Since entry = last kline price, no unrealized loss
|
||||||
|
# Max drawdown should be 0%
|
||||||
|
assert result['max_drawdown'] == 0.0
|
||||||
|
|
||||||
|
def test_open_position_with_loss(self):
|
||||||
|
"""Test open position where price dropped but stop loss didn't trigger"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# $100 -> $90 (10% drop, BUY at $90) -> $85 (stop loss at 5% from $90 = $85.5)
|
||||||
|
# $85 > $85.5? No, $85 < $85.5, so stop loss WOULD trigger
|
||||||
|
# Let me use $86 instead - $86 > $85.5 so no stop loss
|
||||||
|
klines = [
|
||||||
|
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
|
||||||
|
{"close": "86.0", "timestamp": 3000, "open": "86.0", "high": "86.0", "low": "86.0", "volume": "1000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)}")
|
||||||
|
print(f" Position open: {engine.position > 0}")
|
||||||
|
print(f" Entry price: ${engine.entry_price}")
|
||||||
|
print(f" Last kline price: ${engine.last_kline_price}")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
# Position should be open
|
||||||
|
assert engine.position > 0
|
||||||
|
# Entry = $90, stop = $85.50, last = $86 (above stop)
|
||||||
|
# Portfolio: $0 + position * $86
|
||||||
|
# Position: 10000/90 = 111.11 tokens
|
||||||
|
# Portfolio at $86: 111.11 * 86 = $9,555.56
|
||||||
|
# But we only track portfolio at trade points, so max was $10,000
|
||||||
|
# drawdown = (10000 - 9555.56) / 10000 = 4.44%
|
||||||
|
print(f" Expected max drawdown: ~4.4% (marked to market at $86)")
|
||||||
|
|
||||||
|
def test_multiple_buy_sell_cycles(self):
|
||||||
|
"""Test multiple buy/sell cycles"""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 50}], # 50% of balance
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# $100 -> $95 (BUY) -> $104.5 (TAKE PROFIT) -> $95 (BUY) -> $90 (STOP LOSS)
|
||||||
|
klines = [
|
||||||
|
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||||
|
{"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # BUY at $95
|
||||||
|
{"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"}, # TAKE PROFIT
|
||||||
|
{"close": "95.0", "timestamp": 4000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # 9% drop - no buy
|
||||||
|
{"close": "90.0", "timestamp": 5000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"}, # 10.5% drop from $100 - BUY at $90
|
||||||
|
{"close": "85.5", "timestamp": 6000, "open": "85.5", "high": "85.5", "low": "85.5", "volume": "1000"}, # STOP LOSS at 5% from $90 = $85.5
|
||||||
|
]
|
||||||
|
|
||||||
|
engine, result = self._run_backtest(config, klines)
|
||||||
|
self._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"Results:")
|
||||||
|
print(f" Trades: {len(engine.trades)}")
|
||||||
|
print(f" Buy count: {len([t for t in engine.trades if t['type'] == 'buy'])}")
|
||||||
|
print(f" Sell count: {len([t for t in engine.trades if t['type'] == 'sell'])}")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
|
||||||
|
def run_tests():
|
||||||
|
tests = TestBacktestEngine()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 1: Stop Loss Triggers Correctly")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_stop_loss_triggers_correctly()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 2: Take Profit Triggers")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_take_profit_triggers()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 3: Max Drawdown Bounded by Stop Loss")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_max_drawdown_bounded_by_stop_loss()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 4: Open Position Not Closed")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_open_position_not_closed()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 5: Open Position With Loss")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_open_position_with_loss()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST 6: Multiple Buy/Sell Cycles")
|
||||||
|
print("=" * 60)
|
||||||
|
try:
|
||||||
|
tests.test_multiple_buy_sell_cycles()
|
||||||
|
print("PASSED\n")
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAILED: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dca_multiple_buys():
|
||||||
|
"""Test that DCA with multiple consecutive buys uses weighted average for stop loss."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST 7: DCA With Multiple Consecutive Buys")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 20}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
|
||||||
|
},
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3 consecutive 2% drops = 3 buys at $0.58, $0.57, $0.56
|
||||||
|
# Then drop to $0.50 which is below 5% from average (~$0.57 * 0.95 = $0.54)
|
||||||
|
klines = [
|
||||||
|
{"close": "0.60", "timestamp": 1000, "open": "0.60", "high": "0.60", "low": "0.60", "volume": "1000"},
|
||||||
|
{"close": "0.588", "timestamp": 2000}, # 2% drop -> BUY 1 @ $0.588
|
||||||
|
{"close": "0.576", "timestamp": 3000}, # 2% drop -> BUY 2 @ $0.576
|
||||||
|
{"close": "0.565", "timestamp": 4000}, # 2% drop -> BUY 3 @ $0.565
|
||||||
|
{"close": "0.50", "timestamp": 5000}, # Below 5% from avg -> STOP LOSS
|
||||||
|
]
|
||||||
|
|
||||||
|
test = TestBacktestEngine()
|
||||||
|
engine, result = test._run_backtest(config, klines)
|
||||||
|
test._trace_portfolio(engine, 10000.0)
|
||||||
|
|
||||||
|
print(f"\nResults:")
|
||||||
|
print(f" Trades: {len(engine.trades)} (expected 3: 2 buys + stop loss)")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']}%")
|
||||||
|
print(f" Total return: {result['total_return']}%")
|
||||||
|
|
||||||
|
# Verify: 2 buys + 1 sell (stop loss) = 3 trades
|
||||||
|
# The 3rd buy @ $0.565 doesn't happen because stop loss triggers at $0.5 first
|
||||||
|
assert len(engine.trades) == 3, f"Expected 3 trades, got {len(engine.trades)}"
|
||||||
|
|
||||||
|
# Verify last trade is stop loss
|
||||||
|
last_trade = engine.trades[-1]
|
||||||
|
assert last_trade["type"] == "sell", "Last trade should be sell"
|
||||||
|
assert last_trade.get("exit_reason") == "stop_loss", f"Last trade should be stop_loss, got {last_trade.get('exit_reason')}"
|
||||||
|
|
||||||
|
# Verify max drawdown is reasonable (close to stop loss %)
|
||||||
|
# Actual loss should be around 5% from weighted average
|
||||||
|
assert result['max_drawdown'] < 10, f"Max drawdown {result['max_drawdown']}% is too high for 5% stop loss"
|
||||||
|
|
||||||
|
# Position is now 0 after stop loss, so avg_entry_price is None
|
||||||
|
print(f" Position closed: {engine.position == 0}")
|
||||||
|
print(f" Final balance: ${engine.current_balance:.2f}")
|
||||||
|
print("PASSED")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_loss_always_results_in_loss():
|
||||||
|
"""Test that stop loss ALWAYS results in a loss, never a gain.
|
||||||
|
|
||||||
|
This tests the scenario where:
|
||||||
|
- You start with $10,000
|
||||||
|
- Price keeps dropping, triggering multiple buys
|
||||||
|
- Stop loss triggers, selling your entire position
|
||||||
|
- Final balance MUST be less than initial balance
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST 8: Stop Loss Always Results In Loss")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"bot_id": "test",
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
|
||||||
|
"actions": [{"type": "buy", "amount_percent": 20}],
|
||||||
|
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
|
||||||
|
},
|
||||||
|
"initial_balance": 10000.0,
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Price scenario: drops each kline, triggering multiple buys
|
||||||
|
# Final drop triggers stop loss
|
||||||
|
#
|
||||||
|
# $0.60 -> $0.588 (2% drop) -> BUY 1 @ $0.588
|
||||||
|
# $0.588 -> $0.576 (2% drop) -> BUY 2 @ $0.576
|
||||||
|
# $0.576 -> $0.565 (2% drop) -> BUY 3 @ $0.565
|
||||||
|
# $0.565 -> $0.535 (5.3% drop) -> STOP LOSS @ $0.535 (5% from weighted avg ~$0.576)
|
||||||
|
klines = [
|
||||||
|
{"close": "0.60", "timestamp": 1000},
|
||||||
|
{"close": "0.588", "timestamp": 2000}, # BUY 1
|
||||||
|
{"close": "0.576", "timestamp": 3000}, # BUY 2
|
||||||
|
{"close": "0.565", "timestamp": 4000}, # BUY 3
|
||||||
|
{"close": "0.535", "timestamp": 5000}, # STOP LOSS
|
||||||
|
]
|
||||||
|
|
||||||
|
test = TestBacktestEngine()
|
||||||
|
engine, result = test._run_backtest(config, klines)
|
||||||
|
|
||||||
|
print(f"\nSetup:")
|
||||||
|
print(f" Initial balance: $10,000")
|
||||||
|
print(f" Stop loss: 5%")
|
||||||
|
print(f" Each buy: 20% of current balance")
|
||||||
|
print(f"\nTrades:")
|
||||||
|
for i, trade in enumerate(engine.trades):
|
||||||
|
exit_info = f" ({trade.get('exit_reason', '')})" if 'exit_reason' in trade else ""
|
||||||
|
print(f" {i+1}. {trade['type']} @ ${trade['price']} - ${trade['amount']:.2f}{exit_info}")
|
||||||
|
|
||||||
|
print(f"\nResults:")
|
||||||
|
print(f" Final balance: ${engine.current_balance:.2f}")
|
||||||
|
print(f" Total return: {result['total_return']:.2f}%")
|
||||||
|
print(f" Max drawdown: {result['max_drawdown']:.2f}%")
|
||||||
|
|
||||||
|
# CRITICAL ASSERTION: Stop loss MUST result in loss
|
||||||
|
assert engine.current_balance < 10000.0, \
|
||||||
|
f"BUG: Stop loss resulted in GAIN! Balance went from $10,000 to ${engine.current_balance:.2f}"
|
||||||
|
|
||||||
|
# Also verify total return is negative
|
||||||
|
assert result['total_return'] < 0, \
|
||||||
|
f"BUG: Total return is positive ({result['total_return']:.2f}%) after stop loss!"
|
||||||
|
|
||||||
|
# Max drawdown should reflect the actual loss (close to stop loss %)
|
||||||
|
assert result['max_drawdown'] < 10, \
|
||||||
|
f"Max drawdown ({result['max_drawdown']:.2f}%) seems too high"
|
||||||
|
|
||||||
|
print(f"\n✓ PASSED: Stop loss correctly resulted in ${10000 - engine.current_balance:.2f} loss")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_tests()
|
||||||
|
test_dca_multiple_buys()
|
||||||
|
test_stop_loss_always_results_in_loss()
|
||||||
386
src/backend/tests/test_simulate_engine.py
Normal file
386
src/backend/tests/test_simulate_engine.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, 'src/backend')
|
||||||
|
|
||||||
|
from app.services.simulate.engine import SimulateEngine
|
||||||
|
|
||||||
|
|
||||||
|
class MockAveClient:
|
||||||
|
"""Mock AVE client for testing."""
|
||||||
|
|
||||||
|
def __init__(self, klines_data=None):
|
||||||
|
self.klines_data = klines_data or []
|
||||||
|
|
||||||
|
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
|
||||||
|
return self.klines_data
|
||||||
|
|
||||||
|
|
||||||
|
def create_engine(config_override=None, klines_data=None):
|
||||||
|
"""Create a test engine with mock client."""
|
||||||
|
config = {
|
||||||
|
"bot_id": "test-bot",
|
||||||
|
"token": "0x1234567890123456789012345678901234567890",
|
||||||
|
"chain": "bsc",
|
||||||
|
"kline_interval": "1m",
|
||||||
|
"max_candles": 10, # Small number for fast tests
|
||||||
|
"candle_delay": 0, # No delay in tests
|
||||||
|
"auto_execute": False,
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [
|
||||||
|
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{"type": "buy", "amount_percent": 10}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 5,
|
||||||
|
"take_profit_percent": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ave_api_key": "test",
|
||||||
|
"ave_api_plan": "free",
|
||||||
|
}
|
||||||
|
if config_override:
|
||||||
|
config.update(config_override)
|
||||||
|
|
||||||
|
engine = SimulateEngine(config)
|
||||||
|
engine.ave_client = MockAveClient(klines_data)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimulateEngine:
|
||||||
|
"""Unit tests for SimulateEngine."""
|
||||||
|
|
||||||
|
# ==================== Kline Fetching Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetches_klines_on_start(self):
|
||||||
|
"""Engine should fetch klines when run is called."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
assert engine.status == "completed"
|
||||||
|
assert results["candles_processed"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_no_klines_data(self):
|
||||||
|
"""Engine should handle empty klines gracefully."""
|
||||||
|
engine = create_engine(klines_data=[])
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
assert engine.status == "failed"
|
||||||
|
assert "error" in results
|
||||||
|
assert "No kline data" in results["error"]
|
||||||
|
|
||||||
|
# ==================== Price Drop Condition Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_drop_condition_triggers_buy(self):
|
||||||
|
"""Price drop >= threshold should trigger BUY signal."""
|
||||||
|
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
assert results["total_signals"] >= 1
|
||||||
|
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||||
|
assert len(buy_signals) >= 1
|
||||||
|
assert buy_signals[0]["price"] == 90.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_drop_below_threshold_no_signal(self):
|
||||||
|
"""Price drop < threshold should NOT trigger signal."""
|
||||||
|
# Price drops from 100 to 98 (2% drop) - below 5% threshold
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
assert results["total_signals"] == 0
|
||||||
|
|
||||||
|
# ==================== Risk Management Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_loss_triggers_after_buy(self):
|
||||||
|
"""Stop loss should trigger SELL after price drops below threshold."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||||
|
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||||
|
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||||
|
|
||||||
|
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||||
|
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
|
||||||
|
assert "stop_loss" in sell_signals[0]["reasoning"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_take_profit_triggers_after_buy(self):
|
||||||
|
"""Take profit should trigger SELL after price rises above threshold."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||||
|
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||||
|
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||||
|
|
||||||
|
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||||
|
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
|
||||||
|
assert "take_profit" in sell_signals[0]["reasoning"]
|
||||||
|
|
||||||
|
# ==================== Multiple Conditions Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_buy_if_already_in_position(self):
|
||||||
|
"""Should not trigger another BUY if already holding position."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
|
||||||
|
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
|
||||||
|
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||||
|
|
||||||
|
# Should only have 1 buy, not multiple
|
||||||
|
assert len(buy_signals) == 1, "Should only have one BUY signal"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_can_buy_again_after_sell(self):
|
||||||
|
"""Should be able to BUY again after position is closed by risk management."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
# First trade
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
|
||||||
|
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
|
||||||
|
# Second trade
|
||||||
|
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
|
||||||
|
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||||
|
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||||
|
|
||||||
|
assert len(buy_signals) == 2, "Should have two BUY signals"
|
||||||
|
assert len(sell_signals) == 2, "Should have two SELL signals"
|
||||||
|
|
||||||
|
# ==================== Edge Cases ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_zero_price(self):
|
||||||
|
"""Should skip processing for candles with zero price but still count them."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
|
||||||
|
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
# All 3 candles counted, but only 2 valid for condition checking
|
||||||
|
assert results["candles_processed"] == 3
|
||||||
|
# Only 1 signal (the valid candle that dropped 10%)
|
||||||
|
assert results["total_signals"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_max_candles_limit(self):
|
||||||
|
"""Should respect max_candles limit."""
|
||||||
|
klines = [
|
||||||
|
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||||
|
for i in range(1, 201) # 200 candles
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
assert results["candles_processed"] == 50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_interrupts_processing(self):
|
||||||
|
"""Should stop processing when stop() is called."""
|
||||||
|
klines = [
|
||||||
|
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||||
|
for i in range(1, 101)
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
engine.run_id = "test"
|
||||||
|
|
||||||
|
# Stop after a few candles
|
||||||
|
async def stop_after_delay():
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
engine.stop()
|
||||||
|
|
||||||
|
await asyncio.gather(engine.run(), stop_after_delay())
|
||||||
|
|
||||||
|
assert engine.status == "stopped"
|
||||||
|
# Should have processed some candles before stopping
|
||||||
|
assert engine.last_processed_time is not None
|
||||||
|
|
||||||
|
# ==================== Price Movement Display Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_records_all_processed_prices(self):
|
||||||
|
"""Should track last processed time for display purposes."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
|
||||||
|
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
await engine.run()
|
||||||
|
|
||||||
|
# Should have tracked the last candle's time
|
||||||
|
assert engine.last_processed_time == 3000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tracks_price_changes(self):
|
||||||
|
"""Should track price changes for potential chart display."""
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||||
|
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
|
||||||
|
]
|
||||||
|
engine = create_engine(klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
|
||||||
|
await engine.run()
|
||||||
|
|
||||||
|
# Last close should be the last candle's close
|
||||||
|
assert engine.last_close == 104.0
|
||||||
|
|
||||||
|
# ==================== Integration Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_simulation_workflow_generates_signals_and_trades(self):
|
||||||
|
"""
|
||||||
|
Full integration test: provides klines with clear price movements
|
||||||
|
and verifies signals and trade_log are populated.
|
||||||
|
|
||||||
|
This test ensures the simulation is working by:
|
||||||
|
1. Creating klines with obvious price movements (drops > 0.1%)
|
||||||
|
2. Using a very low threshold (0.1%)
|
||||||
|
3. Verifying signals are generated
|
||||||
|
4. Verifying trade_log is populated
|
||||||
|
5. Verifying we have buy/sell actions
|
||||||
|
"""
|
||||||
|
# Create klines with clear price drops and rises
|
||||||
|
klines = [
|
||||||
|
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}, # Flat
|
||||||
|
{"time": 2000, "open": 100, "high": 101, "low": 99.9, "close": 99.95, "volume": 1000}, # 0.05% drop
|
||||||
|
{"time": 3000, "open": 99.95, "high": 100, "low": 99.5, "close": 99.5, "volume": 1000}, # 0.45% drop
|
||||||
|
{"time": 4000, "open": 99.5, "high": 100, "low": 99, "close": 99.2, "volume": 1000}, # 0.30% drop
|
||||||
|
{"time": 5000, "open": 99.2, "high": 100, "low": 98, "close": 98.5, "volume": 1000}, # 0.71% drop
|
||||||
|
{"time": 6000, "open": 98.5, "high": 99, "low": 98, "close": 98.8, "volume": 1000}, # 0.30% rise
|
||||||
|
{"time": 7000, "open": 98.8, "high": 99, "low": 98, "close": 98.3, "volume": 1000}, # 0.51% drop
|
||||||
|
{"time": 8000, "open": 98.3, "high": 99, "low": 97, "close": 97.5, "volume": 1000}, # 0.81% drop
|
||||||
|
{"time": 9000, "open": 97.5, "high": 98, "low": 96, "close": 96.5, "volume": 1000}, # 1.03% drop
|
||||||
|
]
|
||||||
|
|
||||||
|
# Use very low threshold to ensure signals are generated
|
||||||
|
config_override = {
|
||||||
|
"max_candles": 100,
|
||||||
|
"strategy_config": {
|
||||||
|
"conditions": [
|
||||||
|
{"type": "price_drop", "threshold": 0.1, "token": "TEST", "token_address": "0x1234"}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{"type": "buy", "amount_percent": 10}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 5,
|
||||||
|
"take_profit_percent": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = create_engine(config_override=config_override, klines_data=klines)
|
||||||
|
engine.running = True
|
||||||
|
engine.run_id = "integration-test"
|
||||||
|
|
||||||
|
results = await engine.run()
|
||||||
|
|
||||||
|
# Verify results
|
||||||
|
print(f"\n=== Integration Test Results ===")
|
||||||
|
print(f"Status: {engine.status}")
|
||||||
|
print(f"Candles processed: {results.get('candles_processed')}")
|
||||||
|
print(f"Signals count: {len(engine.signals)}")
|
||||||
|
print(f"Trade log count: {len(engine.trade_log)}")
|
||||||
|
|
||||||
|
# ASSERTIONS - These should NEVER fail if simulation is working
|
||||||
|
assert engine.status == "completed", "Simulation should complete successfully"
|
||||||
|
assert results.get("candles_processed") == len(klines), f"Should process all {len(klines)} candles"
|
||||||
|
|
||||||
|
# Critical: signals should NOT be empty
|
||||||
|
assert len(engine.signals) > 0, "SIGNALS SHOULD NOT BE EMPTY! Simulation is not generating signals."
|
||||||
|
print(f"Signals: {[s['signal_type'] for s in engine.signals]}")
|
||||||
|
|
||||||
|
# Critical: trade_log should NOT be empty
|
||||||
|
assert len(engine.trade_log) > 0, "TRADE_LOG SHOULD NOT BE EMPTY! No activity logged."
|
||||||
|
print(f"Trade log: {[t['action'] for t in engine.trade_log]}")
|
||||||
|
|
||||||
|
# Should have at least one BUY signal
|
||||||
|
buy_signals = [s for s in engine.signals if s['signal_type'] == 'buy']
|
||||||
|
assert len(buy_signals) > 0, "Should have at least one BUY signal"
|
||||||
|
print(f"Buy signals: {len(buy_signals)}")
|
||||||
|
|
||||||
|
# Verify trade_log has BUY action
|
||||||
|
buy_trades = [t for t in engine.trade_log if t['action'] == 'buy']
|
||||||
|
assert len(buy_trades) > 0, "Trade log should contain BUY actions"
|
||||||
|
|
||||||
|
# Verify results contain the data
|
||||||
|
assert "signals" in results, "Results should contain signals"
|
||||||
|
assert "trade_log" in results, "Results should contain trade_log"
|
||||||
|
|
||||||
|
print("\n=== Integration Test PASSED ===")
|
||||||
|
print(f"Simulation working correctly!")
|
||||||
|
print(f"Generated {len(engine.signals)} signals and {len(engine.trade_log)} trade log entries")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
2
src/frontend/.env.example
Normal file
2
src/frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8000/api
|
||||||
|
VITE_WS_URL=ws://localhost:8000/ws
|
||||||
23
src/frontend/.gitignore
vendored
Normal file
23
src/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
src/frontend/.npmrc
Normal file
1
src/frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
src/frontend/.vscode/extensions.json
vendored
Normal file
3
src/frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
src/frontend/README.md
Normal file
42
src/frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.15.0 create --template minimal --types ts --no-install .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
1404
src/frontend/package-lock.json
generated
Normal file
1404
src/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
src/frontend/package.json
Normal file
26
src/frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/kit": "^2.57.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"svelte": "^5.55.2",
|
||||||
|
"svelte-check": "^4.4.6",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/frontend/src/app.d.ts
vendored
Normal file
13
src/frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
src/frontend/src/app.html
Normal file
12
src/frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
241
src/frontend/src/lib/api/client.ts
Normal file
241
src/frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import type {
|
||||||
|
User,
|
||||||
|
Bot,
|
||||||
|
BotConversation,
|
||||||
|
Backtest,
|
||||||
|
Simulation,
|
||||||
|
Signal,
|
||||||
|
AuthResponse,
|
||||||
|
BotChatRequest,
|
||||||
|
BotChatResponse,
|
||||||
|
StrategyConfig
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||||
|
|
||||||
|
function getAuthHeaders(): HeadersInit {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
|
||||||
|
let errorMessage = 'An error occurred';
|
||||||
|
|
||||||
|
if (typeof error.detail === 'string') {
|
||||||
|
errorMessage = error.detail;
|
||||||
|
} else if (Array.isArray(error.detail)) {
|
||||||
|
// Handle FastAPI validation error format: [{type, loc, msg, input}]
|
||||||
|
errorMessage = error.detail.map((e: any) => e.msg || JSON.stringify(e)).join(', ');
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
} else {
|
||||||
|
errorMessage = `HTTP error ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
auth: {
|
||||||
|
async register(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: email, password })
|
||||||
|
});
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<User> {
|
||||||
|
const response = await fetch(`${API_URL}/auth/me`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<User>(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bots: {
|
||||||
|
async list(): Promise<Bot[]> {
|
||||||
|
const response = await fetch(`${API_URL}/bots`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Bot[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(name: string, description?: string): Promise<Bot> {
|
||||||
|
const response = await fetch(`${API_URL}/bots`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ name, description })
|
||||||
|
});
|
||||||
|
return handleResponse<Bot>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Bot> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${id}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Bot>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Bot>): Promise<Bot> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return handleResponse<Bot>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${id}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ message } as BotChatRequest),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return handleResponse<BotChatResponse>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHistory(id: string): Promise<BotConversation[]> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${id}/history`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<BotConversation[]>(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backtest: {
|
||||||
|
async start(botId: string, config: { token: string; timeframe: string; start_date: string; end_date: string }): Promise<Backtest> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ ...config, chain: 'bsc' })
|
||||||
|
});
|
||||||
|
return handleResponse<Backtest>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(botId: string, runId: string): Promise<Backtest> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Backtest>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(botId: string): Promise<Backtest[]> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/backtests`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Backtest[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop(botId: string, runId: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTrades(botId: string, runId: string, page: number = 1, perPage: number = 5): Promise<{
|
||||||
|
trades: any[];
|
||||||
|
total_trades: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/trades?page=${page}&per_page=${perPage}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
simulate: {
|
||||||
|
async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
return handleResponse<Simulation>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(botId: string, runId: string): Promise<Simulation> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Simulation>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(botId: string): Promise<Simulation[]> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/simulations`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Simulation[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop(botId: string, runId: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
config: {
|
||||||
|
async getChains(): Promise<string[]> {
|
||||||
|
const response = await fetch(`${API_URL}/config/chains`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<string[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTokens(): Promise<{ symbol: string; chain: string; name: string }[]> {
|
||||||
|
const response = await fetch(`${API_URL}/config/tokens`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
2
src/frontend/src/lib/api/index.ts
Normal file
2
src/frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { api } from './client';
|
||||||
|
export * from './types';
|
||||||
188
src/frontend/src/lib/api/types.ts
Normal file
188
src/frontend/src/lib/api/types.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bot {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
strategy_config: StrategyConfig;
|
||||||
|
llm_config: LLMConfig;
|
||||||
|
status: 'draft' | 'active' | 'paused';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyConfig {
|
||||||
|
conditions: Condition[];
|
||||||
|
actions: Action[];
|
||||||
|
risk_management?: RiskManagement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
|
||||||
|
token: string;
|
||||||
|
token_address?: string;
|
||||||
|
chain?: string;
|
||||||
|
threshold?: number;
|
||||||
|
price?: number;
|
||||||
|
direction?: 'above' | 'below';
|
||||||
|
timeframe?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Action {
|
||||||
|
type: 'buy' | 'sell' | 'hold';
|
||||||
|
amount_percent?: number;
|
||||||
|
token?: string;
|
||||||
|
token_address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskManagement {
|
||||||
|
stop_loss_percent?: number;
|
||||||
|
take_profit_percent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMConfig {
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotConversation {
|
||||||
|
id: string;
|
||||||
|
bot_id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Backtest {
|
||||||
|
id: string;
|
||||||
|
bot_id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'stopped';
|
||||||
|
config: BacktestConfig;
|
||||||
|
result: BacktestResult | null;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestConfig {
|
||||||
|
token: string;
|
||||||
|
token_name?: string;
|
||||||
|
chain: string;
|
||||||
|
timeframe: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestResult {
|
||||||
|
total_return: number;
|
||||||
|
win_rate: number;
|
||||||
|
total_trades: number;
|
||||||
|
buy_signals: number;
|
||||||
|
sell_signals: number;
|
||||||
|
max_drawdown: number;
|
||||||
|
sharpe_ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedTrades {
|
||||||
|
trades: Trade[];
|
||||||
|
total_trades: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trade {
|
||||||
|
type: 'buy' | 'sell';
|
||||||
|
token: string;
|
||||||
|
price: number;
|
||||||
|
amount: number;
|
||||||
|
quantity: number;
|
||||||
|
timestamp: number;
|
||||||
|
exit_reason?: 'stop_loss' | 'take_profit' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Simulation {
|
||||||
|
id: string;
|
||||||
|
bot_id: string;
|
||||||
|
started_at: string;
|
||||||
|
status: 'running' | 'stopped' | 'completed';
|
||||||
|
config: SimulationConfig;
|
||||||
|
signals: Signal[] | null;
|
||||||
|
klines?: { time: number; close: number }[];
|
||||||
|
trade_log?: TradeLogEntry[];
|
||||||
|
portfolio?: Portfolio;
|
||||||
|
current_candle_index?: number;
|
||||||
|
total_candles?: number;
|
||||||
|
candles_processed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationConfig {
|
||||||
|
token: string;
|
||||||
|
chain?: string;
|
||||||
|
kline_interval?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeLogEntry {
|
||||||
|
time: number;
|
||||||
|
price: number;
|
||||||
|
action: 'buy' | 'sell' | 'hold';
|
||||||
|
reason: string;
|
||||||
|
position: number;
|
||||||
|
entry_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Portfolio {
|
||||||
|
initial_balance: number;
|
||||||
|
current_balance: number;
|
||||||
|
position: number;
|
||||||
|
position_token: string;
|
||||||
|
entry_price: number;
|
||||||
|
current_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Signal {
|
||||||
|
id: string;
|
||||||
|
bot_id: string;
|
||||||
|
run_id: string;
|
||||||
|
signal_type: 'buy' | 'sell' | 'hold';
|
||||||
|
token: string;
|
||||||
|
price: number;
|
||||||
|
confidence: number | null;
|
||||||
|
reasoning: string | null;
|
||||||
|
executed: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotChatRequest {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotChatResponse {
|
||||||
|
response: string;
|
||||||
|
thinking: string | null;
|
||||||
|
strategy_config: StrategyConfig | null;
|
||||||
|
success: boolean;
|
||||||
|
strategy_needs_confirmation?: boolean;
|
||||||
|
strategy_data?: StrategyConfig | null;
|
||||||
|
token_search_results?: TokenSearchResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenSearchResult {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
chain: string;
|
||||||
|
}
|
||||||
1
src/frontend/src/lib/assets/favicon.svg
Normal file
1
src/frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BacktestResult } from '$lib/api';
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
results: BacktestResult | null;
|
||||||
|
signals?: Array<{ created_at: string; signal_type: string; price: number }>;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { results, signals = [], height = 300 }: Props = $props();
|
||||||
|
|
||||||
|
let width = $state(800);
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (containerEl) {
|
||||||
|
width = containerEl.clientWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function generatePortfolioCurve(): ChartDataPoint[] {
|
||||||
|
if (!results || signals.length === 0) return [];
|
||||||
|
|
||||||
|
const points: ChartDataPoint[] = [];
|
||||||
|
const startValue = 10000;
|
||||||
|
let currentValue = startValue;
|
||||||
|
|
||||||
|
const sortedSignals = [...signals].sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
timestamp: sortedSignals[0]?.created_at || new Date().toISOString(),
|
||||||
|
value: currentValue
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of sortedSignals) {
|
||||||
|
if (signal.signal_type === 'buy') {
|
||||||
|
currentValue *= 1.05;
|
||||||
|
} else if (signal.signal_type === 'sell') {
|
||||||
|
currentValue *= 0.95;
|
||||||
|
}
|
||||||
|
points.push({
|
||||||
|
timestamp: signal.created_at,
|
||||||
|
value: currentValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartArea(w: number, h: number): { x: number; y: number; width: number; height: number } {
|
||||||
|
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
|
||||||
|
return {
|
||||||
|
x: padding.left,
|
||||||
|
y: padding.top,
|
||||||
|
width: w - padding.left - padding.right,
|
||||||
|
height: h - padding.top - padding.bottom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueRange(pts: ChartDataPoint[]): { min: number; max: number } {
|
||||||
|
if (pts.length === 0) return { min: 0, max: 10000 };
|
||||||
|
const values = pts.map(p => p.value);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const padding = (max - min) * 0.1 || 1000;
|
||||||
|
return { min: min - padding, max: max + padding };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointPosition(point: ChartDataPoint, index: number, total: number, area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): { x: number; y: number } {
|
||||||
|
const x = area.x + (index / Math.max(total - 1, 1)) * area.width;
|
||||||
|
const normalizedValue = (point.value - range.min) / (range.max - range.min);
|
||||||
|
const y = area.y + area.height - normalizedValue * area.height;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYAxisLabels(area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ value: number; y: number }> {
|
||||||
|
const step = (range.max - range.min) / 4;
|
||||||
|
return [0, 1, 2, 3, 4].map(i => ({
|
||||||
|
value: range.max - i * step,
|
||||||
|
y: area.y + (i / 4) * area.height
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXAxisLabels(pts: ChartDataPoint[], area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ label: string; x: number }> {
|
||||||
|
if (pts.length === 0) return [];
|
||||||
|
const step = Math.max(1, Math.floor(pts.length / 5));
|
||||||
|
return pts
|
||||||
|
.filter((_, i) => i % step === 0 || i === pts.length - 1)
|
||||||
|
.map((p, i, arr) => ({
|
||||||
|
label: new Date(p.timestamp).toLocaleDateString(),
|
||||||
|
x: getPointPosition(p, pts.indexOf(p), pts.length, area, range).x
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReturnColor(): string {
|
||||||
|
if (!results) return '#888';
|
||||||
|
return results.total_return >= 0 ? '#22c55e' : '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
let points = $derived(generatePortfolioCurve());
|
||||||
|
let area = $derived(getChartArea(width, height));
|
||||||
|
let range = $derived(getValueRange(points));
|
||||||
|
let yAxisLabels = $derived(getYAxisLabels(area, range));
|
||||||
|
let xAxisLabels = $derived(getXAxisLabels(points, area, range));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backtest-chart" bind:this={containerEl}>
|
||||||
|
{#if !results}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No backtest results to display</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="chart-header">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Return</span>
|
||||||
|
<span class="metric-value" style="color: {getReturnColor()}">
|
||||||
|
{results.total_return >= 0 ? '+' : ''}{results.total_return.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Win Rate</span>
|
||||||
|
<span class="metric-value">{results.win_rate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Trades</span>
|
||||||
|
<span class="metric-value">{results.total_trades}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Sharpe Ratio</span>
|
||||||
|
<span class="metric-value">{results.sharpe_ratio.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg {width} {height} viewBox="0 0 {width} {height}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.4)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="grid-lines">
|
||||||
|
{#each [0, 1, 2, 3, 4] as i}
|
||||||
|
{@const y = area.y + (i / 4) * area.height}
|
||||||
|
<line
|
||||||
|
x1={area.x}
|
||||||
|
y1={y}
|
||||||
|
x2={area.x + area.width}
|
||||||
|
y2={y}
|
||||||
|
stroke="rgba(255,255,255,0.08)"
|
||||||
|
stroke-dasharray="4,4"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="y-axis">
|
||||||
|
{#each yAxisLabels as label}
|
||||||
|
<text x={area.x - 8} y={label.y + 4} class="axis-label" text-anchor="end">
|
||||||
|
${label.value.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="x-axis">
|
||||||
|
{#each xAxisLabels as label}
|
||||||
|
<text x={label.x} y={height - 8} class="axis-label" text-anchor="middle">
|
||||||
|
{label.label}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{#if points.length > 1}
|
||||||
|
<path
|
||||||
|
d={points.map((p, i) => {
|
||||||
|
const pos = getPointPosition(p, i, points.length, area, range);
|
||||||
|
if (i === 0) {
|
||||||
|
return `M ${pos.x} ${area.y + area.height} L ${pos.x} ${pos.y}`;
|
||||||
|
}
|
||||||
|
return `L ${pos.x} ${pos.y}`;
|
||||||
|
}).join(' ') + ` L ${getPointPosition(points[points.length - 1], points.length - 1, points.length, area, range).x} ${area.y + area.height} Z`}
|
||||||
|
fill="url(#portfolioGradient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={points.map((p, i) => {
|
||||||
|
const pos = getPointPosition(p, i, points.length, area, range);
|
||||||
|
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
|
||||||
|
}).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="#667eea"
|
||||||
|
stroke-width="2.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="chart-footer">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Buy Signals</span>
|
||||||
|
<span class="stat-value buy">{results.buy_signals}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Sell Signals</span>
|
||||||
|
<span class="stat-value sell">{results.sell_signals}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Max Drawdown</span>
|
||||||
|
<span class="stat-value negative">-{results.max_drawdown.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backtest-chart {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label {
|
||||||
|
font-size: 10px;
|
||||||
|
fill: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bot: Bot;
|
||||||
|
onOpen?: (botId: string) => void;
|
||||||
|
onDelete?: (botId: string) => void;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { bot, onOpen, onDelete, showActions = true }: Props = $props();
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
onOpen?.(bot.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.(bot.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}>
|
||||||
|
<div class="bot-info">
|
||||||
|
<h3>{bot.name}</h3>
|
||||||
|
{#if bot.description}
|
||||||
|
<p class="bot-description">{bot.description}</p>
|
||||||
|
{/if}
|
||||||
|
<span class="bot-status status-{bot.status}">{bot.status}</span>
|
||||||
|
</div>
|
||||||
|
{#if showActions}
|
||||||
|
<div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group">
|
||||||
|
<button class="btn btn-primary" onclick={handleOpen}>Open</button>
|
||||||
|
<button class="btn btn-danger" onclick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-card:focus {
|
||||||
|
outline: 2px solid #667eea;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-info h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-description {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bots: Bot[];
|
||||||
|
selectedBotId?: string | null;
|
||||||
|
onSelect: (botId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { bots, selectedBotId = null, onSelect, disabled = false, label = 'Select Bot' }: Props = $props();
|
||||||
|
|
||||||
|
function handleChange(e: Event) {
|
||||||
|
const target = e.target as HTMLSelectElement;
|
||||||
|
onSelect(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BOTS = 3;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-selector">
|
||||||
|
{#if label}
|
||||||
|
<label for="bot-select">{label}</label>
|
||||||
|
{/if}
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select
|
||||||
|
id="bot-select"
|
||||||
|
onchange={handleChange}
|
||||||
|
disabled={disabled || bots.length === 0}
|
||||||
|
value={selectedBotId || ''}
|
||||||
|
>
|
||||||
|
{#if bots.length === 0}
|
||||||
|
<option value="" disabled>No bots available</option>
|
||||||
|
{:else}
|
||||||
|
{#each bots as bot}
|
||||||
|
<option value={bot.id}>{bot.name}</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<span class="bot-count">{bots.length}/{MAX_BOTS}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
558
src/frontend/src/lib/components/ChatInterface.svelte
Normal file
558
src/frontend/src/lib/components/ChatInterface.svelte
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
import type { ChatMessage } from '$lib/stores/chatStore';
|
||||||
|
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bot: Bot | null;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
isSending?: boolean;
|
||||||
|
onSendMessage: (message: string) => void;
|
||||||
|
onSelectBot?: (botId: string) => void;
|
||||||
|
availableBots?: Bot[];
|
||||||
|
showBotSelector?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
bot,
|
||||||
|
messages,
|
||||||
|
isSending = false,
|
||||||
|
onSendMessage,
|
||||||
|
onSelectBot,
|
||||||
|
availableBots = [],
|
||||||
|
showBotSelector = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let messageInput = $state('');
|
||||||
|
let chatContainer: HTMLDivElement;
|
||||||
|
let expandedThinking: Record<string, boolean> = $state({});
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!messageInput.trim()) return;
|
||||||
|
onSendMessage(messageInput);
|
||||||
|
messageInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBotChange(e: Event) {
|
||||||
|
const target = e.target as HTMLSelectElement;
|
||||||
|
if (onSelectBot && target.value) {
|
||||||
|
onSelectBot(target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThinkingExpand(messageId: string) {
|
||||||
|
expandedThinking[messageId] = !expandedThinking[messageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (messages.length && chatContainer) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(content: string) {
|
||||||
|
return parseMarkdown(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(segments: InlineSegment[]): string {
|
||||||
|
return segments.map(seg => {
|
||||||
|
switch (seg.type) {
|
||||||
|
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||||
|
case 'italic': return `<em>${seg.content}</em>`;
|
||||||
|
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||||
|
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||||
|
default: return seg.content;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat-interface">
|
||||||
|
{#if showBotSelector && availableBots.length > 0}
|
||||||
|
<div class="bot-selector">
|
||||||
|
<label for="bot-select">Active Bot:</label>
|
||||||
|
<select id="bot-select" onchange={handleBotChange}>
|
||||||
|
{#each availableBots as availableBot}
|
||||||
|
<option value={availableBot.id} selected={availableBot.id === bot?.id}>
|
||||||
|
{availableBot.name}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="chat-messages" bind:this={chatContainer}>
|
||||||
|
{#if messages.length === 0}
|
||||||
|
<div class="welcome-message">
|
||||||
|
<p>Welcome to {bot?.name || 'your bot'}! Describe your trading strategy in plain English.</p>
|
||||||
|
<p class="hint">Example: "Buy PEPE when the price drops by 5% within 1 hour"</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each messages as message}
|
||||||
|
<div class="message {message.role}">
|
||||||
|
{#if message.role === 'assistant' && message.thinking}
|
||||||
|
{@const firstLine = message.thinking.split('\n')[0]}
|
||||||
|
{@const isExpanded = expandedThinking[message.id] ?? false}
|
||||||
|
<div class="thinking-section">
|
||||||
|
<button class="thinking-toggle" onclick={() => toggleThinkingExpand(message.id)}>
|
||||||
|
<span class="thinking-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
<span class="thinking-label">{isExpanded ? 'Hide reasoning' : 'Show reasoning'}</span>
|
||||||
|
{#if !isExpanded}
|
||||||
|
<span class="thinking-preview"> — {firstLine.slice(0, 60)}{firstLine.length > 60 ? '...' : ''}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="thinking-content">
|
||||||
|
{message.thinking}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="message-content">
|
||||||
|
{#each renderContent(message.content) as segment}
|
||||||
|
{#if segment.type === 'bold'}
|
||||||
|
<strong>{segment.content}</strong>
|
||||||
|
{:else if segment.type === 'italic'}
|
||||||
|
<em>{segment.content}</em>
|
||||||
|
{:else if segment.type === 'code'}
|
||||||
|
<code class="inline-code">{segment.content}</code>
|
||||||
|
{:else if segment.type === 'codeBlock'}
|
||||||
|
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||||
|
{:else if segment.type === 'link'}
|
||||||
|
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
|
||||||
|
{:else if segment.type === 'list' && segment.items}
|
||||||
|
<ul>
|
||||||
|
{#each segment.items as item}
|
||||||
|
<li>{@html renderInline(parseInlineElements(item))}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if segment.type === 'table' && segment.headers && segment.rows}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="markdown-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each segment.headers as header}
|
||||||
|
<th>
|
||||||
|
{#each header as cellSeg}
|
||||||
|
{#if cellSeg.type === 'bold'}
|
||||||
|
<strong>{cellSeg.content}</strong>
|
||||||
|
{:else if cellSeg.type === 'italic'}
|
||||||
|
<em>{cellSeg.content}</em>
|
||||||
|
{:else if cellSeg.type === 'code'}
|
||||||
|
<code class="inline-code">{cellSeg.content}</code>
|
||||||
|
{:else if cellSeg.type === 'link'}
|
||||||
|
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||||
|
{:else}
|
||||||
|
{cellSeg.content}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each segment.rows as row}
|
||||||
|
<tr>
|
||||||
|
{#each row as cell}
|
||||||
|
<td>
|
||||||
|
{#each cell as cellSeg}
|
||||||
|
{#if cellSeg.type === 'bold'}
|
||||||
|
<strong>{cellSeg.content}</strong>
|
||||||
|
{:else if cellSeg.type === 'italic'}
|
||||||
|
<em>{cellSeg.content}</em>
|
||||||
|
{:else if cellSeg.type === 'code'}
|
||||||
|
<code class="inline-code">{cellSeg.content}</code>
|
||||||
|
{:else if cellSeg.type === 'link'}
|
||||||
|
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||||
|
{:else}
|
||||||
|
{cellSeg.content}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if segment.type === 'heading'}
|
||||||
|
<h4 class="content-heading">{segment.content}</h4>
|
||||||
|
{:else if segment.type === 'lineBreak'}
|
||||||
|
<br />
|
||||||
|
{:else}
|
||||||
|
{segment.content}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="message-time">
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if isSending}
|
||||||
|
<div class="message assistant">
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="typing">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if bot}
|
||||||
|
<div class="input-container">
|
||||||
|
<textarea
|
||||||
|
bind:value={messageInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="Describe your trading strategy..."
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
<button onclick={handleSend}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-interface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-content {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-section {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-icon {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-label {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-preview {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system .message-content {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-heading {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-heading:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-table th,
|
||||||
|
.markdown-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-table th {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #888;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: typing 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Condition } from '$lib/api';
|
||||||
|
import TokenPicker from './TokenPicker.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conditions: Condition[];
|
||||||
|
onUpdate: (conditions: Condition[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { conditions, onUpdate, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
type ConditionType = Condition['type'];
|
||||||
|
|
||||||
|
const conditionTypes: { value: ConditionType; label: string; description: string }[] = [
|
||||||
|
{ value: 'price_drop', label: 'Price Drop', description: 'Trigger when price falls by X%' },
|
||||||
|
{ value: 'price_rise', label: 'Price Rise', description: 'Trigger when price rises by X%' },
|
||||||
|
{ value: 'volume_spike', label: 'Volume Spike', description: 'Trigger when volume increases by X%' },
|
||||||
|
{ value: 'price_level', label: 'Price Level', description: 'Trigger when price crosses a level' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d'];
|
||||||
|
|
||||||
|
function addCondition() {
|
||||||
|
const newCondition: Condition = {
|
||||||
|
type: 'price_drop',
|
||||||
|
token: '',
|
||||||
|
threshold: 5,
|
||||||
|
timeframe: '1h'
|
||||||
|
};
|
||||||
|
onUpdate([...conditions, newCondition]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(index: number) {
|
||||||
|
onUpdate(conditions.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(index: number, updates: Partial<Condition>) {
|
||||||
|
const updated = conditions.map((c, i) =>
|
||||||
|
i === index ? { ...c, ...updates } : c
|
||||||
|
);
|
||||||
|
onUpdate(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConditionDescription(condition: Condition): string {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return `Price drops ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'price_rise':
|
||||||
|
return `Price rises ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'volume_spike':
|
||||||
|
return `Volume spikes ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'price_level':
|
||||||
|
return `Price crosses ${condition.direction || 'above'} $${condition.price || 0}`;
|
||||||
|
default:
|
||||||
|
return 'Unknown condition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="condition-builder">
|
||||||
|
<div class="conditions-header">
|
||||||
|
<h4>Conditions</h4>
|
||||||
|
<button type="button" class="add-btn" onclick={addCondition} {disabled}>
|
||||||
|
+ Add Condition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if conditions.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No conditions set</p>
|
||||||
|
<p class="hint">Add a condition to define when your strategy triggers</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="conditions-list">
|
||||||
|
{#each conditions as condition, index}
|
||||||
|
<div class="condition-card">
|
||||||
|
<div class="condition-header">
|
||||||
|
<span class="condition-number">#{index + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-btn"
|
||||||
|
onclick={() => removeCondition(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Remove condition"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label for="type-{index}">Type</label>
|
||||||
|
<select
|
||||||
|
id="type-{index}"
|
||||||
|
value={condition.type}
|
||||||
|
onchange={(e) => updateCondition(index, { type: (e.target as HTMLSelectElement).value as ConditionType })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{#each conditionTypes as ct}
|
||||||
|
<option value={ct.value}>{ct.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TokenPicker
|
||||||
|
label="Token"
|
||||||
|
selectedToken={condition.token}
|
||||||
|
selectedChain={condition.chain || ''}
|
||||||
|
onSelect={(token, chain) => updateCondition(index, { token, chain })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if condition.type === 'price_level'}
|
||||||
|
<div class="field">
|
||||||
|
<label for="direction-{index}">Direction</label>
|
||||||
|
<select
|
||||||
|
id="direction-{index}"
|
||||||
|
value={condition.direction || 'above'}
|
||||||
|
onchange={(e) => updateCondition(index, { direction: (e.target as HTMLSelectElement).value as 'above' | 'below' })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="above">Above</option>
|
||||||
|
<option value="below">Below</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="price-{index}">Price ($)</label>
|
||||||
|
<input
|
||||||
|
id="price-{index}"
|
||||||
|
type="number"
|
||||||
|
value={condition.price || ''}
|
||||||
|
oninput={(e) => updateCondition(index, { price: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||||
|
placeholder="0.000001"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="field">
|
||||||
|
<label for="threshold-{index}">Threshold (%)</label>
|
||||||
|
<input
|
||||||
|
id="threshold-{index}"
|
||||||
|
type="number"
|
||||||
|
value={condition.threshold || ''}
|
||||||
|
oninput={(e) => updateCondition(index, { threshold: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||||
|
placeholder="5"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="timeframe-{index}">Timeframe</label>
|
||||||
|
<select
|
||||||
|
id="timeframe-{index}"
|
||||||
|
value={condition.timeframe || '1h'}
|
||||||
|
onchange={(e) => updateCondition(index, { timeframe: (e.target as HTMLSelectElement).value })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{#each timeframes as tf}
|
||||||
|
<option value={tf}>{tf}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-preview">
|
||||||
|
<span class="preview-label">Summary:</span>
|
||||||
|
{getConditionDescription(condition)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.condition-builder {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-number {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-preview {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
color: #666;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
initialBalance?: number;
|
||||||
|
currentBalance?: number;
|
||||||
|
position?: number;
|
||||||
|
positionToken?: string;
|
||||||
|
entryPrice?: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
initialBalance = 10000,
|
||||||
|
currentBalance = 10000,
|
||||||
|
position = 0,
|
||||||
|
positionToken = '',
|
||||||
|
entryPrice = 0,
|
||||||
|
currentPrice = 0
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
let positionValue = $derived(position * currentPrice);
|
||||||
|
let totalValue = $derived(currentBalance + positionValue);
|
||||||
|
let pnl = $derived(totalValue - initialBalance);
|
||||||
|
let pnlPercent = $derived((pnl / initialBalance) * 100);
|
||||||
|
let unrealizedPnL = $derived(position > 0 && entryPrice > 0 ? (currentPrice - entryPrice) / entryPrice * 100 : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="portfolio-summary">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Cash Balance</span>
|
||||||
|
<span class="value">${currentBalance.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if position > 0}
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Position ({positionToken || 'Token'})</span>
|
||||||
|
<span class="value highlight">{position.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Position Value</span>
|
||||||
|
<span class="value">${positionValue.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Entry Price</span>
|
||||||
|
<span class="value">${entryPrice.toFixed(8)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Current Price</span>
|
||||||
|
<span class="value">${currentPrice.toFixed(8)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Unrealized P&L</span>
|
||||||
|
<span class="value" class:positive={unrealizedPnL > 0} class:negative={unrealizedPnL < 0}>
|
||||||
|
{unrealizedPnL >= 0 ? '+' : ''}{unrealizedPnL.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="metric total">
|
||||||
|
<span class="label">Total Value</span>
|
||||||
|
<span class="value">${totalValue.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">P&L</span>
|
||||||
|
<span class="value large" class:positive={pnl > 0} class:negative={pnl < 0}>
|
||||||
|
{pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.portfolio-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value.highlight {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value.large {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.total {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.total .value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #22c55e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
feature?: string;
|
||||||
|
dismissible?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { feature, dismissible = true, onDismiss }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pro-upgrade-banner">
|
||||||
|
<div class="banner-content">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="banner-text">
|
||||||
|
<strong>Upgrade to Pro</strong>
|
||||||
|
{#if feature}
|
||||||
|
<p>{feature}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Unlock advanced features and unlimited bots</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<a href="/settings" class="upgrade-btn">Upgrade Now</a>
|
||||||
|
</div>
|
||||||
|
{#if dismissible && onDismiss}
|
||||||
|
<button class="dismiss-btn" onclick={onDismiss} aria-label="Dismiss">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pro-upgrade-banner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text strong {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
src/frontend/src/lib/components/SignalChart.svelte
Normal file
266
src/frontend/src/lib/components/SignalChart.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Signal } from '$lib/api';
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
signals?: Signal[];
|
||||||
|
klines?: { time: number; close: number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { signals = [], klines = [], height = 200 }: Props = $props();
|
||||||
|
|
||||||
|
let width = $state(800);
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
let canvasEl: HTMLCanvasElement;
|
||||||
|
let initialized = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Set initial width
|
||||||
|
if (containerEl) {
|
||||||
|
width = containerEl.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize observer
|
||||||
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
width = entry.contentRect.width;
|
||||||
|
drawChart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerEl) {
|
||||||
|
resizeObserver.observe(containerEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw when data changes
|
||||||
|
$effect(() => {
|
||||||
|
// Access reactive values to trigger effect
|
||||||
|
const currentSignals = signals;
|
||||||
|
const currentKlines = klines;
|
||||||
|
const currentWidth = width;
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
tick().then(() => {
|
||||||
|
drawChart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
|
if (!canvasEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvasEl.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvasEl.width = width * dpr;
|
||||||
|
canvasEl.height = height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Check if we have data
|
||||||
|
if (klines.length === 0 && signals.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get price data
|
||||||
|
let priceData: { time: number; price: number }[] = [];
|
||||||
|
|
||||||
|
if (klines.length > 0) {
|
||||||
|
priceData = klines.map(k => ({
|
||||||
|
time: k.time,
|
||||||
|
price: typeof k.close === 'string' ? parseFloat(k.close) : k.close
|
||||||
|
})).filter(d => !isNaN(d.price) && d.price > 0);
|
||||||
|
} else if (signals.length > 0) {
|
||||||
|
priceData = signals.map(s => ({ time: 0, price: s.price }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceData.length === 0) return;
|
||||||
|
|
||||||
|
const prices = priceData.map(d => d.price);
|
||||||
|
const padding = { top: 20, right: 20, bottom: 45, left: 60 }; // More bottom padding for time labels
|
||||||
|
const chartWidth = width - padding.left - padding.right;
|
||||||
|
const chartHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
// Price range with padding
|
||||||
|
const minPrice = Math.min(...prices);
|
||||||
|
const maxPrice = Math.max(...prices);
|
||||||
|
const priceRange = maxPrice - minPrice || 1;
|
||||||
|
const paddedMin = minPrice - priceRange * 0.1;
|
||||||
|
const paddedMax = maxPrice + priceRange * 0.1;
|
||||||
|
|
||||||
|
function priceToY(price: number): number {
|
||||||
|
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexToX(index: number): number {
|
||||||
|
return padding.left + (index / Math.max(prices.length - 1, 1)) * chartWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = padding.top + (i / 4) * chartHeight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(width - padding.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Y axis labels
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
|
||||||
|
const y = padding.top + (i / 4) * chartHeight + 4;
|
||||||
|
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw price line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = '#667eea';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.moveTo(indexToX(0), priceToY(prices[0]));
|
||||||
|
for (let i = 1; i < prices.length; i++) {
|
||||||
|
ctx.lineTo(indexToX(i), priceToY(prices[i]));
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Fill area under line
|
||||||
|
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
|
||||||
|
ctx.lineTo(indexToX(0), padding.top + chartHeight);
|
||||||
|
ctx.closePath();
|
||||||
|
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
|
||||||
|
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
|
||||||
|
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw signal markers
|
||||||
|
if (signals.length > 0) {
|
||||||
|
signals.forEach((signal) => {
|
||||||
|
// Find closest price match
|
||||||
|
const signalPrice = signal.price;
|
||||||
|
let closestIndex = 0;
|
||||||
|
let closestDiff = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < priceData.length; i++) {
|
||||||
|
const diff = Math.abs(priceData[i].price - signalPrice);
|
||||||
|
if (diff < closestDiff) {
|
||||||
|
closestDiff = diff;
|
||||||
|
closestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = indexToX(closestIndex);
|
||||||
|
const y = priceToY(signalPrice);
|
||||||
|
const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
|
||||||
|
|
||||||
|
// Vertical dashed line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.moveTo(x, padding.top);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Signal dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw X axis time labels
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
const numTimeLabels = Math.min(5, priceData.length);
|
||||||
|
for (let i = 0; i < numTimeLabels; i++) {
|
||||||
|
const dataIndex = Math.floor(i * (priceData.length - 1) / (numTimeLabels - 1 || 1));
|
||||||
|
const x = indexToX(dataIndex);
|
||||||
|
|
||||||
|
// Convert timestamp to readable time
|
||||||
|
let timeLabel = '';
|
||||||
|
if (priceData[dataIndex].time > 0) {
|
||||||
|
const date = new Date(priceData[dataIndex].time * 1000);
|
||||||
|
timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} else {
|
||||||
|
timeLabel = `${dataIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(timeLabel, x, height - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
if (signals.length > 0) {
|
||||||
|
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
|
||||||
|
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
|
||||||
|
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${priceData.length} Candles`, width / 2, height - 20);
|
||||||
|
} else {
|
||||||
|
ctx.fillText(`${priceData.length} Candles (No signals generated)`, width / 2, height - 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="signal-chart" bind:this={containerEl}>
|
||||||
|
{#if klines.length === 0 && signals.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No data to display. Start a simulation to see price movements.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasEl}
|
||||||
|
style="width: 100%; height: {height}px;"
|
||||||
|
></canvas>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.signal-chart {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
228
src/frontend/src/lib/components/StrategyPreview.svelte
Normal file
228
src/frontend/src/lib/components/StrategyPreview.svelte
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StrategyConfig } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: StrategyConfig | null;
|
||||||
|
editable?: boolean;
|
||||||
|
onUpdate?: (config: StrategyConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { config, editable = false, onUpdate }: Props = $props();
|
||||||
|
|
||||||
|
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
|
||||||
|
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return `${condition.token} drops by ${condition.threshold}%${timeframe}`;
|
||||||
|
case 'price_rise':
|
||||||
|
return `${condition.token} rises by ${condition.threshold}%${timeframe}`;
|
||||||
|
case 'volume_spike':
|
||||||
|
return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`;
|
||||||
|
case 'price_level':
|
||||||
|
return `${condition.token} crosses ${condition.direction} $${condition.price}`;
|
||||||
|
default:
|
||||||
|
return 'Unknown condition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionDescription(action: StrategyConfig['actions'][0]): string {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'buy':
|
||||||
|
return `Buy ${action.amount_percent}% of ${action.token || 'portfolio'}`;
|
||||||
|
case 'sell':
|
||||||
|
return `Sell ${action.amount_percent}% of ${action.token || 'portfolio'}`;
|
||||||
|
case 'hold':
|
||||||
|
return 'Hold';
|
||||||
|
default:
|
||||||
|
return 'Unknown action';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="strategy-preview">
|
||||||
|
{#if !config || (config.conditions.length === 0 && config.actions.length === 0)}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No strategy configured yet.</p>
|
||||||
|
<p class="hint">Describe your trading strategy in the chat to create one.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="strategy-section">
|
||||||
|
<h4>Conditions</h4>
|
||||||
|
{#if config.conditions.length === 0}
|
||||||
|
<p class="empty">No conditions set</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="items-list">
|
||||||
|
{#each config.conditions as condition, i}
|
||||||
|
<li>
|
||||||
|
<span class="condition-badge">{condition.type.replace('_', ' ')}</span>
|
||||||
|
{getConditionDescription(condition)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="strategy-section">
|
||||||
|
<h4>Actions</h4>
|
||||||
|
{#if config.actions.length === 0}
|
||||||
|
<p class="empty">No actions set</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="items-list">
|
||||||
|
{#each config.actions as action}
|
||||||
|
<li>
|
||||||
|
<span class="action-badge action-{action.type}">{action.type}</span>
|
||||||
|
{getActionDescription(action)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if config.risk_management}
|
||||||
|
<div class="strategy-section">
|
||||||
|
<h4>Risk Management</h4>
|
||||||
|
<div class="risk-items">
|
||||||
|
{#if config.risk_management.stop_loss_percent}
|
||||||
|
<div class="risk-item">
|
||||||
|
<span class="risk-label">Stop Loss</span>
|
||||||
|
<span class="risk-value negative">{config.risk_management.stop_loss_percent}%</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if config.risk_management.take_profit_percent}
|
||||||
|
<div class="risk-item">
|
||||||
|
<span class="risk-label">Take Profit</span>
|
||||||
|
<span class="risk-value positive">{config.risk_management.take_profit_percent}%</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.strategy-preview {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-badge,
|
||||||
|
.action-badge {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-badge {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge {
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buy {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sell {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hold {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
symbol: string;
|
||||||
|
chain: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedToken?: string;
|
||||||
|
selectedChain?: string;
|
||||||
|
onSelect: (token: string, chain: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedToken = '', selectedChain = '', onSelect, disabled = false, label = 'Select Token' }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let tokens = $state<Token[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
const commonTokens: Token[] = [
|
||||||
|
{ symbol: 'BTC', chain: 'btc', name: 'Bitcoin' },
|
||||||
|
{ symbol: 'ETH', chain: 'eth', name: 'Ethereum' },
|
||||||
|
{ symbol: 'BNB', chain: 'bsc', name: 'BNB' },
|
||||||
|
{ symbol: 'PEPE', chain: 'bsc', name: 'Pepe' },
|
||||||
|
{ symbol: 'SHIB', chain: 'eth', name: 'Shiba Inu' },
|
||||||
|
{ symbol: 'DOGE', chain: 'doge', name: 'Dogecoin' },
|
||||||
|
{ symbol: 'SOL', chain: 'sol', name: 'Solana' },
|
||||||
|
{ symbol: 'XRP', chain: 'xrp', name: 'Ripple' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerEl && !containerEl.contains(event.target as Node)) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTokens() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
tokens = await api.config.getTokens();
|
||||||
|
} catch (e) {
|
||||||
|
tokens = commonTokens;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredTokens(): Token[] {
|
||||||
|
const allTokens = tokens.length > 0 ? tokens : commonTokens;
|
||||||
|
if (!searchQuery) return allTokens.slice(0, 10);
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return allTokens.filter(
|
||||||
|
t => t.symbol.toLowerCase().includes(query) ||
|
||||||
|
t.name.toLowerCase().includes(query) ||
|
||||||
|
t.chain.toLowerCase().includes(query)
|
||||||
|
).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(token: Token) {
|
||||||
|
onSelect(token.symbol, token.chain);
|
||||||
|
searchQuery = '';
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputFocus() {
|
||||||
|
isOpen = true;
|
||||||
|
if (tokens.length === 0 && !isLoading) {
|
||||||
|
loadTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="token-picker" bind:this={containerEl}>
|
||||||
|
{#if label}
|
||||||
|
<label>{label}</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
onfocus={handleInputFocus}
|
||||||
|
placeholder={selectedToken ? `${selectedToken}${selectedChain ? ` (${selectedChain})` : ''}` : 'Search tokens...'}
|
||||||
|
{disabled}
|
||||||
|
class:has-value={selectedToken}
|
||||||
|
/>
|
||||||
|
{#if selectedToken}
|
||||||
|
<button class="clear-btn" onclick={() => onSelect('', '')} disabled={disabled}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="dropdown">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading tokens...</div>
|
||||||
|
{:else if getFilteredTokens().length === 0}
|
||||||
|
<div class="no-results">No tokens found</div>
|
||||||
|
{:else}
|
||||||
|
{#each getFilteredTokens() as token}
|
||||||
|
<button
|
||||||
|
class="token-option"
|
||||||
|
class:selected={token.symbol === selectedToken && token.chain === selectedChain}
|
||||||
|
onclick={() => handleSelect(token)}
|
||||||
|
>
|
||||||
|
<span class="token-symbol">{token.symbol}</span>
|
||||||
|
<span class="token-chain">{token.chain.toUpperCase()}</span>
|
||||||
|
<span class="token-name">{token.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.token-picker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.has-value {
|
||||||
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover:not(:disabled) {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.no-results {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option.selected {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-symbol {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-chain {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TradeLogEntry } from '$lib/stores/simulationStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tradeLog: TradeLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tradeLog }: Props = $props();
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionColor(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'buy': return '#22c55e';
|
||||||
|
case 'sell': return '#ef4444';
|
||||||
|
default: return '#666';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionIcon(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'buy': return '📈';
|
||||||
|
case 'sell': return '📉';
|
||||||
|
default: return '➡️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to show only buy/sell actions
|
||||||
|
let tradeActions = $derived(tradeLog.filter(t => t.action !== 'hold'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="trade-dashboard">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h3>Trade Activity</h3>
|
||||||
|
<span class="trade-count">
|
||||||
|
{tradeActions.length} trades
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tradeActions.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No trades executed yet. Check the strategy configuration.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="trade-list">
|
||||||
|
{#each tradeActions as entry}
|
||||||
|
<div class="trade-entry action-{entry.action}">
|
||||||
|
<div class="trade-time">
|
||||||
|
<span class="action-icon">{getActionIcon(entry.action)}</span>
|
||||||
|
<span class="action-badge" style="background: {getActionColor(entry.action)}">
|
||||||
|
{entry.action.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="time">{formatTime(entry.time)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="trade-details">
|
||||||
|
<div class="price">
|
||||||
|
<span class="label">Price:</span>
|
||||||
|
<span class="value">${entry.price.toFixed(8)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reason">
|
||||||
|
<span class="label">Reason:</span>
|
||||||
|
<span class="value">{entry.reason}</span>
|
||||||
|
</div>
|
||||||
|
{#if entry.action === 'sell' && entry.position > 0}
|
||||||
|
<div class="pnl">
|
||||||
|
<span class="label">Position:</span>
|
||||||
|
<span class="value">{entry.position.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trade-dashboard {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-entry {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-entry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-entry.action-buy {
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-entry.action-sell {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-details .label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-details .value {
|
||||||
|
color: #fff;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl .value {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
src/frontend/src/lib/components/index.ts
Normal file
11
src/frontend/src/lib/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as ChatInterface } from './ChatInterface.svelte';
|
||||||
|
export { default as BotCard } from './BotCard.svelte';
|
||||||
|
export { default as BotSelector } from './BotSelector.svelte';
|
||||||
|
export { default as StrategyPreview } from './StrategyPreview.svelte';
|
||||||
|
export { default as SignalChart } from './SignalChart.svelte';
|
||||||
|
export { default as TradeDashboard } from './TradeDashboard.svelte';
|
||||||
|
export { default as PortfolioSummary } from './PortfolioSummary.svelte';
|
||||||
|
export { default as BacktestChart } from './BacktestChart.svelte';
|
||||||
|
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
||||||
|
export { default as TokenPicker } from './TokenPicker.svelte';
|
||||||
|
export { default as ConditionBuilder } from './ConditionBuilder.svelte';
|
||||||
1
src/frontend/src/lib/index.ts
Normal file
1
src/frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
50
src/frontend/src/lib/stores/authStore.ts
Normal file
50
src/frontend/src/lib/stores/authStore.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { setUser, clearUser, clearBots } from './index';
|
||||||
|
import { clearSimulationState } from './simulationStore';
|
||||||
|
import { clearBacktestState } from './backtestStore';
|
||||||
|
|
||||||
|
export const isAuthenticated = writable(false);
|
||||||
|
export const isLoading = writable(true);
|
||||||
|
|
||||||
|
export async function initAuth() {
|
||||||
|
isLoading.set(true);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const user = await api.auth.me();
|
||||||
|
setUser(user);
|
||||||
|
isAuthenticated.set(true);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
isAuthenticated.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const response = await api.auth.login(email, password);
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
const user = await api.auth.me();
|
||||||
|
setUser(user);
|
||||||
|
isAuthenticated.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string) {
|
||||||
|
const response = await api.auth.register(email, password);
|
||||||
|
localStorage.setItem('token', response.access_token);
|
||||||
|
const user = await api.auth.me();
|
||||||
|
setUser(user);
|
||||||
|
isAuthenticated.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
api.auth.logout().catch(() => {});
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
clearUser();
|
||||||
|
clearBots();
|
||||||
|
clearBacktestState();
|
||||||
|
clearSimulationState();
|
||||||
|
isAuthenticated.set(false);
|
||||||
|
}
|
||||||
45
src/frontend/src/lib/stores/backtestStore.ts
Normal file
45
src/frontend/src/lib/stores/backtestStore.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Backtest, BacktestResult } from '$lib/api';
|
||||||
|
|
||||||
|
export interface BacktestState {
|
||||||
|
currentBacktest: Backtest | null;
|
||||||
|
backtestHistory: Backtest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: BacktestState = {
|
||||||
|
currentBacktest: null,
|
||||||
|
backtestHistory: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const backtestStore = writable<BacktestState>(initialState);
|
||||||
|
|
||||||
|
export function setCurrentBacktest(backtest: Backtest | null) {
|
||||||
|
backtestStore.update(state => ({ ...state, currentBacktest: backtest }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBacktestToHistory(backtest: Backtest) {
|
||||||
|
backtestStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
backtestHistory: [backtest, ...state.backtestHistory]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBacktestHistory(backtests: Backtest[]) {
|
||||||
|
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBacktestLoading(loading: boolean) {
|
||||||
|
backtestStore.update(state => ({ ...state, isLoading: loading }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBacktestError(error: string | null) {
|
||||||
|
backtestStore.update(state => ({ ...state, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBacktestState() {
|
||||||
|
backtestStore.set(initialState);
|
||||||
|
}
|
||||||
24
src/frontend/src/lib/stores/botsStore.ts
Normal file
24
src/frontend/src/lib/stores/botsStore.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
export const botsStore = writable<Bot[]>([]);
|
||||||
|
|
||||||
|
export function setBots(bots: Bot[]) {
|
||||||
|
botsStore.set(bots);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBot(bot: Bot) {
|
||||||
|
botsStore.update(bots => [...bots, bot]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBot(bot: Bot) {
|
||||||
|
botsStore.update(bots => bots.map(b => b.id === bot.id ? bot : b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBot(botId: string) {
|
||||||
|
botsStore.update(bots => bots.filter(b => b.id !== botId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBots() {
|
||||||
|
botsStore.set([]);
|
||||||
|
}
|
||||||
48
src/frontend/src/lib/stores/chatStore.ts
Normal file
48
src/frontend/src/lib/stores/chatStore.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { BotConversation } from '$lib/api';
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
thinking: string | null;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback UUID generator for environments where crypto.randomUUID is not available
|
||||||
|
function generateId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback: simple UUID v4 implementation
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatStore = writable<ChatMessage[]>([]);
|
||||||
|
|
||||||
|
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
|
||||||
|
const newMessage: ChatMessage = {
|
||||||
|
...message,
|
||||||
|
id: generateId(),
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
chatStore.update(messages => [...messages, newMessage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMessages(messages: BotConversation[]) {
|
||||||
|
chatStore.set(messages.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
thinking: null,
|
||||||
|
timestamp: new Date(m.created_at)
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChat() {
|
||||||
|
chatStore.set([]);
|
||||||
|
}
|
||||||
12
src/frontend/src/lib/stores/currentBotStore.ts
Normal file
12
src/frontend/src/lib/stores/currentBotStore.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
export const currentBotStore = writable<Bot | null>(null);
|
||||||
|
|
||||||
|
export function setCurrentBot(bot: Bot | null) {
|
||||||
|
currentBotStore.set(bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCurrentBot() {
|
||||||
|
currentBotStore.set(null);
|
||||||
|
}
|
||||||
30
src/frontend/src/lib/stores/index.ts
Normal file
30
src/frontend/src/lib/stores/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export { userStore, setUser, clearUser } from './userStore';
|
||||||
|
export { botsStore, setBots, addBot, updateBot, removeBot, clearBots } from './botsStore';
|
||||||
|
export { currentBotStore, setCurrentBot, clearCurrentBot } from './currentBotStore';
|
||||||
|
export { chatStore, addMessage, setMessages, clearChat } from './chatStore';
|
||||||
|
export {
|
||||||
|
backtestStore,
|
||||||
|
setCurrentBacktest,
|
||||||
|
addBacktestToHistory,
|
||||||
|
setBacktestHistory,
|
||||||
|
setBacktestLoading,
|
||||||
|
setBacktestError,
|
||||||
|
clearBacktestState
|
||||||
|
} from './backtestStore';
|
||||||
|
export {
|
||||||
|
simulationStore,
|
||||||
|
setCurrentSimulation,
|
||||||
|
addSignals,
|
||||||
|
clearSignals,
|
||||||
|
setSimulationLoading,
|
||||||
|
setSimulationError,
|
||||||
|
clearSimulationState
|
||||||
|
} from './simulationStore';
|
||||||
|
export {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
initAuth,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout
|
||||||
|
} from './authStore';
|
||||||
94
src/frontend/src/lib/stores/simulationStore.ts
Normal file
94
src/frontend/src/lib/stores/simulationStore.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Simulation, Signal } from '$lib/api';
|
||||||
|
|
||||||
|
export interface KlineData {
|
||||||
|
time: number;
|
||||||
|
close: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeLogEntry {
|
||||||
|
time: number;
|
||||||
|
price: number;
|
||||||
|
action: 'buy' | 'sell' | 'hold';
|
||||||
|
reason: string;
|
||||||
|
position: number;
|
||||||
|
entry_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Portfolio {
|
||||||
|
initial_balance: number;
|
||||||
|
current_balance: number;
|
||||||
|
position: number;
|
||||||
|
position_token: string;
|
||||||
|
entry_price: number;
|
||||||
|
current_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationState {
|
||||||
|
currentSimulation: Simulation | null;
|
||||||
|
signals: Signal[];
|
||||||
|
klines: KlineData[];
|
||||||
|
tradeLog: TradeLogEntry[];
|
||||||
|
portfolio: Portfolio;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SimulationState = {
|
||||||
|
currentSimulation: null,
|
||||||
|
signals: [],
|
||||||
|
klines: [],
|
||||||
|
tradeLog: [],
|
||||||
|
portfolio: {
|
||||||
|
initial_balance: 10000,
|
||||||
|
current_balance: 10000,
|
||||||
|
position: 0,
|
||||||
|
position_token: '',
|
||||||
|
entry_price: 0,
|
||||||
|
current_price: 0
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const simulationStore = writable<SimulationState>(initialState);
|
||||||
|
|
||||||
|
export function setCurrentSimulation(simulation: Simulation | null) {
|
||||||
|
simulationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
currentSimulation: simulation,
|
||||||
|
klines: simulation?.klines || [],
|
||||||
|
tradeLog: simulation?.trade_log || [],
|
||||||
|
portfolio: simulation?.portfolio || state.portfolio
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePortfolio(portfolio: Partial<Portfolio>) {
|
||||||
|
simulationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
portfolio: { ...state.portfolio, ...portfolio }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSignals(newSignals: Signal[]) {
|
||||||
|
simulationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
signals: [...state.signals, ...newSignals]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSignals() {
|
||||||
|
simulationStore.update(state => ({ ...state, signals: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSimulationLoading(loading: boolean) {
|
||||||
|
simulationStore.update(state => ({ ...state, isLoading: loading }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSimulationError(error: string | null) {
|
||||||
|
simulationStore.update(state => ({ ...state, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSimulationState() {
|
||||||
|
simulationStore.set(initialState);
|
||||||
|
}
|
||||||
12
src/frontend/src/lib/stores/userStore.ts
Normal file
12
src/frontend/src/lib/stores/userStore.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { User } from '$lib/api';
|
||||||
|
|
||||||
|
export const userStore = writable<User | null>(null);
|
||||||
|
|
||||||
|
export function setUser(user: User | null) {
|
||||||
|
userStore.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUser() {
|
||||||
|
userStore.set(null);
|
||||||
|
}
|
||||||
256
src/frontend/src/lib/utils/markdown.ts
Normal file
256
src/frontend/src/lib/utils/markdown.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Simple markdown parser for rendering AI responses
|
||||||
|
* Supports: bold, italic, code blocks, inline code, links, lists, tables, headings, line breaks
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InlineSegment {
|
||||||
|
type: 'text' | 'bold' | 'italic' | 'code' | 'link';
|
||||||
|
content: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSegment {
|
||||||
|
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list' | 'table' | 'lineBreak' | 'heading';
|
||||||
|
content: string;
|
||||||
|
items?: string[];
|
||||||
|
headers?: InlineSegment[][];
|
||||||
|
rows?: InlineSegment[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdown(text: string): ParsedSegment[] {
|
||||||
|
const segments: ParsedSegment[] = [];
|
||||||
|
|
||||||
|
// Normalize line endings
|
||||||
|
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
// First, extract code blocks
|
||||||
|
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||||
|
const parts = text.split(codeBlockRegex);
|
||||||
|
const codeBlocks = text.match(codeBlockRegex) || [];
|
||||||
|
|
||||||
|
let partIndex = 0;
|
||||||
|
|
||||||
|
while (partIndex < parts.length) {
|
||||||
|
const part = parts[partIndex];
|
||||||
|
|
||||||
|
if (part.trim()) {
|
||||||
|
// Process non-code content
|
||||||
|
segments.push(...parseInlineContent(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add code block if there's one after this part
|
||||||
|
if (partIndex < codeBlocks.length) {
|
||||||
|
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
|
||||||
|
segments.push({ type: 'codeBlock', content: codeContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
partIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineContent(text: string): ParsedSegment[] {
|
||||||
|
const segments: ParsedSegment[] = [];
|
||||||
|
|
||||||
|
// Check for tables - match table pattern anywhere in text
|
||||||
|
// Table pattern: | header | ... |\n|---|...|\n| row | ... |
|
||||||
|
const tableRegex = /\|.+\|\n\|[-:\s|]+\|\n((?:\|.+\|\n?)*)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let tableMatch;
|
||||||
|
|
||||||
|
while ((tableMatch = tableRegex.exec(text)) !== null) {
|
||||||
|
// Add content before table
|
||||||
|
const beforeTable = text.substring(lastIndex, tableMatch.index);
|
||||||
|
if (beforeTable.trim()) {
|
||||||
|
segments.push(...parseLines(beforeTable));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse table
|
||||||
|
const tableContent = tableMatch[0];
|
||||||
|
const tableSegments = parseTable(tableContent);
|
||||||
|
if (tableSegments.length > 0) {
|
||||||
|
segments.push(...tableSegments);
|
||||||
|
} else {
|
||||||
|
// If table parsing failed, treat as text
|
||||||
|
segments.push(...parseLines(tableContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = tableMatch.index + tableContent.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining content
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
const remaining = text.substring(lastIndex);
|
||||||
|
if (remaining.trim()) {
|
||||||
|
segments.push(...parseLines(remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTable(tableStr: string): ParsedSegment[] {
|
||||||
|
const lines = tableStr.trim().split('\n').filter(line => line.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
// Skip separator line (|---|---|)
|
||||||
|
const dataLines = lines.filter(line => !line.match(/^[\|\s\-:]+$/));
|
||||||
|
if (dataLines.length < 2) return [];
|
||||||
|
|
||||||
|
const headers = parseTableRow(dataLines[0]);
|
||||||
|
const rows = dataLines.slice(1).map(row => parseTableRow(row));
|
||||||
|
|
||||||
|
return [{
|
||||||
|
type: 'table',
|
||||||
|
content: '',
|
||||||
|
headers,
|
||||||
|
rows
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTableRow(row: string): InlineSegment[][] {
|
||||||
|
return row.split('|')
|
||||||
|
.map(cell => cell.trim())
|
||||||
|
.filter(cell => cell !== '')
|
||||||
|
.map(cell => parseInlineElements(cell));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInlineElements(text: string): InlineSegment[] {
|
||||||
|
const segments: InlineSegment[] = [];
|
||||||
|
|
||||||
|
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||||
|
const parts = text.split(inlineRegex);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||||
|
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||||
|
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||||
|
} else if (part.startsWith('[') && part.includes('](')) {
|
||||||
|
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (linkMatch) {
|
||||||
|
segments.push({ type: 'link', content: linkMatch[1], href: linkMatch[2] });
|
||||||
|
}
|
||||||
|
} else if (part) {
|
||||||
|
segments.push({ type: 'text', content: part });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render inline segments to HTML string
|
||||||
|
function renderInlineSegments(segments: InlineSegment[]): string {
|
||||||
|
return segments.map(seg => {
|
||||||
|
switch (seg.type) {
|
||||||
|
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||||
|
case 'italic': return `<em>${seg.content}</em>`;
|
||||||
|
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||||
|
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||||
|
default: return seg.content;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLines(text: string): ParsedSegment[] {
|
||||||
|
const segments: ParsedSegment[] = [];
|
||||||
|
|
||||||
|
// Combined regex for inline formatting
|
||||||
|
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
// Empty line - add line break for paragraph separation
|
||||||
|
segments.push({ type: 'lineBreak', content: '' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for headings
|
||||||
|
if (line.match(/^#{1,6}\s/)) {
|
||||||
|
segments.push({ type: 'heading', content: line.replace(/^#+\s/, '') });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for list items
|
||||||
|
if (line.match(/^[\-\*]\s/)) {
|
||||||
|
const listMatch = line.match(/^([\-\*])\s(.*)/);
|
||||||
|
if (listMatch) {
|
||||||
|
// Parse inline formatting for list item
|
||||||
|
const itemContent = listMatch[2];
|
||||||
|
const inlineSegments = parseInlineElements(itemContent);
|
||||||
|
|
||||||
|
// Check if previous segment is a list
|
||||||
|
const lastSeg = segments[segments.length - 1];
|
||||||
|
if (lastSeg && lastSeg.type === 'list') {
|
||||||
|
lastSeg.items?.push(itemContent);
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for numbered lists
|
||||||
|
if (line.match(/^\d+\.\s/)) {
|
||||||
|
const listMatch = line.match(/^\d+\.\s(.*)/);
|
||||||
|
if (listMatch) {
|
||||||
|
const itemContent = listMatch[1];
|
||||||
|
|
||||||
|
const lastSeg = segments[segments.length - 1];
|
||||||
|
if (lastSeg && lastSeg.type === 'list') {
|
||||||
|
lastSeg.items?.push(itemContent);
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process inline formatting
|
||||||
|
const inlineSegments = parseInlineElementsAsText(line);
|
||||||
|
segments.push(...inlineSegments);
|
||||||
|
|
||||||
|
// Add line break after non-empty lines (except last in a paragraph)
|
||||||
|
if (i < lines.length - 1 && line.trim()) {
|
||||||
|
segments.push({ type: 'lineBreak', content: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineElementsAsText(text: string): ParsedSegment[] {
|
||||||
|
const segments: ParsedSegment[] = [];
|
||||||
|
|
||||||
|
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||||
|
const parts = text.split(inlineRegex);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||||
|
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||||
|
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||||
|
} else if (part.startsWith('[') && part.includes('](')) {
|
||||||
|
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (linkMatch) {
|
||||||
|
segments.push({ type: 'link', content: linkMatch[1] });
|
||||||
|
}
|
||||||
|
} else if (part) {
|
||||||
|
segments.push({ type: 'text', content: part });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
54
src/frontend/src/routes/+layout.svelte
Normal file
54
src/frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { initAuth, isLoading } from '$lib/stores';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initAuth();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if $isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
src/frontend/src/routes/+page.svelte
Normal file
135
src/frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isAuthenticated } from '$lib/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Randebu - AI Trading Bot Platform</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<script>
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
</script>
|
||||||
|
{:else}
|
||||||
|
<main>
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Randebu</h1>
|
||||||
|
<p class="tagline">Create trading bots through conversation with AI</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a href="/register" class="btn btn-primary">Get Started</a>
|
||||||
|
<a href="/login" class="btn btn-secondary">Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="features">
|
||||||
|
<h2>How It Works</h2>
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature">
|
||||||
|
<h3>1. Describe Your Strategy</h3>
|
||||||
|
<p>Tell our AI what kind of trading you want to do in plain English</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>2. Backtest & Validate</h3>
|
||||||
|
<p>Test your strategy against historical data before risking real funds</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>3. Simulate & Monitor</h3>
|
||||||
|
<p>Run real-time simulations and watch for trading signals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
margin-top: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature p {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
446
src/frontend/src/routes/bot/[id]/+page.svelte
Normal file
446
src/frontend/src/routes/bot/[id]/+page.svelte
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { ChatInterface, StrategyPreview } from '$lib/components';
|
||||||
|
import type { TokenSearchResult } from '$lib/api';
|
||||||
|
|
||||||
|
let botId = $derived($page.params.id);
|
||||||
|
let isSending = $state(false);
|
||||||
|
let showStrategy = $state(false);
|
||||||
|
|
||||||
|
// Token address confirmation modal state
|
||||||
|
let showTokenConfirm = $state(false);
|
||||||
|
let pendingStrategyData = $state<any>(null);
|
||||||
|
let tokenAddressInput = $state('');
|
||||||
|
let confirmingMessage = $state('');
|
||||||
|
let tokenSearchResults = $state<TokenSearchResult[]>([]);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated && !$isLoading) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($isAuthenticated && botId) {
|
||||||
|
await loadBot();
|
||||||
|
await loadChatHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBot() {
|
||||||
|
try {
|
||||||
|
const bot = await api.bots.get(botId);
|
||||||
|
setCurrentBot(bot);
|
||||||
|
} catch (e) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatHistory() {
|
||||||
|
try {
|
||||||
|
const history = await api.bots.getHistory(botId);
|
||||||
|
setMessages(history);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load chat history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendMessage(message: string) {
|
||||||
|
if (isSending) return;
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
|
||||||
|
// Add user's message immediately so it shows even before API response
|
||||||
|
addMessage({ role: 'user', content: message });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add timeout to prevent hanging requests
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
const response = await api.bots.chat(botId, message, controller.signal);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Check if token address confirmation is needed
|
||||||
|
if (response.strategy_needs_confirmation && response.strategy_data) {
|
||||||
|
// Show token confirmation modal
|
||||||
|
pendingStrategyData = response.strategy_data;
|
||||||
|
confirmingMessage = response.response;
|
||||||
|
tokenAddressInput = '';
|
||||||
|
tokenSearchResults = response.token_search_results || [];
|
||||||
|
showTokenConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant response with thinking
|
||||||
|
addMessage({ role: 'assistant', content: response.response, thinking: response.thinking || null });
|
||||||
|
|
||||||
|
if (response.strategy_config) {
|
||||||
|
const bot = await api.bots.get(botId);
|
||||||
|
setCurrentBot(bot);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
addMessage({ role: 'assistant', content: 'Request timed out. Please try again.', thinking: null });
|
||||||
|
} else {
|
||||||
|
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.', thinking: null });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStrategy() {
|
||||||
|
showStrategy = !showStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmTokenAddress() {
|
||||||
|
if (!tokenAddressInput.trim() || !pendingStrategyData) {
|
||||||
|
showTokenConfirm = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the pending strategy with the token address
|
||||||
|
const updatedStrategy = { ...pendingStrategyData };
|
||||||
|
|
||||||
|
// Update conditions with token address
|
||||||
|
if (updatedStrategy.conditions) {
|
||||||
|
updatedStrategy.conditions = updatedStrategy.conditions.map((cond: any) => ({
|
||||||
|
...cond,
|
||||||
|
token_address: tokenAddressInput.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update actions with token address
|
||||||
|
if (updatedStrategy.actions) {
|
||||||
|
updatedStrategy.actions = updatedStrategy.actions.map((action: any) => ({
|
||||||
|
...action,
|
||||||
|
token_address: tokenAddressInput.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update bot with the strategy
|
||||||
|
await api.bots.update(botId, { strategy_config: updatedStrategy });
|
||||||
|
|
||||||
|
// Refresh bot data
|
||||||
|
const bot = await api.bots.get(botId);
|
||||||
|
setCurrentBot(bot);
|
||||||
|
|
||||||
|
// Add success message
|
||||||
|
addMessage({ role: 'assistant', content: `Perfect! I've saved your strategy with the token address. You can now run backtests!`, thinking: null });
|
||||||
|
} catch (e) {
|
||||||
|
addMessage({ role: 'assistant', content: 'Failed to save strategy. Please try again.', thinking: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
showTokenConfirm = false;
|
||||||
|
pendingStrategyData = null;
|
||||||
|
tokenAddressInput = '';
|
||||||
|
tokenSearchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTokenResult(result: TokenSearchResult) {
|
||||||
|
tokenAddressInput = result.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTokenConfirm() {
|
||||||
|
showTokenConfirm = false;
|
||||||
|
pendingStrategyData = null;
|
||||||
|
tokenAddressInput = '';
|
||||||
|
tokenSearchResults = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$currentBotStore?.name || 'Bot'} - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if showTokenConfirm}
|
||||||
|
<div class="modal-overlay" onclick={cancelTokenConfirm}>
|
||||||
|
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Select Token Address</h3>
|
||||||
|
<p class="modal-message">{confirmingMessage}</p>
|
||||||
|
|
||||||
|
{#if tokenSearchResults.length > 0}
|
||||||
|
<div class="token-results">
|
||||||
|
<p class="modal-hint">Select a token:</p>
|
||||||
|
{#each tokenSearchResults as result}
|
||||||
|
<button class="token-result" onclick={() => selectTokenResult(result)}>
|
||||||
|
<span class="token-symbol">{result.symbol}</span>
|
||||||
|
<span class="token-name">{result.name}</span>
|
||||||
|
<span class="token-address">{result.address.slice(0, 10)}...{result.address.slice(-8)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="modal-divider">or enter manually:</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input type="text" class="token-input" bind:value={tokenAddressInput} placeholder="0x..."/>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick={cancelTokenConfirm}>Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick={confirmTokenAddress} disabled={!tokenAddressInput.trim()}>Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||||
|
<h1>{$currentBotStore?.name || 'Loading...'}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
{#if $currentBotStore?.strategy_config}
|
||||||
|
<button class="btn btn-secondary" onclick={toggleStrategy}>
|
||||||
|
{showStrategy ? 'Hide' : 'Show'} Strategy
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a href="/bot/{botId}/backtest" class="btn btn-secondary">Backtest</a>
|
||||||
|
<a href="/bot/{botId}/simulate" class="btn btn-secondary">Simulate</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showStrategy && $currentBotStore?.strategy_config}
|
||||||
|
<div class="strategy-panel">
|
||||||
|
<StrategyPreview config={$currentBotStore.strategy_config} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="chat-wrapper">
|
||||||
|
<ChatInterface
|
||||||
|
bot={$currentBotStore}
|
||||||
|
messages={$chatStore}
|
||||||
|
isSending={isSending}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-panel {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 450px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Token Results */
|
||||||
|
.token-results {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-result {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: #fff;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-result:hover {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-result:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-symbol {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-address {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-divider {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
922
src/frontend/src/routes/bot/[id]/backtest/+page.svelte
Normal file
922
src/frontend/src/routes/bot/[id]/backtest/+page.svelte
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, backtestStore, setCurrentBacktest, addBacktestToHistory, setBacktestLoading, setBacktestError } from '$lib/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { BacktestChart } from '$lib/components';
|
||||||
|
import type { Backtest } from '$lib/api';
|
||||||
|
|
||||||
|
let botId = $derived($page.params.id);
|
||||||
|
let tokenName = $state('');
|
||||||
|
let tokenAddress = $state('');
|
||||||
|
let timeframe = $state('1h');
|
||||||
|
let startDate = $state('');
|
||||||
|
let endDate = $state('');
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let selectedBacktest = $state<Backtest | null>(null);
|
||||||
|
|
||||||
|
// Expandable trades state
|
||||||
|
let expandedTrades = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Pagination state for each backtest
|
||||||
|
let tradesPage = $state<Record<string, number>>({});
|
||||||
|
let tradesData = $state<Record<string, any>>({});
|
||||||
|
const TRADES_PER_PAGE = 5;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Set default dates - yesterday only (1 day range for fast testing)
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
// Set max date to yesterday
|
||||||
|
const maxDate = yesterday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Set end to yesterday, start to day before (1 day range)
|
||||||
|
endDate = maxDate;
|
||||||
|
const dayBefore = new Date(yesterday);
|
||||||
|
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||||
|
startDate = dayBefore.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!$isAuthenticated && !$isLoading) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($isAuthenticated && botId) {
|
||||||
|
await loadBot();
|
||||||
|
await loadBacktests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBot() {
|
||||||
|
try {
|
||||||
|
const bot = await api.bots.get(botId);
|
||||||
|
setCurrentBot(bot);
|
||||||
|
|
||||||
|
// Extract token info from strategy config
|
||||||
|
const strategy = bot.strategy_config;
|
||||||
|
if (strategy) {
|
||||||
|
// Try conditions first, then actions
|
||||||
|
const condition = strategy.conditions?.[0];
|
||||||
|
const action = strategy.actions?.[0];
|
||||||
|
tokenName = condition?.token || action?.token || '';
|
||||||
|
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBacktests() {
|
||||||
|
try {
|
||||||
|
const backtests = await api.backtest.list(botId);
|
||||||
|
setBacktestHistory(backtests);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load backtests:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBacktest() {
|
||||||
|
if (!startDate || !endDate) return;
|
||||||
|
|
||||||
|
// Validate date range (max 7 days)
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff > 7) {
|
||||||
|
setBacktestError('Maximum backtest duration is 7 days for fast testing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBacktestError(null);
|
||||||
|
setBacktestLoading(true);
|
||||||
|
isRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backtest = await api.backtest.start(botId, {
|
||||||
|
token: tokenAddress, // Use token address from strategy
|
||||||
|
token_name: tokenName, // Also send token name for display
|
||||||
|
timeframe,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate
|
||||||
|
});
|
||||||
|
setCurrentBacktest(backtest);
|
||||||
|
addBacktestToHistory(backtest);
|
||||||
|
} catch (e) {
|
||||||
|
setBacktestError(e instanceof Error ? e.message : 'Failed to start backtest');
|
||||||
|
} finally {
|
||||||
|
setBacktestLoading(false);
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopBacktest(runId: string) {
|
||||||
|
try {
|
||||||
|
await api.backtest.stop(botId, runId);
|
||||||
|
await loadBacktests();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to stop backtest:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function setBacktestHistory(backtests: any[]) {
|
||||||
|
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBacktest(backtest: Backtest) {
|
||||||
|
if (backtest.status === 'completed' && backtest.result && !backtest.result.error) {
|
||||||
|
selectedBacktest = backtest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrades(backtestId: string) {
|
||||||
|
if (expandedTrades.has(backtestId)) {
|
||||||
|
expandedTrades.delete(backtestId);
|
||||||
|
} else {
|
||||||
|
expandedTrades.add(backtestId);
|
||||||
|
// Load first page of trades if not loaded
|
||||||
|
if (!tradesData[backtestId]) {
|
||||||
|
loadTrades(backtestId, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expandedTrades = new Set(expandedTrades); // Trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrades(backtestId: string, page: number) {
|
||||||
|
try {
|
||||||
|
const data = await api.backtest.getTrades(botId, backtestId, page, TRADES_PER_PAGE);
|
||||||
|
tradesData[backtestId] = { ...data, currentPage: page };
|
||||||
|
tradesData = { ...tradesData }; // Trigger reactivity
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load trades:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTradesPage(backtestId: string) {
|
||||||
|
const data = tradesData[backtestId];
|
||||||
|
if (data && data.has_next) {
|
||||||
|
loadTrades(backtestId, data.page + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevTradesPage(backtestId: string) {
|
||||||
|
const data = tradesData[backtestId];
|
||||||
|
if (data && data.has_prev) {
|
||||||
|
loadTrades(backtestId, data.page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Backtest - {$currentBotStore?.name || 'Bot'} - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
|
||||||
|
<h1>Backtest</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<section class="config-section">
|
||||||
|
<h2>Configure Backtest</h2>
|
||||||
|
|
||||||
|
{#if $backtestStore.error}
|
||||||
|
<div class="error">{$backtestStore.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field token-info">
|
||||||
|
<label>Token</label>
|
||||||
|
<div class="token-display">
|
||||||
|
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||||
|
{#if tokenAddress}
|
||||||
|
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="timeframe">Timeframe</label>
|
||||||
|
<select id="timeframe" bind:value={timeframe}>
|
||||||
|
<option value="1h">1 hour (recommended)</option>
|
||||||
|
<option value="4h">4 hours</option>
|
||||||
|
<option value="1d">1 day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="startDate">Start Date</label>
|
||||||
|
<input type="date" id="startDate" bind:value={startDate} required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="endDate">End Date</label>
|
||||||
|
<input type="date" id="endDate" bind:value={endDate} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isRunning || $backtestStore.isLoading}>
|
||||||
|
{isRunning ? 'Running...' : 'Start Backtest'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Backtest History</h2>
|
||||||
|
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
|
||||||
|
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $backtestStore.backtestHistory.length === 0}
|
||||||
|
<p class="empty-state">No backtests yet. Run your first backtest above.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="backtest-list">
|
||||||
|
{#each $backtestStore.backtestHistory as backtest}
|
||||||
|
<div class="backtest-card">
|
||||||
|
<div class="backtest-header">
|
||||||
|
<span class="backtest-status status-{backtest.status}">{backtest.status}</span>
|
||||||
|
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
{#if backtest.result && backtest.result.error}
|
||||||
|
<div class="backtest-error">
|
||||||
|
<span class="error-label">Error:</span> {typeof backtest.result.error === 'string' ? backtest.result.error : JSON.stringify(backtest.result.error)}
|
||||||
|
</div>
|
||||||
|
{:else if backtest.result}
|
||||||
|
<div class="backtest-results">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Total Return</span>
|
||||||
|
<span class="result-value" class:positive={backtest.result.total_return > 0} class:negative={backtest.result.total_return < 0}>
|
||||||
|
{backtest.result.total_return.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Win Rate</span>
|
||||||
|
<span class="result-value">{backtest.result.win_rate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Total Trades</span>
|
||||||
|
<span class="result-value">{backtest.result.total_trades}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Max Drawdown</span>
|
||||||
|
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="backtest-config">
|
||||||
|
<span class="config-item">
|
||||||
|
<span class="config-label">Token:</span> {backtest.config.token || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span class="config-item">
|
||||||
|
<span class="config-label">TF:</span> {backtest.config.timeframe || '1h'}
|
||||||
|
</span>
|
||||||
|
<span class="config-item">
|
||||||
|
<span class="config-label">Period:</span> {backtest.config.start_date} to {backtest.config.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if backtest.result.trades && backtest.result.trades.length > 0}
|
||||||
|
<button class="btn-toggle-trades" onclick={() => toggleTrades(backtest.id)}>
|
||||||
|
{expandedTrades.has(backtest.id) ? 'Hide' : 'Show'} Trade History ({backtest.result.trades.length})
|
||||||
|
</button>
|
||||||
|
{#if expandedTrades.has(backtest.id)}
|
||||||
|
<div class="trades-inline">
|
||||||
|
{#if tradesData[backtest.id]}
|
||||||
|
<div class="trades-pagination-header">
|
||||||
|
<span class="trades-count">
|
||||||
|
Showing {((tradesData[backtest.id].page - 1) * TRADES_PER_PAGE) + 1}-{Math.min(tradesData[backtest.id].page * TRADES_PER_PAGE, tradesData[backtest.id].total_trades)} of {tradesData[backtest.id].total_trades}
|
||||||
|
</span>
|
||||||
|
{#if tradesData[backtest.id].total_pages > 1}
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn-pagination" onclick={() => prevTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_prev}>← Prev</button>
|
||||||
|
<span class="page-indicator">Page {tradesData[backtest.id].page} of {tradesData[backtest.id].total_pages}</span>
|
||||||
|
<button class="btn-pagination" onclick={() => nextTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_next}>Next →</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="trades-list">
|
||||||
|
{#each tradesData[backtest.id].trades as trade}
|
||||||
|
<div class="trade-item">
|
||||||
|
<span class="trade-type" class:buy={trade.type === 'buy'} class:sell={trade.type === 'sell'}>
|
||||||
|
{trade.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="trade-price">${trade.price?.toFixed(6)}</span>
|
||||||
|
<span class="trade-amount">${trade.amount?.toFixed(2)}</span>
|
||||||
|
<span class="trade-reason">{trade.exit_reason || 'entry'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="trades-loading">Loading trades...</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if backtest.status === 'running'}
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {backtest.progress ?? 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{backtest.progress ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedBacktest}
|
||||||
|
<section class="chart-section">
|
||||||
|
<div class="chart-header">
|
||||||
|
<h2>Portfolio Performance</h2>
|
||||||
|
<button class="close-btn" onclick={() => selectedBacktest = null}>×</button>
|
||||||
|
</div>
|
||||||
|
<BacktestChart results={selectedBacktest.result} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trades Modal */
|
||||||
|
.trades-modal {
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-modal h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-info {
|
||||||
|
background: yellow;
|
||||||
|
color: black;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-table-wrapper {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-table th,
|
||||||
|
.trades-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-table th {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-table td {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-type {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-type.buy {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-type.sell {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-address {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Trades */
|
||||||
|
.trades-inline {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-inline h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .trade-type {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .trade-type.buy {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .trade-type.sell {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-price {
|
||||||
|
color: #ccc;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-amount {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-reason {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-trades {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-trades:hover {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-status {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-failed {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-stopped {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-date {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backtest-config {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
width: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination styles */
|
||||||
|
.trades-pagination-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:hover:not(:disabled) {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trades-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
530
src/frontend/src/routes/bot/[id]/simulate/+page.svelte
Normal file
530
src/frontend/src/routes/bot/[id]/simulate/+page.svelte
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components';
|
||||||
|
|
||||||
|
let botId = $derived($page.params.id);
|
||||||
|
let tokenName = $state('');
|
||||||
|
let tokenAddress = $state('');
|
||||||
|
let klineInterval = $state('1m');
|
||||||
|
let isRunning = $state(false);
|
||||||
|
let isRefreshing = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated && !$isLoading) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($isAuthenticated && botId) {
|
||||||
|
await loadBot();
|
||||||
|
await loadSimulations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBot() {
|
||||||
|
try {
|
||||||
|
const bot = await api.bots.get(botId);
|
||||||
|
setCurrentBot(bot);
|
||||||
|
|
||||||
|
// Extract token info from strategy config
|
||||||
|
const strategy = bot.strategy_config;
|
||||||
|
if (strategy) {
|
||||||
|
const condition = strategy.conditions?.[0];
|
||||||
|
const action = strategy.actions?.[0];
|
||||||
|
tokenName = condition?.token || action?.token || '';
|
||||||
|
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSimulations() {
|
||||||
|
isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const simulations = await api.simulate.list(botId);
|
||||||
|
|
||||||
|
// Find the most recent running simulation, or fall back to most recent
|
||||||
|
let current = simulations.find(s => s.status === 'running') || simulations[0];
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
setCurrentSimulation(current);
|
||||||
|
clearSignals();
|
||||||
|
if (current.signals && current.signals.length > 0) {
|
||||||
|
addSignals(current.signals);
|
||||||
|
}
|
||||||
|
isRunning = current.status === 'running';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load simulations:', e);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSimulation() {
|
||||||
|
setSimulationError(null);
|
||||||
|
setSimulationLoading(true);
|
||||||
|
isRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const simulation = await api.simulate.start(botId, {
|
||||||
|
token: tokenAddress,
|
||||||
|
chain: 'bsc',
|
||||||
|
kline_interval: klineInterval
|
||||||
|
});
|
||||||
|
setCurrentSimulation(simulation);
|
||||||
|
clearSignals();
|
||||||
|
} catch (e) {
|
||||||
|
setSimulationError(e instanceof Error ? e.message : 'Failed to start simulation');
|
||||||
|
isRunning = false;
|
||||||
|
} finally {
|
||||||
|
setSimulationLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSimulation() {
|
||||||
|
if (!$simulationStore.currentSimulation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.simulate.stop(botId, $simulationStore.currentSimulation.id);
|
||||||
|
await loadSimulations();
|
||||||
|
isRunning = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to stop simulation:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Simulate - {$currentBotStore?.name || 'Bot'} - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
|
||||||
|
<h1>Simulation</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
{#if $simulationStore.currentSimulation}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="refresh-btn"
|
||||||
|
onclick={() => loadSimulations()}
|
||||||
|
class:refreshing={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? '⟳ Refreshing...' : '⟳ Refresh'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
<span class="notice-icon">⚠️</span>
|
||||||
|
<span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<section class="config-section">
|
||||||
|
<h2>Configure Simulation</h2>
|
||||||
|
|
||||||
|
{#if $simulationStore.error}
|
||||||
|
<div class="error">{$simulationStore.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field token-info">
|
||||||
|
<label>Token</label>
|
||||||
|
<div class="token-display">
|
||||||
|
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||||
|
{#if tokenAddress}
|
||||||
|
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="klineInterval">Kline Interval</label>
|
||||||
|
<select id="klineInterval" bind:value={klineInterval} disabled={isRunning}>
|
||||||
|
<option value="1m">1 minute</option>
|
||||||
|
<option value="5m">5 minutes</option>
|
||||||
|
<option value="15m">15 minutes</option>
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<button type="button" onclick={stopSimulation} class="btn btn-danger">
|
||||||
|
Stop Simulation
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button type="submit" disabled={$simulationStore.isLoading}>
|
||||||
|
{$simulationStore.isLoading ? 'Starting...' : 'Start Simulation'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="signals-section">
|
||||||
|
<h2>Portfolio</h2>
|
||||||
|
|
||||||
|
<PortfolioSummary
|
||||||
|
initialBalance={$simulationStore.currentSimulation?.portfolio?.initial_balance || 10000}
|
||||||
|
currentBalance={$simulationStore.currentSimulation?.portfolio?.current_balance || 10000}
|
||||||
|
position={$simulationStore.currentSimulation?.portfolio?.position || 0}
|
||||||
|
positionToken={$simulationStore.currentSimulation?.portfolio?.position_token || ''}
|
||||||
|
entryPrice={$simulationStore.currentSimulation?.portfolio?.entry_price || 0}
|
||||||
|
currentPrice={$simulationStore.currentSimulation?.portfolio?.current_price || 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 1.5rem;">Price Chart</h2>
|
||||||
|
|
||||||
|
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={250} />
|
||||||
|
|
||||||
|
<h2 style="margin-top: 1.5rem;">Trade Activity</h2>
|
||||||
|
|
||||||
|
<TradeDashboard tradeLog={$simulationStore.currentSimulation?.trade_log || []} />
|
||||||
|
|
||||||
|
<h2 style="margin-top: 1.5rem;">Signals ({$simulationStore.signals.length})</h2>
|
||||||
|
|
||||||
|
{#if $simulationStore.signals.length === 0}
|
||||||
|
<p class="empty-state">No signals generated. The chart above shows price movement.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="signals-list">
|
||||||
|
{#each $simulationStore.signals as signal}
|
||||||
|
<div class="signal-card">
|
||||||
|
<div class="signal-header">
|
||||||
|
<span class="signal-type type-{signal.signal_type}">{signal.signal_type}</span>
|
||||||
|
<span class="signal-token">{signal.token}</span>
|
||||||
|
<span class="signal-price">${signal.price.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
{#if signal.confidence}
|
||||||
|
<div class="signal-confidence">
|
||||||
|
<span>Confidence: {(signal.confidence * 100).toFixed(1)}%</span>
|
||||||
|
<div class="confidence-bar">
|
||||||
|
<div class="confidence-fill" style="width: {signal.confidence * 100}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if signal.reasoning}
|
||||||
|
<p class="signal-reasoning">{signal.reasoning}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="signal-time">
|
||||||
|
{new Date(signal.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn.refreshing {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-address {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signals-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-type {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-buy {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-sell {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-hold {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-token {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-price {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-confidence {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-reasoning {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
291
src/frontend/src/routes/dashboard/+page.svelte
Normal file
291
src/frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isAuthenticated, isLoading, botsStore, setBots, addBot, removeBot, userStore, logout } from '$lib/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { BotCard } from '$lib/components';
|
||||||
|
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let newBotName = $state('');
|
||||||
|
let newBotDescription = $state('');
|
||||||
|
let isCreating = $state(false);
|
||||||
|
let createError = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated && !$isLoading) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($isAuthenticated) {
|
||||||
|
await loadBots();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBots() {
|
||||||
|
try {
|
||||||
|
const bots = await api.bots.list();
|
||||||
|
setBots(bots);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load bots:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBot() {
|
||||||
|
if (!newBotName.trim()) return;
|
||||||
|
createError = '';
|
||||||
|
isCreating = true;
|
||||||
|
try {
|
||||||
|
const bot = await api.bots.create(newBotName, newBotDescription);
|
||||||
|
addBot(bot);
|
||||||
|
showCreateModal = false;
|
||||||
|
newBotName = '';
|
||||||
|
newBotDescription = '';
|
||||||
|
goto(`/bot/${bot.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof Error ? e.message : 'Failed to create bot';
|
||||||
|
} finally {
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBot(botId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this bot?')) return;
|
||||||
|
try {
|
||||||
|
await api.bots.delete(botId);
|
||||||
|
removeBot(botId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete bot:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dashboard - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="user-email">{$userStore?.email}</span>
|
||||||
|
<a href="/settings" class="btn btn-secondary">Settings</a>
|
||||||
|
<button onclick={handleLogout} class="btn btn-secondary">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="bots-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Your Bots ({$botsStore.length}/3)</h2>
|
||||||
|
{#if $botsStore.length < 3}
|
||||||
|
<button onclick={() => showCreateModal = true} class="btn btn-primary">
|
||||||
|
Create New Bot
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $botsStore.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You haven't created any bots yet.</p>
|
||||||
|
<p>Create your first bot to start trading!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bots-grid">
|
||||||
|
{#each $botsStore as bot}
|
||||||
|
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if showCreateModal}
|
||||||
|
<div class="modal-overlay" onclick={() => showCreateModal = false}>
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Create New Bot</h2>
|
||||||
|
{#if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); createBot(); }}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="botName">Bot Name</label>
|
||||||
|
<input type="text" id="botName" bind:value={newBotName} required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="botDescription">Description (optional)</label>
|
||||||
|
<textarea id="botDescription" bind:value={newBotDescription} rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={() => showCreateModal = false} class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isCreating}>
|
||||||
|
{isCreating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bots-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
src/frontend/src/routes/login/+page.svelte
Normal file
176
src/frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { login, isAuthenticated } from '$lib/stores';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($isAuthenticated) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error = '';
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
goto('/dashboard');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Login failed';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Login - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Login</h1>
|
||||||
|
<p class="subtitle">Welcome back</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" bind:value={email} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" bind:value={password} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="footer">
|
||||||
|
Don't have an account? <a href="/register">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
src/frontend/src/routes/register/+page.svelte
Normal file
193
src/frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { register, isAuthenticated } from '$lib/stores';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($isAuthenticated) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
error = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
error = 'Password must be at least 6 characters';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
await register(email, password);
|
||||||
|
goto('/dashboard');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Registration failed';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Register - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Create Account</h1>
|
||||||
|
<p class="subtitle">Start creating trading bots</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" bind:value={email} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" bind:value={password} required minlength="6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmPassword">Confirm Password</label>
|
||||||
|
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="footer">
|
||||||
|
Already have an account? <a href="/login">Login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
281
src/frontend/src/routes/settings/+page.svelte
Normal file
281
src/frontend/src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isAuthenticated, isLoading, userStore, logout } from '$lib/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let currentPassword = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let isUpdating = $state(false);
|
||||||
|
let updateSuccess = $state('');
|
||||||
|
let updateError = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated && !$isLoading) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($userStore) {
|
||||||
|
email = $userStore.email;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateEmail() {
|
||||||
|
updateSuccess = '';
|
||||||
|
updateError = '';
|
||||||
|
isUpdating = true;
|
||||||
|
try {
|
||||||
|
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
updateSuccess = 'Email updated successfully';
|
||||||
|
} catch (e) {
|
||||||
|
updateError = e instanceof Error ? e.message : 'Failed to update email';
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword() {
|
||||||
|
updateSuccess = '';
|
||||||
|
updateError = '';
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
updateError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
updateError = 'Password must be at least 6 characters';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdating = true;
|
||||||
|
try {
|
||||||
|
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password: newPassword, current_password: currentPassword })
|
||||||
|
});
|
||||||
|
updateSuccess = 'Password updated successfully';
|
||||||
|
currentPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
} catch (e) {
|
||||||
|
updateError = e instanceof Error ? e.message : 'Failed to update password';
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Profile</h2>
|
||||||
|
|
||||||
|
{#if updateSuccess}
|
||||||
|
<div class="success">{updateSuccess}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if updateError}
|
||||||
|
<div class="error">{updateError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); updateEmail(); }}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" bind:value={email} required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isUpdating}>
|
||||||
|
{isUpdating ? 'Updating...' : 'Update Email'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); updatePassword(); }}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="currentPassword">Current Password</label>
|
||||||
|
<input type="password" id="currentPassword" bind:value={currentPassword} required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="newPassword">New Password</label>
|
||||||
|
<input type="password" id="newPassword" bind:value={newPassword} required minlength="6" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmPassword">Confirm New Password</label>
|
||||||
|
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isUpdating}>
|
||||||
|
{isUpdating ? 'Updating...' : 'Update Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section danger-section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<button onclick={handleLogout} class="btn btn-danger">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
color: #86efac;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-section button {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/frontend/static/robots.txt
Normal file
3
src/frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
17
src/frontend/svelte.config.js
Normal file
17
src/frontend/svelte.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
src/frontend/tsconfig.json
Normal file
20
src/frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
6
src/frontend/vite.config.ts
Normal file
6
src/frontend/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user