Compare commits
26 Commits
fix/issue-
...
4cc0d982d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc0d982d6 | ||
| a6e4d28aa7 | |||
|
|
8693946cb8 | ||
| a2f549c056 | |||
|
|
ad6e57655d | ||
| ac5e9d8b81 | |||
|
|
81f3342365 | ||
| 6adad0701d | |||
|
|
405b35c3ba | ||
| dd25d38e7e | |||
|
|
da8327c0e0 | ||
| 8d33ea9a44 | |||
|
|
d81464b869 | ||
| 55b008d4e8 | |||
|
|
04e4c1a487 | ||
| feb65131fa | |||
|
|
50af4e0722 | ||
|
|
786e964e32 | ||
| 41b699f9ee | |||
|
|
ccc0404cd1 | ||
|
|
0a2e347fdb | ||
| 2561759b78 | |||
| b6f99aa8fe | |||
|
|
3806af3e23 | ||
| a892a403fb | |||
|
|
0bb5d9a5d6 |
521
docs/AUDIT_REPORT.md
Normal file
521
docs/AUDIT_REPORT.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# Randebu Trading Bot - Product & Technical Audit Report
|
||||||
|
|
||||||
|
> **Date:** 2026-04-09
|
||||||
|
> **Phase:** Phase 1 Implementation Complete - Pre-Testing Review
|
||||||
|
> **Purpose:** Document current state, issues found, and recommendations for next steps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Overview
|
||||||
|
|
||||||
|
### 1.1 What is Randebu?
|
||||||
|
|
||||||
|
Randebu is an AI-powered trading bot platform where users create and manage automated trading strategies through natural language chat—similar to ChatGPT, but specialized for creating trading bots.
|
||||||
|
|
||||||
|
### 1.2 Core User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Registration → Create Bot → Chat with AI to Define Strategy
|
||||||
|
→ Backtest Strategy → Simulate Trading → (Future) Live Trading
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Phase 1 Scope
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| BNB Chain only | ✅ Intended (not yet enforced) |
|
||||||
|
| Backtest engine | ✅ Implemented |
|
||||||
|
| Simulation engine | ✅ Implemented |
|
||||||
|
| Natural language strategy parsing | ✅ Implemented |
|
||||||
|
| User authentication | ✅ Implemented |
|
||||||
|
| Multi-bot support (max 3) | ✅ Implemented |
|
||||||
|
| Dummy wallet (database record) | ✅ Implemented |
|
||||||
|
|
||||||
|
### 1.4 Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Frontend | Svelte 5 + TypeScript |
|
||||||
|
| Backend | Python FastAPI |
|
||||||
|
| AI Agent | CrewAI + MiniMax LLM |
|
||||||
|
| Database | SQLite |
|
||||||
|
| Trading Data | AVE Cloud API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical Issues (Must Fix Before Testing)
|
||||||
|
|
||||||
|
These issues will cause complete pipeline failure if not addressed.
|
||||||
|
|
||||||
|
### 2.1 Database Tables Never Created
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`, `src/backend/run.py`
|
||||||
|
|
||||||
|
**Problem:** The application starts but never creates the database tables. There is no:
|
||||||
|
- Alembic migration setup
|
||||||
|
- `Base.metadata.create_all()` call on startup
|
||||||
|
- Database initialization script
|
||||||
|
|
||||||
|
**Impact:** First database operation will fail with "table not found" error.
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
```python
|
||||||
|
# core/database.py defines Base, but nothing calls:
|
||||||
|
# Base.metadata.create_all(engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Add database initialization on application startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Strategy Config Schema Mismatch
|
||||||
|
|
||||||
|
**Location:** Multiple files - see mapping below
|
||||||
|
|
||||||
|
**Problem:** The LLM outputs one schema format, but the backtest and simulation engines expect a completely different format. This is a **complete pipeline break** - strategies parsed by AI will never trigger any trades in backtesting.
|
||||||
|
|
||||||
|
#### Schema Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ LLM OUTPUT (llm_connector.py) - What AI actually produces │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ { │
|
||||||
|
│ "type": "price_drop", │
|
||||||
|
│ "params": { │
|
||||||
|
│ "token": "PEPE", │
|
||||||
|
│ "threshold_percent": 5 │
|
||||||
|
│ } │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND VALIDATOR (crew.py - StrategyValidator.validate()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # Validator expects params.threshold_percent - THIS WORKS │
|
||||||
|
│ if "threshold_percent" not in params: │
|
||||||
|
│ errors.append(f"Condition {i}: missing 'threshold_percent'") │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ (But engines look for flat fields)
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKTEST ENGINE (services/backtest/engine.py - _check_condition()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # What engine actually looks for: │
|
||||||
|
│ threshold = condition.get("threshold", 0) # ❌ Returns 0! │
|
||||||
|
│ token = condition.get("token") # ❌ Wrong path! │
|
||||||
|
│ timeframe = condition.get("timeframe") # ❌ Not in params! │
|
||||||
|
│ │
|
||||||
|
│ # Result: Conditions NEVER trigger because field names don't match │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SIMULATE ENGINE (services/simulate/engine.py - _check_condition()) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ # Same issue as backtest engine │
|
||||||
|
│ threshold = condition.get("threshold", 0) # ❌ Returns 0 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND TYPES (src/frontend/src/lib/api/types.ts) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ interface Condition { │
|
||||||
|
│ type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';│
|
||||||
|
│ token: string; # Flat - no params wrapper │
|
||||||
|
│ threshold?: number; # Not threshold_percent! │
|
||||||
|
│ timeframe?: string; # Exists here │
|
||||||
|
│ } │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field Mapping Table
|
||||||
|
|
||||||
|
| Component | Token Field | Threshold Field | Timeframe Field |
|
||||||
|
|-----------|-----------|-----------------|-----------------|
|
||||||
|
| LLM Output | `params.token` | `params.threshold_percent` | N/A |
|
||||||
|
| Validator | `params.token` | `params.threshold_percent` | N/A |
|
||||||
|
| Backtest Engine | `token` | `threshold` | `timeframe` |
|
||||||
|
| Simulate Engine | `token` | `threshold` | `timeframe` |
|
||||||
|
| Frontend Types | `token` | `threshold` | `timeframe` |
|
||||||
|
|
||||||
|
**Fix Required:** Normalize to ONE consistent schema across the entire pipeline. Recommended: Use the flat structure (token, threshold, timeframe) as it's simpler and already used by engines and frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Bot Creation Will Fail
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/db/schemas.py` (BotCreate)
|
||||||
|
- `src/frontend/src/lib/api/client.ts` (bots.create)
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
|
||||||
|
| Issue | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| Backend requires | `strategy_config: dict` (REQUIRED) |
|
||||||
|
| Backend requires | `llm_config: dict` (REQUIRED) |
|
||||||
|
| Frontend sends | Only `name` and optional `description` |
|
||||||
|
|
||||||
|
**Impact:** Users cannot create bots through the frontend - API will return validation error.
|
||||||
|
|
||||||
|
**Fix Required:** Either:
|
||||||
|
1. Make `strategy_config` and `llm_config` optional in backend with default values
|
||||||
|
2. OR update frontend to send default config values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Config Endpoints Return Empty Data
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/config.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/chains")
|
||||||
|
def get_chains():
|
||||||
|
return {"chains": []} # ❌ Always empty
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
def get_tokens():
|
||||||
|
return {"tokens": []} # ❌ Always empty
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Frontend cannot populate dropdowns for chain/token selection.
|
||||||
|
|
||||||
|
**Fix Required:** Return BSC (BNB Chain) as the only supported chain in Phase 1, and query AVE API for available tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Major Issues
|
||||||
|
|
||||||
|
### 3.1 Risk Management Not Implemented
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/db/models.py` (schema supports it)
|
||||||
|
- `src/backend/app/services/backtest/engine.py`
|
||||||
|
- `src/backend/app/services/simulate/engine.py`
|
||||||
|
|
||||||
|
**Problem:** The database schema and frontend UI support `risk_management` configuration:
|
||||||
|
```typescript
|
||||||
|
interface RiskManagement {
|
||||||
|
stop_loss_percent?: number;
|
||||||
|
take_profit_percent?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
However, neither the backtest nor simulation engines actually check or use stop-loss/take-profit logic during trade execution. The config is saved but ignored.
|
||||||
|
|
||||||
|
**Fix Required:** Implement actual stop-loss and take-profit checks in both engines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Duplicate AveCloudClient Implementations
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- `src/backend/app/services/ave/client.py`
|
||||||
|
- `src/backend/app/services/backtest/ave_client.py`
|
||||||
|
|
||||||
|
**Problem:** Two different AveCloudClient classes with different methods:
|
||||||
|
|
||||||
|
| `services/ave/client.py` | `services/backtest/ave_client.py` |
|
||||||
|
|--------------------------|-----------------------------------|
|
||||||
|
| `get_tokens()` | ❌ Missing |
|
||||||
|
| `get_batch_prices()` | ✅ `get_batch_prices()` |
|
||||||
|
| `get_token_details()` | ❌ Missing |
|
||||||
|
| `get_klines()` | ✅ `get_klines()` |
|
||||||
|
| `get_trending_tokens()` | ❌ Missing |
|
||||||
|
| `get_token_risk()` | ❌ Missing |
|
||||||
|
| `get_chain_quote()` | ❌ Missing |
|
||||||
|
| `get_chain_swap()` | ❌ Missing |
|
||||||
|
| ❌ Missing | `get_token_price()` |
|
||||||
|
|
||||||
|
Additionally, the simulate engine imports from the wrong location:
|
||||||
|
```python
|
||||||
|
# services/simulate/engine.py
|
||||||
|
from ..backtest.ave_client import AveCloudClient # ❌ Wrong import
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Consolidate into ONE AveCloudClient class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Silent Error Handling in Simulation
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/services/simulate/engine.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
# ... API calls ...
|
||||||
|
except Exception as e:
|
||||||
|
pass # ❌ Silently swallows ALL errors!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** If AVE API fails or returns bad data, the simulation continues silently with no logging or user feedback.
|
||||||
|
|
||||||
|
**Fix Required:** Add proper error logging and user-facing error messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 No Chain Validation for Phase 1
|
||||||
|
|
||||||
|
**Problem:** You mentioned limiting to BNB Chain only for Phase 1, but:
|
||||||
|
- No backend validation enforces this
|
||||||
|
- Users can specify any chain in backtest/simulate config
|
||||||
|
- The config endpoints return empty arrays
|
||||||
|
|
||||||
|
**Fix Required:** Add chain validation that only allows "bsc" for Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 In-Memory Token Blacklist
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/auth.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
TOKEN_BLACKLIST = set() # ❌ In-memory only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems:**
|
||||||
|
- Resets when server restarts
|
||||||
|
- Doesn't work with multiple workers/processes
|
||||||
|
- Logout doesn't truly invalidate tokens in production
|
||||||
|
|
||||||
|
**Fix Required:** Use Redis or database-backed token blacklist for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 Conversation History Not Passed to Crew
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/api/bots.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
history_for_crew = conversation_history[-10:] # Gets history
|
||||||
|
crew = get_trading_crew() # ❌ Doesn't pass history!
|
||||||
|
result = crew.chat(user_message, history_for_crew)
|
||||||
|
```
|
||||||
|
|
||||||
|
The history is fetched but not actually used by the agent - each chat starts fresh.
|
||||||
|
|
||||||
|
**Fix Required:** Pass conversation history to the crew agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 No Rate Limiting Applied
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.state.limiter = limiter # Set up but not used on most endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
The rate limiter is initialized but only applied to the login endpoint. Other endpoints have no protection.
|
||||||
|
|
||||||
|
**Fix Required:** Apply rate limiting to sensitive endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.8 CORS Wide Open
|
||||||
|
|
||||||
|
**Location:** `src/backend/app/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
allow_origins=["*"] # ❌ Should be restricted to frontend domain
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Limit CORS to the frontend domain in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.9 No WebSocket for Real-Time Updates
|
||||||
|
|
||||||
|
**Problem:** Users must poll the API to see:
|
||||||
|
- Backtest progress
|
||||||
|
- Simulation signals (new signals only appear on refresh)
|
||||||
|
|
||||||
|
**Impact:** Poor UX during long-running operations.
|
||||||
|
|
||||||
|
**Fix Required:** Add WebSocket support for real-time updates (Phase 2 or later).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Minor Issues
|
||||||
|
|
||||||
|
### 4.1 Unused Dependencies
|
||||||
|
|
||||||
|
**Location:** `src/backend/requirements.txt`
|
||||||
|
|
||||||
|
```python
|
||||||
|
anthropic>=0.18.0 # Included but project uses MiniMax
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:** Remove unused dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Missing .env Example
|
||||||
|
|
||||||
|
**Problem:** No `.env.example` file to guide deployment.
|
||||||
|
|
||||||
|
**Fix Required:** Create `.env.example` with all required variables documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 No Input Sanitization
|
||||||
|
|
||||||
|
User-provided data (bot names, chat messages) isn't sanitized before storage or display.
|
||||||
|
|
||||||
|
**Fix Required:** Add input validation and sanitization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Inconsistent Error Responses
|
||||||
|
|
||||||
|
Some endpoints return `{"detail": "..."}` (FastAPI default), others return custom error shapes.
|
||||||
|
|
||||||
|
**Fix Required:** Standardize error response format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 No Integration Tests
|
||||||
|
|
||||||
|
No tests that verify the full pipeline (chat → config → backtest).
|
||||||
|
|
||||||
|
**Fix Required:** Add integration tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Missing Documentation Files
|
||||||
|
|
||||||
|
The following should be created:
|
||||||
|
|
||||||
|
1. **`.env.example`** - All environment variables with descriptions
|
||||||
|
2. **`docs/STRATEGY_SCHEMA.md`** - Single source of truth for strategy config schema
|
||||||
|
3. **`docs/API_SCHEMA.md`** - API contract documentation
|
||||||
|
4. **`init_db.py`** - Database initialization script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommendations Summary
|
||||||
|
|
||||||
|
### Priority Matrix
|
||||||
|
|
||||||
|
| Priority | Issue | Effort | Impact |
|
||||||
|
|----------|-------|--------|--------|
|
||||||
|
| **P0** | Database tables not created | Small | App crashes on startup |
|
||||||
|
| **P0** | Bot creation fails | Small | Users can't create bots |
|
||||||
|
| **P0** | Strategy schema mismatch | Medium | Backtesting completely broken |
|
||||||
|
| **P0** | Config endpoints empty | Small | No chain/token selection |
|
||||||
|
| **P1** | Risk management not implemented | Medium | No stop-loss/take-profit |
|
||||||
|
| **P1** | Chain validation missing | Small | Can use non-BSC chains |
|
||||||
|
| **P1** | Silent error handling | Small | Hard to debug issues |
|
||||||
|
| **P2** | Duplicate AveCloudClient | Medium | Maintenance burden |
|
||||||
|
| **P2** | CORS restricted | Small | Security hardening |
|
||||||
|
| **P2** | Token blacklist (production) | Medium | Security |
|
||||||
|
| **P2** | Rate limiting | Medium | DoS protection |
|
||||||
|
| **P3** | WebSocket support | Large | UX improvement |
|
||||||
|
| **P3** | Integration tests | Medium | Code quality |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AVE Cloud Integration Notes
|
||||||
|
|
||||||
|
### Rate Limit Strategy
|
||||||
|
|
||||||
|
| Tier | TPS | Recommended Approach |
|
||||||
|
|------|-----|---------------------|
|
||||||
|
| Free | 1 | Aggressive caching, batch requests |
|
||||||
|
| Normal | 5 | Moderate caching |
|
||||||
|
| Pro | 20 | Minimal caching |
|
||||||
|
|
||||||
|
### Caching Recommendations
|
||||||
|
|
||||||
|
1. **Token prices:** Cache for 30-60 seconds
|
||||||
|
2. **Trending tokens:** Cache for 5-10 minutes
|
||||||
|
3. **Token details:** Cache for 5-10 minutes
|
||||||
|
4. **Risk assessments:** Cache for 15-30 minutes
|
||||||
|
|
||||||
|
### No Testnet Warning
|
||||||
|
|
||||||
|
AVE Cloud has **no testnet**. All API calls use real money:
|
||||||
|
- Use quote/dry-run mode for testing
|
||||||
|
- Start with minimal amounts ($1-10)
|
||||||
|
- Contact AVE support about sandbox options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Next Steps
|
||||||
|
|
||||||
|
### Immediate (Before Testing)
|
||||||
|
|
||||||
|
1. Add database initialization to startup
|
||||||
|
2. Fix bot creation (frontend or backend)
|
||||||
|
3. **Normalize strategy schema** - Choose flat structure, update all components
|
||||||
|
4. Populate config endpoints with BSC + default tokens
|
||||||
|
5. Add BSC-only chain validation
|
||||||
|
|
||||||
|
### Short Term
|
||||||
|
|
||||||
|
6. Implement risk management (stop-loss/take-profit)
|
||||||
|
7. Consolidate AveCloudClient
|
||||||
|
8. Add proper error handling
|
||||||
|
9. Create .env.example
|
||||||
|
10. Add input sanitization
|
||||||
|
|
||||||
|
### Medium Term
|
||||||
|
|
||||||
|
11. Add WebSocket for real-time updates
|
||||||
|
12. Implement production token blacklist (Redis)
|
||||||
|
13. Apply rate limiting
|
||||||
|
14. Restrict CORS
|
||||||
|
15. Add integration tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Files Reference
|
||||||
|
|
||||||
|
### Key Backend Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/backend/app/main.py` | FastAPI app initialization |
|
||||||
|
| `src/backend/app/api/bots.py` | Bot CRUD + chat endpoint |
|
||||||
|
| `src/backend/app/api/backtest.py` | Backtest API |
|
||||||
|
| `src/backend/app/api/simulate.py` | Simulation API |
|
||||||
|
| `src/backend/app/api/ave.py` | AVE Cloud proxy endpoints |
|
||||||
|
| `src/backend/app/api/config.py` | Config endpoints |
|
||||||
|
| `src/backend/app/db/schemas.py` | Pydantic schemas |
|
||||||
|
| `src/backend/app/db/models.py` | SQLAlchemy models |
|
||||||
|
| `src/backend/app/services/ai_agent/crew.py` | CrewAI agents |
|
||||||
|
| `src/backend/app/services/ai_agent/llm_connector.py` | MiniMax LLM |
|
||||||
|
| `src/backend/app/services/backtest/engine.py` | Backtest logic |
|
||||||
|
| `src/backend/app/services/simulate/engine.py` | Simulation logic |
|
||||||
|
| `src/backend/app/services/ave/client.py` | AVE Cloud client |
|
||||||
|
|
||||||
|
### Key Frontend Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/frontend/src/lib/api/client.ts` | API client |
|
||||||
|
| `src/frontend/src/lib/api/types.ts` | TypeScript types |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/+page.svelte` | Bot chat page |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/backtest/+page.svelte` | Backtest page |
|
||||||
|
| `src/frontend/src/routes/bot/[id]/simulate/+page.svelte` | Simulation page |
|
||||||
|
| `src/frontend/src/lib/components/ChatInterface.svelte` | Chat UI |
|
||||||
|
| `src/frontend/src/lib/components/StrategyPreview.svelte` | Strategy display |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Audit Complete
|
||||||
|
|
||||||
|
This audit was conducted by reviewing:
|
||||||
|
- All source code in `src/backend/` and `src/frontend/`
|
||||||
|
- Documentation in `docs/`
|
||||||
|
- Database models and schemas
|
||||||
|
- API endpoints and their implementations
|
||||||
|
|
||||||
|
The product has a **solid architectural foundation** and addresses a real market need. The core issues are manageable - primarily schema standardization and missing initialization code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Audit Report*
|
||||||
279
docs/STRATEGY_SCHEMA.md
Normal file
279
docs/STRATEGY_SCHEMA.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Strategy Config Schema
|
||||||
|
|
||||||
|
> **Status:** DRAFT - Needs to be normalized with implementation
|
||||||
|
> **Purpose:** Single source of truth for strategy configuration format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document defines the structure of the `strategy_config` JSON object that represents a trading bot's strategy. This config is:
|
||||||
|
- Generated by the AI from natural language input
|
||||||
|
- Validated by the backend
|
||||||
|
- Used by backtest and simulation engines
|
||||||
|
- Displayed in the frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schema Version
|
||||||
|
|
||||||
|
**Current Version:** 1.0
|
||||||
|
**Status:** Flat structure (NOT nested in `params`)
|
||||||
|
|
||||||
|
> **IMPORTANT:** The current implementation has a mismatch where the LLM outputs a nested `params` structure but the engines expect flat fields. This document defines the **TARGET** schema to normalize all components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Full Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_drop",
|
||||||
|
"token": "PEPE",
|
||||||
|
"chain": "bsc",
|
||||||
|
"threshold": 5,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 10,
|
||||||
|
"token": "PEPE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 3,
|
||||||
|
"take_profit_percent": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Field Definitions
|
||||||
|
|
||||||
|
### 4.1 Root Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `version` | string | No | Schema version (for future compatibility) |
|
||||||
|
| `conditions` | array | Yes | List of trigger conditions |
|
||||||
|
| `actions` | array | Yes | List of actions to execute when conditions are met |
|
||||||
|
| `risk_management` | object | No | Risk management settings |
|
||||||
|
|
||||||
|
### 4.2 Condition Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | Yes | Condition type (see supported types below) |
|
||||||
|
| `token` | string | Yes | Token symbol or address (e.g., "PEPE" or "0x123...-bsc") |
|
||||||
|
| `chain` | string | No | Blockchain chain (default: "bsc") |
|
||||||
|
| `threshold` | number | For price_drop/rise/volume_spike | Percentage threshold (e.g., 5 = 5%) |
|
||||||
|
| `price` | number | For price_level | Price level to trigger on |
|
||||||
|
| `direction` | string | For price_level | "above" or "below" |
|
||||||
|
| `timeframe` | string | No | Time window for calculation (e.g., "1h", "15m") |
|
||||||
|
|
||||||
|
#### Supported Condition Types
|
||||||
|
|
||||||
|
| Type | Description | Required Fields |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `price_drop` | Triggers when token price drops by threshold % | token, threshold |
|
||||||
|
| `price_rise` | Triggers when token price rises by threshold % | token, threshold |
|
||||||
|
| `volume_spike` | Triggers when trading volume increases by threshold % | token, threshold |
|
||||||
|
| `price_level` | Triggers when price crosses a specific level | token, price, direction |
|
||||||
|
|
||||||
|
### 4.3 Action Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | Yes | Action type (buy, sell, hold, notify) |
|
||||||
|
| `amount_percent` | number | For buy/sell | Percentage of portfolio to trade |
|
||||||
|
| `token` | string | No | Token to trade (defaults to condition token) |
|
||||||
|
|
||||||
|
#### Supported Action Types
|
||||||
|
|
||||||
|
| Type | Description | Required Fields |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| `buy` | Purchase tokens | amount_percent |
|
||||||
|
| `sell` | Sell tokens | amount_percent |
|
||||||
|
| `hold` | Do nothing (log only) | - |
|
||||||
|
| `notify` | Send notification to user | - |
|
||||||
|
|
||||||
|
### 4.4 Risk Management Object
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `stop_loss_percent` | number | No | Exit trade if loss exceeds this % |
|
||||||
|
| `take_profit_percent` | number | No | Exit trade if profit reaches this % |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Examples
|
||||||
|
|
||||||
|
### 5.1 Simple Buy on Price Drop
|
||||||
|
|
||||||
|
> "Buy PEPE when it drops 5% in 1 hour"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_drop",
|
||||||
|
"token": "PEPE",
|
||||||
|
"chain": "bsc",
|
||||||
|
"threshold": 5,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Buy on Price Rise with Stop Loss
|
||||||
|
|
||||||
|
> "Buy when PEPE rises 10%, but stop loss at 3%"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_rise",
|
||||||
|
"token": "PEPE",
|
||||||
|
"threshold": 10,
|
||||||
|
"timeframe": "4h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"amount_percent": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risk_management": {
|
||||||
|
"stop_loss_percent": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Sell on Price Level
|
||||||
|
|
||||||
|
> "Sell when PEPE reaches $0.0001"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "price_level",
|
||||||
|
"token": "PEPE",
|
||||||
|
"price": 0.0001,
|
||||||
|
"direction": "above"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "sell",
|
||||||
|
"amount_percent": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Volume Spike Alert
|
||||||
|
|
||||||
|
> "Notify me when PEPE volume spikes 50%"
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"type": "volume_spike",
|
||||||
|
"token": "PEPE",
|
||||||
|
"threshold": 50,
|
||||||
|
"timeframe": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation Rules
|
||||||
|
|
||||||
|
### 6.1 Conditions
|
||||||
|
|
||||||
|
- At least one condition is required
|
||||||
|
- Each condition must have a valid `type`
|
||||||
|
- Token must be specified
|
||||||
|
- Threshold must be positive number (for applicable types)
|
||||||
|
- Price level must be specified for `price_level` type
|
||||||
|
- Direction must be "above" or "below" for `price_level` type
|
||||||
|
|
||||||
|
### 6.2 Actions
|
||||||
|
|
||||||
|
- At least one action is required
|
||||||
|
- Each action must have a valid `type`
|
||||||
|
- `amount_percent` must be between 0 and 100
|
||||||
|
|
||||||
|
### 6.3 Risk Management
|
||||||
|
|
||||||
|
- `stop_loss_percent` must be positive
|
||||||
|
- `take_profit_percent` must be positive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation Status
|
||||||
|
|
||||||
|
### Components Using This Schema
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Backend Validator (crew.py) | ❌ Mismatch | Uses nested `params` structure |
|
||||||
|
| Backtest Engine | ❌ Mismatch | Uses flat structure (correct) |
|
||||||
|
| Simulate Engine | ❌ Mismatch | Uses flat structure (correct) |
|
||||||
|
| Frontend Types | ✅ Match | Uses flat structure |
|
||||||
|
| Frontend StrategyPreview | ✅ Match | Uses flat structure |
|
||||||
|
|
||||||
|
### Normalization Required
|
||||||
|
|
||||||
|
The LLM output parser should be updated to output flat structure (not nested in `params`) to match what the engines and frontend expect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Future Extensions
|
||||||
|
|
||||||
|
### Potential Condition Types (Phase 2+)
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rsi_oversold` | RSI indicator below threshold |
|
||||||
|
| `rsi_overbought` | RSI indicator above threshold |
|
||||||
|
| `ma_crossover` | Moving average crossover |
|
||||||
|
| `bollinger_breakout` | Bollinger Band breakout |
|
||||||
|
| `news_sentiment` | Based on news sentiment analysis |
|
||||||
|
|
||||||
|
### Potential Action Types (Phase 2+)
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `dca_buy` | Dollar cost averaging buy |
|
||||||
|
| `trailing_stop` | Trailing stop loss |
|
||||||
|
| `smart_rebalance` | Portfolio rebalancing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 1.0*
|
||||||
|
*Last Updated: 2026-04-09*
|
||||||
@@ -1,11 +1,68 @@
|
|||||||
|
# Randebu Trading Bot - Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SQLite database path (relative or absolute)
|
||||||
|
# Example: sqlite:///./data/app.db
|
||||||
DATABASE_URL=sqlite:///./data/app.db
|
DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTHENTICATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Secret key for JWT token signing
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
SECRET_KEY=your-super-secret-key-change-in-production
|
SECRET_KEY=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
|
# JWT algorithm (HS256 is recommended)
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
|
# Token expiration time in minutes (1440 = 24 hours)
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MINIMAX LLM
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# MiniMax API key (get from https://platform.minimax.chat/)
|
||||||
MINIMAX_API_KEY=your-minimax-api-key
|
MINIMAX_API_KEY=your-minimax-api-key
|
||||||
|
|
||||||
|
# MiniMax model to use
|
||||||
|
# Common options: MiniMax-Text-01, MiniMax-M2.1
|
||||||
MINIMAX_MODEL=MiniMax-Text-01
|
MINIMAX_MODEL=MiniMax-Text-01
|
||||||
AVE_API_KEY=your-ave-cloud-api-key
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
|
|||||||
265
src/backend/app/api/ave.py
Normal file
265
src/backend/app/api/ave.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .auth import get_current_user
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..db.models import User
|
||||||
|
from ..services.ave import AveCloudClient, check_tier_access
|
||||||
|
from ..db.schemas import (
|
||||||
|
AveBatchPricesRequest,
|
||||||
|
AveKlinesRequest,
|
||||||
|
AveChainQuoteRequest,
|
||||||
|
AveChainSwapRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ave_client() -> AveCloudClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens")
|
||||||
|
async def search_tokens(
|
||||||
|
query: Optional[str] = None,
|
||||||
|
chain: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
tokens = await client.get_tokens(query=query, chain=chain, limit=limit)
|
||||||
|
return {"tokens": tokens}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch tokens: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/price")
|
||||||
|
async def get_batch_prices(
|
||||||
|
request: AveBatchPricesRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
prices = await client.get_batch_prices(request.token_ids)
|
||||||
|
return {"prices": prices}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch batch prices: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens/{token_id}")
|
||||||
|
async def get_token_details(
|
||||||
|
token_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
token = await client.get_token_details(token_id)
|
||||||
|
if token is None:
|
||||||
|
return {"token": None, "upsell_message": None}
|
||||||
|
return {"token": token}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch token details: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/klines/{token_id}")
|
||||||
|
async def get_klines(
|
||||||
|
token_id: str,
|
||||||
|
interval: str = "1h",
|
||||||
|
limit: int = 100,
|
||||||
|
start_time: Optional[int] = None,
|
||||||
|
end_time: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
klines = await client.get_klines(
|
||||||
|
token_id=token_id,
|
||||||
|
interval=interval,
|
||||||
|
limit=limit,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
return {"klines": klines}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch klines: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens/trending")
|
||||||
|
async def get_trending_tokens(
|
||||||
|
chain: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
tokens = await client.get_trending_tokens(chain=chain, limit=limit)
|
||||||
|
return {"tokens": tokens}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch trending tokens: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts/{contract_id}")
|
||||||
|
async def get_token_risk(
|
||||||
|
contract_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
risk = await client.get_token_risk(contract_id)
|
||||||
|
if risk is None:
|
||||||
|
return {"risk": None, "upsell_message": None}
|
||||||
|
return {"risk": risk}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch token risk: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chain/quote")
|
||||||
|
async def get_chain_quote(
|
||||||
|
request: AveChainQuoteRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
quote = await client.get_chain_quote(
|
||||||
|
chain=request.chain,
|
||||||
|
from_token=request.from_token,
|
||||||
|
to_token=request.to_token,
|
||||||
|
amount=request.amount,
|
||||||
|
slippage=request.slippage,
|
||||||
|
)
|
||||||
|
if quote is None:
|
||||||
|
return {"quote": None, "upsell_message": None}
|
||||||
|
return {"quote": quote}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch chain quote: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chain/swap")
|
||||||
|
async def get_chain_swap(
|
||||||
|
request: AveChainSwapRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
client = get_ave_client()
|
||||||
|
try:
|
||||||
|
swap = await client.get_chain_swap(
|
||||||
|
chain=request.chain,
|
||||||
|
from_token=request.from_token,
|
||||||
|
to_token=request.to_token,
|
||||||
|
amount=request.amount,
|
||||||
|
slippage=request.slippage,
|
||||||
|
wallet_address=request.wallet_address,
|
||||||
|
)
|
||||||
|
if swap is None:
|
||||||
|
return {"swap": None, "upsell_message": None}
|
||||||
|
return {"swap": swap}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"AVE API error: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch chain swap: {str(e)}",
|
||||||
|
)
|
||||||
@@ -1,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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from .api import auth, bots, backtest, simulate, config
|
from .api import auth, bots, backtest, simulate, config, ave
|
||||||
from .core.limiter import limiter
|
from .core.limiter import limiter
|
||||||
|
from .core.database import engine, Base
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Initialize database on startup."""
|
||||||
|
# Import all models to ensure they're registered
|
||||||
|
from .db.models import User, Bot, BotConversation, Backtest, Simulation, Signal
|
||||||
|
|
||||||
|
# Create tables if they don't exist
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
yield
|
||||||
|
# Cleanup on shutdown if needed
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Randebu Trading Bot API",
|
title="Randebu Trading Bot API",
|
||||||
description="AI-powered trading bot platform API",
|
description="AI-powered trading bot platform API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
@@ -26,6 +47,7 @@ app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
|
|||||||
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
||||||
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
||||||
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
||||||
|
app.include_router(ave.router, prefix="/api/ave", tags=["ave"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .crew import CrewAgent
|
from .crew import TradingCrew, get_trading_crew
|
||||||
from .llm_connector import LLMConnector
|
from .llm_connector import MiniMaxLLM, MiniMaxConnector
|
||||||
|
|
||||||
__all__ = ["CrewAgent", "LLMConnector"]
|
__all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"]
|
||||||
|
|||||||
@@ -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
|
||||||
from .llm_connector import MiniMaxConnector, MiniMaxLLM
|
from .llm_connector import MiniMaxConnector, MiniMaxLLM
|
||||||
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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class MiniMaxLLM(LLM):
|
|||||||
super().__init__(**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 = {
|
||||||
@@ -61,9 +61,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 +71,17 @@ Output ONLY valid JSON with this schema:
|
|||||||
"conditions": [
|
"conditions": [
|
||||||
{
|
{
|
||||||
"type": "price_drop|price_rise|volume_spike|price_level",
|
"type": "price_drop|price_rise|volume_spike|price_level",
|
||||||
"params": {
|
"token": "TOKEN_SYMBOL",
|
||||||
"token": "TOKEN_SYMBOL",
|
"chain": "bsc",
|
||||||
"threshold_percent": number, // for price_drop, price_rise, volume_spike
|
"threshold": number, // for price_drop, price_rise, volume_spike
|
||||||
"price": number, // for price_level
|
"price": number, // for price_level
|
||||||
"direction": "above|below" // for price_level
|
"direction": "above|below", // for price_level
|
||||||
}
|
"timeframe": "1h"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"type": "buy|sell|notify",
|
"type": "buy|sell|notify"
|
||||||
"params": {}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/backend/app/services/ave/__init__.py
Normal file
3
src/backend/app/services/ave/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .client import AveCloudClient, check_tier_access
|
||||||
|
|
||||||
|
__all__ = ["AveCloudClient", "check_tier_access"]
|
||||||
229
src/backend/app/services/ave/client.py
Normal file
229
src/backend/app/services/ave/client.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import httpx
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AveCloudClient:
|
|
||||||
BASE_URL = "https://prod.ave-api.com"
|
|
||||||
|
|
||||||
def __init__(self, api_key: str, plan: str = "free"):
|
|
||||||
self.api_key = api_key
|
|
||||||
self.plan = plan
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
return {"X-API-KEY": self.api_key}
|
|
||||||
|
|
||||||
async def get_klines(
|
|
||||||
self,
|
|
||||||
token_id: str,
|
|
||||||
interval: str = "1h",
|
|
||||||
limit: int = 100,
|
|
||||||
start_time: Optional[int] = None,
|
|
||||||
end_time: Optional[int] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
url = f"{self.BASE_URL}/v2/klines/token/{token_id}"
|
|
||||||
params = {"interval": interval, "limit": limit}
|
|
||||||
if start_time:
|
|
||||||
params["start_time"] = start_time
|
|
||||||
if end_time:
|
|
||||||
params["end_time"] = end_time
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(
|
|
||||||
url, headers=self._headers(), params=params, timeout=30.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("status") == 200:
|
|
||||||
return data.get("data", [])
|
|
||||||
raise Exception(f"Failed to fetch klines: {data}")
|
|
||||||
|
|
||||||
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
headers=self._headers(),
|
|
||||||
json={"token_ids": [token_id]},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("status") == 200:
|
|
||||||
prices = data.get("data", {})
|
|
||||||
return prices.get(token_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
|
||||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
headers=self._headers(),
|
|
||||||
json={"token_ids": token_ids},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("status") == 200:
|
|
||||||
return data.get("data", {})
|
|
||||||
return {}
|
|
||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from .ave_client import AveCloudClient
|
from ..ave.client import AveCloudClient
|
||||||
|
|
||||||
|
|
||||||
class BacktestEngine:
|
class BacktestEngine:
|
||||||
@@ -20,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()),
|
||||||
|
|||||||
@@ -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,20 +222,41 @@ 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"
|
||||||
|
|
||||||
signal = {
|
for action in self.actions:
|
||||||
"id": str(uuid.uuid4()),
|
action_type = action.get("type", "")
|
||||||
"bot_id": self.bot_id,
|
if action_type == "buy":
|
||||||
"run_id": self.run_id,
|
amount_percent = action.get("amount_percent", 10)
|
||||||
"signal_type": "signal",
|
amount = self.current_balance * (amount_percent / 100)
|
||||||
"token": token,
|
self.position += amount / price
|
||||||
"price": price,
|
self.position_token = token
|
||||||
"confidence": 0.8,
|
self.entry_price = price
|
||||||
"reasoning": reasoning,
|
self.entry_time = timestamp
|
||||||
"executed": self.auto_execute,
|
self.current_balance -= amount
|
||||||
"created_at": datetime.utcnow(),
|
self.trades.append(
|
||||||
}
|
{
|
||||||
|
"type": "buy",
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"amount": amount,
|
||||||
|
"quantity": amount / price,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.signals.append(signal)
|
signal = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"bot_id": self.bot_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"signal_type": action_type,
|
||||||
|
"token": token,
|
||||||
|
"price": price,
|
||||||
|
"confidence": 0.8,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"executed": self.auto_execute,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.signals.append(signal)
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BacktestResult } from '$lib/api';
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
results: BacktestResult | null;
|
||||||
|
signals?: Array<{ created_at: string; signal_type: string; price: number }>;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { results, signals = [], height = 300 }: Props = $props();
|
||||||
|
|
||||||
|
let width = $state(800);
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (containerEl) {
|
||||||
|
width = containerEl.clientWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function generatePortfolioCurve(): ChartDataPoint[] {
|
||||||
|
if (!results || signals.length === 0) return [];
|
||||||
|
|
||||||
|
const points: ChartDataPoint[] = [];
|
||||||
|
const startValue = 10000;
|
||||||
|
let currentValue = startValue;
|
||||||
|
|
||||||
|
const sortedSignals = [...signals].sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
timestamp: sortedSignals[0]?.created_at || new Date().toISOString(),
|
||||||
|
value: currentValue
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of sortedSignals) {
|
||||||
|
if (signal.signal_type === 'buy') {
|
||||||
|
currentValue *= 1.05;
|
||||||
|
} else if (signal.signal_type === 'sell') {
|
||||||
|
currentValue *= 0.95;
|
||||||
|
}
|
||||||
|
points.push({
|
||||||
|
timestamp: signal.created_at,
|
||||||
|
value: currentValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartArea(w: number, h: number): { x: number; y: number; width: number; height: number } {
|
||||||
|
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
|
||||||
|
return {
|
||||||
|
x: padding.left,
|
||||||
|
y: padding.top,
|
||||||
|
width: w - padding.left - padding.right,
|
||||||
|
height: h - padding.top - padding.bottom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueRange(pts: ChartDataPoint[]): { min: number; max: number } {
|
||||||
|
if (pts.length === 0) return { min: 0, max: 10000 };
|
||||||
|
const values = pts.map(p => p.value);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const padding = (max - min) * 0.1 || 1000;
|
||||||
|
return { min: min - padding, max: max + padding };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointPosition(point: ChartDataPoint, index: number, total: number, area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): { x: number; y: number } {
|
||||||
|
const x = area.x + (index / Math.max(total - 1, 1)) * area.width;
|
||||||
|
const normalizedValue = (point.value - range.min) / (range.max - range.min);
|
||||||
|
const y = area.y + area.height - normalizedValue * area.height;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYAxisLabels(area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ value: number; y: number }> {
|
||||||
|
const step = (range.max - range.min) / 4;
|
||||||
|
return [0, 1, 2, 3, 4].map(i => ({
|
||||||
|
value: range.max - i * step,
|
||||||
|
y: area.y + (i / 4) * area.height
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXAxisLabels(pts: ChartDataPoint[], area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ label: string; x: number }> {
|
||||||
|
if (pts.length === 0) return [];
|
||||||
|
const step = Math.max(1, Math.floor(pts.length / 5));
|
||||||
|
return pts
|
||||||
|
.filter((_, i) => i % step === 0 || i === pts.length - 1)
|
||||||
|
.map((p, i, arr) => ({
|
||||||
|
label: new Date(p.timestamp).toLocaleDateString(),
|
||||||
|
x: getPointPosition(p, pts.indexOf(p), pts.length, area, range).x
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReturnColor(): string {
|
||||||
|
if (!results) return '#888';
|
||||||
|
return results.total_return >= 0 ? '#22c55e' : '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
let points = $derived(generatePortfolioCurve());
|
||||||
|
let area = $derived(getChartArea(width, height));
|
||||||
|
let range = $derived(getValueRange(points));
|
||||||
|
let yAxisLabels = $derived(getYAxisLabels(area, range));
|
||||||
|
let xAxisLabels = $derived(getXAxisLabels(points, area, range));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backtest-chart" bind:this={containerEl}>
|
||||||
|
{#if !results}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No backtest results to display</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="chart-header">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Return</span>
|
||||||
|
<span class="metric-value" style="color: {getReturnColor()}">
|
||||||
|
{results.total_return >= 0 ? '+' : ''}{results.total_return.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Win Rate</span>
|
||||||
|
<span class="metric-value">{results.win_rate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Trades</span>
|
||||||
|
<span class="metric-value">{results.total_trades}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Sharpe Ratio</span>
|
||||||
|
<span class="metric-value">{results.sharpe_ratio.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg {width} {height} viewBox="0 0 {width} {height}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.4)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="grid-lines">
|
||||||
|
{#each [0, 1, 2, 3, 4] as i}
|
||||||
|
{@const y = area.y + (i / 4) * area.height}
|
||||||
|
<line
|
||||||
|
x1={area.x}
|
||||||
|
y1={y}
|
||||||
|
x2={area.x + area.width}
|
||||||
|
y2={y}
|
||||||
|
stroke="rgba(255,255,255,0.08)"
|
||||||
|
stroke-dasharray="4,4"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="y-axis">
|
||||||
|
{#each yAxisLabels as label}
|
||||||
|
<text x={area.x - 8} y={label.y + 4} class="axis-label" text-anchor="end">
|
||||||
|
${label.value.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="x-axis">
|
||||||
|
{#each xAxisLabels as label}
|
||||||
|
<text x={label.x} y={height - 8} class="axis-label" text-anchor="middle">
|
||||||
|
{label.label}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{#if points.length > 1}
|
||||||
|
<path
|
||||||
|
d={points.map((p, i) => {
|
||||||
|
const pos = getPointPosition(p, i, points.length, area, range);
|
||||||
|
if (i === 0) {
|
||||||
|
return `M ${pos.x} ${area.y + area.height} L ${pos.x} ${pos.y}`;
|
||||||
|
}
|
||||||
|
return `L ${pos.x} ${pos.y}`;
|
||||||
|
}).join(' ') + ` L ${getPointPosition(points[points.length - 1], points.length - 1, points.length, area, range).x} ${area.y + area.height} Z`}
|
||||||
|
fill="url(#portfolioGradient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={points.map((p, i) => {
|
||||||
|
const pos = getPointPosition(p, i, points.length, area, range);
|
||||||
|
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
|
||||||
|
}).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="#667eea"
|
||||||
|
stroke-width="2.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="chart-footer">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Buy Signals</span>
|
||||||
|
<span class="stat-value buy">{results.buy_signals}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Sell Signals</span>
|
||||||
|
<span class="stat-value sell">{results.sell_signals}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Max Drawdown</span>
|
||||||
|
<span class="stat-value negative">-{results.max_drawdown.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backtest-chart {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label {
|
||||||
|
font-size: 10px;
|
||||||
|
fill: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bot: Bot;
|
||||||
|
onOpen?: (botId: string) => void;
|
||||||
|
onDelete?: (botId: string) => void;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { bot, onOpen, onDelete, showActions = true }: Props = $props();
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
onOpen?.(bot.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.(bot.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}>
|
||||||
|
<div class="bot-info">
|
||||||
|
<h3>{bot.name}</h3>
|
||||||
|
{#if bot.description}
|
||||||
|
<p class="bot-description">{bot.description}</p>
|
||||||
|
{/if}
|
||||||
|
<span class="bot-status status-{bot.status}">{bot.status}</span>
|
||||||
|
</div>
|
||||||
|
{#if showActions}
|
||||||
|
<div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group">
|
||||||
|
<button class="btn btn-primary" onclick={handleOpen}>Open</button>
|
||||||
|
<button class="btn btn-danger" onclick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-card:focus {
|
||||||
|
outline: 2px solid #667eea;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-info h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-description {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bots: Bot[];
|
||||||
|
selectedBotId?: string | null;
|
||||||
|
onSelect: (botId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { bots, selectedBotId = null, onSelect, disabled = false, label = 'Select Bot' }: Props = $props();
|
||||||
|
|
||||||
|
function handleChange(e: Event) {
|
||||||
|
const target = e.target as HTMLSelectElement;
|
||||||
|
onSelect(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BOTS = 3;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-selector">
|
||||||
|
{#if label}
|
||||||
|
<label for="bot-select">{label}</label>
|
||||||
|
{/if}
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select
|
||||||
|
id="bot-select"
|
||||||
|
onchange={handleChange}
|
||||||
|
disabled={disabled || bots.length === 0}
|
||||||
|
value={selectedBotId || ''}
|
||||||
|
>
|
||||||
|
{#if bots.length === 0}
|
||||||
|
<option value="" disabled>No bots available</option>
|
||||||
|
{:else}
|
||||||
|
{#each bots as bot}
|
||||||
|
<option value={bot.id}>{bot.name}</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<span class="bot-count">{bots.length}/{MAX_BOTS}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
src/frontend/src/lib/components/ChatInterface.svelte
Normal file
300
src/frontend/src/lib/components/ChatInterface.svelte
Normal 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>
|
||||||
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Condition } from '$lib/api';
|
||||||
|
import TokenPicker from './TokenPicker.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conditions: Condition[];
|
||||||
|
onUpdate: (conditions: Condition[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { conditions, onUpdate, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
type ConditionType = Condition['type'];
|
||||||
|
|
||||||
|
const conditionTypes: { value: ConditionType; label: string; description: string }[] = [
|
||||||
|
{ value: 'price_drop', label: 'Price Drop', description: 'Trigger when price falls by X%' },
|
||||||
|
{ value: 'price_rise', label: 'Price Rise', description: 'Trigger when price rises by X%' },
|
||||||
|
{ value: 'volume_spike', label: 'Volume Spike', description: 'Trigger when volume increases by X%' },
|
||||||
|
{ value: 'price_level', label: 'Price Level', description: 'Trigger when price crosses a level' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d'];
|
||||||
|
|
||||||
|
function addCondition() {
|
||||||
|
const newCondition: Condition = {
|
||||||
|
type: 'price_drop',
|
||||||
|
token: '',
|
||||||
|
threshold: 5,
|
||||||
|
timeframe: '1h'
|
||||||
|
};
|
||||||
|
onUpdate([...conditions, newCondition]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(index: number) {
|
||||||
|
onUpdate(conditions.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(index: number, updates: Partial<Condition>) {
|
||||||
|
const updated = conditions.map((c, i) =>
|
||||||
|
i === index ? { ...c, ...updates } : c
|
||||||
|
);
|
||||||
|
onUpdate(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConditionDescription(condition: Condition): string {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'price_drop':
|
||||||
|
return `Price drops ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'price_rise':
|
||||||
|
return `Price rises ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'volume_spike':
|
||||||
|
return `Volume spikes ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||||
|
case 'price_level':
|
||||||
|
return `Price crosses ${condition.direction || 'above'} $${condition.price || 0}`;
|
||||||
|
default:
|
||||||
|
return 'Unknown condition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="condition-builder">
|
||||||
|
<div class="conditions-header">
|
||||||
|
<h4>Conditions</h4>
|
||||||
|
<button type="button" class="add-btn" onclick={addCondition} {disabled}>
|
||||||
|
+ Add Condition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if conditions.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No conditions set</p>
|
||||||
|
<p class="hint">Add a condition to define when your strategy triggers</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="conditions-list">
|
||||||
|
{#each conditions as condition, index}
|
||||||
|
<div class="condition-card">
|
||||||
|
<div class="condition-header">
|
||||||
|
<span class="condition-number">#{index + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-btn"
|
||||||
|
onclick={() => removeCondition(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Remove condition"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label for="type-{index}">Type</label>
|
||||||
|
<select
|
||||||
|
id="type-{index}"
|
||||||
|
value={condition.type}
|
||||||
|
onchange={(e) => updateCondition(index, { type: (e.target as HTMLSelectElement).value as ConditionType })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{#each conditionTypes as ct}
|
||||||
|
<option value={ct.value}>{ct.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TokenPicker
|
||||||
|
label="Token"
|
||||||
|
selectedToken={condition.token}
|
||||||
|
selectedChain={condition.chain || ''}
|
||||||
|
onSelect={(token, chain) => updateCondition(index, { token, chain })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if condition.type === 'price_level'}
|
||||||
|
<div class="field">
|
||||||
|
<label for="direction-{index}">Direction</label>
|
||||||
|
<select
|
||||||
|
id="direction-{index}"
|
||||||
|
value={condition.direction || 'above'}
|
||||||
|
onchange={(e) => updateCondition(index, { direction: (e.target as HTMLSelectElement).value as 'above' | 'below' })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="above">Above</option>
|
||||||
|
<option value="below">Below</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="price-{index}">Price ($)</label>
|
||||||
|
<input
|
||||||
|
id="price-{index}"
|
||||||
|
type="number"
|
||||||
|
value={condition.price || ''}
|
||||||
|
oninput={(e) => updateCondition(index, { price: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||||
|
placeholder="0.000001"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="field">
|
||||||
|
<label for="threshold-{index}">Threshold (%)</label>
|
||||||
|
<input
|
||||||
|
id="threshold-{index}"
|
||||||
|
type="number"
|
||||||
|
value={condition.threshold || ''}
|
||||||
|
oninput={(e) => updateCondition(index, { threshold: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||||
|
placeholder="5"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="timeframe-{index}">Timeframe</label>
|
||||||
|
<select
|
||||||
|
id="timeframe-{index}"
|
||||||
|
value={condition.timeframe || '1h'}
|
||||||
|
onchange={(e) => updateCondition(index, { timeframe: (e.target as HTMLSelectElement).value })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{#each timeframes as tf}
|
||||||
|
<option value={tf}>{tf}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-preview">
|
||||||
|
<span class="preview-label">Summary:</span>
|
||||||
|
{getConditionDescription(condition)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.condition-builder {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-number {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-preview {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
color: #666;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
feature?: string;
|
||||||
|
dismissible?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { feature, dismissible = true, onDismiss }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pro-upgrade-banner">
|
||||||
|
<div class="banner-content">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="banner-text">
|
||||||
|
<strong>Upgrade to Pro</strong>
|
||||||
|
{#if feature}
|
||||||
|
<p>{feature}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Unlock advanced features and unlimited bots</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<a href="/settings" class="upgrade-btn">Upgrade Now</a>
|
||||||
|
</div>
|
||||||
|
{#if dismissible && onDismiss}
|
||||||
|
<button class="dismiss-btn" onclick={onDismiss} aria-label="Dismiss">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pro-upgrade-banner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text strong {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
228
src/frontend/src/lib/components/SignalChart.svelte
Normal file
228
src/frontend/src/lib/components/SignalChart.svelte
Normal 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>
|
||||||
227
src/frontend/src/lib/components/StrategyPreview.svelte
Normal file
227
src/frontend/src/lib/components/StrategyPreview.svelte
Normal 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>
|
||||||
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
symbol: string;
|
||||||
|
chain: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedToken?: string;
|
||||||
|
selectedChain?: string;
|
||||||
|
onSelect: (token: string, chain: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedToken = '', selectedChain = '', onSelect, disabled = false, label = 'Select Token' }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let tokens = $state<Token[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
const commonTokens: Token[] = [
|
||||||
|
{ symbol: 'BTC', chain: 'btc', name: 'Bitcoin' },
|
||||||
|
{ symbol: 'ETH', chain: 'eth', name: 'Ethereum' },
|
||||||
|
{ symbol: 'BNB', chain: 'bsc', name: 'BNB' },
|
||||||
|
{ symbol: 'PEPE', chain: 'bsc', name: 'Pepe' },
|
||||||
|
{ symbol: 'SHIB', chain: 'eth', name: 'Shiba Inu' },
|
||||||
|
{ symbol: 'DOGE', chain: 'doge', name: 'Dogecoin' },
|
||||||
|
{ symbol: 'SOL', chain: 'sol', name: 'Solana' },
|
||||||
|
{ symbol: 'XRP', chain: 'xrp', name: 'Ripple' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerEl && !containerEl.contains(event.target as Node)) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTokens() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
tokens = await api.config.getTokens();
|
||||||
|
} catch (e) {
|
||||||
|
tokens = commonTokens;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredTokens(): Token[] {
|
||||||
|
const allTokens = tokens.length > 0 ? tokens : commonTokens;
|
||||||
|
if (!searchQuery) return allTokens.slice(0, 10);
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return allTokens.filter(
|
||||||
|
t => t.symbol.toLowerCase().includes(query) ||
|
||||||
|
t.name.toLowerCase().includes(query) ||
|
||||||
|
t.chain.toLowerCase().includes(query)
|
||||||
|
).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(token: Token) {
|
||||||
|
onSelect(token.symbol, token.chain);
|
||||||
|
searchQuery = '';
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputFocus() {
|
||||||
|
isOpen = true;
|
||||||
|
if (tokens.length === 0 && !isLoading) {
|
||||||
|
loadTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="token-picker" bind:this={containerEl}>
|
||||||
|
{#if label}
|
||||||
|
<label>{label}</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
onfocus={handleInputFocus}
|
||||||
|
placeholder={selectedToken ? `${selectedToken}${selectedChain ? ` (${selectedChain})` : ''}` : 'Search tokens...'}
|
||||||
|
{disabled}
|
||||||
|
class:has-value={selectedToken}
|
||||||
|
/>
|
||||||
|
{#if selectedToken}
|
||||||
|
<button class="clear-btn" onclick={() => onSelect('', '')} disabled={disabled}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="dropdown">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading tokens...</div>
|
||||||
|
{:else if getFilteredTokens().length === 0}
|
||||||
|
<div class="no-results">No tokens found</div>
|
||||||
|
{:else}
|
||||||
|
{#each getFilteredTokens() as token}
|
||||||
|
<button
|
||||||
|
class="token-option"
|
||||||
|
class:selected={token.symbol === selectedToken && token.chain === selectedChain}
|
||||||
|
onclick={() => handleSelect(token)}
|
||||||
|
>
|
||||||
|
<span class="token-symbol">{token.symbol}</span>
|
||||||
|
<span class="token-chain">{token.chain.toUpperCase()}</span>
|
||||||
|
<span class="token-name">{token.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.token-picker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.has-value {
|
||||||
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover:not(:disabled) {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.no-results {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-option.selected {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-symbol {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-chain {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/frontend/src/lib/components/index.ts
Normal file
9
src/frontend/src/lib/components/index.ts
Normal 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';
|
||||||
@@ -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,18 @@
|
|||||||
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 });
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.bots.chat(botId, userMessage);
|
const response = await api.bots.chat(botId, message);
|
||||||
addMessage({ role: 'assistant', content: response.response });
|
addMessage({ role: 'assistant', content: response.response });
|
||||||
|
|
||||||
if (response.strategy_config) {
|
if (response.strategy_config) {
|
||||||
@@ -62,23 +56,11 @@
|
|||||||
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 +75,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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each $chatStore 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 $currentBotStore}
|
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="chat-wrapper">
|
||||||
|
<ChatInterface
|
||||||
|
bot={$currentBotStore}
|
||||||
|
messages={$chatStore}
|
||||||
|
{isSending}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProUpgradeBanner feature="Auto-execute trades with your bot" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -217,138 +176,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;
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user