Compare commits
141 Commits
fix/issue-
...
2b7f54703e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7f54703e | ||
|
|
99dded8d16 | ||
|
|
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 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "ave-cloud-skill"]
|
||||||
|
path = ave-cloud-skill
|
||||||
|
url = https://github.com/AveCloud/ave-cloud-skill.git
|
||||||
1
ave-cloud-skill
Submodule
1
ave-cloud-skill
Submodule
Submodule ave-cloud-skill added at 5eaef99e15
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)}",
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ def run_backtest_sync(
|
|||||||
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||||
):
|
):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from ..services.backtest.engine import BacktestEngine
|
from ..services.backtest.engine import BacktestEngine
|
||||||
from ..core.database import SessionLocal
|
from ..core.database import SessionLocal
|
||||||
|
|
||||||
@@ -31,6 +32,19 @@ def run_backtest_sync(
|
|||||||
running_backtests[backtest_id] = engine
|
running_backtests[backtest_id] = engine
|
||||||
try:
|
try:
|
||||||
results = await engine.run()
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
||||||
@@ -41,17 +55,18 @@ def run_backtest_sync(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
for signal in engine.signals:
|
for signal in engine.signals:
|
||||||
|
signal_data = convert_datetime(signal)
|
||||||
db_signal = Signal(
|
db_signal = Signal(
|
||||||
id=signal["id"],
|
id=signal_data["id"],
|
||||||
bot_id=signal["bot_id"],
|
bot_id=signal_data["bot_id"],
|
||||||
run_id=signal["run_id"],
|
run_id=signal_data["run_id"],
|
||||||
signal_type=signal["signal_type"],
|
signal_type=signal_data["signal_type"],
|
||||||
token=signal["token"],
|
token=signal_data["token"],
|
||||||
price=signal["price"],
|
price=signal_data["price"],
|
||||||
confidence=signal.get("confidence"),
|
confidence=signal_data.get("confidence"),
|
||||||
reasoning=signal.get("reasoning"),
|
reasoning=signal_data.get("reasoning"),
|
||||||
executed=signal.get("executed", False),
|
executed=signal_data.get("executed", False),
|
||||||
created_at=signal["created_at"],
|
created_at=signal["created_at"], # Use original datetime, not converted string
|
||||||
)
|
)
|
||||||
db.add(db_signal)
|
db.add(db_signal)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -154,9 +169,81 @@ def get_backtest(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
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
|
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(
|
def list_backtests(
|
||||||
bot_id: str,
|
bot_id: str,
|
||||||
@@ -177,6 +264,7 @@ def list_backtests(
|
|||||||
db.query(Backtest)
|
db.query(Backtest)
|
||||||
.filter(Backtest.bot_id == bot_id)
|
.filter(Backtest.bot_id == bot_id)
|
||||||
.order_by(Backtest.started_at.desc())
|
.order_by(Backtest.started_at.desc())
|
||||||
|
.limit(5)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return backtests
|
return backtests
|
||||||
@@ -211,7 +299,12 @@ def stop_backtest(
|
|||||||
|
|
||||||
if run_id in running_backtests:
|
if run_id in running_backtests:
|
||||||
engine = running_backtests[run_id]
|
engine = running_backtests[run_id]
|
||||||
asyncio.create_task(engine.stop())
|
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.status = "stopped"
|
||||||
backtest.ended_at = datetime.utcnow()
|
backtest.ended_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..db.schemas import (
|
|||||||
)
|
)
|
||||||
from ..db.models import Bot, BotConversation, User
|
from ..db.models import Bot, BotConversation, User
|
||||||
from ..services.ai_agent.crew import get_trading_crew
|
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
|
MAX_BOTS_PER_USER = 3
|
||||||
@@ -183,69 +184,49 @@ def chat(
|
|||||||
.order_by(BotConversation.created_at)
|
.order_by(BotConversation.created_at)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
history_for_crew = [
|
history_for_agent = [
|
||||||
{"role": conv.role, "content": conv.content}
|
{"role": conv.role, "content": conv.content}
|
||||||
for conv in conversation_history[-10:]
|
for conv in conversation_history[-10:]
|
||||||
]
|
]
|
||||||
|
|
||||||
user_message = request.message
|
user_message = request.message
|
||||||
if request.strategy_config:
|
|
||||||
crew = get_trading_crew()
|
|
||||||
result = crew.chat(user_message, history_for_crew)
|
|
||||||
|
|
||||||
assistant_content = result.get("response", "I couldn't process your request.")
|
# Use ConversationalAgent for natural chat with tool-calling
|
||||||
if result.get("success") and result.get("strategy_config"):
|
agent = get_conversational_agent(bot_id=bot_id)
|
||||||
bot.strategy_config = result["strategy_config"]
|
result = agent.chat(user_message, history_for_agent)
|
||||||
db.commit()
|
|
||||||
|
|
||||||
db_conversation = BotConversation(
|
assistant_content = result.get("response", "I couldn't process your request.")
|
||||||
bot_id=bot_id,
|
|
||||||
role="user",
|
|
||||||
content=user_message,
|
|
||||||
)
|
|
||||||
db.add(db_conversation)
|
|
||||||
|
|
||||||
db_assistant = BotConversation(
|
# Save conversation
|
||||||
bot_id=bot_id,
|
db_conversation = BotConversation(
|
||||||
role="assistant",
|
bot_id=bot_id,
|
||||||
content=assistant_content,
|
role="user",
|
||||||
)
|
content=user_message,
|
||||||
db.add(db_assistant)
|
)
|
||||||
db.commit()
|
db.add(db_conversation)
|
||||||
db.refresh(db_assistant)
|
|
||||||
|
|
||||||
return BotChatResponse(
|
db_assistant = BotConversation(
|
||||||
response=assistant_content,
|
bot_id=bot_id,
|
||||||
strategy_config=result.get("strategy_config"),
|
role="assistant",
|
||||||
success=result.get("success", False),
|
content=assistant_content,
|
||||||
)
|
)
|
||||||
else:
|
db.add(db_assistant)
|
||||||
crew = get_trading_crew()
|
db.commit()
|
||||||
result = crew.chat(user_message, history_for_crew)
|
db.refresh(db_assistant)
|
||||||
|
|
||||||
assistant_content = result.get("response", "I couldn't process your request.")
|
# If strategy was updated via tool, refresh bot data
|
||||||
|
if result.get("strategy_updated"):
|
||||||
|
db.refresh(bot)
|
||||||
|
|
||||||
db_conversation = BotConversation(
|
return BotChatResponse(
|
||||||
bot_id=bot_id,
|
response=assistant_content,
|
||||||
role="user",
|
thinking=result.get("thinking"),
|
||||||
content=user_message,
|
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
|
||||||
)
|
success=result.get("success", False),
|
||||||
db.add(db_conversation)
|
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
|
||||||
|
strategy_data=result.get("strategy_data") if result.get("strategy_needs_confirmation") else None,
|
||||||
db_assistant = BotConversation(
|
token_search_results=result.get("token_search_results"),
|
||||||
bot_id=bot_id,
|
)
|
||||||
role="assistant",
|
|
||||||
content=assistant_content,
|
|
||||||
)
|
|
||||||
db.add(db_assistant)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_assistant)
|
|
||||||
|
|
||||||
return BotChatResponse(
|
|
||||||
response=assistant_content,
|
|
||||||
strategy_config=result.get("strategy_config"),
|
|
||||||
success=result.get("success", False),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
|
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -11,6 +12,9 @@ from ..core.database import get_db
|
|||||||
from ..core.config import get_settings
|
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 ..db.models import Bot, Simulation, Signal, User
|
||||||
|
from ..services.ave.client import AveCloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ def run_simulation_sync(
|
|||||||
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||||
):
|
):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from ..services.simulate.engine import SimulateEngine
|
from ..services.simulate.engine import SimulateEngine
|
||||||
from ..core.database import SessionLocal
|
from ..core.database import SessionLocal
|
||||||
|
|
||||||
@@ -29,8 +34,19 @@ def run_simulation_sync(
|
|||||||
engine = SimulateEngine(config)
|
engine = SimulateEngine(config)
|
||||||
engine.run_id = simulation_id
|
engine.run_id = simulation_id
|
||||||
running_simulations[simulation_id] = engine
|
running_simulations[simulation_id] = engine
|
||||||
try:
|
|
||||||
results = await engine.run()
|
# 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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
simulation = (
|
simulation = (
|
||||||
@@ -38,27 +54,50 @@ def run_simulation_sync(
|
|||||||
)
|
)
|
||||||
if simulation:
|
if simulation:
|
||||||
simulation.status = engine.status
|
simulation.status = engine.status
|
||||||
simulation.signals = engine.signals
|
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()
|
db.commit()
|
||||||
|
|
||||||
for signal in engine.signals:
|
|
||||||
db_signal = Signal(
|
|
||||||
id=signal["id"],
|
|
||||||
bot_id=signal["bot_id"],
|
|
||||||
run_id=signal["run_id"],
|
|
||||||
signal_type=signal["signal_type"],
|
|
||||||
token=signal["token"],
|
|
||||||
price=signal["price"],
|
|
||||||
confidence=signal.get("confidence"),
|
|
||||||
reasoning=signal.get("reasoning"),
|
|
||||||
executed=signal.get("executed", False),
|
|
||||||
created_at=signal["created_at"],
|
|
||||||
)
|
|
||||||
db.add(db_signal)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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:
|
finally:
|
||||||
|
# Save final state
|
||||||
|
save_progress()
|
||||||
if simulation_id in running_simulations:
|
if simulation_id in running_simulations:
|
||||||
del running_simulations[simulation_id]
|
del running_simulations[simulation_id]
|
||||||
|
|
||||||
@@ -87,20 +126,35 @@ async def start_simulation(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
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()
|
settings = get_settings()
|
||||||
simulation_id = str(uuid.uuid4())
|
simulation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
check_interval = config.check_interval
|
# Create AVE client for klines fetching
|
||||||
if settings.AVE_API_PLAN != "pro" and check_interval < 60:
|
ave_client = AveCloudClient(
|
||||||
check_interval = 60
|
api_key=settings.AVE_API_KEY,
|
||||||
|
plan=settings.AVE_API_PLAN,
|
||||||
|
)
|
||||||
|
|
||||||
simulation_config = {
|
simulation_config = {
|
||||||
"bot_id": bot_id,
|
"bot_id": bot_id,
|
||||||
"token": config.token,
|
"token": config.token,
|
||||||
"chain": config.chain,
|
"chain": config.chain,
|
||||||
"duration_seconds": config.duration_seconds,
|
"kline_interval": config.kline_interval,
|
||||||
"check_interval": check_interval,
|
"auto_execute": False, # Always paper trade
|
||||||
"auto_execute": config.auto_execute,
|
|
||||||
"strategy_config": bot.strategy_config,
|
"strategy_config": bot.strategy_config,
|
||||||
"ave_api_key": settings.AVE_API_KEY,
|
"ave_api_key": settings.AVE_API_KEY,
|
||||||
"ave_api_plan": settings.AVE_API_PLAN,
|
"ave_api_plan": settings.AVE_API_PLAN,
|
||||||
@@ -114,19 +168,46 @@ async def start_simulation(
|
|||||||
config={
|
config={
|
||||||
"token": config.token,
|
"token": config.token,
|
||||||
"chain": config.chain,
|
"chain": config.chain,
|
||||||
"duration_seconds": config.duration_seconds,
|
"kline_interval": config.kline_interval,
|
||||||
"check_interval": check_interval,
|
|
||||||
"auto_execute": config.auto_execute,
|
|
||||||
},
|
},
|
||||||
signals=[],
|
signals=[],
|
||||||
|
klines=[],
|
||||||
)
|
)
|
||||||
db.add(simulation)
|
db.add(simulation)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(simulation)
|
db.refresh(simulation)
|
||||||
|
|
||||||
db_url = str(settings.DATABASE_URL)
|
# 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(
|
background_tasks.add_task(
|
||||||
run_simulation_sync, simulation_id, db_url, bot_id, simulation_config
|
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
|
||||||
)
|
)
|
||||||
|
|
||||||
return simulation
|
return simulation
|
||||||
@@ -193,6 +274,9 @@ def list_simulations(
|
|||||||
if sim.id in running_simulations:
|
if sim.id in running_simulations:
|
||||||
engine = running_simulations[sim.id]
|
engine = running_simulations[sim.id]
|
||||||
sim.signals = engine.get_signals()
|
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
|
return simulations
|
||||||
|
|
||||||
@@ -224,10 +308,15 @@ def stop_simulation(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
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:
|
if run_id in running_simulations:
|
||||||
engine = running_simulations[run_id]
|
engine = running_simulations[run_id]
|
||||||
asyncio.create_task(engine.stop())
|
engine.stop()
|
||||||
simulation.status = "stopped"
|
del running_simulations[run_id]
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "stopping", "run_id": run_id}
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "stopped", "run_id": run_id}
|
||||||
|
|||||||
1
src/backend/app/ave
Symbolic link
1
src/backend/app/ave
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../ave-cloud-skill/scripts/ave
|
||||||
@@ -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,9 +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"
|
||||||
check_interval: int = 60
|
|
||||||
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):
|
||||||
@@ -98,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
|
||||||
@@ -126,8 +151,12 @@ class BotChatRequest(BaseModel):
|
|||||||
|
|
||||||
class BotChatResponse(BaseModel):
|
class BotChatResponse(BaseModel):
|
||||||
response: str
|
response: str
|
||||||
|
thinking: Optional[str] = None
|
||||||
strategy_config: Optional[dict] = None
|
strategy_config: Optional[dict] = None
|
||||||
success: bool = False
|
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):
|
||||||
@@ -144,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"]
|
||||||
|
|||||||
1113
src/backend/app/services/ai_agent/conversational.py
Normal file
1113
src/backend/app/services/ai_agent/conversational.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from crewai import Agent, Task, Crew
|
from crewai import Agent, Task, Crew, LLM
|
||||||
from .llm_connector import MiniMaxConnector, MiniMaxLLM
|
from .llm_connector import MiniMaxConnector
|
||||||
from ..core.config import get_settings
|
from ...core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
class StrategyValidator:
|
class StrategyValidator:
|
||||||
@@ -33,29 +33,24 @@ class StrategyValidator:
|
|||||||
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
|
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
params = condition.get("params", {})
|
|
||||||
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
|
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
|
||||||
if "token" not in params:
|
if "token" not in condition:
|
||||||
errors.append(f"Condition {i}: missing 'token'")
|
errors.append(f"Condition {i}: missing 'token'")
|
||||||
if "threshold_percent" not in params:
|
if "threshold" not in condition:
|
||||||
errors.append(f"Condition {i}: missing 'threshold_percent'")
|
errors.append(f"Condition {i}: missing 'threshold'")
|
||||||
elif not isinstance(params["threshold_percent"], (int, float)):
|
elif not isinstance(condition["threshold"], (int, float)):
|
||||||
errors.append(
|
errors.append(f"Condition {i}: 'threshold' must be a number")
|
||||||
f"Condition {i}: 'threshold_percent' must be a number"
|
elif condition["threshold"] <= 0:
|
||||||
)
|
errors.append(f"Condition {i}: 'threshold' must be positive")
|
||||||
elif params["threshold_percent"] <= 0:
|
|
||||||
errors.append(
|
|
||||||
f"Condition {i}: 'threshold_percent' must be positive"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif cond_type == "price_level":
|
elif cond_type == "price_level":
|
||||||
if "token" not in params:
|
if "token" not in condition:
|
||||||
errors.append(f"Condition {i}: missing 'token'")
|
errors.append(f"Condition {i}: missing 'token'")
|
||||||
if "price" not in params:
|
if "price" not in condition:
|
||||||
errors.append(f"Condition {i}: missing 'price'")
|
errors.append(f"Condition {i}: missing 'price'")
|
||||||
if "direction" not in params:
|
if "direction" not in condition:
|
||||||
errors.append(f"Condition {i}: missing 'direction'")
|
errors.append(f"Condition {i}: missing 'direction'")
|
||||||
elif params["direction"] not in ["above", "below"]:
|
elif condition["direction"] not in ["above", "below"]:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Condition {i}: direction must be 'above' or 'below'"
|
f"Condition {i}: direction must be 'above' or 'below'"
|
||||||
)
|
)
|
||||||
@@ -85,23 +80,22 @@ class StrategyExplainer:
|
|||||||
explanations.append("This strategy will trigger when:")
|
explanations.append("This strategy will trigger when:")
|
||||||
for cond in cond_list:
|
for cond in cond_list:
|
||||||
cond_type = cond.get("type")
|
cond_type = cond.get("type")
|
||||||
params = cond.get("params", {})
|
token = cond.get("token", "the token")
|
||||||
token = params.get("token", "the token")
|
|
||||||
|
|
||||||
if cond_type == "price_drop":
|
if cond_type == "price_drop":
|
||||||
pct = params.get("threshold_percent", 0)
|
pct = cond.get("threshold", 0)
|
||||||
explanations.append(f" - {token} price drops by {pct}%")
|
explanations.append(f" - {token} price drops by {pct}%")
|
||||||
elif cond_type == "price_rise":
|
elif cond_type == "price_rise":
|
||||||
pct = params.get("threshold_percent", 0)
|
pct = cond.get("threshold", 0)
|
||||||
explanations.append(f" - {token} price rises by {pct}%")
|
explanations.append(f" - {token} price rises by {pct}%")
|
||||||
elif cond_type == "volume_spike":
|
elif cond_type == "volume_spike":
|
||||||
pct = params.get("threshold_percent", 0)
|
pct = cond.get("threshold", 0)
|
||||||
explanations.append(
|
explanations.append(
|
||||||
f" - {token} trading volume increases by {pct}%"
|
f" - {token} trading volume increases by {pct}%"
|
||||||
)
|
)
|
||||||
elif cond_type == "price_level":
|
elif cond_type == "price_level":
|
||||||
price = params.get("price", 0)
|
price = cond.get("price", 0)
|
||||||
direction = params.get("direction", "unknown")
|
direction = cond.get("direction", "unknown")
|
||||||
explanations.append(
|
explanations.append(
|
||||||
f" - {token} price crosses {direction} ${price}"
|
f" - {token} price crosses {direction} ${price}"
|
||||||
)
|
)
|
||||||
@@ -126,7 +120,7 @@ class StrategyExplainer:
|
|||||||
|
|
||||||
|
|
||||||
def create_trading_designer_agent(
|
def create_trading_designer_agent(
|
||||||
api_key: str, model: str = "MiniMax-Text-01"
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
connector = MiniMaxConnector(api_key=api_key, model=model)
|
connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||||
|
|
||||||
@@ -147,13 +141,13 @@ def create_trading_designer_agent(
|
|||||||
role="Trading Strategy Designer",
|
role="Trading Strategy Designer",
|
||||||
goal="Convert natural language trading requests into precise strategy configurations",
|
goal="Convert natural language trading requests into precise strategy configurations",
|
||||||
backstory=system_prompt,
|
backstory=system_prompt,
|
||||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_strategy_validator_agent(
|
def create_strategy_validator_agent(
|
||||||
api_key: str, model: str = "MiniMax-Text-01"
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
return Agent(
|
return Agent(
|
||||||
role="Strategy Validator",
|
role="Strategy Validator",
|
||||||
@@ -161,13 +155,13 @@ def create_strategy_validator_agent(
|
|||||||
backstory="""You are a meticulous strategy validator with expertise in trading systems.
|
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
|
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.""",
|
strategy makes logical sense. You never approve strategies with missing or invalid data.""",
|
||||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_strategy_explainer_agent(
|
def create_strategy_explainer_agent(
|
||||||
api_key: str, model: str = "MiniMax-Text-01"
|
api_key: str, model: str = "MiniMax-M2.7"
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
return Agent(
|
return Agent(
|
||||||
role="Strategy Explainer",
|
role="Strategy Explainer",
|
||||||
@@ -175,13 +169,13 @@ def create_strategy_explainer_agent(
|
|||||||
backstory="""You are a patient trading strategy explainer. You translate complex
|
backstory="""You are a patient trading strategy explainer. You translate complex
|
||||||
strategy configurations into easy-to-understand language. You help users understand
|
strategy configurations into easy-to-understand language. You help users understand
|
||||||
exactly what their strategies will do when triggered.""",
|
exactly what their strategies will do when triggered.""",
|
||||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TradingCrew:
|
class TradingCrew:
|
||||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
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
|
||||||
self.validator = StrategyValidator()
|
self.validator = StrategyValidator()
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
import httpx
|
import httpx
|
||||||
from crewai import LLM
|
|
||||||
|
|
||||||
|
|
||||||
class MiniMaxLLM(LLM):
|
class MiniMaxLLM:
|
||||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01", **kwargs):
|
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs):
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
self.base_url = "https://api.minimax.chat/v1"
|
self.base_url = "https://api.minimax.io/v1"
|
||||||
|
|
||||||
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||||
headers = {
|
headers = {
|
||||||
@@ -23,7 +21,7 @@ class MiniMaxLLM(LLM):
|
|||||||
}
|
}
|
||||||
with httpx.Client(timeout=60.0) as client:
|
with httpx.Client(timeout=60.0) as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"{self.base_url}/chat/completions",
|
f"{self.base_url}/text/chatcompletion_v2",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
@@ -35,7 +33,7 @@ class MiniMaxLLM(LLM):
|
|||||||
|
|
||||||
|
|
||||||
class MiniMaxConnector:
|
class MiniMaxConnector:
|
||||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
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
|
||||||
|
|
||||||
@@ -61,9 +59,9 @@ class MiniMaxConnector:
|
|||||||
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
|
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
|
||||||
|
|
||||||
Supported conditions (MVP):
|
Supported conditions (MVP):
|
||||||
- price_drop: Token price drops by X% (requires: token, threshold_percent)
|
- price_drop: Token price drops by X% (requires: token, threshold)
|
||||||
- price_rise: Token price rises by X% (requires: token, threshold_percent)
|
- price_rise: Token price rises by X% (requires: token, threshold)
|
||||||
- volume_spike: Trading volume increases X% (requires: token, threshold_percent)
|
- volume_spike: Trading volume increases X% (requires: token, threshold)
|
||||||
- price_level: Price crosses above/below X (requires: token, price, direction)
|
- price_level: Price crosses above/below X (requires: token, price, direction)
|
||||||
|
|
||||||
Output ONLY valid JSON with this schema:
|
Output ONLY valid JSON with this schema:
|
||||||
@@ -71,18 +69,17 @@ Output ONLY valid JSON with this schema:
|
|||||||
"conditions": [
|
"conditions": [
|
||||||
{
|
{
|
||||||
"type": "price_drop|price_rise|volume_spike|price_level",
|
"type": "price_drop|price_rise|volume_spike|price_level",
|
||||||
"params": {
|
"token": "TOKEN_SYMBOL",
|
||||||
"token": "TOKEN_SYMBOL",
|
"chain": "bsc",
|
||||||
"threshold_percent": number, // for price_drop, price_rise, volume_spike
|
"threshold": number, // for price_drop, price_rise, volume_spike
|
||||||
"price": number, // for price_level
|
"price": number, // for price_level
|
||||||
"direction": "above|below" // for price_level
|
"direction": "above|below", // for price_level
|
||||||
}
|
"timeframe": "1h"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"type": "buy|sell|notify",
|
"type": "buy|sell|notify"
|
||||||
"params": {}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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,70 +0,0 @@
|
|||||||
import httpx
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AveCloudClient:
|
|
||||||
BASE_URL = "https://prod.ave-api.com"
|
|
||||||
|
|
||||||
def __init__(self, api_key: str, plan: str = "free"):
|
|
||||||
self.api_key = api_key
|
|
||||||
self.plan = plan
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
return {"X-API-KEY": self.api_key}
|
|
||||||
|
|
||||||
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]]:
|
|
||||||
url = f"{self.BASE_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._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 klines: {data}")
|
|
||||||
|
|
||||||
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
headers=self._headers(),
|
|
||||||
json={"token_ids": [token_id]},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("status") == 200:
|
|
||||||
prices = data.get("data", {})
|
|
||||||
return prices.get(token_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
|
||||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
headers=self._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 {}
|
|
||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from .ave_client import AveCloudClient
|
from ..ave.client import AveCloudClient
|
||||||
|
|
||||||
|
|
||||||
class BacktestEngine:
|
class BacktestEngine:
|
||||||
@@ -20,12 +20,21 @@ class BacktestEngine:
|
|||||||
self.strategy_config = config.get("strategy_config", {})
|
self.strategy_config = config.get("strategy_config", {})
|
||||||
self.conditions = self.strategy_config.get("conditions", [])
|
self.conditions = self.strategy_config.get("conditions", [])
|
||||||
self.actions = self.strategy_config.get("actions", [])
|
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.initial_balance = config.get("initial_balance", 10000.0)
|
||||||
self.current_balance = self.initial_balance
|
self.current_balance = self.initial_balance
|
||||||
self.position = 0.0
|
self.position = 0.0
|
||||||
self.position_token = ""
|
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.trades: List[Dict[str, Any]] = []
|
||||||
self.running = False
|
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]:
|
||||||
self.running = True
|
self.running = True
|
||||||
@@ -33,20 +42,28 @@ class BacktestEngine:
|
|||||||
started_at = datetime.utcnow()
|
started_at = datetime.utcnow()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = self.config.get("token", "")
|
|
||||||
chain = self.config.get("chain", "bsc")
|
chain = self.config.get("chain", "bsc")
|
||||||
timeframe = self.config.get("timeframe", "1h")
|
timeframe = self.config.get("timeframe", "1h")
|
||||||
start_date = self.config.get("start_date", "")
|
start_date = self.config.get("start_date", "")
|
||||||
end_date = self.config.get("end_date", "")
|
end_date = self.config.get("end_date", "")
|
||||||
|
|
||||||
token_id = (
|
# Get token address from strategy config (saved when user confirmed token)
|
||||||
f"{token}-{chain}"
|
token_address = None
|
||||||
if token and not token.endswith(f"-{chain}")
|
token_symbol = None
|
||||||
else token
|
|
||||||
)
|
|
||||||
|
|
||||||
if not token_id or token_id == f"-{chain}":
|
# Try to get from conditions first
|
||||||
raise ValueError("Token ID is required")
|
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
|
start_ts = None
|
||||||
end_ts = None
|
end_ts = None
|
||||||
@@ -92,22 +109,126 @@ class BacktestEngine:
|
|||||||
|
|
||||||
return self.results
|
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]]):
|
async def _process_klines(self, klines: List[Dict[str, Any]]):
|
||||||
|
self.total_klines = len(klines)
|
||||||
for i, kline in enumerate(klines):
|
for i, kline in enumerate(klines):
|
||||||
if not self.running:
|
if not self.running:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
|
||||||
|
|
||||||
price = float(kline.get("close", 0))
|
price = float(kline.get("close", 0))
|
||||||
if price <= 0:
|
if price <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
self.last_kline_price = price # Track last price for open position valuation
|
||||||
|
|
||||||
timestamp = kline.get("timestamp", 0)
|
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:
|
for condition in self.conditions:
|
||||||
if self._check_condition(condition, klines, i, price):
|
if self._check_condition(condition, klines, i, price):
|
||||||
await self._execute_actions(price, timestamp, condition)
|
await self._execute_actions(price, timestamp, condition)
|
||||||
break
|
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(
|
def _check_condition(
|
||||||
self,
|
self,
|
||||||
condition: Dict[str, Any],
|
condition: Dict[str, Any],
|
||||||
@@ -170,16 +291,20 @@ class BacktestEngine:
|
|||||||
amount = self.current_balance * (amount_percent / 100)
|
amount = self.current_balance * (amount_percent / 100)
|
||||||
|
|
||||||
if action_type == "buy" and self.current_balance >= amount:
|
if action_type == "buy" and self.current_balance >= amount:
|
||||||
self.position += amount / price
|
quantity = amount / price
|
||||||
|
self.position += quantity
|
||||||
self.current_balance -= amount
|
self.current_balance -= amount
|
||||||
|
self.cost_basis += amount # Track total cost for average price
|
||||||
self.position_token = token
|
self.position_token = token
|
||||||
|
self.entry_price = price # Keep last entry price for reference
|
||||||
|
self.entry_time = timestamp
|
||||||
self.trades.append(
|
self.trades.append(
|
||||||
{
|
{
|
||||||
"type": "buy",
|
"type": "buy",
|
||||||
"token": token,
|
"token": token,
|
||||||
"price": price,
|
"price": price,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"quantity": amount / price,
|
"quantity": quantity,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -209,9 +334,12 @@ class BacktestEngine:
|
|||||||
"amount": sell_amount,
|
"amount": sell_amount,
|
||||||
"quantity": self.position,
|
"quantity": self.position,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
"exit_reason": "manual",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.position = 0
|
self.position = 0
|
||||||
|
self.entry_price = None
|
||||||
|
self.entry_time = None
|
||||||
self.signals.append(
|
self.signals.append(
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
@@ -228,11 +356,17 @@ class BacktestEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_metrics(self):
|
def _calculate_metrics(self):
|
||||||
final_balance = self.current_balance + (
|
# For open positions, use the last kline price to mark to market
|
||||||
self.position * self.trades[-1]["price"]
|
# If no last kline price, fall back to entry price
|
||||||
if self.trades and self.position > 0
|
position_price = self.last_kline_price
|
||||||
else 0
|
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 = (
|
total_return = (
|
||||||
(final_balance - self.initial_balance) / self.initial_balance
|
(final_balance - self.initial_balance) / self.initial_balance
|
||||||
) * 100
|
) * 100
|
||||||
@@ -259,18 +393,23 @@ class BacktestEngine:
|
|||||||
|
|
||||||
for trade in self.trades:
|
for trade in self.trades:
|
||||||
if trade["type"] == "buy":
|
if trade["type"] == "buy":
|
||||||
running_position = trade["quantity"]
|
running_position += trade["quantity"] # Add to existing position (DCA)
|
||||||
running_balance = trade["amount"]
|
running_balance -= trade["amount"] # Subtract amount spent
|
||||||
current_token = trade["token"]
|
current_token = trade["token"]
|
||||||
last_price = trade["price"]
|
last_price = trade["price"]
|
||||||
else:
|
else: # sell
|
||||||
running_balance = trade["amount"]
|
running_balance += trade["amount"] # Add amount received
|
||||||
running_position = 0
|
running_position = 0 # Close entire position
|
||||||
last_price = trade["price"]
|
last_price = trade["price"]
|
||||||
|
|
||||||
portfolio_value = running_balance + (running_position * last_price)
|
portfolio_value = running_balance + (running_position * last_price)
|
||||||
portfolio_values.append(portfolio_value)
|
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_value = self.initial_balance
|
||||||
max_drawdown = 0.0
|
max_drawdown = 0.0
|
||||||
for value in portfolio_values:
|
for value in portfolio_values:
|
||||||
@@ -308,10 +447,13 @@ class BacktestEngine:
|
|||||||
"sharpe_ratio": round(sharpe_ratio, 2),
|
"sharpe_ratio": round(sharpe_ratio, 2),
|
||||||
"final_balance": round(final_balance, 2),
|
"final_balance": round(final_balance, 2),
|
||||||
"signals": self.signals,
|
"signals": self.signals,
|
||||||
|
"trades": self.trades, # Include trades in results for storage
|
||||||
}
|
}
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self.progress = 0
|
||||||
|
self.total_klines = 0
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self._calculate_metrics()
|
self._calculate_metrics()
|
||||||
|
|
||||||
@@ -321,4 +463,13 @@ class BacktestEngine:
|
|||||||
"status": self.status,
|
"status": self.status,
|
||||||
"results": self.results,
|
"results": self.results,
|
||||||
"signals": self.signals,
|
"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,8 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from ..backtest.ave_client import AveCloudClient
|
|
||||||
|
from ..ave.client import AveCloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SimulateEngine:
|
class SimulateEngine:
|
||||||
@@ -20,16 +24,69 @@ class SimulateEngine:
|
|||||||
self.strategy_config = config.get("strategy_config", {})
|
self.strategy_config = config.get("strategy_config", {})
|
||||||
self.conditions = self.strategy_config.get("conditions", [])
|
self.conditions = self.strategy_config.get("conditions", [])
|
||||||
self.actions = self.strategy_config.get("actions", [])
|
self.actions = self.strategy_config.get("actions", [])
|
||||||
self.check_interval = config.get("check_interval", 60)
|
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||||
self.duration_seconds = config.get("duration_seconds", 3600)
|
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.auto_execute = config.get("auto_execute", False)
|
||||||
self.token = config.get("token", "")
|
self.token = config.get("token", "")
|
||||||
self.chain = config.get("chain", "bsc")
|
self.chain = config.get("chain", "bsc")
|
||||||
self.running = False
|
self.running = False
|
||||||
self.started_at: Optional[datetime] = None
|
self.started_at: Optional[datetime] = None
|
||||||
self.last_price: Optional[float] = None
|
|
||||||
|
# Price tracking (for conditions)
|
||||||
|
self.last_close: Optional[float] = None
|
||||||
self.last_volume: 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
|
||||||
|
|
||||||
|
def _get_interval_seconds(self, interval: str) -> int:
|
||||||
|
"""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]:
|
async def run(self) -> Dict[str, Any]:
|
||||||
self.running = True
|
self.running = True
|
||||||
self.status = "running"
|
self.status = "running"
|
||||||
@@ -46,58 +103,231 @@ class SimulateEngine:
|
|||||||
self.results = {"error": "Token ID is required"}
|
self.results = {"error": "Token ID is required"}
|
||||||
return self.results
|
return self.results
|
||||||
|
|
||||||
end_time = datetime.utcnow().timestamp() + self.duration_seconds
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.running and datetime.utcnow().timestamp() < end_time:
|
# Step 1: Fetch klines (only once for simulation)
|
||||||
try:
|
self.klines = await self._fetch_klines(token_id)
|
||||||
price_data = await self.ave_client.get_token_price(token_id)
|
|
||||||
if price_data:
|
|
||||||
current_price = float(price_data.get("price", 0))
|
|
||||||
current_volume = float(price_data.get("volume", 0))
|
|
||||||
|
|
||||||
if current_price > 0:
|
if not self.klines:
|
||||||
await self._check_conditions(
|
self.status = "failed"
|
||||||
current_price, current_volume, price_data
|
self.results = {"error": "No kline data available"}
|
||||||
)
|
return self.results
|
||||||
|
|
||||||
self.last_price = current_price
|
logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
|
||||||
self.last_volume = current_volume
|
|
||||||
|
|
||||||
except Exception as e:
|
# Step 2: Process candles (with limit)
|
||||||
pass
|
candles_processed = 0
|
||||||
|
self.total_candles = min(len(self.klines), self.max_candles)
|
||||||
|
self.current_candle_index = 0
|
||||||
|
|
||||||
for _ in range(self.check_interval):
|
for i, candle in enumerate(self.klines):
|
||||||
if not self.running:
|
if not self.running:
|
||||||
break
|
break
|
||||||
await asyncio.sleep(1)
|
if candles_processed >= self.max_candles:
|
||||||
|
logger.info(f"Reached max candles limit ({self.max_candles})")
|
||||||
|
break
|
||||||
|
|
||||||
if self.running:
|
self.current_candle_index = candles_processed
|
||||||
self.status = "completed"
|
candle_time = int(candle.get("time", 0))
|
||||||
else:
|
|
||||||
self.status = "stopped"
|
# 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:
|
except Exception as e:
|
||||||
|
logger.error(f"Simulation error: {e}")
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.results = {"error": str(e)}
|
self.results = {"error": str(e)}
|
||||||
|
self.errors.append(str(e))
|
||||||
|
|
||||||
self.results = self.results or {}
|
self.results = self.results or {}
|
||||||
self.results["total_signals"] = len(self.signals)
|
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["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["started_at"] = self.started_at
|
||||||
self.results["ended_at"] = datetime.utcnow()
|
self.results["ended_at"] = datetime.utcnow()
|
||||||
|
|
||||||
return self.results
|
return self.results
|
||||||
|
|
||||||
async def _check_conditions(
|
async def _fetch_klines(
|
||||||
self, current_price: float, current_volume: float, price_data: Dict[str, Any]
|
self,
|
||||||
):
|
token_id: str,
|
||||||
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
for condition in self.conditions:
|
# Sort by time ascending (oldest first)
|
||||||
if self._check_condition(condition, current_price, current_volume):
|
klines = sorted(klines, key=lambda x: x.get("time", 0))
|
||||||
await self._execute_actions(current_price, timestamp, condition)
|
return klines
|
||||||
break
|
|
||||||
|
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(
|
def _check_condition(
|
||||||
self,
|
self,
|
||||||
@@ -105,32 +335,34 @@ class SimulateEngine:
|
|||||||
current_price: float,
|
current_price: float,
|
||||||
current_volume: float,
|
current_volume: float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
"""Check if a condition is met based on price movement."""
|
||||||
cond_type = condition.get("type", "")
|
cond_type = condition.get("type", "")
|
||||||
threshold = condition.get("threshold", 0)
|
threshold = condition.get("threshold", 0)
|
||||||
price_level = condition.get("price")
|
|
||||||
direction = condition.get("direction", "above")
|
|
||||||
|
|
||||||
if cond_type == "price_drop":
|
if cond_type == "price_drop":
|
||||||
if self.last_price is None or self.last_price <= 0:
|
# Price dropped by threshold % from last close
|
||||||
|
if self.last_close is None or self.last_close <= 0:
|
||||||
return False
|
return False
|
||||||
drop_pct = ((self.last_price - current_price) / self.last_price) * 100
|
drop_pct = ((self.last_close - current_price) / self.last_close) * 100
|
||||||
return drop_pct >= threshold
|
return drop_pct >= threshold
|
||||||
|
|
||||||
elif cond_type == "price_rise":
|
elif cond_type == "price_rise":
|
||||||
if self.last_price is None or self.last_price <= 0:
|
# Price rose by threshold % from last close
|
||||||
|
if self.last_close is None or self.last_close <= 0:
|
||||||
return False
|
return False
|
||||||
rise_pct = ((current_price - self.last_price) / self.last_price) * 100
|
rise_pct = ((current_price - self.last_close) / self.last_close) * 100
|
||||||
return rise_pct >= threshold
|
return rise_pct >= threshold
|
||||||
|
|
||||||
elif cond_type == "volume_spike":
|
elif cond_type == "volume_spike":
|
||||||
|
# Volume increased significantly
|
||||||
if self.last_volume is None or self.last_volume <= 0:
|
if self.last_volume is None or self.last_volume <= 0:
|
||||||
return False
|
return False
|
||||||
volume_increase = (
|
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
|
||||||
(current_volume - self.last_volume) / self.last_volume
|
|
||||||
) * 100
|
|
||||||
return volume_increase >= threshold
|
return volume_increase >= threshold
|
||||||
|
|
||||||
elif cond_type == "price_level":
|
elif cond_type == "price_level":
|
||||||
|
price_level = condition.get("price")
|
||||||
|
direction = condition.get("direction", "above")
|
||||||
if price_level is None:
|
if price_level is None:
|
||||||
return False
|
return False
|
||||||
if direction == "above":
|
if direction == "above":
|
||||||
@@ -143,29 +375,56 @@ class SimulateEngine:
|
|||||||
async def _execute_actions(
|
async def _execute_actions(
|
||||||
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
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)
|
token = matched_condition.get("token", self.token)
|
||||||
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
||||||
|
|
||||||
signal = {
|
for action in self.actions:
|
||||||
"id": str(uuid.uuid4()),
|
action_type = action.get("type", "")
|
||||||
"bot_id": self.bot_id,
|
if action_type == "buy":
|
||||||
"run_id": self.run_id,
|
amount_percent = action.get("amount_percent", 10)
|
||||||
"signal_type": "signal",
|
amount = self.current_balance * (amount_percent / 100)
|
||||||
"token": token,
|
quantity = amount / price
|
||||||
"price": price,
|
|
||||||
"confidence": 0.8,
|
|
||||||
"reasoning": reasoning,
|
|
||||||
"executed": self.auto_execute,
|
|
||||||
"created_at": datetime.utcnow(),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.signals.append(signal)
|
self.position += quantity
|
||||||
|
self.position_token = token
|
||||||
|
self.entry_price = price
|
||||||
|
self.entry_time = timestamp
|
||||||
|
self.current_balance -= amount
|
||||||
|
|
||||||
async def stop(self):
|
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.running = False
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
|
|
||||||
def get_results(self) -> Dict[str, Any]:
|
def get_results(self) -> Dict[str, Any]:
|
||||||
|
"""Get simulation results."""
|
||||||
return {
|
return {
|
||||||
"id": self.run_id,
|
"id": self.run_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
@@ -174,4 +433,5 @@ class SimulateEngine:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_signals(self) -> List[Dict[str, Any]]:
|
def get_signals(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get current signals."""
|
||||||
return self.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