Compare commits

...

42 Commits

Author SHA1 Message Date
shokollm
765e390b9b feat: implement conversational AI agent with tool-calling
Prototype implementation that allows:
1. Normal conversation with the AI
2. Tool-calling to update trading strategies

Created new ConversationalAgent that uses CrewAI with tools:
- get_current_strategy: Check current bot strategy
- update_trading_strategy: Update bot's trading configuration

The agent can now respond to questions like 'What is this?' without
forcing JSON output, and can update strategies when user provides
specific parameters.

Refs #51
2026-04-10 05:00:22 +00:00
21ce282cae Merge pull request 'fix: add fallback UUID generator for crypto.randomUUID compatibility' (#50) from fix/crypto-randomuuid-fallback into main 2026-04-10 06:26:49 +02:00
shokollm
4fa9b0456a fix: add fallback UUID generator for crypto.randomUUID compatibility
crypto.randomUUID() is not available in all environments (e.g., older browsers,
non-secure contexts). Added a fallback UUID v4 implementation.
2026-04-10 04:19:45 +00:00
af9900d0ba Merge pull request 'fix: add timeout for chat requests and improve error handling' (#49) from fix/chat-timeout-handling into main 2026-04-10 06:15:45 +02:00
shokollm
b3ab004447 fix: add timeout for chat requests and improve error handling
Changes:
1. Add 30-second timeout for chat API requests using AbortController
2. User's message now shows immediately before API response (already done in previous PR)
3. Differentiate between timeout errors and other errors in error messages
4. API client now accepts optional signal parameter for abort support
2026-04-10 04:09:30 +00:00
d394bc0857 Merge pull request 'fix: display user messages in chat interface' (#48) from fix/display-user-messages into main 2026-04-10 06:04:23 +02:00
shokollm
dfa806ab53 fix: add user's message to frontend chat store when sending
Previously, only the assistant's response was added to the frontend store.
Now both user and assistant messages are stored, so the conversation
displays correctly in the chat interface.
2026-04-10 04:00:51 +00:00
3493775b7f Merge pull request 'fix: update MiniMaxConnector default model to MiniMax-M2.7' (#47) from fix/minimax-connector-model into main 2026-04-10 06:00:36 +02:00
shokollm
82645dfb3b fix: update MiniMaxConnector default model to MiniMax-M2.7 2026-04-10 03:53:26 +00:00
c17fa243a1 Merge pull request 'fix: use MiniMax text/chatcompletion_v2 endpoint' (#46) from fix/minimax-endpoint-v2 into main 2026-04-10 05:44:25 +02:00
shokollm
a55ed9cc04 fix: use MiniMax text/chatcompletion_v2 endpoint instead of chat/completions
The /v1/chat/completions endpoint returns 529 (overloaded) while
/v1/text/chatcompletion_v2 works reliably.
2026-04-10 03:42:20 +00:00
d1408b74b4 Merge pull request 'fix: properly configure MiniMax API endpoint and CrewAI LLM' (#45) from fix/minimax-api-endpoint-v2 into main 2026-04-10 05:31:13 +02:00
shokollm
4197475eed fix: properly configure CrewAI LLM with MiniMax api_base
- Use CrewAI's LLM class directly with api_base parameter instead of custom subclass
- Remove broken MiniMaxLLM inheritance from LLM
- Update agent creation to use LLM(model, api_key, api_base) pattern

The issue was that inheriting from CrewAI's LLM class caused the api_base
to be set to None. Now we use CrewAI's LLM directly with the correct parameters.

Fixes #43
2026-04-10 03:19:51 +00:00
87bac8894a Merge pull request 'fix: update MiniMax API endpoint to api.minimax.io' (#44) from fix/minimax-api-endpoint into main 2026-04-10 05:10:17 +02:00
shokollm
bef4479675 fix: update MiniMax API endpoint and default model
Changes:
1. Updated API endpoint from api.minimax.chat to api.minimax.io
2. Changed default model from MiniMax-Text-01 to MiniMax-M2.7
   (MiniMax-Text-01 is not available for all API key plans)
3. Updated .env.example with correct default model

MiniMax API docs: https://platform.minimax.io/docs/api-reference/text-anthropic-api

Fixes #43
2026-04-10 03:07:02 +00:00
75970c57e3 Merge pull request 'feat: return access token on user registration' (#42) from feat/41-return-token-on-register into main 2026-04-10 03:31:15 +02:00
shokollm
f23044465a feat: return access token on user registration
After successful registration, the backend now returns an access token
(along with token_type) so the frontend can:
- Store the token in localStorage
- Fetch the user profile
- Redirect to dashboard

Fixes #41
2026-04-10 01:28:01 +00:00
a6e4d28aa7 Merge pull request 'fix: add bcrypt version constraint for passlib compatibility' (#40) from fix/bcrypt-compatibility into main 2026-04-10 02:55:36 +02:00
shokollm
8693946cb8 fix: add bcrypt version constraint for passlib compatibility
bcrypt 5.0.0 is incompatible with passlib 1.7.x - passlib tries to
access bcrypt.__about__.__version__ which was removed in bcrypt 5.x.

Constrain bcrypt to >=4.0,<5.0 to maintain compatibility.
2026-04-10 00:55:18 +00:00
a2f549c056 Merge pull request 'fix: correct import paths in ai_agent module' (#39) from fix/ai-agent-imports into main 2026-04-09 17:32:21 +02:00
shokollm
ad6e57655d fix: correct import paths in ai_agent module
- Fix relative import path in crew.py (from ..core to ...core)
- Update __init__.py exports to match actual class names
- Remove incorrect CrewAgent and LLMConnector exports
2026-04-09 15:27:09 +00:00
ac5e9d8b81 Merge pull request 'fix: add error logging to simulate engine to prevent silent failures' (#38) from fix/issue-30 into main 2026-04-09 12:19:36 +02:00
shokollm
81f3342365 fix: add error logging to simulate engine to prevent silent failures
Errors during price fetching are now logged and stored in an errors list,
allowing users to see error count/warnings in simulation results.

Acceptance Criteria:
- [x] Errors are logged (not silently swallowed)
- [x] User can see error count/warnings in simulation results
- [x] Simulation completes even if some price fetches fail (graceful degradation)
2026-04-09 10:16:22 +00:00
6adad0701d Merge pull request 'fix: consolidate AveCloudClient to single implementation' (#37) from fix/issue-29 into main 2026-04-09 12:11:59 +02:00
shokollm
405b35c3ba fix: consolidate AveCloudClient to single implementation in services/ave/client.py 2026-04-09 10:06:16 +00:00
dd25d38e7e Merge pull request 'feat: implement stop-loss and take-profit risk management' (#36) from fix/issue-28 into main 2026-04-09 11:39:50 +02:00
shokollm
da8327c0e0 feat: implement stop-loss and take-profit in backtest and simulate engines 2026-04-09 09:14:08 +00:00
8d33ea9a44 Merge pull request 'fix: flatten strategy config schema (backtesting broken)' (#35) from fix/issue-25 into main 2026-04-09 09:32:49 +02:00
shokollm
d81464b869 fix: flatten strategy config schema to match engine expectations
LLM was outputting nested params structure but engines expect flat fields.
This caused backtesting and simulation to never trigger any trades.

Changes:
- llm_connector.py: Update prompt to output flat condition structure
- crew.py: Update StrategyValidator to validate flat structure
- crew.py: Update StrategyExplainer to read flat structure

Fixes #25
2026-04-09 07:31:09 +00:00
55b008d4e8 Merge pull request 'fix: validate chain is 'bsc' for Phase 1' (#34) from fix/issue-31 into main 2026-04-09 09:10:55 +02:00
shokollm
04e4c1a487 fix: validate chain is 'bsc' for BacktestCreate and SimulationCreate 2026-04-09 06:58:16 +00:00
feb65131fa Merge pull request 'fix: populate config endpoints with chain and token data' (#33) from fix/issue-27 into main 2026-04-09 08:23:43 +02:00
shokollm
50af4e0722 fix: reduce tokens limit to 20 per review 2026-04-09 06:18:31 +00:00
shokollm
786e964e32 fix: return bsc chain and tokens from AVE API in config endpoints 2026-04-09 06:02:05 +00:00
41b699f9ee Merge pull request 'fix: make strategy_config and llm_config optional in BotCreate' (#32) from fix/issue-26 into main 2026-04-09 07:54:20 +02:00
shokollm
ccc0404cd1 fix: make strategy_config and llm_config optional in BotCreate schema 2026-04-09 05:30:12 +00:00
shokollm
0a2e347fdb feat: Add database init on startup and documentation
- Add lifespan handler to main.py for automatic DB table creation
- Expand .env.example with detailed variable documentation
- Add AUDIT_REPORT.md with comprehensive product/technical review
- Add STRATEGY_SCHEMA.md as single source of truth for strategy config
- Remove redundant init_db.py script (DB init now handled by app startup)
2026-04-09 04:49:11 +00:00
2561759b78 feat: Add deployment documentation and templates (issue #12) (#23) 2026-04-09 01:23:53 +02:00
b6f99aa8fe Merge pull request '[Backend] AVE Cloud Integration - Data and Trading APIs' (#22) from fix/issue-11 into main 2026-04-08 16:49:13 +02:00
shokollm
3806af3e23 feat(backend): Implement AVE Cloud integration for Data and Trading APIs
- Add tier field to User model for plan detection (free/normal/pro)
- Create AVE Cloud API client with all Data API endpoints:
  - Token search (GET /v2/tokens)
  - Batch prices (POST /v2/tokens/price)
  - Token details (GET /v2/tokens/{id})
  - Kline data (GET /v2/klines/token/{id})
  - Trending tokens (GET /v2/tokens/trending)
  - Token risk (GET /v2/contracts/{id})
- Add Trading API endpoints:
  - Chain wallet quote (POST /v1/chain/quote)
  - Chain wallet swap (POST /v1/chain/swap)
- Add tier gating with upsell messaging for Pro features
- Handle rate limiting gracefully with 429 responses
- Add Pydantic schemas for AVE API requests/responses

Fixes #11
2026-04-08 14:41:40 +00:00
a892a403fb Merge pull request '[Frontend] Components - Chat UI, Dashboard, Visualizations' (#21) from fix/issue-10 into main 2026-04-08 16:23:23 +02:00
shokollm
0bb5d9a5d6 feat: Implement frontend UI components for issue #10
Created the following components:
- ChatInterface: Message input, AI responses, chat history with bot selector dropdown
- BotCard: Bot preview card for dashboard
- BotSelector: Dropdown to select bot (max 3 bots)
- StrategyPreview: Shows parsed strategy config in readable format
- SignalChart: Visual representation of signals over time (SVG-based)
- BacktestChart: Portfolio value chart with metrics display
- ProUpgradeBanner: Upsell banner for Pro features
- TokenPicker: Search/select tokens for conditions
- ConditionBuilder: UI for building trading conditions

Updated pages to use new components:
- Dashboard now uses BotCard
- Bot detail page now uses ChatInterface and StrategyPreview
- Backtest page now uses BacktestChart
- Simulate page now uses SignalChart and ProUpgradeBanner
2026-04-08 13:41:43 +00:00
40 changed files with 4403 additions and 476 deletions

223
deployment/DEPLOYMENT.md Normal file
View 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
View 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"

View File

@@ -0,0 +1,59 @@
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;
}
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;
}

View File

@@ -0,0 +1,21 @@
[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
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
View 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*

279
docs/STRATEGY_SCHEMA.md Normal file
View 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*

View File

@@ -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

View File

@@ -58,7 +58,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,7 +75,10 @@ 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)

265
src/backend/app/api/ave.py Normal file
View 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)}",
)

View File

@@ -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,21 +184,20 @@ 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() # Use ConversationalAgent for natural chat with tool-calling
result = crew.chat(user_message, history_for_crew) agent = get_conversational_agent(bot_id=bot_id)
result = agent.chat(user_message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.") assistant_content = result.get("response", "I couldn't process your request.")
if result.get("success") and result.get("strategy_config"):
bot.strategy_config = result["strategy_config"]
db.commit()
# Save conversation
db_conversation = BotConversation( db_conversation = BotConversation(
bot_id=bot_id, bot_id=bot_id,
role="user", role="user",
@@ -214,36 +214,13 @@ def chat(
db.commit() db.commit()
db.refresh(db_assistant) db.refresh(db_assistant)
return BotChatResponse( # If strategy was updated via tool, refresh bot data
response=assistant_content, if result.get("strategy_updated"):
strategy_config=result.get("strategy_config"), db.refresh(bot)
success=result.get("success", False),
)
else:
crew = get_trading_crew()
result = crew.chat(user_message, history_for_crew)
assistant_content = result.get("response", "I couldn't process your request.")
db_conversation = BotConversation(
bot_id=bot_id,
role="user",
content=user_message,
)
db.add(db_conversation)
db_assistant = BotConversation(
bot_id=bot_id,
role="assistant",
content=assistant_content,
)
db.add(db_assistant)
db.commit()
db.refresh(db_assistant)
return BotChatResponse( return BotChatResponse(
response=assistant_content, response=assistant_content,
strategy_config=result.get("strategy_config"), strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
success=result.get("success", False), success=result.get("success", False),
) )

View File

@@ -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}

View File

@@ -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)

View File

@@ -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
@@ -35,8 +35,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):
@@ -69,6 +69,13 @@ class BacktestCreate(BaseModel):
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
@@ -90,6 +97,13 @@ class SimulationCreate(BaseModel):
check_interval: int = 60 check_interval: int = 60
auto_execute: bool = False 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):
id: str id: str
@@ -144,3 +158,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

View File

@@ -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("/")

View File

@@ -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"]

View File

@@ -0,0 +1,167 @@
"""
Conversational Trading Agent
This agent can:
1. Have normal conversations with users
2. Update trading strategies when user provides specific instructions
Uses CrewAI's tool-calling capabilities for structured updates.
"""
from typing import List, Optional, Dict, Any
from crewai import Agent, LLM
from crewai.tools import tool
from sqlalchemy.orm import Session
from ...core.config import get_settings
from ...db.models import Bot
# Tool definitions
@tool
def get_current_strategy(bot_id: str) -> str:
"""Get the current trading strategy configuration for a bot.
Use this tool to check the current strategy before making changes.
Args:
bot_id: The ID of the bot to get strategy for
Returns:
JSON string with current strategy configuration
"""
from ...core.database import get_db
from ...db.models import Bot
db = next(get_db())
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
return '{"error": "Bot not found"}'
return str(bot.strategy_config)
@tool
def update_trading_strategy(
bot_id: str,
conditions: List[Dict],
actions: List[Dict],
risk_management: Optional[Dict] = None
) -> str:
"""Update the trading strategy configuration for a bot.
Call this tool when the user provides specific trading parameters like:
- Buy/sell conditions (price drops, price rises, etc.)
- Take profit percentages
- Stop loss percentages
Args:
bot_id: The ID of the bot to update
conditions: List of trigger conditions (e.g., [{"type": "price_drop", "token": "PEPE", "threshold": 5}])
actions: List of actions to take (e.g., [{"type": "buy", "amount_percent": 50}])
risk_management: Optional risk settings (e.g., {"stop_loss_percent": 10, "take_profit_percent": 50})
Returns:
Confirmation message with updated strategy
"""
from ...core.database import get_db
from ...db.models import Bot
db = next(get_db())
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
return '{"error": "Bot not found"}'
new_config = {
"conditions": conditions,
"actions": actions,
}
if risk_management:
new_config["risk_management"] = risk_management
bot.strategy_config = new_config
db.commit()
return f'Successfully updated trading strategy. New config: {new_config}'
SYSTEM_PROMPT = """You are a helpful AI trading assistant. You can:
1. Have normal conversations - answer questions about trading, tokens, strategies, etc.
2. Help users configure their trading bots when they provide specific parameters
When a user asks general questions, just answer conversationally.
When a user provides specific trading parameters (like percentages, tokens, conditions),
use the update_trading_strategy tool to save their configuration.
Example conversations:
- User: "What is this?" → Answer conversationally about the trading bot platform
- User: "I want take profit at 200%" → Use update_trading_strategy with that parameter
- User: "Alert me when PEPE drops 5%" → Use update_trading_strategy with that condition
Be friendly, helpful, and clear in your responses."""
class ConversationalAgent:
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", bot_id: str = None):
self.api_key = api_key
self.model = model
self.bot_id = bot_id
self.llm = LLM(
model=model,
api_key=api_key,
api_base="https://api.minimax.io/v1"
)
# Create agent with tools
self.agent = Agent(
role="Trading Assistant",
goal="Help users with trading strategies and general questions",
backstory=SYSTEM_PROMPT,
tools=[get_current_strategy, update_trading_strategy],
llm=self.llm,
verbose=True,
allow_delegation=False,
)
def chat(self, user_message: str, conversation_history: List[Dict] = None) -> Dict[str, Any]:
"""Process a user message and return a response.
Args:
user_message: The user's message
conversation_history: Optional list of previous messages
Returns:
Dict with 'response' (the assistant's reply) and 'strategy_updated' (bool)
"""
# Execute agent using kickoff
try:
result = self.agent.kickoff(user_message)
# Check if strategy was updated
result_str = str(result)
strategy_updated = "update_trading_strategy" in result_str or \
"Successfully updated" in result_str
return {
"response": result_str,
"strategy_updated": strategy_updated,
"success": True
}
except Exception as e:
return {
"response": f"I encountered an error: {str(e)}. Please try again.",
"strategy_updated": False,
"success": False
}
def get_conversational_agent(api_key: str = None, model: str = None, bot_id: str = None) -> ConversationalAgent:
"""Get or create a ConversationalAgent instance."""
if api_key is None:
settings = get_settings()
api_key = settings.MINIMAX_API_KEY
if model is None:
settings = get_settings()
model = settings.MINIMAX_MODEL
return ConversationalAgent(api_key=api_key, model=model, bot_id=bot_id)

View File

@@ -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()

View File

@@ -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",
"threshold_percent": number, // for price_drop, price_rise, volume_spike "chain": "bsc",
"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": {}
} }
] ]
} }

View File

@@ -0,0 +1,3 @@
from .client import AveCloudClient, check_tier_access
__all__ = ["AveCloudClient", "check_tier_access"]

View File

@@ -0,0 +1,229 @@
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]]:
url = f"{self.DATA_API_URL}/v2/tokens"
params = {"limit": limit}
if query:
params["query"] = query
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 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]]:
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()
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.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") == 200:
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."
)

View File

@@ -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 {}

View File

@@ -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,10 +20,15 @@ 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.entry_time: Optional[int] = None
self.trades: List[Dict[str, Any]] = [] self.trades: List[Dict[str, Any]] = []
self.running = False self.running = False
@@ -103,11 +108,73 @@ class BacktestEngine:
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
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
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]
):
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.entry_time = None
def _check_condition( def _check_condition(
self, self,
condition: Dict[str, Any], condition: Dict[str, Any],
@@ -173,6 +240,8 @@ class BacktestEngine:
self.position += amount / price self.position += amount / price
self.current_balance -= amount self.current_balance -= amount
self.position_token = token self.position_token = token
self.entry_price = price
self.entry_time = timestamp
self.trades.append( self.trades.append(
{ {
"type": "buy", "type": "buy",
@@ -209,9 +278,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()),

View File

@@ -1,8 +1,11 @@
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,6 +23,9 @@ 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.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.check_interval = config.get("check_interval", 60) self.check_interval = config.get("check_interval", 60)
self.duration_seconds = config.get("duration_seconds", 3600) self.duration_seconds = config.get("duration_seconds", 3600)
self.auto_execute = config.get("auto_execute", False) self.auto_execute = config.get("auto_execute", False)
@@ -29,6 +35,13 @@ class SimulateEngine:
self.started_at: Optional[datetime] = None self.started_at: Optional[datetime] = None
self.last_price: Optional[float] = None self.last_price: Optional[float] = None
self.last_volume: Optional[float] = None self.last_volume: Optional[float] = None
self.position: float = 0.0
self.position_token: str = ""
self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None
self.current_balance: float = config.get("initial_balance", 10000.0)
self.trades: List[Dict[str, Any]] = []
self.errors: List[str] = []
async def run(self) -> Dict[str, Any]: async def run(self) -> Dict[str, Any]:
self.running = True self.running = True
@@ -65,7 +78,9 @@ class SimulateEngine:
self.last_volume = current_volume self.last_volume = current_volume
except Exception as e: except Exception as e:
pass logger.warning(f"Failed to get price for {token_id}: {e}")
self.errors.append(f"Price fetch failed for {token_id}: {str(e)}")
continue
for _ in range(self.check_interval): for _ in range(self.check_interval):
if not self.running: if not self.running:
@@ -83,6 +98,8 @@ class SimulateEngine:
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_errors"] = len(self.errors)
self.results["errors"] = self.errors
self.results["signals"] = self.signals self.results["signals"] = self.signals
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()
@@ -94,11 +111,70 @@ class SimulateEngine:
): ):
timestamp = int(datetime.utcnow().timestamp() * 1000) timestamp = int(datetime.utcnow().timestamp() * 1000)
if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(current_price, timestamp)
if exit_info:
await self._execute_risk_exit(current_price, timestamp, exit_info)
return
for condition in self.conditions: for condition in self.conditions:
if self._check_condition(condition, current_price, current_volume): if self._check_condition(condition, current_price, current_volume):
await self._execute_actions(current_price, timestamp, condition) await self._execute_actions(current_price, timestamp, condition)
break break
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
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]
):
if self.position <= 0:
return
reason = exit_info["reason"]
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"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": 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,
condition: Dict[str, Any], condition: Dict[str, Any],
@@ -146,11 +222,32 @@ class SimulateEngine:
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"
for action in self.actions:
action_type = action.get("type", "")
if action_type == "buy":
amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100)
self.position += amount / price
self.position_token = token
self.entry_price = price
self.entry_time = timestamp
self.current_balance -= amount
self.trades.append(
{
"type": "buy",
"token": token,
"price": price,
"amount": amount,
"quantity": amount / price,
"timestamp": timestamp,
}
)
signal = { signal = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"bot_id": self.bot_id, "bot_id": self.bot_id,
"run_id": self.run_id, "run_id": self.run_id,
"signal_type": "signal", "signal_type": action_type,
"token": token, "token": token,
"price": price, "price": price,
"confidence": 0.8, "confidence": 0.8,

View File

@@ -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

View File

@@ -104,11 +104,12 @@ export const api = {
} }
}, },
async chat(id: string, message: string): Promise<BotChatResponse> { async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> {
const response = await fetch(`${API_URL}/bots/${id}/chat`, { const response = await fetch(`${API_URL}/bots/${id}/chat`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ message } as BotChatRequest) body: JSON.stringify({ message } as BotChatRequest),
signal
}); });
return handleResponse<BotChatResponse>(response); return handleResponse<BotChatResponse>(response);
}, },

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import type { Bot } from '$lib/api';
import type { ChatMessage } from '$lib/stores/chatStore';
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;
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);
}
}
$effect(() => {
if (messages.length && chatContainer) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 50);
}
});
</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}">
<div class="message-content">
{message.content}
</div>
<div class="message-time">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
{/each}
{#if isSending}
<div class="message assistant">
<div class="message-content typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
{/if}
</div>
{#if bot}
<div class="input-container">
<textarea
bind:value={messageInput}
onkeydown={handleKeydown}
placeholder="Describe your trading strategy..."
rows="1"
disabled={isSending}
></textarea>
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
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;
}
.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);
}
.message-time {
font-size: 0.7rem;
color: #666;
margin-top: 0.25rem;
padding: 0 0.5rem;
}
.typing {
display: flex;
gap: 4px;
padding: 1rem 1.25rem;
}
.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>

View 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>

View 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>

View File

@@ -0,0 +1,228 @@
<script lang="ts">
import type { Signal } from '$lib/api';
interface Props {
signals: Signal[];
height?: number;
}
let { signals, height = 200 }: Props = $props();
let width = $state(800);
let containerEl: HTMLDivElement;
$effect(() => {
if (containerEl) {
width = containerEl.clientWidth;
}
});
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
const padding = 30;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
const priceRange = getPriceRange();
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
const y = padding + (1 - normalizedPrice) * chartHeight;
return { x, y };
}
function getPriceRange(): { min: number; max: number } {
if (signals.length === 0) return { min: 0, max: 1 };
const prices = signals.map(s => s.price);
const min = Math.min(...prices);
const max = Math.max(...prices);
const padding = (max - min) * 0.1 || 1;
return { min: min - padding, max: max + padding };
}
function getSignalColor(signal: Signal): string {
switch (signal.signal_type) {
case 'buy': return '#22c55e';
case 'sell': return '#ef4444';
case 'hold': return '#fbbf24';
default: return '#888';
}
}
function getYAxisLabels(): string[] {
const range = getPriceRange();
const step = (range.max - range.min) / 4;
return [
range.max.toFixed(6),
(range.max - step).toFixed(6),
(range.min + step).toFixed(6),
range.min.toFixed(6)
];
}
function getXAxisLabels(): string[] {
if (signals.length === 0) return [];
const step = Math.max(1, Math.floor(signals.length / 5));
const labels: string[] = [];
for (let i = 0; i < signals.length; i += step) {
labels.push(new Date(signals[i].created_at).toLocaleTimeString());
}
return labels;
}
</script>
<div class="signal-chart" bind:this={containerEl}>
{#if signals.length === 0}
<div class="empty-state">
<p>No signals to display</p>
</div>
{:else}
<svg {width} {height} viewBox="0 0 {width} {height}">
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
</linearGradient>
</defs>
<g class="grid-lines">
{#each [0, 1, 2, 3] as i}
{@const y = 30 + (i / 3) * (height - 60)}
<line
x1="30" y1={y}
x2={width - 30} y2={y}
stroke="rgba(255,255,255,0.1)"
stroke-dasharray="4,4"
/>
{/each}
</g>
<g class="y-axis">
{#each getYAxisLabels() as label, i}
{@const y = 30 + (i / 3) * (height - 60)}
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
{/each}
</g>
<g class="x-axis">
{#each getXAxisLabels() as label, i}
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
{/each}
</g>
<path
d={signals.map((s, i) => {
const pos = getSignalPosition(s, i, signals.length);
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
}).join(' ')}
fill="none"
stroke="#667eea"
stroke-width="2"
/>
{#each signals as signal, i}
{@const pos = getSignalPosition(signal, i, signals.length)}
{@const color = getSignalColor(signal)}
<circle
cx={pos.x}
cy={pos.y}
r="6"
fill={color}
stroke={color}
stroke-width="2"
class="signal-dot"
>
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
</circle>
{/each}
</svg>
<div class="legend">
<div class="legend-item">
<span class="legend-dot buy"></span>
<span>Buy</span>
</div>
<div class="legend-item">
<span class="legend-dot sell"></span>
<span>Sell</span>
</div>
<div class="legend-item">
<span class="legend-dot hold"></span>
<span>Hold</span>
</div>
</div>
{/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;
}
svg {
display: block;
width: 100%;
height: auto;
}
.axis-label {
font-size: 10px;
fill: #666;
}
.signal-dot {
cursor: pointer;
transition: r 0.2s;
}
.signal-dot:hover {
r: 8;
}
.legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.buy {
background: #22c55e;
}
.legend-dot.sell {
background: #ef4444;
}
.legend-dot.hold {
background: #fbbf24;
}
</style>

View File

@@ -0,0 +1,227 @@
<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 {
switch (condition.type) {
case 'price_drop':
return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`;
case 'price_rise':
return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`;
case 'volume_spike':
return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.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>

View 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>

View File

@@ -0,0 +1,9 @@
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 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';

View File

@@ -8,12 +8,25 @@ export interface ChatMessage {
timestamp: Date; 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 const chatStore = writable<ChatMessage[]>([]);
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) { export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
const newMessage: ChatMessage = { const newMessage: ChatMessage = {
...message, ...message,
id: crypto.randomUUID(), id: generateId(),
timestamp: new Date() timestamp: new Date()
}; };
chatStore.update(messages => [...messages, newMessage]); chatStore.update(messages => [...messages, newMessage]);

View File

@@ -4,11 +4,11 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores'; import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let messageInput = $state('');
let isSending = $state(false); let isSending = $state(false);
let chatContainer: HTMLDivElement; let showStrategy = $state(false);
onMount(async () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
@@ -34,24 +34,27 @@
try { try {
const history = await api.bots.getHistory(botId); const history = await api.bots.getHistory(botId);
setMessages(history); setMessages(history);
scrollToBottom();
} catch (e) { } catch (e) {
console.error('Failed to load chat history:', e); console.error('Failed to load chat history:', e);
} }
} }
async function sendMessage() { async function handleSendMessage(message: string) {
if (!messageInput.trim() || isSending) return; if (isSending) return;
const userMessage = messageInput;
messageInput = '';
isSending = true; isSending = true;
addMessage({ role: 'user', content: userMessage }); // Add user's message immediately so it shows even before API response
scrollToBottom(); addMessage({ role: 'user', content: message });
try { try {
const response = await api.bots.chat(botId, userMessage); // 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);
addMessage({ role: 'assistant', content: response.response }); addMessage({ role: 'assistant', content: response.response });
if (response.strategy_config) { if (response.strategy_config) {
@@ -59,26 +62,18 @@
setCurrentBot(bot); setCurrentBot(bot);
} }
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
addMessage({ role: 'assistant', content: 'Request timed out. Please try again.' });
} else {
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }); addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
}
} finally { } finally {
isSending = false; isSending = false;
scrollToBottom();
} }
} }
function scrollToBottom() { function toggleStrategy() {
setTimeout(() => { showStrategy = !showStrategy;
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}, 50);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
} }
</script> </script>
@@ -93,55 +88,32 @@
<h1>{$currentBotStore?.name || 'Loading...'}</h1> <h1>{$currentBotStore?.name || 'Loading...'}</h1>
</div> </div>
<div class="header-actions"> <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}/backtest" class="btn btn-secondary">Backtest</a>
<a href="/bot/{botId}/simulate" class="btn btn-secondary">Simulate</a> <a href="/bot/{botId}/simulate" class="btn btn-secondary">Simulate</a>
</div> </div>
</header> </header>
<div class="chat-container" bind:this={chatContainer}> {#if showStrategy && $currentBotStore?.strategy_config}
{#if $chatStore.length === 0} <div class="strategy-panel">
<div class="welcome-message"> <StrategyPreview config={$currentBotStore.strategy_config} />
<p>Welcome to {$currentBotStore?.name}! 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> </div>
{/if} {/if}
{#each $chatStore as message} <div class="chat-wrapper">
<div class="message {message.role}"> <ChatInterface
<div class="message-content"> bot={$currentBotStore}
{message.content} messages={$chatStore}
</div> {isSending}
<div class="message-time"> onSendMessage={handleSendMessage}
{message.timestamp.toLocaleTimeString()} />
</div>
</div>
{/each}
{#if isSending}
<div class="message assistant">
<div class="message-content typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
{/if}
</div> </div>
{#if $currentBotStore} <ProUpgradeBanner feature="Auto-execute trades with your bot" />
<div class="input-container">
<textarea
bind:value={messageInput}
onkeydown={handleKeydown}
placeholder="Describe your trading strategy..."
rows="1"
disabled={isSending}
></textarea>
<button onclick={sendMessage} disabled={isSending || !messageInput.trim()}>
Send
</button>
</div>
{/if}
</main> </main>
<style> <style>
@@ -217,138 +189,14 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.chat-container { .strategy-panel {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
.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; margin-bottom: 1rem;
} }
.message.user { .chat-wrapper {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message-content {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 12px;
line-height: 1.5;
}
.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;
}
.message-time {
font-size: 0.7rem;
color: #666;
margin-top: 0.25rem;
padding: 0 0.5rem;
}
.typing {
display: flex;
gap: 4px;
padding: 1rem 1.25rem;
}
.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-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
textarea {
flex: 1; flex: 1;
padding: 0.75rem 1rem; overflow: hidden;
border-radius: 12px; display: flex;
border: 1px solid rgba(255, 255, 255, 0.2); flex-direction: column;
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> </style>

View File

@@ -4,6 +4,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, backtestStore, setCurrentBacktest, addBacktestToHistory, setBacktestLoading, setBacktestError } from '$lib/stores'; import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, backtestStore, setCurrentBacktest, addBacktestToHistory, setBacktestLoading, setBacktestError } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { BacktestChart } from '$lib/components';
import type { Backtest } from '$lib/api';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let token = $state('PEPE'); let token = $state('PEPE');
@@ -11,6 +13,7 @@
let startDate = $state(''); let startDate = $state('');
let endDate = $state(''); let endDate = $state('');
let isRunning = $state(false); let isRunning = $state(false);
let selectedBacktest = $state<Backtest | null>(null);
onMount(async () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
@@ -76,6 +79,12 @@
function setBacktestHistory(backtests: any[]) { function setBacktestHistory(backtests: any[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests })); backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
} }
function selectBacktest(backtest: Backtest) {
if (backtest.status === 'completed' && backtest.result) {
selectedBacktest = backtest;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -177,6 +186,16 @@
</div> </div>
{/if} {/if}
</section> </section>
{#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> </div>
</main> </main>
@@ -388,4 +407,36 @@
color: #fca5a5; color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4); 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;
}
</style> </style>

View File

@@ -4,6 +4,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores'; import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { SignalChart, ProUpgradeBanner } from '$lib/components';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let token = $state('PEPE'); let token = $state('PEPE');
@@ -142,12 +143,16 @@
</form> </form>
</section> </section>
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
<section class="signals-section"> <section class="signals-section">
<h2>Signals ({$simulationStore.signals.length})</h2> <h2>Signals ({$simulationStore.signals.length})</h2>
{#if $simulationStore.signals.length === 0} {#if $simulationStore.signals.length === 0}
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p> <p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
{:else} {:else}
<SignalChart signals={$simulationStore.signals} height={200} />
<div class="signals-list"> <div class="signals-list">
{#each $simulationStore.signals as signal} {#each $simulationStore.signals as signal}
<div class="signal-card"> <div class="signal-card">

View File

@@ -3,6 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, botsStore, setBots, addBot, removeBot, userStore, logout } from '$lib/stores'; import { isAuthenticated, isLoading, botsStore, setBots, addBot, removeBot, userStore, logout } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { BotCard } from '$lib/components';
let showCreateModal = $state(false); let showCreateModal = $state(false);
let newBotName = $state(''); let newBotName = $state('');
@@ -30,7 +31,7 @@
} }
async function createBot() { async function createBot() {
if (!$newBotName.trim()) return; if (!newBotName.trim()) return;
createError = ''; createError = '';
isCreating = true; isCreating = true;
try { try {
@@ -95,19 +96,7 @@
{:else} {:else}
<div class="bots-grid"> <div class="bots-grid">
{#each $botsStore as bot} {#each $botsStore as bot}
<div class="bot-card"> <BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
<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>
<div class="bot-actions">
<a href="/bot/{bot.id}" class="btn btn-primary">Open</a>
<button onclick={() => deleteBot(bot.id)} class="btn btn-danger">Delete</button>
</div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -201,57 +190,6 @@
gap: 1.5rem; gap: 1.5rem;
} }
.bot-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.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 { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 8px; border-radius: 8px;
@@ -274,12 +212,6 @@
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.btn:hover { .btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }