Compare commits
153 Commits
fix/issue-
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46cad4379 | ||
|
|
1b8761d1f4 | ||
| 958dc3bb1f | |||
|
|
5ae8d76bde | ||
| a9679bbb5d | |||
|
|
b1ddad0808 | ||
|
|
f705269e34 | ||
| 8acce849f4 | |||
|
|
2d125ede22 | ||
|
|
7a64632a63 | ||
|
|
bb62e53093 | ||
|
|
cf74251ad0 | ||
|
|
1efc0eaba6 | ||
|
|
f4f6168f68 | ||
|
|
62bcd6e099 | ||
|
|
6b8912a7eb | ||
|
|
2c3b6ef073 | ||
|
|
613ec0dc1f | ||
|
|
7bdd49a56c | ||
|
|
e92506a787 | ||
|
|
696d3934d5 | ||
|
|
466fdf1fe9 | ||
|
|
39a27caf05 | ||
|
|
61b9da295b | ||
|
|
38e45b9fd0 | ||
|
|
e41d07486b | ||
| 7e03101e7b | |||
|
|
70dfba2ffc | ||
|
|
6d204b537d | ||
|
|
2b7f54703e | ||
|
|
99dded8d16 | ||
|
|
29b7634c34 | ||
|
|
fd5c2b56d7 | ||
|
|
632e1bf524 | ||
|
|
5ae1165ad9 | ||
|
|
283573f5a8 | ||
|
|
90fa66bd39 | ||
|
|
84d8a6f4a6 | ||
|
|
a8e0baf0c0 | ||
|
|
6c39e4e89d | ||
|
|
bba773251a | ||
|
|
3013326ded | ||
|
|
a82185de60 | ||
|
|
cadea23e40 | ||
|
|
984656c83c | ||
|
|
1505bc9913 | ||
|
|
dd61c32ea7 | ||
|
|
01ec8bc539 | ||
|
|
a253aae766 | ||
|
|
13e899c851 | ||
|
|
384f84e772 | ||
|
|
cd1a41d1d7 | ||
|
|
6a20cc174f | ||
|
|
ce8a29c0a4 | ||
|
|
f425ae08d7 | ||
|
|
d4400f5dcd | ||
|
|
1591fcb1ca | ||
|
|
b0131aa566 | ||
|
|
52adc93b25 | ||
|
|
79c3ec7d16 | ||
|
|
3505cf4ade | ||
|
|
1b1358353f | ||
|
|
726e579f5f | ||
|
|
b111e4d79f | ||
|
|
0d63a10ac8 | ||
|
|
19f28fc599 | ||
|
|
5f7667992e | ||
|
|
cd4583ca90 | ||
|
|
6cadb7a67b | ||
|
|
02e0b0ccab | ||
|
|
29ec67cced | ||
|
|
c86e71c3a3 | ||
|
|
44fb840731 | ||
|
|
6a5694f74b | ||
|
|
680a9322e3 | ||
|
|
9973b8f6e2 | ||
|
|
30476e782b | ||
|
|
02ca452655 | ||
|
|
cb9558d54f | ||
|
|
638e17eb73 | ||
|
|
69a8b06462 | ||
|
|
15e72b009c | ||
|
|
19ba0c7cc6 | ||
|
|
847890b634 | ||
|
|
6658a418cc | ||
|
|
5c9e46e693 | ||
|
|
194c4f8a62 | ||
|
|
7afcb983e8 | ||
|
|
caef4b36ed | ||
|
|
3bf2877df2 | ||
|
|
145c6710d1 | ||
|
|
3c8c85aefc | ||
|
|
39b2b558a5 | ||
|
|
7795753aaa | ||
|
|
36dcfdb6e2 | ||
|
|
48fc323dac | ||
|
|
0af2de7209 | ||
|
|
e82b8b3549 | ||
|
|
6f23b322d3 | ||
|
|
297a185215 | ||
|
|
f86ff75525 | ||
|
|
6f9564790f | ||
|
|
f43eb11f6f | ||
|
|
446da96ce4 | ||
|
|
922ef89c1e | ||
|
|
a601ebb08b | ||
|
|
bb40193fc3 | ||
|
|
3a7d3a3732 | ||
|
|
0f558a5e8e | ||
|
|
9e9ff6fa7f | ||
|
|
4c48932ece | ||
|
|
bfc85648db | ||
|
|
925920eee1 | ||
|
|
299e74cffa | ||
|
|
2b875cfa27 | ||
|
|
ae612ad725 | ||
|
|
08912019c2 | ||
|
|
44453877b3 | ||
|
|
ad4a1e89d5 | ||
|
|
57fa200ba9 | ||
|
|
db4fb83243 | ||
|
|
560b61c431 | ||
|
|
c6baadf8b8 | ||
|
|
937cc2da60 | ||
| 32cd7184ea | |||
|
|
765e390b9b | ||
| 21ce282cae | |||
|
|
4fa9b0456a | ||
| af9900d0ba | |||
|
|
b3ab004447 | ||
| d394bc0857 | |||
|
|
dfa806ab53 | ||
| 3493775b7f | |||
|
|
82645dfb3b | ||
| c17fa243a1 | |||
|
|
a55ed9cc04 | ||
| d1408b74b4 | |||
|
|
4197475eed | ||
| 87bac8894a | |||
|
|
bef4479675 | ||
| 75970c57e3 | |||
|
|
f23044465a | ||
| a6e4d28aa7 | |||
|
|
8693946cb8 | ||
| a2f549c056 | |||
|
|
ad6e57655d | ||
| ac5e9d8b81 | |||
|
|
81f3342365 | ||
| 6adad0701d | |||
|
|
405b35c3ba | ||
| dd25d38e7e | |||
|
|
da8327c0e0 | ||
| 8d33ea9a44 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "ave-cloud-skill"]
|
||||
path = ave-cloud-skill
|
||||
url = https://github.com/AveCloud/ave-cloud-skill.git
|
||||
1
ave-cloud-skill
Submodule
1
ave-cloud-skill
Submodule
Submodule ave-cloud-skill added at 5eaef99e15
@@ -34,6 +34,9 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
|
||||
@@ -10,6 +10,8 @@ Environment="PATH=/var/www/bot/src/backend/venv/bin"
|
||||
ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=300
|
||||
|
||||
EnvironmentFile=/var/www/bot/data/.env
|
||||
|
||||
|
||||
27
docs/ISSUES.md
Normal file
27
docs/ISSUES.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Open Issues
|
||||
|
||||
## Frontend
|
||||
|
||||
### Token Address Confirmation Dialog
|
||||
- **Priority**: High
|
||||
- **Status**: Open
|
||||
- **Description**: When user configures a trading strategy via chat and mentions a token (e.g., "buy PEPE"), the AI asks for the token contract address. The frontend should show a confirmation dialog allowing user to:
|
||||
1. See the token the AI detected (PEPE)
|
||||
2. Enter/confirm the BSC contract address
|
||||
3. Save the strategy with the confirmed address
|
||||
|
||||
**Related Files**:
|
||||
- Frontend: `src/frontend/src/routes/bot/[id]/+page.svelte`
|
||||
- Backend: `src/backend/app/services/ai_agent/conversational.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Modal/dialog appears when AI detects a token without address
|
||||
- [ ] User can enter the contract address (0x...)
|
||||
- [ ] Strategy is saved only after user confirmation
|
||||
- [ ] Clear error handling if address is invalid
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
*No open backend issues*
|
||||
@@ -32,7 +32,7 @@ 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-M2.7
|
||||
|
||||
# =============================================================================
|
||||
# AVE CLOUD API
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Annotated
|
||||
from typing import Optional, Annotated
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import (
|
||||
@@ -14,6 +14,7 @@ from ..core.config import get_settings
|
||||
from ..core.limiter import limiter
|
||||
from ..db.schemas import (
|
||||
UserCreate,
|
||||
LoginRequest,
|
||||
UserResponse,
|
||||
Token,
|
||||
UserSettings,
|
||||
@@ -25,6 +26,14 @@ router = APIRouter()
|
||||
settings = get_settings()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
# Custom optional token extractor that doesn't raise on missing token
|
||||
def get_optional_token(request: Request) -> Optional[str]:
|
||||
"""Extract bearer token from Authorization header, returning None if not present."""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
return auth_header[7:] # Remove "Bearer " prefix
|
||||
return None
|
||||
|
||||
TOKEN_BLACKLIST = set()
|
||||
|
||||
|
||||
@@ -57,8 +66,33 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Optional[User]:
|
||||
"""Get current user, returning None if not authenticated."""
|
||||
token = get_optional_token(request)
|
||||
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if token in TOKEN_BLACKLIST:
|
||||
return None
|
||||
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
return user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||
"/register", response_model=Token, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
||||
@@ -75,18 +109,21 @@ def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
# Generate and return access token so frontend can proceed immediately
|
||||
access_token = create_access_token(data={"sub": db_user.id})
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@limiter.limit("5/minute")
|
||||
def login(
|
||||
request: Request,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
login_data: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
user = db.query(User).filter(User.email == login_data.username).first()
|
||||
if not user or not verify_password(login_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .auth import get_current_user
|
||||
from .auth import get_optional_user, get_current_user
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import BacktestCreate, BacktestResponse
|
||||
from ..db.models import Bot, Backtest, Signal, User
|
||||
from ..db.models import Bot, Backtest, Signal, User, AnonymousUser
|
||||
from ..services.rate_limiter import RateLimiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +23,7 @@ def run_backtest_sync(
|
||||
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
import asyncio
|
||||
import json
|
||||
from ..services.backtest.engine import BacktestEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
@@ -31,6 +33,19 @@ def run_backtest_sync(
|
||||
running_backtests[backtest_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
|
||||
# Convert datetime objects to ISO strings for JSON serialization
|
||||
def convert_datetime(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, dict):
|
||||
return {k: convert_datetime(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_datetime(i) for i in obj]
|
||||
return obj
|
||||
|
||||
results = convert_datetime(results)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
||||
@@ -41,17 +56,18 @@ def run_backtest_sync(
|
||||
db.commit()
|
||||
|
||||
for signal in engine.signals:
|
||||
signal_data = convert_datetime(signal)
|
||||
db_signal = Signal(
|
||||
id=signal["id"],
|
||||
bot_id=signal["bot_id"],
|
||||
run_id=signal["run_id"],
|
||||
signal_type=signal["signal_type"],
|
||||
token=signal["token"],
|
||||
price=signal["price"],
|
||||
confidence=signal.get("confidence"),
|
||||
reasoning=signal.get("reasoning"),
|
||||
executed=signal.get("executed", False),
|
||||
created_at=signal["created_at"],
|
||||
id=signal_data["id"],
|
||||
bot_id=signal_data["bot_id"],
|
||||
run_id=signal_data["run_id"],
|
||||
signal_type=signal_data["signal_type"],
|
||||
token=signal_data["token"],
|
||||
price=signal_data["price"],
|
||||
confidence=signal_data.get("confidence"),
|
||||
reasoning=signal_data.get("reasoning"),
|
||||
executed=signal_data.get("executed", False),
|
||||
created_at=signal["created_at"], # Use original datetime, not converted string
|
||||
)
|
||||
db.add(db_signal)
|
||||
db.commit()
|
||||
@@ -73,18 +89,41 @@ async def start_backtest(
|
||||
bot_id: str,
|
||||
config: BacktestCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None,
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
# Check authorization
|
||||
if current_user:
|
||||
# Authenticated user - must own the bot
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
else:
|
||||
# Anonymous user - can only run backtests on anonymous bots (user_id = None)
|
||||
if bot.user_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You need to be logged in to run backtests on this bot"
|
||||
)
|
||||
|
||||
# Rate limit anonymous backtests
|
||||
anonymous_token = request.cookies.get("anonymous_token") if request else None
|
||||
if anonymous_token:
|
||||
try:
|
||||
RateLimiter.check_anonymous_backtest_limit(db, anonymous_token)
|
||||
except HTTPException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You've reached the maximum of 1 backtest as an anonymous user. Please login or create an account for unlimited backtests."
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
backtest_id = str(uuid.uuid4())
|
||||
@@ -119,6 +158,10 @@ async def start_backtest(
|
||||
db.commit()
|
||||
db.refresh(backtest)
|
||||
|
||||
# Increment anonymous backtest count
|
||||
if not current_user and anonymous_token:
|
||||
RateLimiter.increment_backtest_count(db, anonymous_token)
|
||||
|
||||
db_url = str(settings.DATABASE_URL)
|
||||
background_tasks.add_task(
|
||||
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config
|
||||
@@ -154,9 +197,81 @@ def get_backtest(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||
)
|
||||
|
||||
# Add progress from running engine if available
|
||||
if backtest.status == "running" and run_id in running_backtests:
|
||||
engine = running_backtests[run_id]
|
||||
backtest.progress = engine.progress
|
||||
|
||||
return backtest
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/backtest/{run_id}/trades")
|
||||
def get_backtest_trades(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
page: int = 1,
|
||||
per_page: int = 5,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get paginated trade history for a specific backtest.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
per_page: Number of trades per page (default 5, max 20)
|
||||
"""
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
backtest = (
|
||||
db.query(Backtest)
|
||||
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
|
||||
.first()
|
||||
)
|
||||
if not backtest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||
)
|
||||
|
||||
# Get trades from result
|
||||
result = backtest.result or {}
|
||||
# Handle case where result might be a JSON string
|
||||
if isinstance(result, str):
|
||||
import json
|
||||
result = json.loads(result)
|
||||
all_trades = result.get("trades", []) or []
|
||||
total_trades = len(all_trades)
|
||||
|
||||
# Validate pagination params
|
||||
per_page = min(max(per_page, 1), 20) # Clamp between 1 and 20
|
||||
page = max(page, 1)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = max(1, (total_trades + per_page - 1) // per_page) if total_trades > 0 else 1
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
|
||||
# Get page of trades (return empty list if start_idx >= total_trades)
|
||||
paginated_trades = all_trades[start_idx:end_idx] if start_idx < total_trades else []
|
||||
|
||||
return {
|
||||
"backtest_id": run_id,
|
||||
"trades": paginated_trades,
|
||||
"total_trades": total_trades,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
}
|
||||
|
||||
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
|
||||
def list_backtests(
|
||||
bot_id: str,
|
||||
@@ -177,6 +292,7 @@ def list_backtests(
|
||||
db.query(Backtest)
|
||||
.filter(Backtest.bot_id == bot_id)
|
||||
.order_by(Backtest.started_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return backtests
|
||||
@@ -211,7 +327,12 @@ def stop_backtest(
|
||||
|
||||
if run_id in running_backtests:
|
||||
engine = running_backtests[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
engine.running = False # Direct sync access to running flag
|
||||
backtest.status = "stopped"
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
elif backtest.status == "running":
|
||||
# Engine already finished but status not updated
|
||||
backtest.status = "stopped"
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Annotated
|
||||
from typing import List, Annotated, Optional
|
||||
|
||||
from .auth import get_current_user
|
||||
from .auth import get_current_user, get_optional_user
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import (
|
||||
@@ -16,6 +16,7 @@ from ..db.schemas import (
|
||||
)
|
||||
from ..db.models import Bot, BotConversation, User
|
||||
from ..services.ai_agent.crew import get_trading_crew
|
||||
from ..services.ai_agent import get_conversational_agent
|
||||
|
||||
router = APIRouter()
|
||||
MAX_BOTS_PER_USER = 3
|
||||
@@ -70,7 +71,7 @@ def create_bot(
|
||||
@router.get("/{bot_id}", response_model=BotResponse)
|
||||
def get_bot(
|
||||
bot_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
@@ -79,11 +80,22 @@ def get_bot(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bot not found",
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot",
|
||||
)
|
||||
|
||||
# Check authorization
|
||||
if current_user:
|
||||
# Authenticated user - must own the bot
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot",
|
||||
)
|
||||
else:
|
||||
# Anonymous user - can only access anonymous bots (user_id = None)
|
||||
if bot.user_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot",
|
||||
)
|
||||
return bot
|
||||
|
||||
|
||||
@@ -162,7 +174,7 @@ def delete_bot(
|
||||
def chat(
|
||||
bot_id: str,
|
||||
request: BotChatRequest,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
@@ -171,11 +183,21 @@ def chat(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bot not found",
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to chat with this bot",
|
||||
)
|
||||
# Check authorization
|
||||
if current_user:
|
||||
# Authenticated user - must own the bot
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to chat with this bot",
|
||||
)
|
||||
else:
|
||||
# Anonymous user - can only chat with anonymous bots (user_id = None)
|
||||
if bot.user_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to chat with this bot",
|
||||
)
|
||||
|
||||
conversation_history = (
|
||||
db.query(BotConversation)
|
||||
@@ -183,75 +205,66 @@ def chat(
|
||||
.order_by(BotConversation.created_at)
|
||||
.all()
|
||||
)
|
||||
history_for_crew = [
|
||||
history_for_agent = [
|
||||
{"role": conv.role, "content": conv.content}
|
||||
for conv in conversation_history[-10:]
|
||||
]
|
||||
|
||||
user_message = request.message
|
||||
if request.strategy_config:
|
||||
crew = get_trading_crew()
|
||||
result = crew.chat(user_message, history_for_crew)
|
||||
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
if result.get("success") and result.get("strategy_config"):
|
||||
bot.strategy_config = result["strategy_config"]
|
||||
db.commit()
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"chat endpoint: current_user={current_user}, user_id={current_user.id if current_user else None}")
|
||||
|
||||
db_conversation = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(db_conversation)
|
||||
# Use ConversationalAgent for natural chat with tool-calling
|
||||
agent = get_conversational_agent(
|
||||
bot_id=bot_id,
|
||||
user_id=current_user.id if current_user else None
|
||||
)
|
||||
logger.warning(f"chat endpoint: agent.user_id={agent.user_id}")
|
||||
|
||||
db_assistant = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(db_assistant)
|
||||
db.commit()
|
||||
db.refresh(db_assistant)
|
||||
result = agent.chat(user_message, history_for_agent)
|
||||
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
strategy_config=result.get("strategy_config"),
|
||||
success=result.get("success", False),
|
||||
)
|
||||
else:
|
||||
crew = get_trading_crew()
|
||||
result = crew.chat(user_message, history_for_crew)
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
# Save conversation
|
||||
db_conversation = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(db_conversation)
|
||||
|
||||
db_conversation = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(db_conversation)
|
||||
db_assistant = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(db_assistant)
|
||||
db.commit()
|
||||
db.refresh(db_assistant)
|
||||
|
||||
db_assistant = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(db_assistant)
|
||||
db.commit()
|
||||
db.refresh(db_assistant)
|
||||
# If strategy was updated via tool, refresh bot data
|
||||
if result.get("strategy_updated"):
|
||||
db.refresh(bot)
|
||||
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
strategy_config=result.get("strategy_config"),
|
||||
success=result.get("success", False),
|
||||
)
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
thinking=result.get("thinking"),
|
||||
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
|
||||
success=result.get("success", False),
|
||||
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
|
||||
strategy_data=result.get("strategy_data")
|
||||
if result.get("strategy_needs_confirmation")
|
||||
else None,
|
||||
token_search_results=result.get("token_search_results"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
|
||||
def get_history(
|
||||
bot_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
@@ -260,11 +273,21 @@ def get_history(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bot not found",
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot's history",
|
||||
)
|
||||
# Check authorization
|
||||
if current_user:
|
||||
# Authenticated user - must own the bot
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot's history",
|
||||
)
|
||||
else:
|
||||
# Anonymous user - can only access anonymous bots (user_id = None)
|
||||
if bot.user_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this bot's history",
|
||||
)
|
||||
|
||||
conversations = (
|
||||
db.query(BotConversation)
|
||||
|
||||
350
src/backend/app/api/conversations.py
Normal file
350
src/backend/app/api/conversations.py
Normal file
@@ -0,0 +1,350 @@
|
||||
import secrets
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Annotated
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..db.models import Conversation, Message, User, AnonymousUser, Bot
|
||||
from ..db.schemas import ChatRequest
|
||||
from ..api.auth import get_optional_user
|
||||
from ..services.rate_limiter import RateLimiter
|
||||
from ..services.ai_agent import get_conversational_agent
|
||||
|
||||
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
|
||||
|
||||
|
||||
def get_or_create_anonymous_token(
|
||||
request: Request, response: Response, db: Session
|
||||
) -> str:
|
||||
token = request.cookies.get("anonymous_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
response.set_cookie(
|
||||
key="anonymous_token",
|
||||
value=token,
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
httponly=True,
|
||||
)
|
||||
anon = AnonymousUser(id=token)
|
||||
db.add(anon)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_conversations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
if current_user:
|
||||
return (
|
||||
db.query(Conversation)
|
||||
.filter(Conversation.user_id == current_user.id)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_conversation(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
request: Request = None,
|
||||
response: Response = None,
|
||||
):
|
||||
anonymous_token = None
|
||||
if not current_user and request:
|
||||
anonymous_token = get_or_create_anonymous_token(request, response, db)
|
||||
|
||||
conversation = Conversation(
|
||||
user_id=current_user.id if current_user else None,
|
||||
anonymous_token=anonymous_token,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.commit()
|
||||
db.refresh(conversation)
|
||||
return conversation
|
||||
|
||||
|
||||
@router.get("/{conversation_id}")
|
||||
def get_conversation(
|
||||
conversation_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
conversation = (
|
||||
db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id and current_user and conversation.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Get messages for this conversation
|
||||
messages = (
|
||||
db.query(Message)
|
||||
.filter(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build response with messages
|
||||
return {
|
||||
"id": conversation.id,
|
||||
"user_id": conversation.user_id,
|
||||
"bot_id": conversation.bot_id,
|
||||
"title": conversation.title,
|
||||
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
|
||||
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
|
||||
"messages": [
|
||||
{
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
for msg in messages
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_conversation(
|
||||
conversation_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
conversation = (
|
||||
db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id and current_user and conversation.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
db.delete(conversation)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/set-bot")
|
||||
def set_bot_for_conversation(
|
||||
conversation_id: str,
|
||||
bot_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
request: Request = None,
|
||||
):
|
||||
conversation = (
|
||||
db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id and current_user and conversation.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if not current_user:
|
||||
anonymous_token = request.cookies.get("anonymous_token") if request else None
|
||||
if anonymous_token:
|
||||
RateLimiter.check_anonymous_bot_limit(db, anonymous_token)
|
||||
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
if current_user and bot.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to use this bot")
|
||||
|
||||
conversation.bot_id = bot_id
|
||||
db.commit()
|
||||
|
||||
if not current_user and request:
|
||||
anonymous_token = request.cookies.get("anonymous_token")
|
||||
if anonymous_token:
|
||||
RateLimiter.set_bot_created(db, anonymous_token)
|
||||
|
||||
return {"status": "updated", "bot_id": bot_id}
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/chat")
|
||||
def chat_in_conversation(
|
||||
conversation_id: str,
|
||||
body: ChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
request: Request = None,
|
||||
response: Response = None,
|
||||
):
|
||||
conversation = (
|
||||
db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
)
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
if conversation.user_id and current_user and conversation.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
warning = None
|
||||
user_is_authenticated = current_user is not None
|
||||
|
||||
# Get anonymous_token from cookies or from the conversation itself
|
||||
anonymous_token = None
|
||||
if not current_user:
|
||||
RateLimiter.check_system_limit(db)
|
||||
|
||||
# First try to get from conversation (more reliable)
|
||||
anonymous_token = conversation.anonymous_token
|
||||
|
||||
# If not on conversation, try cookies
|
||||
if not anonymous_token and request:
|
||||
anonymous_token = request.cookies.get("anonymous_token")
|
||||
|
||||
# If still not found, create new one
|
||||
if not anonymous_token:
|
||||
anonymous_token = get_or_create_anonymous_token(request, response, db)
|
||||
# Also set it on the conversation for future use
|
||||
conversation.anonymous_token = anonymous_token
|
||||
db.commit()
|
||||
|
||||
# Debug logging
|
||||
import logging
|
||||
logging.info(f"Anonymous chat: token={anonymous_token}, checking limit")
|
||||
anon = RateLimiter.check_anonymous_limit(db, anonymous_token)
|
||||
if anon:
|
||||
logging.info(f"Anonymous user found: chat_count={anon.chat_count}")
|
||||
else:
|
||||
logging.info("Anonymous user NOT found in DB")
|
||||
RateLimiter.increment_chat_count(db, anonymous_token)
|
||||
|
||||
if anon and anon.chat_count > 40:
|
||||
warning = "Your progress is not saved."
|
||||
|
||||
# Always save the user's message first
|
||||
user_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=body.message,
|
||||
)
|
||||
db.add(user_msg)
|
||||
|
||||
# Get conversation history for context
|
||||
conversation_history = (
|
||||
db.query(Message)
|
||||
.filter(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
history_for_agent = [
|
||||
{"role": msg.role, "content": msg.content} for msg in conversation_history[-10:]
|
||||
]
|
||||
|
||||
# Get user_id
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# Debug logging
|
||||
print(f"DEBUG: conversation_id={conversation_id}")
|
||||
print(f"DEBUG: conversation.bot_id={conversation.bot_id}")
|
||||
print(f"DEBUG: conversation.anonymous_token={conversation.anonymous_token[:20] if conversation.anonymous_token else None}")
|
||||
print(f"DEBUG: anonymous_token variable={anonymous_token[:20] if anonymous_token else None}")
|
||||
|
||||
# If no bot is set, use a general-purpose agent (without bot-specific context)
|
||||
if not conversation.bot_id:
|
||||
# Use the conversational agent with user context
|
||||
agent = get_conversational_agent(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
anonymous_token=anonymous_token,
|
||||
)
|
||||
result = agent.chat(body.message, history_for_agent)
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
|
||||
# Refresh conversation to get updated bot_id (in case agent set it)
|
||||
db.refresh(conversation)
|
||||
|
||||
# Save the assistant's response
|
||||
assistant_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
db.commit()
|
||||
|
||||
# Fetch updated conversation with messages
|
||||
conversation_history = (
|
||||
db.query(Message)
|
||||
.filter(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"id": conversation.id,
|
||||
"user_id": conversation.user_id,
|
||||
"bot_id": conversation.bot_id,
|
||||
"title": conversation.title,
|
||||
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
|
||||
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
|
||||
"messages": [
|
||||
{
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
for msg in conversation_history
|
||||
],
|
||||
}
|
||||
|
||||
# Bot is set - process with the AI agent
|
||||
agent = get_conversational_agent(
|
||||
bot_id=conversation.bot_id,
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
anonymous_token=anonymous_token,
|
||||
)
|
||||
result = agent.chat(body.message, history_for_agent)
|
||||
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
|
||||
# Save the assistant's response
|
||||
assistant_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Fetch updated conversation with messages
|
||||
conversation_history = (
|
||||
db.query(Message)
|
||||
.filter(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"id": conversation.id,
|
||||
"user_id": conversation.user_id,
|
||||
"bot_id": conversation.bot_id,
|
||||
"title": conversation.title,
|
||||
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
|
||||
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
|
||||
"messages": [
|
||||
{
|
||||
"id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
for msg in conversation_history
|
||||
],
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,6 +12,9 @@ from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import SimulationCreate, SimulationResponse
|
||||
from ..db.models import Bot, Simulation, Signal, User
|
||||
from ..services.ave.client import AveCloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +26,7 @@ def run_simulation_sync(
|
||||
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
import asyncio
|
||||
import time
|
||||
from ..services.simulate.engine import SimulateEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
@@ -29,8 +34,19 @@ def run_simulation_sync(
|
||||
engine = SimulateEngine(config)
|
||||
engine.run_id = simulation_id
|
||||
running_simulations[simulation_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
|
||||
# Serialize signals for JSON storage (convert datetime to string)
|
||||
def serialize_signal(s):
|
||||
created = s.get("created_at")
|
||||
if hasattr(created, "isoformat"):
|
||||
created = created.isoformat()
|
||||
return {
|
||||
**s,
|
||||
"created_at": created
|
||||
}
|
||||
|
||||
def save_progress():
|
||||
"""Save current progress to database."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
simulation = (
|
||||
@@ -38,27 +54,50 @@ def run_simulation_sync(
|
||||
)
|
||||
if simulation:
|
||||
simulation.status = engine.status
|
||||
simulation.signals = engine.signals
|
||||
simulation.signals = [serialize_signal(s) for s in engine.signals]
|
||||
simulation.klines = [
|
||||
{"time": k.get("time"), "close": k.get("close")}
|
||||
for k in engine.klines
|
||||
]
|
||||
simulation.trade_log = engine.trade_log
|
||||
# Save portfolio data
|
||||
simulation.portfolio = {
|
||||
"initial_balance": engine.config.get("initial_balance", 10000),
|
||||
"current_balance": engine.current_balance,
|
||||
"position": engine.position,
|
||||
"position_token": engine.position_token,
|
||||
"entry_price": engine.entry_price,
|
||||
"current_price": engine.last_close,
|
||||
}
|
||||
db.commit()
|
||||
|
||||
for signal in engine.signals:
|
||||
db_signal = Signal(
|
||||
id=signal["id"],
|
||||
bot_id=signal["bot_id"],
|
||||
run_id=signal["run_id"],
|
||||
signal_type=signal["signal_type"],
|
||||
token=signal["token"],
|
||||
price=signal["price"],
|
||||
confidence=signal.get("confidence"),
|
||||
reasoning=signal.get("reasoning"),
|
||||
executed=signal.get("executed", False),
|
||||
created_at=signal["created_at"],
|
||||
)
|
||||
db.add(db_signal)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def run_with_progress_save():
|
||||
"""Run simulation and save progress periodically."""
|
||||
last_save_time = time.time()
|
||||
save_interval = 5 # Save every 5 seconds
|
||||
|
||||
while engine.running and engine.status == "running":
|
||||
await asyncio.sleep(1) # Check every second
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_save_time >= save_interval:
|
||||
save_progress()
|
||||
last_save_time = current_time
|
||||
|
||||
# Final save when done
|
||||
save_progress()
|
||||
|
||||
try:
|
||||
# Run both simulation and progress saving concurrently
|
||||
await asyncio.gather(
|
||||
engine.run(),
|
||||
run_with_progress_save()
|
||||
)
|
||||
finally:
|
||||
# Save final state
|
||||
save_progress()
|
||||
if simulation_id in running_simulations:
|
||||
del running_simulations[simulation_id]
|
||||
|
||||
@@ -87,20 +126,35 @@ async def start_simulation(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
# Check if there's already a running simulation for this bot
|
||||
existing_simulation = (
|
||||
db.query(Simulation)
|
||||
.filter(Simulation.bot_id == bot_id, Simulation.status == "running")
|
||||
.first()
|
||||
)
|
||||
if existing_simulation:
|
||||
# Stop the existing simulation first
|
||||
if existing_simulation.id in running_simulations:
|
||||
running_simulations[existing_simulation.id].stop()
|
||||
del running_simulations[existing_simulation.id]
|
||||
existing_simulation.status = "stopped"
|
||||
db.commit()
|
||||
|
||||
settings = get_settings()
|
||||
simulation_id = str(uuid.uuid4())
|
||||
|
||||
check_interval = config.check_interval
|
||||
if settings.AVE_API_PLAN != "pro" and check_interval < 60:
|
||||
check_interval = 60
|
||||
# Create AVE client for klines fetching
|
||||
ave_client = AveCloudClient(
|
||||
api_key=settings.AVE_API_KEY,
|
||||
plan=settings.AVE_API_PLAN,
|
||||
)
|
||||
|
||||
simulation_config = {
|
||||
"bot_id": bot_id,
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"duration_seconds": config.duration_seconds,
|
||||
"check_interval": check_interval,
|
||||
"auto_execute": config.auto_execute,
|
||||
"kline_interval": config.kline_interval,
|
||||
"auto_execute": False, # Always paper trade
|
||||
"strategy_config": bot.strategy_config,
|
||||
"ave_api_key": settings.AVE_API_KEY,
|
||||
"ave_api_plan": settings.AVE_API_PLAN,
|
||||
@@ -114,19 +168,46 @@ async def start_simulation(
|
||||
config={
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"duration_seconds": config.duration_seconds,
|
||||
"check_interval": check_interval,
|
||||
"auto_execute": config.auto_execute,
|
||||
"kline_interval": config.kline_interval,
|
||||
},
|
||||
signals=[],
|
||||
klines=[],
|
||||
)
|
||||
db.add(simulation)
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
|
||||
db_url = str(settings.DATABASE_URL)
|
||||
# Fetch klines SYNCHRONOUSLY so user can see chart immediately
|
||||
try:
|
||||
token_id = f"{config.token}-{config.chain}"
|
||||
|
||||
# Calculate time range (last 1 hour)
|
||||
import time
|
||||
end_time = int(time.time() * 1000)
|
||||
start_time = end_time - (60 * 60 * 1000) # 1 hour ago
|
||||
|
||||
klines_data = await ave_client.get_klines(
|
||||
token_id,
|
||||
interval=config.kline_interval,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=500
|
||||
)
|
||||
klines_for_chart = [
|
||||
{"time": k.get("time"), "close": k.get("close")}
|
||||
for k in sorted(klines_data, key=lambda x: x.get("time", 0))
|
||||
]
|
||||
# Update simulation with klines
|
||||
simulation.klines = klines_for_chart
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
logger.info(f"Fetched {len(klines_for_chart)} klines for simulation {simulation_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch klines: {e}")
|
||||
|
||||
# Run simulation in background for signal processing
|
||||
background_tasks.add_task(
|
||||
run_simulation_sync, simulation_id, db_url, bot_id, simulation_config
|
||||
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
|
||||
)
|
||||
|
||||
return simulation
|
||||
@@ -193,6 +274,9 @@ def list_simulations(
|
||||
if sim.id in running_simulations:
|
||||
engine = running_simulations[sim.id]
|
||||
sim.signals = engine.get_signals()
|
||||
# Include klines from running engine for chart display
|
||||
if hasattr(engine, 'klines'):
|
||||
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
|
||||
|
||||
return simulations
|
||||
|
||||
@@ -224,10 +308,15 @@ def stop_simulation(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||
)
|
||||
|
||||
# Always update status to stopped, even if engine is not in memory
|
||||
simulation.status = "stopped"
|
||||
|
||||
# Try to stop the engine if it's still in memory
|
||||
if run_id in running_simulations:
|
||||
engine = running_simulations[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
simulation.status = "stopped"
|
||||
db.commit()
|
||||
engine.stop()
|
||||
del running_simulations[run_id]
|
||||
|
||||
return {"status": "stopping", "run_id": run_id}
|
||||
db.commit()
|
||||
|
||||
return {"status": "stopped", "run_id": run_id}
|
||||
|
||||
1
src/backend/app/ave
Symbolic link
1
src/backend/app/ave
Symbolic link
@@ -0,0 +1 @@
|
||||
../../ave-cloud-skill/scripts/ave
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
Index,
|
||||
JSON,
|
||||
Integer,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..core.database import Base
|
||||
@@ -30,13 +31,16 @@ class User(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan")
|
||||
conversations = relationship(
|
||||
"Conversation", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Bot(Base):
|
||||
__tablename__ = "bots"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||
user_id = Column(String, ForeignKey("users.id"), nullable=True) # nullable for anonymous bots
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text)
|
||||
strategy_config = Column(JSON, nullable=False)
|
||||
@@ -47,6 +51,9 @@ class Bot(Base):
|
||||
|
||||
user = relationship("User", back_populates="bots")
|
||||
conversations = relationship(
|
||||
"Conversation", back_populates="bot", cascade="all, delete-orphan"
|
||||
)
|
||||
bot_conversations = relationship(
|
||||
"BotConversation", back_populates="bot", cascade="all, delete-orphan"
|
||||
)
|
||||
backtests = relationship(
|
||||
@@ -58,6 +65,47 @@ class Bot(Base):
|
||||
signals = relationship("Signal", back_populates="bot", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String, ForeignKey("users.id"), nullable=True)
|
||||
anonymous_token = Column(String(64), nullable=True)
|
||||
bot_id = Column(String, ForeignKey("bots.id"), nullable=True)
|
||||
title = Column(String(255), default="New Conversation")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="conversations")
|
||||
bot = relationship("Bot", back_populates="conversations")
|
||||
messages = relationship(
|
||||
"Message", back_populates="conversation", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
conversation_id = Column(String, ForeignKey("conversations.id"), nullable=True)
|
||||
role = Column(String, nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
|
||||
|
||||
class AnonymousUser(Base):
|
||||
__tablename__ = "anonymous_users"
|
||||
|
||||
id = Column(String(64), primary_key=True)
|
||||
chat_count = Column(Integer, default=0)
|
||||
bot_created = Column(Boolean, default=False)
|
||||
backtest_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class BotConversation(Base):
|
||||
__tablename__ = "bot_conversations"
|
||||
|
||||
@@ -67,7 +115,7 @@ class BotConversation(Base):
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
bot = relationship("Bot", back_populates="conversations")
|
||||
bot = relationship("Bot", back_populates="bot_conversations")
|
||||
|
||||
|
||||
class Backtest(Base):
|
||||
@@ -93,6 +141,9 @@ class Simulation(Base):
|
||||
status = Column(String, nullable=False)
|
||||
config = Column(JSON, nullable=False)
|
||||
signals = Column(JSON)
|
||||
klines = Column(JSON) # Price data for chart display
|
||||
trade_log = Column(JSON) # Trade activity log
|
||||
portfolio = Column(JSON) # Portfolio data
|
||||
|
||||
bot = relationship("Bot", back_populates="simulations")
|
||||
|
||||
@@ -115,7 +166,10 @@ class Signal(Base):
|
||||
|
||||
|
||||
Index("idx_bots_user_id", Bot.user_id)
|
||||
Index("idx_conversations_bot_id", BotConversation.bot_id)
|
||||
Index("idx_conversations_user_id", Conversation.user_id)
|
||||
Index("idx_conversations_bot_id", Conversation.bot_id)
|
||||
Index("idx_messages_conversation_id", Message.conversation_id)
|
||||
Index("idx_bot_conversations_bot_id", BotConversation.bot_id)
|
||||
Index("idx_backtests_bot_id", Backtest.bot_id)
|
||||
Index("idx_simulations_bot_id", Simulation.bot_id)
|
||||
Index("idx_signals_bot_id", Signal.bot_id)
|
||||
|
||||
@@ -8,6 +8,11 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
@@ -49,7 +54,7 @@ class BotUpdate(BaseModel):
|
||||
|
||||
class BotResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: Optional[str] # None for anonymous bots
|
||||
name: str
|
||||
description: Optional[str]
|
||||
strategy_config: dict
|
||||
@@ -64,6 +69,7 @@ class BotResponse(BaseModel):
|
||||
|
||||
class BacktestCreate(BaseModel):
|
||||
token: str
|
||||
token_name: Optional[str] = None
|
||||
chain: str
|
||||
timeframe: str
|
||||
start_date: str
|
||||
@@ -85,6 +91,7 @@ class BacktestResponse(BaseModel):
|
||||
status: str
|
||||
config: dict
|
||||
result: Optional[dict]
|
||||
progress: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -93,9 +100,7 @@ class BacktestResponse(BaseModel):
|
||||
class SimulationCreate(BaseModel):
|
||||
token: str
|
||||
chain: str
|
||||
duration_seconds: int = 3600
|
||||
check_interval: int = 60
|
||||
auto_execute: bool = False
|
||||
kline_interval: str = "1m"
|
||||
|
||||
@field_validator("chain")
|
||||
@classmethod
|
||||
@@ -112,6 +117,12 @@ class SimulationResponse(BaseModel):
|
||||
status: str
|
||||
config: dict
|
||||
signals: Optional[List[dict]]
|
||||
klines: Optional[List[dict]] = None # Price data for chart
|
||||
trade_log: Optional[List[dict]] = None # Trade activity log
|
||||
portfolio: Optional[dict] = None # Portfolio data
|
||||
current_candle_index: Optional[int] = None # Progress: current candle
|
||||
total_candles: Optional[int] = None # Progress: total candles
|
||||
candles_processed: Optional[int] = None # Progress: candles processed
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -140,8 +151,12 @@ class BotChatRequest(BaseModel):
|
||||
|
||||
class BotChatResponse(BaseModel):
|
||||
response: str
|
||||
thinking: Optional[str] = None
|
||||
strategy_config: Optional[dict] = None
|
||||
success: bool = False
|
||||
strategy_needs_confirmation: Optional[bool] = False
|
||||
strategy_data: Optional[dict] = None
|
||||
token_search_results: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class SignalResponse(BaseModel):
|
||||
@@ -227,3 +242,57 @@ class AveChainSwapRequest(BaseModel):
|
||||
class AveChainSwapResponse(BaseModel):
|
||||
swap: Optional[dict] = None
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
user_id: Optional[str]
|
||||
anonymous_token: Optional[str]
|
||||
bot_id: Optional[str]
|
||||
title: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
conversation_id: Optional[str]
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ConversationWithMessagesResponse(BaseModel):
|
||||
id: str
|
||||
user_id: Optional[str]
|
||||
anonymous_token: Optional[str]
|
||||
bot_id: Optional[str]
|
||||
title: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
messages: List[MessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SetBotRequest(BaseModel):
|
||||
bot_id: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
response: str
|
||||
thinking: Optional[str] = None
|
||||
strategy_config: Optional[dict] = None
|
||||
success: bool = False
|
||||
warning: Optional[str] = None
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from .api import auth, bots, backtest, simulate, config, ave
|
||||
from .api import auth, bots, backtest, simulate, config, ave, conversations
|
||||
from .core.limiter import limiter
|
||||
from .core.database import engine, Base
|
||||
|
||||
@@ -15,7 +15,17 @@ logger = logging.getLogger(__name__)
|
||||
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
|
||||
from .db.models import (
|
||||
User,
|
||||
Bot,
|
||||
BotConversation,
|
||||
Backtest,
|
||||
Simulation,
|
||||
Signal,
|
||||
Conversation,
|
||||
Message,
|
||||
AnonymousUser,
|
||||
)
|
||||
|
||||
# Create tables if they don't exist
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -44,6 +54,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
|
||||
app.include_router(conversations.router, tags=["conversations"])
|
||||
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
||||
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
||||
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
from .crew import CrewAgent
|
||||
from .llm_connector import LLMConnector
|
||||
"""AI Agent module for conversational trading."""
|
||||
|
||||
__all__ = ["CrewAgent", "LLMConnector"]
|
||||
from .agent import ConversationalAgent, get_conversational_agent
|
||||
from .client import MiniMaxClient
|
||||
from .tools import get_tool_registry, TOOL_REGISTRY
|
||||
from .help import (
|
||||
format_tools_list,
|
||||
format_general_help,
|
||||
format_tool_help,
|
||||
format_skill_acknowledgment,
|
||||
)
|
||||
from .crew import TradingCrew, get_trading_crew
|
||||
from .llm_connector import MiniMaxLLM, MiniMaxConnector
|
||||
|
||||
__all__ = [
|
||||
"ConversationalAgent",
|
||||
"get_conversational_agent",
|
||||
"MiniMaxClient",
|
||||
"get_tool_registry",
|
||||
"TOOL_REGISTRY",
|
||||
"format_tools_list",
|
||||
"format_general_help",
|
||||
"format_tool_help",
|
||||
"format_skill_acknowledgment",
|
||||
"TradingCrew",
|
||||
"get_trading_crew",
|
||||
"MiniMaxLLM",
|
||||
"MiniMaxConnector",
|
||||
]
|
||||
|
||||
2926
src/backend/app/services/ai_agent/agent.py
Normal file
2926
src/backend/app/services/ai_agent/agent.py
Normal file
File diff suppressed because it is too large
Load Diff
451
src/backend/app/services/ai_agent/client.py
Normal file
451
src/backend/app/services/ai_agent/client.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""MiniMax API client for the conversational agent."""
|
||||
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a helpful AI trading assistant named Randebu. You help users manage their trading bots.
|
||||
|
||||
IMPORTANT CHAIN LIMITATION:
|
||||
- We ONLY support BSC (Binance Smart Chain) blockchain
|
||||
- If user asks about any other chain (Solana, ETH, Base, etc.), respond with: "Currently we only support BSC (Binance Smart Chain). All trading strategies and token searches are performed on BSC."
|
||||
- Never search or recommend tokens on other chains
|
||||
- The search_tokens tool defaults to BSC, never change this
|
||||
|
||||
Your response must be valid JSON with exactly this structure:
|
||||
{
|
||||
"thinking": "Your internal reasoning and analysis (what you're thinking about)",
|
||||
"response": "Your actual response to the user (be concise and helpful)",
|
||||
"strategy_update": null or {
|
||||
"conditions": [{"type": "price_drop" | "price_rise" | "volume_spike" | "price_level", "token": "TOKEN_SYMBOL", "token_address": null, "threshold": number, ...}],
|
||||
"actions": [{"type": "buy" | "sell" | "hold", "amount_percent": number, ...}],
|
||||
"risk_management": {"stop_loss_percent": number, "take_profit_percent": number}
|
||||
}
|
||||
}
|
||||
|
||||
Guidelines:
|
||||
- "thinking" should be detailed reasoning about the user's request
|
||||
- "response" should be conversational and clear
|
||||
- "strategy_update" should be populated ONLY when the user provides specific trading parameters (percentages, tokens, conditions, etc.)
|
||||
- IMPORTANT: When a token is mentioned, set "token_address": null and ask user to confirm the token address before saving. Your response should say something like: "I need to confirm the token address. Could you provide the contract address for [TOKEN]?"
|
||||
- If no strategy parameters are provided, set "strategy_update" to null
|
||||
- Be friendly, concise, and helpful in your response
|
||||
|
||||
Example 1 (no strategy update):
|
||||
User: "What can this bot do?"
|
||||
{
|
||||
"thinking": "The user is asking about the bot's capabilities. I should explain the main features.",
|
||||
"response": "Randebu is your AI trading assistant! It can monitor cryptocurrency prices and execute trades based on your configured strategies. Tell me your trading parameters and I'll set them up for you.",
|
||||
"strategy_update": null
|
||||
}
|
||||
|
||||
Example 2 (token needs confirmation):
|
||||
User: "I want to buy PEPE when it drops 10%"
|
||||
{
|
||||
"thinking": "User wants to buy PEPE. I need the token contract address to proceed. I should ask for confirmation.",
|
||||
"response": "I'd be happy to set up a buy order for PEPE! However, I need to confirm the token contract address. Could you provide the BSC contract address for PEPE? (It usually starts with 0x...)",
|
||||
"strategy_update": {
|
||||
"conditions": [{"type": "price_drop", "token": "PEPE", "token_address": null, "threshold": 10}],
|
||||
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||
"risk_management": null
|
||||
}
|
||||
}
|
||||
|
||||
Example 3 (with token address provided by user):
|
||||
User: "Buy 0x6982508145454Ce125dDE157d8d64a26D53f60a2 when it drops 10%"
|
||||
{
|
||||
"thinking": "User provided a contract address, I can use it directly.",
|
||||
"response": "Perfect! I've configured your strategy to buy the token when it drops 10%.",
|
||||
"strategy_update": {
|
||||
"conditions": [{"type": "price_drop", "token": "TOKEN", "token_address": "0x6982508145454Ce125dDE157d8d64a26D53f60a2", "threshold": 10}],
|
||||
"actions": [{"type": "buy", "amount_percent": 100}],
|
||||
"risk_management": null
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_tokens",
|
||||
"description": "Search for tokens by keyword on BSC blockchain. Use this when user asks to search for a specific token or find tokens by name/symbol.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "Token symbol or name to search for (e.g., 'PEPE', 'BTC')",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of tokens to return (default: 10)",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["keyword"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_token",
|
||||
"description": "Get detailed information about a specific token including price, market cap, and pairs. Use when user asks for token details or wants to find a specific token by contract address.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"description": "Blockchain chain (default: bsc)",
|
||||
"default": "bsc",
|
||||
},
|
||||
},
|
||||
"required": ["address"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_price",
|
||||
"description": "Get current price(s) for tokens. Use when user asks for token price or wants to compare prices of multiple tokens.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_ids": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of token IDs with chain suffix (e.g., 'PEPE-bsc,TRUMP-bsc')",
|
||||
}
|
||||
},
|
||||
"required": ["token_ids"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_risk",
|
||||
"description": "Get risk analysis for a token contract. Use when user asks about token risk, honeypot analysis, or safety assessment before trading.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"description": "Blockchain chain (default: bsc)",
|
||||
"default": "bsc",
|
||||
},
|
||||
},
|
||||
"required": ["address"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_trending",
|
||||
"description": "Get trending tokens on a blockchain. Use when user asks what's trending, top tokens, or popular tokens right now.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"description": "Blockchain chain (default: bsc)",
|
||||
"default": "bsc",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of trending tokens to return (default: 10, max: 50)",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_backtest",
|
||||
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically. Returns key metrics like ROI, win rate, max drawdown, etc. Use this when user asks to backtest, test strategy, or check historical performance.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_address": {
|
||||
"type": "string",
|
||||
"description": "The BSC contract address of the token to backtest (required)",
|
||||
},
|
||||
"timeframe": {
|
||||
"type": "string",
|
||||
"description": "Timeframe for klines: '1d' (1 day), '4h' (4 hours), '1h' (1 hour), '15m' (15 minutes)",
|
||||
"default": "1d",
|
||||
},
|
||||
"start_date": {
|
||||
"type": "string",
|
||||
"description": "Start date for backtest in YYYY-MM-DD format (e.g., '2024-01-01')",
|
||||
},
|
||||
"end_date": {
|
||||
"type": "string",
|
||||
"description": "End date for backtest in YYYY-MM-DD format (e.g., '2024-12-01')",
|
||||
},
|
||||
},
|
||||
"required": ["token_address"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_simulation",
|
||||
"description": "Manage trading simulations: start, stop, or check status. Simulations run on real-time klines and show live portfolio updates. Use when user asks to run simulation, check simulation status, or stop simulation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["start", "stop", "status", "results"],
|
||||
"description": "Action to perform: 'start' (begin new simulation), 'stop' (stop running simulation), 'status' (check if simulation is running), 'results' (get results from current or latest simulation)",
|
||||
},
|
||||
"token_address": {
|
||||
"type": "string",
|
||||
"description": "Token contract address for simulation (required for 'start' action)",
|
||||
},
|
||||
"kline_interval": {
|
||||
"type": "string",
|
||||
"description": "Kline interval: '1m', '5m', '15m', '1h' (default: '1m')",
|
||||
"default": "1m",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_bot",
|
||||
"description": "Create a new trading bot. Use this when user wants to create a new trading bot. Returns the bot ID and confirmation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name for the new bot (required)",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"description": "Trading strategy description in plain English (optional, can be set later)",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_bots",
|
||||
"description": "List all trading bots owned by the user. Use this when user wants to see their bots or asks which bots they have.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_bot",
|
||||
"description": "Set (associate) a bot with the current conversation. Use this when user wants to switch which bot they're working with.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bot_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the bot to set for this conversation (required)",
|
||||
},
|
||||
},
|
||||
"required": ["bot_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_bot_info",
|
||||
"description": "Get details of a specific bot including name, strategy, and status. Use this when user wants to see details of a bot.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bot_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the bot to get info for (optional, defaults to current bot)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_strategy",
|
||||
"description": "Update (save) the trading strategy for a bot. This SAVES the strategy to the database. ALWAYS call this tool when user wants to configure or update a trading strategy. The strategy will be persisted and used for backtests.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"description": "Description of the strategy (e.g., 'Dip buying strategy', 'Mean reversion')",
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Array of condition objects. Each condition has: type (price_drop, price_rise, volume_spike, price_level), token, token_address, threshold",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["price_drop", "price_rise", "volume_spike", "price_level"],
|
||||
"description": "Type of condition"
|
||||
},
|
||||
"token": {"type": "string", "description": "Token symbol (e.g., 'SHIB', 'PEPE')"},
|
||||
"token_address": {"type": "string", "description": "Token contract address on BSC (e.g., '0x...') - REQUIRED"},
|
||||
"threshold": {"type": "number", "description": "Threshold value (e.g., 5 for 5% drop/rise)"},
|
||||
},
|
||||
"required": ["type", "token_address", "threshold"]
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"description": "Array of action objects. Each action has: type (buy, sell, hold), amount_percent",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["buy", "sell", "hold"],
|
||||
"description": "Type of action"
|
||||
},
|
||||
"amount_percent": {"type": "number", "description": "Percentage of funds to use (e.g., 20 for 20%)"},
|
||||
},
|
||||
"required": ["type", "amount_percent"]
|
||||
}
|
||||
},
|
||||
"stop_loss": {"type": "number", "description": "Stop loss percentage (e.g., 15 for 15% loss)"},
|
||||
"take_profit": {"type": "number", "description": "Take profit percentage (e.g., 20 for 20% gain)"},
|
||||
},
|
||||
"required": ["conditions", "actions"]
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT_WITH_TOOLS = (
|
||||
SYSTEM_PROMPT
|
||||
+ """
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. When user asks to run a backtest or simulation, first check if a bot exists using list_bots.
|
||||
2. If no bot exists, ASK THE USER what they want to name their bot (e.g., "What would you like to name your trading bot?").
|
||||
3. When user provides a bot name (after being asked), call create_bot with that name.
|
||||
4. If a bot exists but has NO STRATEGY SET, the user MUST set up a strategy before running backtests. Ask the user for their trading strategy (e.g., "What token should I monitor?", "What price drop percentage should trigger a buy?", "What percentage of funds should be used?"). Help them configure the strategy.
|
||||
5. IMPORTANT: When configuring a strategy, you MUST call the update_strategy tool with COMPLETE details:
|
||||
- conditions: Array of {type: "price_drop"|"price_rise"|"volume_spike", token: "SYMBOL", token_address: "0x...", threshold: number}
|
||||
- actions: Array of {type: "buy"|"sell", amount_percent: number}
|
||||
- stop_loss: number (e.g., 5 for 5%)
|
||||
- take_profit: number (e.g., 15 for 15%)
|
||||
- ALWAYS include the token_address from search results when configuring strategy!
|
||||
6. IMPORTANT: After executing ANY tool, you MUST incorporate the ACTUAL tool result into your response. Do NOT ignore what the tool returned. If the tool returned an error, you MUST tell the user about the error - do NOT pretend the operation succeeded.
|
||||
7. NEVER tell users about internal tool names like "create_bot", "list_bots", etc. Use natural language instead.
|
||||
8. NEVER say "Let me..." or "I'll..." - IMMEDIATELY call the appropriate tool. If user asks for trending tokens, call get_trending NOW. If user asks for token info, call get_token NOW. Do NOT ask for permission or say you will do something - just do it.
|
||||
9. When asking for information from the user, be specific and actionable (e.g., "What token do you want to backtest?", "What would you like to name your bot?").
|
||||
10. If user asks you to look up/search/find tokens, IMMEDIATELY call search_tokens or get_trending tool. Do not ask follow-up questions first.
|
||||
11. IMPORTANT: When user says they want to use a token from search results (e.g., "that main PEPE" or "the first one"), ALWAYS extract the token_address from the search results and include it in update_strategy. Do NOT ask for the address again!
|
||||
|
||||
You have access to tools:
|
||||
- search_tokens(keyword, limit): Search for tokens by keyword/symbol.
|
||||
- get_token(address, chain): Get detailed token info.
|
||||
- get_price(token_ids): Get current token prices.
|
||||
- get_risk(address, chain): Get risk/honeypot analysis.
|
||||
- get_trending(chain, limit): Get trending tokens.
|
||||
- run_backtest(token_address, timeframe, start_date, end_date): Run backtest. REQUIRES a bot to be set first.
|
||||
- manage_simulation(action, token_address, kline_interval): Manage simulations. REQUIRES a bot.
|
||||
- create_bot(name, strategy): Create a new trading bot.
|
||||
- list_bots(): List user's bots.
|
||||
- set_bot(bot_id): Switch bots in conversation.
|
||||
- get_bot_info(bot_id): Get bot details including strategy conditions and actions.
|
||||
- update_strategy(conditions, actions, stop_loss, take_profit): Save trading strategy to bot. MUST include token_address in conditions!
|
||||
|
||||
Take action immediately. Do not ask for confirmation. Do not describe what you will do - just do it.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class MiniMaxClient:
|
||||
"""Client for MiniMax extended thinking API."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.endpoint = "https://api.minimax.io/v1/text/chatcompletion_v2"
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
system_prompt: str,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 3000,
|
||||
thinking_budget: int = 1500,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a chat request to MiniMax API."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
all_messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": all_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"thinking": {"type": "human", "budget_tokens": thinking_budget},
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
resp = requests.post(self.endpoint, headers=headers, json=payload)
|
||||
|
||||
# Check for HTTP errors
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text
|
||||
print(f"API Error {resp.status_code}: {error_text[:500]}")
|
||||
return {"error": f"API returned {resp.status_code}: {error_text[:200]}"}
|
||||
|
||||
return resp.json() or {}
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Check if API is reachable."""
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.endpoint,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from crewai import Agent, Task, Crew
|
||||
from .llm_connector import MiniMaxConnector, MiniMaxLLM
|
||||
from ..core.config import get_settings
|
||||
from crewai import Agent, Task, Crew, LLM
|
||||
from .llm_connector import MiniMaxConnector
|
||||
from ...core.config import get_settings
|
||||
|
||||
|
||||
class StrategyValidator:
|
||||
@@ -120,7 +120,7 @@ class StrategyExplainer:
|
||||
|
||||
|
||||
def create_trading_designer_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
api_key: str, model: str = "MiniMax-M2.7"
|
||||
) -> Agent:
|
||||
connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||
|
||||
@@ -141,13 +141,13 @@ def create_trading_designer_agent(
|
||||
role="Trading Strategy Designer",
|
||||
goal="Convert natural language trading requests into precise strategy configurations",
|
||||
backstory=system_prompt,
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
def create_strategy_validator_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
api_key: str, model: str = "MiniMax-M2.7"
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
role="Strategy Validator",
|
||||
@@ -155,13 +155,13 @@ def create_strategy_validator_agent(
|
||||
backstory="""You are a meticulous strategy validator with expertise in trading systems.
|
||||
You check that all required parameters are present, values are reasonable, and the
|
||||
strategy makes logical sense. You never approve strategies with missing or invalid data.""",
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
def create_strategy_explainer_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
api_key: str, model: str = "MiniMax-M2.7"
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
role="Strategy Explainer",
|
||||
@@ -169,13 +169,13 @@ def create_strategy_explainer_agent(
|
||||
backstory="""You are a patient trading strategy explainer. You translate complex
|
||||
strategy configurations into easy-to-understand language. You help users understand
|
||||
exactly what their strategies will do when triggered.""",
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
class TradingCrew:
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.validator = StrategyValidator()
|
||||
|
||||
83
src/backend/app/services/ai_agent/help.py
Normal file
83
src/backend/app/services/ai_agent/help.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Help formatters for slash commands and tool documentation."""
|
||||
|
||||
from typing import Optional
|
||||
from .tools import get_tool_registry, SKILL_EMOJIS
|
||||
|
||||
|
||||
def format_tools_list() -> str:
|
||||
"""Format the tool registry as a help message."""
|
||||
message = "📋 Available Tools\n\n"
|
||||
|
||||
for category in ["randebu", "ave"]:
|
||||
tools = get_tool_registry().get(category, [])
|
||||
if category == "randebu":
|
||||
message += "🤖 Randebu Built-in:\n"
|
||||
else:
|
||||
message += "☁️ AVE Cloud Skills:\n"
|
||||
|
||||
for tool in tools:
|
||||
message += f" • {tool['command']} - {tool['description']}\n"
|
||||
message += "\n"
|
||||
|
||||
message = (
|
||||
message.rstrip() + "\n\nType /<tool-name> for detailed help on a specific tool."
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def format_skill_acknowledgment(tool_name: str, description: str) -> str:
|
||||
"""Format a brief acknowledgment when a skill is activated."""
|
||||
emoji = SKILL_EMOJIS.get(tool_name.lower(), "✨")
|
||||
return f"{emoji} **{tool_name}** loaded. Ready for *{description}*, ask me away!"
|
||||
|
||||
|
||||
def format_tool_help(tool_name: str) -> str:
|
||||
"""Format detailed help for a specific tool."""
|
||||
tool_name = tool_name.lstrip("/")
|
||||
|
||||
for category in ["randebu", "ave"]:
|
||||
for tool in get_tool_registry().get(category, []):
|
||||
if tool["name"].lower() == tool_name.lower():
|
||||
cat_label = (
|
||||
"Randebu Built-in" if category == "randebu" else "AVE Cloud Skill"
|
||||
)
|
||||
details = tool["details"]
|
||||
message = (
|
||||
f"🔍 {tool['command']} - {details['description']} ({cat_label})\n\n"
|
||||
)
|
||||
message += f"**Description:** {details['description']}\n"
|
||||
message += f"**Commands:**\n {details['usage']}\n\n"
|
||||
message += f"**Example:**\n```\n{details['example']}\n```"
|
||||
return message
|
||||
|
||||
return f"Tool '{tool_name}' not found. Type / to see all available tools."
|
||||
|
||||
|
||||
def format_general_help() -> str:
|
||||
"""Format general help about Randebu."""
|
||||
return """🤖 **Randebu - AI Trading Assistant**
|
||||
|
||||
Randebu is your AI trading assistant that helps you manage your trading bots on BSC (Binance Smart Chain).
|
||||
|
||||
**Getting Started:**
|
||||
1. Create a bot on the dashboard
|
||||
2. Describe your trading strategy in plain English
|
||||
3. Run backtests to validate your strategy
|
||||
4. Start simulations to see live trading
|
||||
|
||||
**Example Strategies:**
|
||||
- "Buy PEPE when it drops 5%"
|
||||
- "Sell if price rises 10% within 1 hour"
|
||||
- "Buy when volume spikes by 200%"
|
||||
|
||||
**Slash Commands:**
|
||||
- `/` - Show all available tools
|
||||
- `/help` - Show this help message
|
||||
- `/<tool-name>` - Get help on a specific tool
|
||||
|
||||
**Natural Language:**
|
||||
You can also just describe what you want in natural language. For example:
|
||||
- "What's the price of PEPE?"
|
||||
- "Run a backtest on 0x... token"
|
||||
- "Start a simulation on TRUMP"
|
||||
"""
|
||||
@@ -1,14 +1,12 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import httpx
|
||||
from crewai import LLM
|
||||
|
||||
|
||||
class MiniMaxLLM(LLM):
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
class MiniMaxLLM:
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = "https://api.minimax.chat/v1"
|
||||
self.base_url = "https://api.minimax.io/v1"
|
||||
|
||||
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||
headers = {
|
||||
@@ -23,7 +21,7 @@ class MiniMaxLLM(LLM):
|
||||
}
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
f"{self.base_url}/text/chatcompletion_v2",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
@@ -35,7 +33,7 @@ class MiniMaxLLM(LLM):
|
||||
|
||||
|
||||
class MiniMaxConnector:
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
|
||||
164
src/backend/app/services/ai_agent/mock_client.py
Normal file
164
src/backend/app/services/ai_agent/mock_client.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Mock client for testing the ConversationalAgent without hitting real APIs."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
|
||||
class MockMiniMaxClient:
|
||||
"""Mock client that returns predefined responses for testing.
|
||||
|
||||
Usage:
|
||||
mock = MockMiniMaxClient()
|
||||
mock.add_response({\"choices\": [...]}) # Add responses in order
|
||||
mock.add_response({\"choices\": [...]}) # Second call gets this
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat(\"hello\")
|
||||
"""
|
||||
|
||||
def __init__(self, responses: List[Dict[str, Any]] = None):
|
||||
self.responses = responses or []
|
||||
self.call_count = 0
|
||||
self.calls: List[Dict[str, Any]] = [] # Record all calls for assertions
|
||||
|
||||
def add_response(self, response: Dict[str, Any]):
|
||||
"""Add a response to be returned on the next call."""
|
||||
self.responses.append(response)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
system_prompt: str,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
thinking_budget: int = 1500,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the next predefined response."""
|
||||
# Record the call for debugging/tests
|
||||
self.calls.append({
|
||||
"messages": messages,
|
||||
"system_prompt": system_prompt[:100] if system_prompt else None,
|
||||
"tool_calls_count": len(tools) if tools else 0,
|
||||
})
|
||||
|
||||
if self.call_count < len(self.responses):
|
||||
response = self.responses[self.call_count]
|
||||
self.call_count += 1
|
||||
return response
|
||||
|
||||
# Default response if no more predefined responses
|
||||
return {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Mock response - no more responses configured",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
"""Reset call count and calls list."""
|
||||
self.call_count = 0
|
||||
self.calls = []
|
||||
|
||||
def verify_call(self, call_index: int, expected_messages: int = None, expected_tool_count: int = None):
|
||||
"""Verify a specific call was made correctly."""
|
||||
if call_index >= len(self.calls):
|
||||
raise AssertionError(f"Call {call_index} was not made. Total calls: {len(self.calls)}")
|
||||
|
||||
call = self.calls[call_index]
|
||||
if expected_messages is not None:
|
||||
actual = len(call["messages"])
|
||||
if actual != expected_messages:
|
||||
raise AssertionError(
|
||||
f"Call {call_index}: expected {expected_messages} messages, got {actual}"
|
||||
)
|
||||
if expected_tool_count is not None:
|
||||
actual = call["tool_calls_count"]
|
||||
if actual != expected_tool_count:
|
||||
raise AssertionError(
|
||||
f"Call {call_index}: expected {expected_tool_count} tools, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
class MockMiniMaxClientWithToolCall(MockMiniMaxClient):
|
||||
"""Mock client that generates tool call responses based on message content."""
|
||||
|
||||
def __init__(self, tool_handlers: Dict[str, Dict[str, Any]] = None):
|
||||
"""tool_handlers: dict mapping tool names to their responses."""
|
||||
super().__init__()
|
||||
self.tool_handlers = tool_handlers or {}
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
system_prompt: str,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
thinking_budget: int = 1500,
|
||||
) -> Dict[str, Any]:
|
||||
"""Check if last message contains a tool call request and return appropriate response."""
|
||||
self.calls.append({
|
||||
"messages": messages,
|
||||
"system_prompt": system_prompt[:100] if system_prompt else None,
|
||||
"tool_calls_count": len(tools) if tools else 0,
|
||||
})
|
||||
|
||||
# Get the last message
|
||||
if not messages:
|
||||
return {"choices": [{"message": {"content": "No messages", "role": "assistant"}}]}
|
||||
|
||||
last_msg = messages[-1]
|
||||
|
||||
# If it's a tool result, look for the tool that was called
|
||||
if last_msg.get("role") == "user" and "[TOOL RESULT]" in last_msg.get("content", ""):
|
||||
# Extract tool name from the content
|
||||
content = last_msg["content"]
|
||||
# Format: [TOOL RESULT] tool_name: result
|
||||
for tool_name in self.tool_handlers:
|
||||
if f"[TOOL RESULT] {tool_name}:" in content:
|
||||
return self.tool_handlers[tool_name]
|
||||
|
||||
# Check if we should generate a tool call
|
||||
last_user_msg = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and "[TOOL RESULT]" not in msg.get("content", ""):
|
||||
last_user_msg = msg.get("content", "")
|
||||
break
|
||||
|
||||
# Check each tool's trigger
|
||||
for tool_name, handler in self.tool_handlers.items():
|
||||
trigger = handler.get("trigger", "")
|
||||
if trigger and trigger.lower() in last_user_msg.lower():
|
||||
return {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": handler.get("content", ""),
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": f"call_{tool_name}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": handler.get("arguments", "{}")
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Default: return first available response or empty
|
||||
if self.responses:
|
||||
response = self.responses[0]
|
||||
self.call_count += 1
|
||||
return response
|
||||
|
||||
return {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "How can I help you with your trading bot?",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
}
|
||||
172
src/backend/app/services/ai_agent/tools.py
Normal file
172
src/backend/app/services/ai_agent/tools.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Tool registry and definitions for the conversational agent."""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
TOOL_REGISTRY: Dict[str, Any] = {
|
||||
"randebu": [
|
||||
{
|
||||
"name": "backtest",
|
||||
"description": "Run strategy backtest",
|
||||
"category": "Randebu Built-in",
|
||||
"command": "/backtest",
|
||||
"details": {
|
||||
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically.",
|
||||
"usage": "/backtest [token_address] [--timeframe 1d|4h|1h|15m] [--start YYYY-MM-DD] [--end YYYY-MM-DD]",
|
||||
"example": "Run a backtest on PEPE for the last 30 days",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "simulate",
|
||||
"description": "Start/stop simulation",
|
||||
"category": "Randebu Built-in",
|
||||
"command": "/simulate",
|
||||
"details": {
|
||||
"description": "Start or stop trading simulations that run on real-time klines.",
|
||||
"usage": "/simulate start|stop|status|results [token_address]",
|
||||
"example": "Start a simulation on PEPE",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "strategy",
|
||||
"description": "View/update strategy",
|
||||
"category": "Randebu Built-in",
|
||||
"command": "/strategy",
|
||||
"details": {
|
||||
"description": "View your current trading strategy or update it with new parameters.",
|
||||
"usage": "Describe your strategy in plain English, e.g., 'Buy PEPE when price drops 5%'",
|
||||
"example": "Buy PEPE when it drops 10% within 1 hour",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_bot",
|
||||
"description": "Create a new trading bot",
|
||||
"category": "Randebu Built-in",
|
||||
"command": None,
|
||||
"details": {
|
||||
"description": "Create a new trading bot linked to the current conversation.",
|
||||
"usage": "create_bot <name> [--strategy <strategy_desc>]",
|
||||
"example": "create_bot MyBot --strategy Buy PEPE when it drops 5%",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_bots",
|
||||
"description": "List your trading bots",
|
||||
"category": "Randebu Built-in",
|
||||
"command": None,
|
||||
"details": {
|
||||
"description": "List all trading bots you own.",
|
||||
"usage": "list_bots",
|
||||
"example": "list_bots",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_bot",
|
||||
"description": "Set bot for this conversation",
|
||||
"category": "Randebu Built-in",
|
||||
"command": None,
|
||||
"details": {
|
||||
"description": "Associate a bot with the current conversation.",
|
||||
"usage": "set_bot <bot_id>",
|
||||
"example": "set_bot abc-123-def",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_bot_info",
|
||||
"description": "Get current bot details",
|
||||
"category": "Randebu Built-in",
|
||||
"command": None,
|
||||
"details": {
|
||||
"description": "Get details of the current bot for display in the right pane.",
|
||||
"usage": "get_bot_info [bot_id]",
|
||||
"example": "get_bot_info abc-123-def",
|
||||
},
|
||||
},
|
||||
],
|
||||
"ave": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Token search",
|
||||
"category": "AVE Cloud Skills",
|
||||
"command": "/search",
|
||||
"details": {
|
||||
"description": "Find tokens by keyword, symbol, or contract address on BSC.",
|
||||
"usage": "search <keyword> [--chain bsc] [--limit 20]",
|
||||
"example": "search PEPE\nsearch 0x1234... --chain bsc",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "trending",
|
||||
"description": "Popular tokens",
|
||||
"category": "AVE Cloud Skills",
|
||||
"command": "/trending",
|
||||
"details": {
|
||||
"description": "Get list of trending/popular tokens on BSC.",
|
||||
"usage": "trending [--chain bsc] [--limit 20]",
|
||||
"example": "trending --chain bsc\ntrending --limit 10",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "risk",
|
||||
"description": "Honeypot detection",
|
||||
"category": "AVE Cloud Skills",
|
||||
"command": "/risk",
|
||||
"details": {
|
||||
"description": "Get risk analysis for a token contract including honeypot assessment.",
|
||||
"usage": "risk <token_address> [--chain bsc]",
|
||||
"example": "risk 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"description": "Token details",
|
||||
"category": "AVE Cloud Skills",
|
||||
"command": "/token",
|
||||
"details": {
|
||||
"description": "Get detailed information about a specific token including price, market cap, and pairs.",
|
||||
"usage": "token <address> [--chain bsc]",
|
||||
"example": "token 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "price",
|
||||
"description": "Batch prices",
|
||||
"category": "AVE Cloud Skills",
|
||||
"command": "/price",
|
||||
"details": {
|
||||
"description": "Get current price(s) for multiple tokens.",
|
||||
"usage": "price <token_id>,<token_id>,... (e.g., PEPE-bsc,TRUMP-bsc)",
|
||||
"example": "price PEPE-bsc,TRUMP-bsc",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
SKILL_EMOJIS: Dict[str, str] = {
|
||||
"backtest": "📊",
|
||||
"simulate": "🎮",
|
||||
"strategy": "📝",
|
||||
"search": "🔍",
|
||||
"trending": "📈",
|
||||
"risk": "📉",
|
||||
"token": "🪙",
|
||||
"price": "💰",
|
||||
}
|
||||
|
||||
|
||||
def get_tool_registry() -> Dict[str, Any]:
|
||||
"""Return the tool registry for slash command help."""
|
||||
return TOOL_REGISTRY
|
||||
|
||||
|
||||
def get_tools_by_category(category: str) -> List[Dict[str, Any]]:
|
||||
"""Get tools filtered by category."""
|
||||
return TOOL_REGISTRY.get(category, [])
|
||||
|
||||
|
||||
def get_tool_by_name(tool_name: str) -> Dict[str, Any]:
|
||||
"""Get a tool by its name."""
|
||||
for category in ["randebu", "ave"]:
|
||||
for tool in TOOL_REGISTRY.get(category, []):
|
||||
if tool["name"].lower() == tool_name.lower():
|
||||
return tool
|
||||
return None
|
||||
@@ -23,10 +23,9 @@ class AveCloudClient:
|
||||
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
|
||||
# Use trending endpoint which supports chain filter
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/trending"
|
||||
params = {"limit": min(limit, 100)} # API returns max 100
|
||||
if chain:
|
||||
params["chain"] = chain
|
||||
|
||||
@@ -36,8 +35,18 @@ class AveCloudClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
if data.get("status") == 1: # 1 = SUCCESS
|
||||
tokens = data.get("data", {}).get("tokens", [])
|
||||
# Filter by query if provided
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
tokens = [
|
||||
t for t in tokens
|
||||
if query_lower in t.get("symbol", "").lower()
|
||||
or query_lower in t.get("name", "").lower()
|
||||
]
|
||||
return tokens[:limit]
|
||||
return []
|
||||
raise Exception(f"Failed to fetch tokens: {data}")
|
||||
|
||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -73,6 +82,10 @@ class AveCloudClient:
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Token ID must be in format "{contract_address}-bsc" for the AVE API
|
||||
if not token_id.endswith("-bsc") and token_id.startswith("0x"):
|
||||
token_id = f"{token_id}-bsc"
|
||||
|
||||
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
|
||||
params = {"interval": interval, "limit": limit}
|
||||
if start_time:
|
||||
@@ -86,10 +99,27 @@ class AveCloudClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
# AVE API returns status: 1 for success, not 200
|
||||
if data.get("status") == 1:
|
||||
return data.get("data", {}).get("points", [])
|
||||
raise Exception(f"Failed to fetch klines: {data}")
|
||||
|
||||
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/price"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self._data_headers(),
|
||||
json={"token_ids": [token_id]},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 1:
|
||||
prices = data.get("data", {})
|
||||
return prices.get(token_id)
|
||||
return None
|
||||
|
||||
async def get_trending_tokens(
|
||||
self, chain: Optional[str] = None, limit: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -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
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from .ave_client import AveCloudClient
|
||||
from ..ave.client import AveCloudClient
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
@@ -20,12 +20,21 @@ class BacktestEngine:
|
||||
self.strategy_config = config.get("strategy_config", {})
|
||||
self.conditions = self.strategy_config.get("conditions", [])
|
||||
self.actions = self.strategy_config.get("actions", [])
|
||||
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
|
||||
self.take_profit_percent = self.risk_management.get("take_profit_percent")
|
||||
self.initial_balance = config.get("initial_balance", 10000.0)
|
||||
self.current_balance = self.initial_balance
|
||||
self.position = 0.0
|
||||
self.position_token = ""
|
||||
self.entry_price: Optional[float] = None
|
||||
self.cost_basis = 0.0 # Track total amount spent on current position for average price calc
|
||||
self.entry_time: Optional[int] = None
|
||||
self.trades: List[Dict[str, Any]] = []
|
||||
self.running = False
|
||||
self.progress = 0
|
||||
self.total_klines = 0
|
||||
self.last_kline_price: Optional[float] = None # Track last price for open position valuation
|
||||
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
self.running = True
|
||||
@@ -33,20 +42,28 @@ class BacktestEngine:
|
||||
started_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
token = self.config.get("token", "")
|
||||
chain = self.config.get("chain", "bsc")
|
||||
timeframe = self.config.get("timeframe", "1h")
|
||||
start_date = self.config.get("start_date", "")
|
||||
end_date = self.config.get("end_date", "")
|
||||
|
||||
token_id = (
|
||||
f"{token}-{chain}"
|
||||
if token and not token.endswith(f"-{chain}")
|
||||
else token
|
||||
)
|
||||
# Get token address from strategy config (saved when user confirmed token)
|
||||
token_address = None
|
||||
token_symbol = None
|
||||
|
||||
if not token_id or token_id == f"-{chain}":
|
||||
raise ValueError("Token ID is required")
|
||||
# Try to get from conditions first
|
||||
if self.conditions:
|
||||
token_address = self.conditions[0].get("token_address")
|
||||
token_symbol = self.conditions[0].get("token")
|
||||
# Fallback to actions
|
||||
if not token_address and self.actions:
|
||||
token_address = self.actions[0].get("token_address")
|
||||
token_symbol = self.actions[0].get("token") or token_symbol
|
||||
|
||||
if not token_address:
|
||||
raise ValueError("Token address not found in strategy. Please update your strategy with a valid token.")
|
||||
|
||||
token_id = token_address
|
||||
|
||||
start_ts = None
|
||||
end_ts = None
|
||||
@@ -71,6 +88,60 @@ class BacktestEngine:
|
||||
end_time=end_ts,
|
||||
)
|
||||
|
||||
if not klines:
|
||||
self.status = "failed"
|
||||
self.results = {"error": "No kline data available"}
|
||||
return self.results
|
||||
|
||||
# Debug: log first and last few klines to verify price data
|
||||
print(f"DEBUG BacktestEngine: Got {len(klines)} klines for {token_id}")
|
||||
if klines:
|
||||
print(f"DEBUG BacktestEngine: First kline: {klines[0]}")
|
||||
print(f"DEBUG BacktestEngine: Last kline: {klines[-1]}")
|
||||
|
||||
# Validate Kline data - check for obviously wrong prices
|
||||
first_close = float(klines[0].get('close', 0))
|
||||
last_close = float(klines[-1].get('close', 0))
|
||||
|
||||
print(f"DEBUG BacktestEngine: first_close={first_close}, last_close={last_close}")
|
||||
|
||||
# If price changes by more than 1000x, data is likely wrong
|
||||
if first_close > 0 and last_close > 0:
|
||||
price_ratio = max(first_close, last_close) / min(first_close, last_close)
|
||||
print(f"DEBUG BacktestEngine: price_ratio={price_ratio:.0f}x")
|
||||
|
||||
if price_ratio > 1000:
|
||||
self.status = "failed"
|
||||
self.results = {
|
||||
"error": f"Kline data appears incorrect. Price changed by {price_ratio:.0f}x during the period. "
|
||||
f"First price: ${first_close:.8f}, Last price: ${last_close:.8f}. "
|
||||
f"This may be due to incorrect token data from the API. Please try a different token or timeframe."
|
||||
}
|
||||
return self.results
|
||||
|
||||
await self._process_klines(klines)
|
||||
self._calculate_metrics()
|
||||
self.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
self.status = "failed"
|
||||
self.results = {"error": str(e)}
|
||||
|
||||
ended_at = datetime.utcnow()
|
||||
self.results = self.results or {}
|
||||
self.results["started_at"] = started_at
|
||||
self.results["ended_at"] = ended_at
|
||||
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
|
||||
|
||||
return self.results
|
||||
|
||||
async def run_with_klines(self, klines: List[Dict[str, Any]]):
|
||||
"""Test helper method that runs backtest with provided klines (bypasses API call)."""
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
started_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
if not klines:
|
||||
self.status = "failed"
|
||||
self.results = {"error": "No kline data available"}
|
||||
@@ -93,21 +164,116 @@ class BacktestEngine:
|
||||
return self.results
|
||||
|
||||
async def _process_klines(self, klines: List[Dict[str, Any]]):
|
||||
self.total_klines = len(klines)
|
||||
|
||||
# Debug: log strategy config
|
||||
print(f"DEBUG _process_klines: {len(klines)} klines to process")
|
||||
print(f"DEBUG _process_klines: conditions = {self.conditions}")
|
||||
print(f"DEBUG _process_klines: actions = {self.actions}")
|
||||
print(f"DEBUG _process_klines: stop_loss_percent = {self.stop_loss_percent}")
|
||||
print(f"DEBUG _process_klines: take_profit_percent = {self.take_profit_percent}")
|
||||
|
||||
# Count price drops for debugging
|
||||
dip_opportunities = 0
|
||||
|
||||
for i, kline in enumerate(klines):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
|
||||
|
||||
price = float(kline.get("close", 0))
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
self.last_kline_price = price # Track last price for mark to market
|
||||
|
||||
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:
|
||||
print(f"DEBUG: Kline {i} - Risk exit triggered: {exit_info['reason']} at price {price}")
|
||||
await self._execute_risk_exit(price, timestamp, exit_info)
|
||||
continue
|
||||
|
||||
# Check each condition
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, klines, i, price):
|
||||
cond_result = self._check_condition(condition, klines, i, price)
|
||||
if cond_result:
|
||||
dip_opportunities += 1
|
||||
if self.position == 0:
|
||||
print(f"DEBUG: Kline {i} - BUY condition triggered: {condition['type']} at price {price}")
|
||||
await self._execute_actions(price, timestamp, condition)
|
||||
break
|
||||
|
||||
print(f"DEBUG _process_klines: Total dip opportunities: {dip_opportunities}")
|
||||
|
||||
@property
|
||||
def average_entry_price(self) -> Optional[float]:
|
||||
"""Calculate weighted average entry price based on cost basis."""
|
||||
if self.position <= 0 or self.cost_basis <= 0:
|
||||
return None
|
||||
return self.cost_basis / self.position
|
||||
|
||||
def _check_risk_management(
|
||||
self, current_price: float, timestamp: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if self.position <= 0 or self.average_entry_price is None:
|
||||
return None
|
||||
|
||||
if self.stop_loss_percent is not None:
|
||||
stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100)
|
||||
if current_price <= stop_loss_price:
|
||||
return {"reason": "stop_loss", "price": stop_loss_price}
|
||||
|
||||
if self.take_profit_percent is not None:
|
||||
take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100)
|
||||
# Use small epsilon to handle floating point precision
|
||||
if current_price >= take_profit_price - 0.001:
|
||||
return {"reason": "take_profit", "price": take_profit_price}
|
||||
|
||||
return None
|
||||
|
||||
async def _execute_risk_exit(
|
||||
self, price: float, timestamp: int, exit_info: Dict[str, Any]
|
||||
):
|
||||
if self.position <= 0:
|
||||
return
|
||||
|
||||
reason = exit_info["reason"]
|
||||
sell_amount = self.position * price
|
||||
self.current_balance += sell_amount
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"amount": sell_amount,
|
||||
"quantity": self.position,
|
||||
"timestamp": timestamp,
|
||||
"exit_reason": reason,
|
||||
}
|
||||
)
|
||||
self.signals.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"confidence": 1.0,
|
||||
"reasoning": f"Risk management triggered {reason}",
|
||||
"executed": False,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
self.position = 0
|
||||
self.entry_price = None
|
||||
self.cost_basis = 0.0
|
||||
self.entry_time = None
|
||||
|
||||
def _check_condition(
|
||||
self,
|
||||
condition: Dict[str, Any],
|
||||
@@ -128,6 +294,9 @@ class BacktestEngine:
|
||||
if prev_price <= 0:
|
||||
return False
|
||||
drop_pct = ((prev_price - current_price) / prev_price) * 100
|
||||
# Debug first few to see what's happening
|
||||
if current_idx < 5 or drop_pct >= threshold:
|
||||
print(f"DEBUG _check_condition: idx={current_idx}, prev={prev_price}, curr={current_price}, drop={drop_pct:.4f}%, threshold={threshold}, trigger={drop_pct >= threshold}")
|
||||
return drop_pct >= threshold
|
||||
|
||||
elif cond_type == "price_rise":
|
||||
@@ -170,16 +339,23 @@ class BacktestEngine:
|
||||
amount = self.current_balance * (amount_percent / 100)
|
||||
|
||||
if action_type == "buy" and self.current_balance >= amount:
|
||||
self.position += amount / price
|
||||
# Buy if we have funds available (supports DCA - buy more on each dip)
|
||||
# position > 0 just means we already have some tokens, we can still buy more
|
||||
quantity = amount / price
|
||||
self.position += quantity
|
||||
self.current_balance -= amount
|
||||
self.cost_basis += amount # Track total cost for average price
|
||||
self.position_token = token
|
||||
self.entry_price = price # Keep last entry price for reference
|
||||
self.entry_time = timestamp
|
||||
print(f"DEBUG _execute_actions: BUY - amount=${amount:.2f}, price={price}, quantity={quantity}, position={self.position}")
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "buy",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"quantity": amount / price,
|
||||
"quantity": quantity,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
@@ -199,19 +375,36 @@ class BacktestEngine:
|
||||
)
|
||||
|
||||
elif action_type == "sell" and self.position > 0:
|
||||
sell_amount = self.position * price
|
||||
# Sell amount_percent of current position (default 100% if not specified)
|
||||
sell_percent = action.get("amount_percent", 100) / 100.0
|
||||
sell_quantity = self.position * sell_percent
|
||||
sell_amount = sell_quantity * price
|
||||
self.current_balance += sell_amount
|
||||
|
||||
# Proportionally reduce cost_basis
|
||||
sold_cost_basis = self.cost_basis * sell_percent
|
||||
self.cost_basis -= sold_cost_basis
|
||||
|
||||
print(f"DEBUG _execute_actions: SELL - position={self.position}, sell_percent={sell_percent*100}%, quantity={sell_quantity}, price={price}, sell_amount=${sell_amount:.2f}")
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"amount": sell_amount,
|
||||
"quantity": self.position,
|
||||
"quantity": sell_quantity,
|
||||
"timestamp": timestamp,
|
||||
"exit_reason": "manual",
|
||||
}
|
||||
)
|
||||
self.position = 0
|
||||
|
||||
# Update remaining position
|
||||
self.position -= sell_quantity
|
||||
if self.position <= 0.00000001: # Account for floating point
|
||||
self.position = 0
|
||||
self.entry_price = None
|
||||
self.cost_basis = 0.0
|
||||
self.entry_time = None
|
||||
self.signals.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
@@ -228,15 +421,49 @@ class BacktestEngine:
|
||||
)
|
||||
|
||||
def _calculate_metrics(self):
|
||||
final_balance = self.current_balance + (
|
||||
self.position * self.trades[-1]["price"]
|
||||
if self.trades and self.position > 0
|
||||
else 0
|
||||
)
|
||||
# Debug: log all trades for analysis
|
||||
print(f"DEBUG _calculate_metrics: {len(self.trades)} total trades")
|
||||
buy_trades = [t for t in self.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in self.trades if t["type"] == "sell"]
|
||||
print(f" Buy trades: {len(buy_trades)}")
|
||||
print(f" Sell trades: {len(sell_trades)}")
|
||||
|
||||
if buy_trades:
|
||||
print(f" First buy: amount=${buy_trades[0]['amount']:.2f}, price={buy_trades[0]['price']}, quantity={buy_trades[0]['quantity']}")
|
||||
print(f" Last buy: amount=${buy_trades[-1]['amount']:.2f}, price={buy_trades[-1]['price']}, quantity={buy_trades[-1]['quantity']}")
|
||||
|
||||
if sell_trades:
|
||||
print(f" First sell: amount=${sell_trades[0]['amount']:.2f}, price={sell_trades[0]['price']}, exit_reason={sell_trades[0].get('exit_reason')}")
|
||||
print(f" Last sell: amount=${sell_trades[-1]['amount']:.2f}, price={sell_trades[-1]['price']}, exit_reason={sell_trades[-1].get('exit_reason')}")
|
||||
|
||||
# For open positions, use the last kline price to mark to market
|
||||
# If no last kline price, fall back to entry price
|
||||
position_price = self.last_kline_price
|
||||
if position_price is None and self.trades and self.position > 0:
|
||||
position_price = self.trades[-1]["price"] # Fall back to entry price
|
||||
|
||||
# Debug logging
|
||||
print(f"DEBUG _calculate_metrics:")
|
||||
print(f" initial_balance: {self.initial_balance}")
|
||||
print(f" current_balance: {self.current_balance}")
|
||||
print(f" position: {self.position}")
|
||||
print(f" position_price: {position_price}")
|
||||
print(f" last_kline_price: {self.last_kline_price}")
|
||||
|
||||
# Calculate final balance: use marked-to-market value if position open, otherwise current balance
|
||||
if self.position > 0 and position_price:
|
||||
final_balance = self.current_balance + self.position * position_price
|
||||
else:
|
||||
final_balance = self.current_balance
|
||||
|
||||
print(f" final_balance calculated: {final_balance}")
|
||||
|
||||
total_return = (
|
||||
(final_balance - self.initial_balance) / self.initial_balance
|
||||
) * 100
|
||||
|
||||
print(f" total_return calculated: {total_return}%")
|
||||
|
||||
buy_trades = [t for t in self.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in self.trades if t["type"] == "sell"]
|
||||
total_trades = len(buy_trades) + len(sell_trades)
|
||||
@@ -259,18 +486,23 @@ class BacktestEngine:
|
||||
|
||||
for trade in self.trades:
|
||||
if trade["type"] == "buy":
|
||||
running_position = trade["quantity"]
|
||||
running_balance = trade["amount"]
|
||||
running_position += trade["quantity"] # Add to existing position (DCA)
|
||||
running_balance -= trade["amount"] # Subtract amount spent
|
||||
current_token = trade["token"]
|
||||
last_price = trade["price"]
|
||||
else:
|
||||
running_balance = trade["amount"]
|
||||
running_position = 0
|
||||
else: # sell
|
||||
running_balance += trade["amount"] # Add amount received
|
||||
running_position = 0 # Close entire position
|
||||
last_price = trade["price"]
|
||||
|
||||
portfolio_value = running_balance + (running_position * last_price)
|
||||
portfolio_values.append(portfolio_value)
|
||||
|
||||
# If there's an open position, add final marked-to-market value
|
||||
if self.position > 0 and self.last_kline_price:
|
||||
final_portfolio_value = self.current_balance + (self.position * self.last_kline_price)
|
||||
portfolio_values.append(final_portfolio_value)
|
||||
|
||||
max_value = self.initial_balance
|
||||
max_drawdown = 0.0
|
||||
for value in portfolio_values:
|
||||
@@ -308,10 +540,13 @@ class BacktestEngine:
|
||||
"sharpe_ratio": round(sharpe_ratio, 2),
|
||||
"final_balance": round(final_balance, 2),
|
||||
"signals": self.signals,
|
||||
"trades": self.trades, # Include trades in results for storage
|
||||
}
|
||||
|
||||
async def stop(self):
|
||||
self.running = False
|
||||
self.progress = 0
|
||||
self.total_klines = 0
|
||||
self.status = "stopped"
|
||||
self._calculate_metrics()
|
||||
|
||||
@@ -321,4 +556,13 @@ class BacktestEngine:
|
||||
"status": self.status,
|
||||
"results": self.results,
|
||||
"signals": self.signals,
|
||||
"progress": self.progress,
|
||||
"total_klines": self.total_klines,
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"progress": self.progress,
|
||||
"total_klines": self.total_klines,
|
||||
}
|
||||
|
||||
95
src/backend/app/services/rate_limiter.py
Normal file
95
src/backend/app/services/rate_limiter.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from fastapi import HTTPException
|
||||
from ..db.models import Message, AnonymousUser
|
||||
|
||||
MAX_CHATS_PER_5HOURS = int(os.getenv("MAX_CHATS_PER_5HOURS", "500"))
|
||||
MAX_ANONYMOUS_CHATS = 50
|
||||
MAX_ANONYMOUS_BOTS = 1
|
||||
MAX_ANONYMOUS_BACKTESTS = 1
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
@staticmethod
|
||||
def check_system_limit(db):
|
||||
cutoff = datetime.utcnow() - timedelta(hours=5)
|
||||
count = (
|
||||
db.query(func.count(Message.id))
|
||||
.filter(Message.created_at >= cutoff)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
if count >= MAX_CHATS_PER_5HOURS:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limited from the agent service. Please come back later.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_anonymous_limit(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon and anon.chat_count >= MAX_ANONYMOUS_CHATS:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You've reached the limit. Please create an account to continue.",
|
||||
)
|
||||
|
||||
return anon
|
||||
|
||||
@staticmethod
|
||||
def check_anonymous_bot_limit(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon and anon.bot_created:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You've reached the limit. Please create an account to continue.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_anonymous_backtest_limit(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon and anon.backtest_count >= MAX_ANONYMOUS_BACKTESTS:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You've reached the limit. Please create an account to continue.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def increment_chat_count(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon:
|
||||
anon.chat_count += 1
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def set_bot_created(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon:
|
||||
anon.bot_created = True
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def increment_backtest_count(db, anonymous_token: str):
|
||||
anon = (
|
||||
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
|
||||
)
|
||||
|
||||
if anon:
|
||||
anon.backtest_count += 1
|
||||
db.commit()
|
||||
@@ -1,8 +1,12 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from ..backtest.ave_client import AveCloudClient
|
||||
|
||||
from ..ave.client import AveCloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimulateEngine:
|
||||
@@ -20,16 +24,69 @@ class SimulateEngine:
|
||||
self.strategy_config = config.get("strategy_config", {})
|
||||
self.conditions = self.strategy_config.get("conditions", [])
|
||||
self.actions = self.strategy_config.get("actions", [])
|
||||
self.check_interval = config.get("check_interval", 60)
|
||||
self.duration_seconds = config.get("duration_seconds", 3600)
|
||||
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
|
||||
self.take_profit_percent = self.risk_management.get("take_profit_percent")
|
||||
|
||||
# Kline-based settings
|
||||
self.kline_interval = config.get("kline_interval", "1m")
|
||||
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
|
||||
|
||||
# Delay between candles (in seconds) to simulate real-time
|
||||
# e.g., 1m interval -> 30s delay between candles
|
||||
# Use config value if provided, otherwise calculate
|
||||
if "candle_delay" in config and config["candle_delay"] is not None:
|
||||
self.candle_delay = config["candle_delay"]
|
||||
else:
|
||||
self.candle_delay = self._get_interval_seconds(self.kline_interval) / 2
|
||||
|
||||
self.auto_execute = config.get("auto_execute", False)
|
||||
self.token = config.get("token", "")
|
||||
self.chain = config.get("chain", "bsc")
|
||||
self.running = False
|
||||
self.started_at: Optional[datetime] = None
|
||||
self.last_price: Optional[float] = None
|
||||
|
||||
# Price tracking (for conditions)
|
||||
self.last_close: Optional[float] = None
|
||||
self.last_volume: Optional[float] = None
|
||||
|
||||
# Position tracking (for risk management)
|
||||
self.position: float = 0.0
|
||||
self.position_token: str = ""
|
||||
self.entry_price: Optional[float] = None
|
||||
self.entry_time: Optional[int] = None
|
||||
|
||||
# Portfolio
|
||||
self.current_balance: float = config.get("initial_balance", 10000.0)
|
||||
self.trades: List[Dict[str, Any]] = []
|
||||
|
||||
# Error tracking
|
||||
self.errors: List[str] = []
|
||||
|
||||
# Kline data
|
||||
self.klines: List[Dict[str, Any]] = []
|
||||
self.last_processed_time: Optional[int] = None
|
||||
|
||||
# Trade log - tracks what happened at each candle
|
||||
self.trade_log: List[Dict[str, Any]] = []
|
||||
|
||||
# Current candle being processed (for frontend to show progress)
|
||||
self.current_candle_index = 0
|
||||
self.total_candles = 0
|
||||
|
||||
def _get_interval_seconds(self, interval: str) -> int:
|
||||
"""Convert kline interval to seconds."""
|
||||
mapping = {
|
||||
"1m": 60,
|
||||
"5m": 300,
|
||||
"15m": 900,
|
||||
"30m": 1800,
|
||||
"1h": 3600,
|
||||
"4h": 14400,
|
||||
"1d": 86400,
|
||||
}
|
||||
return mapping.get(interval, 60)
|
||||
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
@@ -46,58 +103,231 @@ class SimulateEngine:
|
||||
self.results = {"error": "Token ID is required"}
|
||||
return self.results
|
||||
|
||||
end_time = datetime.utcnow().timestamp() + self.duration_seconds
|
||||
|
||||
try:
|
||||
while self.running and datetime.utcnow().timestamp() < end_time:
|
||||
try:
|
||||
price_data = await self.ave_client.get_token_price(token_id)
|
||||
if price_data:
|
||||
current_price = float(price_data.get("price", 0))
|
||||
current_volume = float(price_data.get("volume", 0))
|
||||
# Step 1: Fetch klines (only once for simulation)
|
||||
self.klines = await self._fetch_klines(token_id)
|
||||
|
||||
if current_price > 0:
|
||||
await self._check_conditions(
|
||||
current_price, current_volume, price_data
|
||||
)
|
||||
if not self.klines:
|
||||
self.status = "failed"
|
||||
self.results = {"error": "No kline data available"}
|
||||
return self.results
|
||||
|
||||
self.last_price = current_price
|
||||
self.last_volume = current_volume
|
||||
logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
# Step 2: Process candles (with limit)
|
||||
candles_processed = 0
|
||||
self.total_candles = min(len(self.klines), self.max_candles)
|
||||
self.current_candle_index = 0
|
||||
|
||||
for _ in range(self.check_interval):
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
for i, candle in enumerate(self.klines):
|
||||
if not self.running:
|
||||
break
|
||||
if candles_processed >= self.max_candles:
|
||||
logger.info(f"Reached max candles limit ({self.max_candles})")
|
||||
break
|
||||
|
||||
if self.running:
|
||||
self.status = "completed"
|
||||
else:
|
||||
self.status = "stopped"
|
||||
self.current_candle_index = candles_processed
|
||||
candle_time = int(candle.get("time", 0))
|
||||
|
||||
# Get OHLCV data from candle
|
||||
close_price = float(candle.get("close", 0))
|
||||
volume = float(candle.get("volume", 0))
|
||||
|
||||
if close_price > 0:
|
||||
# Process candle
|
||||
await self._process_candle(close_price, volume, candle_time)
|
||||
|
||||
# Update last close for next iteration
|
||||
self.last_close = close_price
|
||||
self.last_volume = volume
|
||||
|
||||
# Track last processed time
|
||||
self.last_processed_time = candle_time
|
||||
|
||||
candles_processed += 1
|
||||
|
||||
# Delay to simulate real-time (only for visible candles, not initial batch)
|
||||
if candles_processed > 1 and self.candle_delay > 0:
|
||||
await asyncio.sleep(self.candle_delay)
|
||||
|
||||
self.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Simulation error: {e}")
|
||||
self.status = "failed"
|
||||
self.results = {"error": str(e)}
|
||||
self.errors.append(str(e))
|
||||
|
||||
self.results = self.results or {}
|
||||
self.results["total_signals"] = len(self.signals)
|
||||
self.results["total_trades"] = len(self.trades)
|
||||
self.results["total_errors"] = len(self.errors)
|
||||
self.results["errors"] = self.errors
|
||||
self.results["signals"] = self.signals
|
||||
self.results["candles_processed"] = candles_processed
|
||||
self.results["current_candle_index"] = self.current_candle_index
|
||||
self.results["total_candles"] = self.total_candles
|
||||
self.results["klines"] = self.klines # Include klines for chart display
|
||||
self.results["trade_log"] = self.trade_log # Include trade log for dashboard
|
||||
self.results["portfolio"] = {
|
||||
"initial_balance": self.config.get("initial_balance", 10000),
|
||||
"current_balance": self.current_balance,
|
||||
"position": self.position,
|
||||
"position_token": self.position_token,
|
||||
"entry_price": self.entry_price,
|
||||
"current_price": self.last_close,
|
||||
}
|
||||
self.results["started_at"] = self.started_at
|
||||
self.results["ended_at"] = datetime.utcnow()
|
||||
|
||||
return self.results
|
||||
|
||||
async def _check_conditions(
|
||||
self, current_price: float, current_volume: float, price_data: Dict[str, Any]
|
||||
):
|
||||
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
||||
async def _fetch_klines(
|
||||
self,
|
||||
token_id: str,
|
||||
limit: int = 500
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch klines from AVE API."""
|
||||
try:
|
||||
klines = await self.ave_client.get_klines(
|
||||
token_id,
|
||||
interval=self.kline_interval,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, current_price, current_volume):
|
||||
await self._execute_actions(current_price, timestamp, condition)
|
||||
break
|
||||
# Sort by time ascending (oldest first)
|
||||
klines = sorted(klines, key=lambda x: x.get("time", 0))
|
||||
return klines
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch klines for {token_id}: {e}")
|
||||
self.errors.append(f"Kline fetch failed: {str(e)}")
|
||||
return []
|
||||
|
||||
async def _process_candle(
|
||||
self,
|
||||
close_price: float,
|
||||
volume: float,
|
||||
timestamp: int
|
||||
):
|
||||
"""Process a single candle - check conditions and risk management."""
|
||||
|
||||
action = "hold" # Default action
|
||||
reason = ""
|
||||
|
||||
# Check risk management first (for open positions)
|
||||
if self.position > 0 and self.entry_price is not None:
|
||||
exit_info = self._check_risk_management(close_price, timestamp)
|
||||
if exit_info:
|
||||
await self._execute_risk_exit(close_price, timestamp, exit_info)
|
||||
action = "sell"
|
||||
reason = exit_info["reason"]
|
||||
# Log the action
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
return
|
||||
|
||||
# Check conditions (only if no open position)
|
||||
if self.position == 0:
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, close_price, volume):
|
||||
await self._execute_actions(close_price, timestamp, condition)
|
||||
action = "buy"
|
||||
reason = f"{condition.get('type')} {condition.get('threshold')}%".format(
|
||||
type=condition.get('type'),
|
||||
threshold=condition.get('threshold')
|
||||
)
|
||||
# Log the action
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
break
|
||||
|
||||
# Log hold action (no signal)
|
||||
if action == "hold":
|
||||
# Only log every 10th candle to reduce data
|
||||
if len(self.trade_log) == 0 or (len(self.klines) - len(self.trade_log) > 10):
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": "hold",
|
||||
"reason": "no_signal",
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
|
||||
def _check_risk_management(
|
||||
self, current_price: float, timestamp: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if stop loss or take profit is triggered."""
|
||||
if self.position <= 0 or self.entry_price is None:
|
||||
return None
|
||||
|
||||
if self.stop_loss_percent is not None:
|
||||
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
|
||||
if current_price <= stop_loss_price:
|
||||
return {"reason": "stop_loss", "price": stop_loss_price}
|
||||
|
||||
if self.take_profit_percent is not None:
|
||||
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
|
||||
if current_price >= take_profit_price:
|
||||
return {"reason": "take_profit", "price": take_profit_price}
|
||||
|
||||
return None
|
||||
|
||||
async def _execute_risk_exit(
|
||||
self, price: float, timestamp: int, exit_info: Dict[str, Any]
|
||||
):
|
||||
"""Execute stop loss or take profit."""
|
||||
if self.position <= 0:
|
||||
return
|
||||
|
||||
reason = exit_info["reason"]
|
||||
quantity = self.position
|
||||
sale_proceeds = quantity * price
|
||||
|
||||
# Add sale proceeds to cash balance
|
||||
self.current_balance += sale_proceeds
|
||||
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"quantity": quantity,
|
||||
"amount": sale_proceeds,
|
||||
"timestamp": timestamp,
|
||||
"exit_reason": reason,
|
||||
}
|
||||
)
|
||||
self.signals.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"confidence": 1.0,
|
||||
"reasoning": f"Risk management triggered {reason}",
|
||||
"executed": self.auto_execute,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
self.position = 0
|
||||
self.entry_price = None
|
||||
self.entry_time = None
|
||||
|
||||
def _check_condition(
|
||||
self,
|
||||
@@ -105,32 +335,34 @@ class SimulateEngine:
|
||||
current_price: float,
|
||||
current_volume: float,
|
||||
) -> bool:
|
||||
"""Check if a condition is met based on price movement."""
|
||||
cond_type = condition.get("type", "")
|
||||
threshold = condition.get("threshold", 0)
|
||||
price_level = condition.get("price")
|
||||
direction = condition.get("direction", "above")
|
||||
|
||||
if cond_type == "price_drop":
|
||||
if self.last_price is None or self.last_price <= 0:
|
||||
# Price dropped by threshold % from last close
|
||||
if self.last_close is None or self.last_close <= 0:
|
||||
return False
|
||||
drop_pct = ((self.last_price - current_price) / self.last_price) * 100
|
||||
drop_pct = ((self.last_close - current_price) / self.last_close) * 100
|
||||
return drop_pct >= threshold
|
||||
|
||||
elif cond_type == "price_rise":
|
||||
if self.last_price is None or self.last_price <= 0:
|
||||
# Price rose by threshold % from last close
|
||||
if self.last_close is None or self.last_close <= 0:
|
||||
return False
|
||||
rise_pct = ((current_price - self.last_price) / self.last_price) * 100
|
||||
rise_pct = ((current_price - self.last_close) / self.last_close) * 100
|
||||
return rise_pct >= threshold
|
||||
|
||||
elif cond_type == "volume_spike":
|
||||
# Volume increased significantly
|
||||
if self.last_volume is None or self.last_volume <= 0:
|
||||
return False
|
||||
volume_increase = (
|
||||
(current_volume - self.last_volume) / self.last_volume
|
||||
) * 100
|
||||
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
|
||||
return volume_increase >= threshold
|
||||
|
||||
elif cond_type == "price_level":
|
||||
price_level = condition.get("price")
|
||||
direction = condition.get("direction", "above")
|
||||
if price_level is None:
|
||||
return False
|
||||
if direction == "above":
|
||||
@@ -143,29 +375,56 @@ class SimulateEngine:
|
||||
async def _execute_actions(
|
||||
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||
):
|
||||
"""Execute buy/sell actions based on matched condition."""
|
||||
token = matched_condition.get("token", self.token)
|
||||
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
||||
|
||||
signal = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "signal",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"confidence": 0.8,
|
||||
"reasoning": reasoning,
|
||||
"executed": self.auto_execute,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
for action in self.actions:
|
||||
action_type = action.get("type", "")
|
||||
if action_type == "buy":
|
||||
amount_percent = action.get("amount_percent", 10)
|
||||
amount = self.current_balance * (amount_percent / 100)
|
||||
quantity = amount / price
|
||||
|
||||
self.signals.append(signal)
|
||||
self.position += quantity
|
||||
self.position_token = token
|
||||
self.entry_price = price
|
||||
self.entry_time = timestamp
|
||||
self.current_balance -= amount
|
||||
|
||||
async def stop(self):
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "buy",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
signal = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": action_type,
|
||||
"token": token,
|
||||
"price": price,
|
||||
"confidence": 0.8,
|
||||
"reasoning": reasoning,
|
||||
"executed": self.auto_execute,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
self.signals.append(signal)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the simulation."""
|
||||
self.running = False
|
||||
self.status = "stopped"
|
||||
|
||||
def get_results(self) -> Dict[str, Any]:
|
||||
"""Get simulation results."""
|
||||
return {
|
||||
"id": self.run_id,
|
||||
"status": self.status,
|
||||
@@ -174,4 +433,5 @@ class SimulateEngine:
|
||||
}
|
||||
|
||||
def get_signals(self) -> List[Dict[str, Any]]:
|
||||
"""Get current signals."""
|
||||
return self.signals
|
||||
|
||||
@@ -6,6 +6,7 @@ pydantic-settings>=2.1.0
|
||||
email-validator>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt>=4.0,<5.0 # Required for passlib compatibility
|
||||
crewai>=0.1.0
|
||||
anthropic>=0.18.0
|
||||
httpx>=0.26.0
|
||||
|
||||
@@ -8,4 +8,5 @@ if __name__ == "__main__":
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
timeout_keep_alive=300,
|
||||
)
|
||||
|
||||
609
src/backend/tests/test_agent.py
Normal file
609
src/backend/tests/test_agent.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""Tests for ConversationalAgent using mock client."""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.services.ai_agent.agent import ConversationalAgent
|
||||
from app.services.ai_agent.mock_client import MockMiniMaxClient, MockMiniMaxClientWithToolCall
|
||||
|
||||
|
||||
class TestConversationalAgent:
|
||||
"""Test ConversationalAgent with mocked MiniMax API."""
|
||||
|
||||
def test_greeting_response(self):
|
||||
"""Test that agent responds to greeting."""
|
||||
mock = MockMiniMaxClient()
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello! How can I help you with your trading today?",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("Hello")
|
||||
|
||||
assert "Hello" in result.get("response", "")
|
||||
assert mock.call_count == 1
|
||||
|
||||
def test_api_error_returns_error_message(self):
|
||||
"""Test that when API returns an error (like 529 overloaded), we return an error message."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# API returns an error on all 3 retry attempts
|
||||
for _ in range(3):
|
||||
mock.add_response({
|
||||
"error": "API returned 529: Server overloaded"
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("Hello")
|
||||
|
||||
# Should return error message, not empty string
|
||||
response = result.get("response", "")
|
||||
print(f"Response: {response}")
|
||||
print(f"Call count: {mock.call_count}")
|
||||
|
||||
assert response != "", "Response should not be empty when API errors"
|
||||
assert "trouble" in response.lower() or "error" in response.lower() or "sorry" in response.lower(), \
|
||||
f"Response should mention error: {response}"
|
||||
assert result.get("success") == False
|
||||
|
||||
def test_api_empty_choices_returns_error_message(self):
|
||||
"""Test that when API returns choices=None (like 520 error), we return an error message."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# API returns empty choices on all 3 retry attempts
|
||||
for _ in range(3):
|
||||
mock.add_response({
|
||||
"id": "test-id",
|
||||
"choices": None, # Empty choices = API error
|
||||
"model": "MiniMax-M2.7",
|
||||
"base_resp": {"status_code": 1000, "status_msg": "unknown error, 520"}
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("Hello")
|
||||
|
||||
# Should return error message, not empty string
|
||||
response = result.get("response", "")
|
||||
print(f"Response: {response}")
|
||||
print(f"Call count: {mock.call_count}")
|
||||
|
||||
assert response != "", "Response should not be empty when API returns empty choices"
|
||||
assert "trouble" in response.lower() or "error" in response.lower() or "sorry" in response.lower(), \
|
||||
f"Response should mention error: {response}"
|
||||
assert result.get("success") == False
|
||||
|
||||
def test_tool_call_list_bots(self):
|
||||
"""Test that agent calls list_bots tool when asked about bots."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model decides to call list_bots
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_bots",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: after tool result, model gives final response
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "You don't have any bots yet. Would you like to create one?",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Pass anonymous_token so _execute_list_bots doesn't return error
|
||||
agent = ConversationalAgent(client=mock, anonymous_token="test-token")
|
||||
result = agent.chat("What bots do I have?")
|
||||
|
||||
assert mock.call_count == 2
|
||||
# The response should be from the second call
|
||||
assert "bot" in result.get("response", "").lower()
|
||||
|
||||
def test_tool_result_with_empty_content_returns_tool_result(self):
|
||||
"""Test that if second API call returns empty content but has tool_calls, we fallback to tool result."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model calls list_bots
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_bots",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: model calls ANOTHER tool (not providing text)
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "\n", # Whitespace only
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_bot_info",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("What bots do I have?")
|
||||
|
||||
# Should fallback to the tool result text, not empty string
|
||||
assert result.get("response", "") != ""
|
||||
|
||||
def test_content_with_tool_calls_returns_content(self):
|
||||
"""Test that if second API call returns BOTH content AND tool_calls, we return the content.
|
||||
|
||||
This tests the exact scenario from production:
|
||||
- Tool result: 'Backtest failed: Token address not found...'
|
||||
- Model response: 'Got it! Running the backtest now.' (with tool_calls)
|
||||
- Expected: Since tool result is an ERROR, we should return the error, NOT model content
|
||||
"""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model calls run_backtest
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_backtest",
|
||||
"arguments": '{"token_address": "0x..."}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: model returns misleading positive content AND has tool_calls
|
||||
# But tool result was an ERROR, so we should return the error
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "Got it! Running the backtest now.", # Misleading!
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_bot_info",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(
|
||||
client=mock,
|
||||
conversation_id="test-conv",
|
||||
anonymous_token="test-token"
|
||||
)
|
||||
result = agent.chat("Run backtest on SHIB")
|
||||
|
||||
# Since tool result is an error, we should return the ERROR, not model content
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
|
||||
# The key assertion: tool result was an error, so we should return the error
|
||||
assert "not found" in response.lower() or "error" in response.lower() or "couldn't" in response.lower(), \
|
||||
f"Expected error from tool result, got: {response}"
|
||||
assert "Got it" not in response, f"Should NOT return misleading positive content"
|
||||
|
||||
def test_content_without_tool_calls_returns_content(self):
|
||||
"""Test that if second API call returns content WITHOUT tool_calls, we return the content.
|
||||
Note: This test uses list_bots which returns a friendly message, not an error."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model calls list_bots
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_bots",
|
||||
"arguments": '{}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: model returns ONLY content, no tool_calls
|
||||
# Tool result was "You don't have any bots yet" (not an error keyword), use model content
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"content": "You don't have any bots. Would you like to create one?", # Content only
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(
|
||||
client=mock,
|
||||
conversation_id="test-conv",
|
||||
anonymous_token="test-token"
|
||||
)
|
||||
result = agent.chat("What bots do I have?")
|
||||
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
assert "You don't have any bots" in response
|
||||
|
||||
def test_second_api_call_error_returns_tool_result(self):
|
||||
"""Test that if second API call (in _send_tool_result_to_model) returns error, we return tool result."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model calls run_backtest
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_backtest",
|
||||
"arguments": '{}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: API returns error on all 3 retry attempts
|
||||
for _ in range(3):
|
||||
mock.add_response({
|
||||
"error": "API returned 529: Server overloaded"
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(
|
||||
client=mock,
|
||||
conversation_id="test-conv",
|
||||
anonymous_token="test-token"
|
||||
)
|
||||
result = agent.chat("Run backtest")
|
||||
|
||||
# Should fallback to tool result (backtest not found message), not empty string
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
print(f"Call count: {mock.call_count}")
|
||||
|
||||
assert response != "", "Response should not be empty when second API errors"
|
||||
# The tool result should be "I couldn't find the bot..."
|
||||
assert "bot" in response.lower() or "couldn't find" in response.lower(), \
|
||||
f"Response should contain tool result: {response}"
|
||||
|
||||
def test_chained_tool_calls_with_empty_content(self):
|
||||
"""Test that if model returns empty content but has tool_calls, we execute the next tool.
|
||||
|
||||
This is the exact scenario from production:
|
||||
- User asks about bots
|
||||
- Model calls list_bots tool
|
||||
- Tool returns bot list
|
||||
- Model responds with EMPTY content but has get_bot_info tool call
|
||||
- We should execute get_bot_info and continue, NOT return empty string
|
||||
"""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model decides to call list_bots
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_bots",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: model returns EMPTY content but wants to call get_bot_info
|
||||
# This is the BUG scenario - model just wants to do more tool calls
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "", # Empty content!
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_bot_info",
|
||||
"arguments": '{"bot_id": "test-bot-123"}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Third call: after get_bot_info, model finally returns content
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "I found your bot 'Test Bot' with 2 strategy conditions configured.",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock, anonymous_token="test-token")
|
||||
result = agent.chat("Tell me about my bots")
|
||||
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
print(f"Call count: {mock.call_count}")
|
||||
|
||||
# Should NOT return empty string - should continue with tool calls
|
||||
assert response != "", "Response should not be empty when model has more tool calls"
|
||||
# get_bot_info returns error since bot doesn't exist in DB, but we continued the chain
|
||||
# (which is the key behavior we're testing - it didn't return empty string)
|
||||
assert "❌" in response or "bot" in response.lower(), f"Response should mention bot or error: {response}"
|
||||
|
||||
def test_json_response_extraction(self):
|
||||
"""Test that JSON-formatted responses are properly extracted."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# Model returns JSON in code block with response field
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "```json\n{\n \"thinking\": \"The user wants a bot. Creating one now.\",\n \"response\": \"✅ Bot created successfully!\"\n}\n```",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("create a bot")
|
||||
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
|
||||
# Should extract the response field, not show JSON
|
||||
assert "✅ Bot created successfully!" in response, f"Should contain bot message: {response}"
|
||||
assert "thinking" not in response.lower() or "json" not in response.lower(), f"Should not contain raw JSON: {response}"
|
||||
assert "```json" not in response, f"Should not contain code block markers: {response}"
|
||||
|
||||
def test_retry_succeeds_on_second_attempt(self):
|
||||
"""Test that if first API call fails but second succeeds, we use the successful response."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First attempt fails with error
|
||||
mock.add_response({"error": "API returned 529: Server overloaded"})
|
||||
# Second attempt succeeds
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello! How can I help you?",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(client=mock)
|
||||
result = agent.chat("Hello")
|
||||
|
||||
response = result.get("response", "")
|
||||
print(f"Response: '{response}'")
|
||||
print(f"Call count: {mock.call_count}")
|
||||
|
||||
# Should use the successful response from 2nd attempt
|
||||
assert "Hello" in response
|
||||
assert "trouble" not in response.lower()
|
||||
assert mock.call_count == 2 # Two calls: 1 error + 1 success
|
||||
|
||||
def test_model_json_response_is_parsed(self):
|
||||
"""Test that if model returns JSON-formatted response in _send_tool_result_to_model, we extract only the response field."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# User message triggers tool call
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_bot",
|
||||
"arguments": '{"name": "TestBot"}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Model's second response is JSON (this is the bug scenario)
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
# Model returns JSON instead of plain text
|
||||
"content": '{"thinking": "Bot created", "response": "Your bot TestBot is ready! What strategy?", "strategy_update": null}',
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(
|
||||
client=mock,
|
||||
conversation_id="test-conv",
|
||||
anonymous_token="test-token"
|
||||
)
|
||||
|
||||
result = agent.chat("Create a bot named TestBot")
|
||||
|
||||
# The response should NOT contain JSON
|
||||
response_text = result.get("response", "")
|
||||
print(f"Response: {response_text}")
|
||||
|
||||
# Should NOT contain JSON artifacts
|
||||
assert "{\"thinking\":" not in response_text
|
||||
assert "\"strategy_update\":" not in response_text
|
||||
assert "```json" not in response_text
|
||||
|
||||
# Should contain the actual response text
|
||||
assert "bot TestBot is ready" in response_text or "strategy" in response_text.lower()
|
||||
|
||||
def test_create_bot_flow(self):
|
||||
"""Test the full flow of creating a bot."""
|
||||
mock = MockMiniMaxClient()
|
||||
|
||||
# First call: model asks for bot name
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "I'd be happy to help you create a trading bot! What would you like to name it?",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Second call: user provides name, model calls create_bot
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"content": "",
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_create",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_bot",
|
||||
"arguments": '{"name": "TestBot"}'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
# Third call: after create_bot result, model gives PLAIN TEXT response (not JSON)
|
||||
mock.add_response({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "✅ Your bot 'TestBot' has been created! Now let's set up your trading strategy.",
|
||||
"role": "assistant",
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
agent = ConversationalAgent(
|
||||
client=mock,
|
||||
conversation_id="test-conv-123",
|
||||
anonymous_token="test-token"
|
||||
)
|
||||
|
||||
# First message - greeting
|
||||
result1 = agent.chat("I want to run a backtest")
|
||||
assert mock.call_count == 1
|
||||
|
||||
# Second message - bot name
|
||||
result2 = agent.chat("MyBot")
|
||||
assert mock.call_count == 3 # Two calls due to tool execution
|
||||
|
||||
assert "bot" in result2.get("response", "").lower()
|
||||
|
||||
|
||||
class TestMockMiniMaxClient:
|
||||
"""Test the mock client itself."""
|
||||
|
||||
def test_add_and_retrieve_responses(self):
|
||||
"""Test that responses are returned in order."""
|
||||
mock = MockMiniMaxClient()
|
||||
mock.add_response({"result": "first"})
|
||||
mock.add_response({"result": "second"})
|
||||
|
||||
assert mock.chat({}, "") == {"result": "first"}
|
||||
assert mock.chat({}, "") == {"result": "second"}
|
||||
|
||||
def test_calls_are_recorded(self):
|
||||
"""Test that all calls are recorded."""
|
||||
mock = MockMiniMaxClient()
|
||||
mock.add_response({"result": "ok"})
|
||||
|
||||
mock.chat(["msg1"], "system")
|
||||
mock.chat(["msg2"], "system")
|
||||
|
||||
assert len(mock.calls) == 2
|
||||
assert mock.calls[0]["messages"] == ["msg1"]
|
||||
assert mock.calls[1]["messages"] == ["msg2"]
|
||||
|
||||
def test_default_response_when_exhausted(self):
|
||||
"""Test default response when all predefined responses are used."""
|
||||
mock = MockMiniMaxClient()
|
||||
mock.add_response({"result": "only"})
|
||||
|
||||
result1 = mock.chat([], "")
|
||||
result2 = mock.chat([], "") # No more responses
|
||||
|
||||
assert result1 == {"result": "only"}
|
||||
assert "Mock response" in result2["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
505
src/backend/tests/test_backtest_engine.py
Normal file
505
src/backend/tests/test_backtest_engine.py
Normal file
@@ -0,0 +1,505 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the backend directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'app'))
|
||||
|
||||
from app.services.backtest.engine import BacktestEngine
|
||||
|
||||
|
||||
def create_klines(start_price, num_klines, interval=1.0, volatility=0.01):
|
||||
"""Helper to create kline data with predictable price movements.
|
||||
|
||||
Args:
|
||||
start_price: Starting price
|
||||
num_klines: Number of klines to create
|
||||
interval: Price change per kline (can be positive or negative)
|
||||
volatility: Random noise factor (0.01 = 1%)
|
||||
"""
|
||||
import random
|
||||
klines = []
|
||||
price = start_price
|
||||
base_time = 1704067200 # 2024-01-01 00:00:00 UTC
|
||||
|
||||
for i in range(num_klines):
|
||||
# Add some randomness
|
||||
noise = (random.random() - 0.5) * 2 * volatility * price
|
||||
price = max(0.00000001, price + interval + noise)
|
||||
|
||||
open_price = price - (random.random() * volatility * price)
|
||||
close_price = price
|
||||
high_price = max(open_price, close_price) + (random.random() * volatility * price)
|
||||
low_price = min(open_price, close_price) - (random.random() * volatility * price)
|
||||
|
||||
klines.append({
|
||||
"open": str(open_price),
|
||||
"high": str(high_price),
|
||||
"low": str(low_price),
|
||||
"close": str(close_price),
|
||||
"volume": str(1000 + random.random() * 100),
|
||||
"amount": str(1000 * price),
|
||||
"time": base_time + (i * 3600) # 1 hour intervals
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
|
||||
class MockAveClient:
|
||||
"""Mock AVE client that returns test klines."""
|
||||
|
||||
def __init__(self, klines):
|
||||
self.klines = klines
|
||||
|
||||
async def get_klines(self, token_id, interval, limit, start_time=None, end_time=None):
|
||||
return self.klines
|
||||
|
||||
|
||||
class TestBacktestEngine:
|
||||
"""Test cases for BacktestEngine."""
|
||||
|
||||
@pytest.fixture
|
||||
def base_config(self):
|
||||
"""Base config for backtest."""
|
||||
return {
|
||||
"bot_id": "test-bot-123",
|
||||
"token": "0xtest",
|
||||
"chain": "bsc",
|
||||
"timeframe": "1h",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-02",
|
||||
"ave_api_key": "test-key",
|
||||
"ave_api_plan": "free",
|
||||
"initial_balance": 10000.0,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def simple_strategy(self):
|
||||
"""Simple strategy: buy on 1% drop, no auto sell."""
|
||||
return {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def partial_sell_strategy(self):
|
||||
"""Strategy with partial sells: buy on 1% drop, sell 50% on rise (via risk management take profit)."""
|
||||
return {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"take_profit_percent": 1.5 # 1.5% take profit to trigger on price rise
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def stop_loss_strategy(self):
|
||||
"""Strategy with stop loss and take profit."""
|
||||
return {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5, # 5% stop loss
|
||||
"take_profit_percent": 10 # 10% take profit
|
||||
}
|
||||
}
|
||||
|
||||
def test_single_buy_and_hold(self, base_config, simple_strategy):
|
||||
"""Test buying once and holding (no sell triggers)."""
|
||||
# Create klines that drop 0.5% each (below 1% threshold, no buy)
|
||||
# Then rise 0.5% each (still no sell since no position)
|
||||
klines = create_klines(100, 10, interval=0.5) # Rising trend
|
||||
|
||||
config = {**base_config, "strategy_config": simple_strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
# Should have 0 trades since price never dropped 1%
|
||||
assert results.get("total_trades") == 0
|
||||
assert results.get("final_balance") == 10000.0
|
||||
|
||||
def test_multiple_dips_multiple_buys(self, base_config, simple_strategy):
|
||||
"""Test multiple dips triggering multiple buys (DCA)."""
|
||||
# Create price that drops 1.5% then rises 0.5%, repeats
|
||||
# This should trigger buy on each drop
|
||||
klines = []
|
||||
price = 100.0
|
||||
base_time = 1704067200
|
||||
|
||||
for i in range(20):
|
||||
if i % 3 == 0:
|
||||
# Drop by 1.5%
|
||||
price = price * 0.985 # 1.5% drop
|
||||
else:
|
||||
# Rise by 0.5%
|
||||
price = price * 1.005
|
||||
|
||||
klines.append({
|
||||
"open": str(price * 0.99),
|
||||
"high": str(price * 1.01),
|
||||
"low": str(price * 0.98),
|
||||
"close": str(price),
|
||||
"volume": "1000",
|
||||
"amount": str(1000 * price),
|
||||
"time": base_time + (i * 3600)
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": simple_strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
|
||||
# Should have multiple buys (6-7 dips at 1.5% threshold out of ~7 drops)
|
||||
assert results.get("total_trades") >= 6
|
||||
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
|
||||
assert len(buy_trades) >= 6
|
||||
|
||||
# Should end with position > 0 (still holding since no sell triggers)
|
||||
assert engine.position > 0
|
||||
|
||||
def test_partial_sells(self, base_config, partial_sell_strategy):
|
||||
"""Test partial sells - selling some portion via take profit."""
|
||||
# Create: drop 1.5% (buy), rise 1.5% (sell via take profit), drop 1.5% (buy), rise 1.5% (sell via take profit)
|
||||
klines = []
|
||||
price = 100.0
|
||||
base_time = 1704067200
|
||||
|
||||
for i in range(8):
|
||||
if i % 2 == 0:
|
||||
# Even: drop 1.5% (should trigger buy)
|
||||
price = price * 0.985
|
||||
else:
|
||||
# Odd: rise 1.5% (should trigger take profit sell)
|
||||
price = price * 1.015
|
||||
|
||||
klines.append({
|
||||
"open": str(price * 0.99),
|
||||
"high": str(price * 1.01),
|
||||
"low": str(price * 0.98),
|
||||
"close": str(price),
|
||||
"volume": "1000",
|
||||
"amount": str(1000 * price),
|
||||
"time": base_time + (i * 3600)
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": partial_sell_strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
|
||||
# Multiple cycles happen due to price oscillation
|
||||
# At minimum we should have some trades
|
||||
assert results.get("total_trades") >= 2
|
||||
|
||||
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
|
||||
# Should have executed some trades
|
||||
assert len(buy_trades) >= 1
|
||||
assert len(sell_trades) >= 1
|
||||
|
||||
# Should have made profit from the sells
|
||||
assert results.get("total_return") > 0
|
||||
|
||||
def test_stop_loss_trigger(self, base_config, stop_loss_strategy):
|
||||
"""Test stop loss triggers correctly."""
|
||||
# Create: buy, then continue dropping to trigger stop loss
|
||||
klines = []
|
||||
base_time = 1704067200
|
||||
|
||||
# Kline 0: reference price (skipped due to idx=0 check)
|
||||
klines.append({
|
||||
"open": "100",
|
||||
"high": "101",
|
||||
"low": "99",
|
||||
"close": "100",
|
||||
"volume": "1000",
|
||||
"amount": "100000",
|
||||
"time": base_time
|
||||
})
|
||||
|
||||
# Kline 1: drop triggers buy at 98.5
|
||||
klines.append({
|
||||
"open": "100",
|
||||
"high": "101",
|
||||
"low": "98",
|
||||
"close": "98.5", # 1.5% drop from 100
|
||||
"volume": "1000",
|
||||
"amount": "98500",
|
||||
"time": base_time + 3600
|
||||
})
|
||||
|
||||
# Kline 2: price rises slightly, no condition trigger
|
||||
klines.append({
|
||||
"open": "98.5",
|
||||
"high": "100",
|
||||
"low": "98",
|
||||
"close": "99",
|
||||
"volume": "1000",
|
||||
"amount": "99000",
|
||||
"time": base_time + 7200
|
||||
})
|
||||
|
||||
# Kline 3: price drops below stop loss
|
||||
# Entry was 98.5, stop loss is 5%, so SL = 98.5 * 0.95 = 93.575
|
||||
# This close (92) is below SL
|
||||
klines.append({
|
||||
"open": "99",
|
||||
"high": "100",
|
||||
"low": "90", # Well below stop loss
|
||||
"close": "92", # Below stop loss (93.575)
|
||||
"volume": "1000",
|
||||
"amount": "92000",
|
||||
"time": base_time + 10800
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": stop_loss_strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
print(f"DEBUG: stop_loss_percent = {engine.stop_loss_percent}")
|
||||
print(f"DEBUG: take_profit_percent = {engine.take_profit_percent}")
|
||||
|
||||
# Should have 2 trades: 1 buy, 1 sell (stop loss)
|
||||
assert results.get("total_trades") == 2
|
||||
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
assert len(sell_trades) == 1
|
||||
assert sell_trades[0]["exit_reason"] == "stop_loss"
|
||||
|
||||
# Should have lost money (stop loss triggered)
|
||||
assert results.get("total_return") < 0
|
||||
assert results.get("final_balance") < 10000.0
|
||||
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
assert len(sell_trades) == 1
|
||||
assert sell_trades[0]["exit_reason"] == "stop_loss"
|
||||
|
||||
# Should have lost money (stop loss triggered)
|
||||
assert results.get("total_return") < 0
|
||||
assert results.get("final_balance") < 10000.0
|
||||
|
||||
def test_take_profit_trigger(self, base_config, stop_loss_strategy):
|
||||
"""Test take profit triggers correctly."""
|
||||
# Create: buy, then price rises to trigger take profit
|
||||
klines = []
|
||||
base_time = 1704067200
|
||||
|
||||
# Kline 0: reference price (skipped due to idx=0)
|
||||
klines.append({
|
||||
"open": "100",
|
||||
"high": "101",
|
||||
"low": "99",
|
||||
"close": "100",
|
||||
"volume": "1000",
|
||||
"amount": "100000",
|
||||
"time": base_time
|
||||
})
|
||||
|
||||
# Kline 1: drop triggers buy at 98.5 (1.5% drop from 100)
|
||||
klines.append({
|
||||
"open": "100",
|
||||
"high": "101",
|
||||
"low": "98",
|
||||
"close": "98.5",
|
||||
"volume": "1000",
|
||||
"amount": "98500",
|
||||
"time": base_time + 3600
|
||||
})
|
||||
|
||||
# Kline 2: price stays roughly flat
|
||||
klines.append({
|
||||
"open": "98.5",
|
||||
"high": "99",
|
||||
"low": "98",
|
||||
"close": "98.8",
|
||||
"volume": "1000",
|
||||
"amount": "98800",
|
||||
"time": base_time + 7200
|
||||
})
|
||||
|
||||
# Kline 3: price rises above take profit
|
||||
# Entry was 98.5, take profit is 10%, so TP = 98.5 * 1.10 = 108.35
|
||||
klines.append({
|
||||
"open": "98.8",
|
||||
"high": "120", # Way above TP
|
||||
"low": "98",
|
||||
"close": "115", # Above 108.35
|
||||
"volume": "1000",
|
||||
"amount": "115000",
|
||||
"time": base_time + 10800
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": stop_loss_strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
print(f"DEBUG: stop_loss_percent = {engine.stop_loss_percent}")
|
||||
print(f"DEBUG: take_profit_percent = {engine.take_profit_percent}")
|
||||
|
||||
# Should have 2 trades: 1 buy, 1 sell (take profit)
|
||||
assert results.get("total_trades") == 2
|
||||
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
assert len(sell_trades) == 1
|
||||
assert sell_trades[0]["exit_reason"] == "take_profit"
|
||||
|
||||
# Should have made profit (take profit triggered)
|
||||
assert results.get("total_return") > 0
|
||||
assert results.get("final_balance") > 10000.0
|
||||
|
||||
def test_full_cycle_dip_buy_sell(self, base_config):
|
||||
"""Test a complete cycle: buy on dip, then sell via take profit."""
|
||||
# Strategy: buy 10% on 1% dip, take profit 2% to sell
|
||||
strategy = {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"take_profit_percent": 2.0 # Sell when price rises 2%
|
||||
}
|
||||
}
|
||||
|
||||
klines = []
|
||||
base_time = 1704067200
|
||||
|
||||
# Klines:
|
||||
# 0: price 100 (reference)
|
||||
# 1: price 98.5 - 1.5% drop, triggers buy at 98.5
|
||||
# 2: price 100.5 - 2% rise from 98.5 = 100.47, triggers take profit
|
||||
klines.append({
|
||||
"open": "100", "high": "101", "low": "99", "close": "100",
|
||||
"volume": "1000", "amount": "100000", "time": base_time
|
||||
})
|
||||
klines.append({
|
||||
"open": "100", "high": "101", "low": "97.5", "close": "98.5",
|
||||
"volume": "1000", "amount": "98500", "time": base_time + 3600
|
||||
})
|
||||
klines.append({
|
||||
"open": "98.5", "high": "101", "low": "98", "close": "100.5",
|
||||
"volume": "1000", "amount": "100500", "time": base_time + 7200
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
|
||||
# Should have 2 trades: 1 buy, 1 sell (take profit)
|
||||
assert results.get("total_trades") == 2
|
||||
|
||||
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
|
||||
assert len(buy_trades) == 1
|
||||
assert len(sell_trades) == 1
|
||||
assert sell_trades[0]["exit_reason"] == "take_profit"
|
||||
|
||||
# Should have made profit
|
||||
assert results.get("total_return") > 0
|
||||
|
||||
def test_multiple_buys_then_multiple_sells(self, base_config):
|
||||
"""Test multiple buys followed by multiple sells via take profit."""
|
||||
# Create strategy: buy 10% on 2% dip, take profit 2% to sell
|
||||
strategy = {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 2.0}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"take_profit_percent": 2.5 # Sell when price rises 2.5% (slightly above entry drop)
|
||||
}
|
||||
}
|
||||
|
||||
# Price pattern: drop 2.5% (buy), rise 2.5% (take profit sells), repeat 3 times
|
||||
klines = []
|
||||
base_time = 1704067200
|
||||
price = 100.0
|
||||
|
||||
for i in range(12):
|
||||
if i % 2 == 0:
|
||||
price = price * 0.975 # Drop 2.5% - triggers buy
|
||||
else:
|
||||
price = price * 1.026 # Rise 2.5% - triggers take profit sell
|
||||
|
||||
klines.append({
|
||||
"open": str(price * 0.99),
|
||||
"high": str(price * 1.01),
|
||||
"low": str(price * 0.98),
|
||||
"close": str(price),
|
||||
"volume": "1000",
|
||||
"amount": str(1000 * price),
|
||||
"time": base_time + (i * 3600)
|
||||
})
|
||||
|
||||
config = {**base_config, "strategy_config": strategy}
|
||||
engine = BacktestEngine(config)
|
||||
engine.ave_client = MockAveClient(klines)
|
||||
|
||||
results = asyncio.run(engine.run())
|
||||
|
||||
print(f"Results: {results}")
|
||||
print(f"Trades: {engine.trades}")
|
||||
|
||||
# Price oscillates creating multiple dip/sell cycles
|
||||
# Should have 10 trades: 5 buys, 5 sells (via take profit)
|
||||
assert results.get("total_trades") == 10
|
||||
|
||||
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
|
||||
assert len(buy_trades) == 5
|
||||
assert len(sell_trades) == 5
|
||||
|
||||
# Position should be 0 after all sells
|
||||
assert engine.position == 0
|
||||
|
||||
# Should have profitable trades
|
||||
assert results.get("total_return") > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
386
src/backend/tests/test_simulate_engine.py
Normal file
386
src/backend/tests/test_simulate_engine.py
Normal file
@@ -0,0 +1,386 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'src/backend')
|
||||
|
||||
from app.services.simulate.engine import SimulateEngine
|
||||
|
||||
|
||||
class MockAveClient:
|
||||
"""Mock AVE client for testing."""
|
||||
|
||||
def __init__(self, klines_data=None):
|
||||
self.klines_data = klines_data or []
|
||||
|
||||
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
|
||||
return self.klines_data
|
||||
|
||||
|
||||
def create_engine(config_override=None, klines_data=None):
|
||||
"""Create a test engine with mock client."""
|
||||
config = {
|
||||
"bot_id": "test-bot",
|
||||
"token": "0x1234567890123456789012345678901234567890",
|
||||
"chain": "bsc",
|
||||
"kline_interval": "1m",
|
||||
"max_candles": 10, # Small number for fast tests
|
||||
"candle_delay": 0, # No delay in tests
|
||||
"auto_execute": False,
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 10
|
||||
}
|
||||
},
|
||||
"ave_api_key": "test",
|
||||
"ave_api_plan": "free",
|
||||
}
|
||||
if config_override:
|
||||
config.update(config_override)
|
||||
|
||||
engine = SimulateEngine(config)
|
||||
engine.ave_client = MockAveClient(klines_data)
|
||||
return engine
|
||||
|
||||
|
||||
class TestSimulateEngine:
|
||||
"""Unit tests for SimulateEngine."""
|
||||
|
||||
# ==================== Kline Fetching Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetches_klines_on_start(self):
|
||||
"""Engine should fetch klines when run is called."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
|
||||
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert engine.status == "completed"
|
||||
assert results["candles_processed"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_no_klines_data(self):
|
||||
"""Engine should handle empty klines gracefully."""
|
||||
engine = create_engine(klines_data=[])
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert engine.status == "failed"
|
||||
assert "error" in results
|
||||
assert "No kline data" in results["error"]
|
||||
|
||||
# ==================== Price Drop Condition Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_drop_condition_triggers_buy(self):
|
||||
"""Price drop >= threshold should trigger BUY signal."""
|
||||
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["total_signals"] >= 1
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
assert len(buy_signals) >= 1
|
||||
assert buy_signals[0]["price"] == 90.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_drop_below_threshold_no_signal(self):
|
||||
"""Price drop < threshold should NOT trigger signal."""
|
||||
# Price drops from 100 to 98 (2% drop) - below 5% threshold
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["total_signals"] == 0
|
||||
|
||||
# ==================== Risk Management Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_loss_triggers_after_buy(self):
|
||||
"""Stop loss should trigger SELL after price drops below threshold."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
|
||||
assert "stop_loss" in sell_signals[0]["reasoning"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_take_profit_triggers_after_buy(self):
|
||||
"""Take profit should trigger SELL after price rises above threshold."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
|
||||
assert "take_profit" in sell_signals[0]["reasoning"]
|
||||
|
||||
# ==================== Multiple Conditions Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_buy_if_already_in_position(self):
|
||||
"""Should not trigger another BUY if already holding position."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
|
||||
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
|
||||
# Should only have 1 buy, not multiple
|
||||
assert len(buy_signals) == 1, "Should only have one BUY signal"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_buy_again_after_sell(self):
|
||||
"""Should be able to BUY again after position is closed by risk management."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
# First trade
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
|
||||
# Second trade
|
||||
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
|
||||
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) == 2, "Should have two BUY signals"
|
||||
assert len(sell_signals) == 2, "Should have two SELL signals"
|
||||
|
||||
# ==================== Edge Cases ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_zero_price(self):
|
||||
"""Should skip processing for candles with zero price but still count them."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
|
||||
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
# All 3 candles counted, but only 2 valid for condition checking
|
||||
assert results["candles_processed"] == 3
|
||||
# Only 1 signal (the valid candle that dropped 10%)
|
||||
assert results["total_signals"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_candles_limit(self):
|
||||
"""Should respect max_candles limit."""
|
||||
klines = [
|
||||
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||
for i in range(1, 201) # 200 candles
|
||||
]
|
||||
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["candles_processed"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_interrupts_processing(self):
|
||||
"""Should stop processing when stop() is called."""
|
||||
klines = [
|
||||
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||
for i in range(1, 101)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
engine.run_id = "test"
|
||||
|
||||
# Stop after a few candles
|
||||
async def stop_after_delay():
|
||||
await asyncio.sleep(0.1)
|
||||
engine.stop()
|
||||
|
||||
await asyncio.gather(engine.run(), stop_after_delay())
|
||||
|
||||
assert engine.status == "stopped"
|
||||
# Should have processed some candles before stopping
|
||||
assert engine.last_processed_time is not None
|
||||
|
||||
# ==================== Price Movement Display Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_all_processed_prices(self):
|
||||
"""Should track last processed time for display purposes."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
|
||||
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
await engine.run()
|
||||
|
||||
# Should have tracked the last candle's time
|
||||
assert engine.last_processed_time == 3000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracks_price_changes(self):
|
||||
"""Should track price changes for potential chart display."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
await engine.run()
|
||||
|
||||
# Last close should be the last candle's close
|
||||
assert engine.last_close == 104.0
|
||||
|
||||
# ==================== Integration Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_simulation_workflow_generates_signals_and_trades(self):
|
||||
"""
|
||||
Full integration test: provides klines with clear price movements
|
||||
and verifies signals and trade_log are populated.
|
||||
|
||||
This test ensures the simulation is working by:
|
||||
1. Creating klines with obvious price movements (drops > 0.1%)
|
||||
2. Using a very low threshold (0.1%)
|
||||
3. Verifying signals are generated
|
||||
4. Verifying trade_log is populated
|
||||
5. Verifying we have buy/sell actions
|
||||
"""
|
||||
# Create klines with clear price drops and rises
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}, # Flat
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 99.9, "close": 99.95, "volume": 1000}, # 0.05% drop
|
||||
{"time": 3000, "open": 99.95, "high": 100, "low": 99.5, "close": 99.5, "volume": 1000}, # 0.45% drop
|
||||
{"time": 4000, "open": 99.5, "high": 100, "low": 99, "close": 99.2, "volume": 1000}, # 0.30% drop
|
||||
{"time": 5000, "open": 99.2, "high": 100, "low": 98, "close": 98.5, "volume": 1000}, # 0.71% drop
|
||||
{"time": 6000, "open": 98.5, "high": 99, "low": 98, "close": 98.8, "volume": 1000}, # 0.30% rise
|
||||
{"time": 7000, "open": 98.8, "high": 99, "low": 98, "close": 98.3, "volume": 1000}, # 0.51% drop
|
||||
{"time": 8000, "open": 98.3, "high": 99, "low": 97, "close": 97.5, "volume": 1000}, # 0.81% drop
|
||||
{"time": 9000, "open": 97.5, "high": 98, "low": 96, "close": 96.5, "volume": 1000}, # 1.03% drop
|
||||
]
|
||||
|
||||
# Use very low threshold to ensure signals are generated
|
||||
config_override = {
|
||||
"max_candles": 100,
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "threshold": 0.1, "token": "TEST", "token_address": "0x1234"}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine = create_engine(config_override=config_override, klines_data=klines)
|
||||
engine.running = True
|
||||
engine.run_id = "integration-test"
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
# Verify results
|
||||
print(f"\n=== Integration Test Results ===")
|
||||
print(f"Status: {engine.status}")
|
||||
print(f"Candles processed: {results.get('candles_processed')}")
|
||||
print(f"Signals count: {len(engine.signals)}")
|
||||
print(f"Trade log count: {len(engine.trade_log)}")
|
||||
|
||||
# ASSERTIONS - These should NEVER fail if simulation is working
|
||||
assert engine.status == "completed", "Simulation should complete successfully"
|
||||
assert results.get("candles_processed") == len(klines), f"Should process all {len(klines)} candles"
|
||||
|
||||
# Critical: signals should NOT be empty
|
||||
assert len(engine.signals) > 0, "SIGNALS SHOULD NOT BE EMPTY! Simulation is not generating signals."
|
||||
print(f"Signals: {[s['signal_type'] for s in engine.signals]}")
|
||||
|
||||
# Critical: trade_log should NOT be empty
|
||||
assert len(engine.trade_log) > 0, "TRADE_LOG SHOULD NOT BE EMPTY! No activity logged."
|
||||
print(f"Trade log: {[t['action'] for t in engine.trade_log]}")
|
||||
|
||||
# Should have at least one BUY signal
|
||||
buy_signals = [s for s in engine.signals if s['signal_type'] == 'buy']
|
||||
assert len(buy_signals) > 0, "Should have at least one BUY signal"
|
||||
print(f"Buy signals: {len(buy_signals)}")
|
||||
|
||||
# Verify trade_log has BUY action
|
||||
buy_trades = [t for t in engine.trade_log if t['action'] == 'buy']
|
||||
assert len(buy_trades) > 0, "Trade log should contain BUY actions"
|
||||
|
||||
# Verify results contain the data
|
||||
assert "signals" in results, "Results should contain signals"
|
||||
assert "trade_log" in results, "Results should contain trade_log"
|
||||
|
||||
print("\n=== Integration Test PASSED ===")
|
||||
print(f"Simulation working correctly!")
|
||||
print(f"Generated {len(engine.signals)} signals and {len(engine.trade_log)} trade log entries")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
21
src/frontend/package-lock.json
generated
21
src/frontend/package-lock.json
generated
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
@@ -101,6 +104,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
@@ -569,6 +578,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
AuthResponse,
|
||||
BotChatRequest,
|
||||
BotChatResponse,
|
||||
StrategyConfig
|
||||
StrategyConfig,
|
||||
Conversation,
|
||||
ConversationWithMessages,
|
||||
Message
|
||||
} from './types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||
@@ -18,10 +21,33 @@ function getAuthHeaders(): HeadersInit {
|
||||
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
|
||||
throw new Error(error.detail || `HTTP error ${response.status}`);
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (typeof error.detail === 'string') {
|
||||
errorMessage = error.detail;
|
||||
} else if (Array.isArray(error.detail)) {
|
||||
// Handle FastAPI validation error format: [{type, loc, msg, input}]
|
||||
errorMessage = error.detail.map((e: any) => e.msg || JSON.stringify(e)).join(', ');
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
} else {
|
||||
errorMessage = `HTTP error ${response.status}`;
|
||||
}
|
||||
|
||||
throw new ApiError(errorMessage, response.status);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -41,7 +67,7 @@ export const api = {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({ username: email, password })
|
||||
});
|
||||
return handleResponse<AuthResponse>(response);
|
||||
},
|
||||
@@ -104,11 +130,12 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
async chat(id: string, message: string): Promise<BotChatResponse> {
|
||||
async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> {
|
||||
const response = await fetch(`${API_URL}/bots/${id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ message } as BotChatRequest)
|
||||
body: JSON.stringify({ message } as BotChatRequest),
|
||||
signal
|
||||
});
|
||||
return handleResponse<BotChatResponse>(response);
|
||||
},
|
||||
@@ -126,7 +153,7 @@ export const api = {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
body: JSON.stringify({ ...config, chain: 'bsc' })
|
||||
});
|
||||
return handleResponse<Backtest>(response);
|
||||
},
|
||||
@@ -153,11 +180,29 @@ export const api = {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
async getTrades(botId: string, runId: string, page: number = 1, perPage: number = 5): Promise<{
|
||||
trades: any[];
|
||||
total_trades: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}> {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/trades?page=${page}&per_page=${perPage}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
},
|
||||
|
||||
simulate: {
|
||||
async start(botId: string, config: { token: string; interval_seconds: number; auto_execute: boolean }): Promise<Simulation> {
|
||||
async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
@@ -205,5 +250,58 @@ export const api = {
|
||||
});
|
||||
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
|
||||
}
|
||||
},
|
||||
|
||||
conversations: {
|
||||
async list(): Promise<Conversation[]> {
|
||||
const response = await fetch(`${API_URL}/conversations`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
return handleResponse<Conversation[]>(response);
|
||||
},
|
||||
|
||||
async create(): Promise<Conversation> {
|
||||
const response = await fetch(`${API_URL}/conversations`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
return handleResponse<Conversation>(response);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<ConversationWithMessages> {
|
||||
const response = await fetch(`${API_URL}/conversations/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
return handleResponse<ConversationWithMessages>(response);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
async chat(id: string, message: string, signal?: AbortSignal): Promise<ConversationWithMessages> {
|
||||
const response = await fetch(`${API_URL}/conversations/${id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ message }),
|
||||
signal
|
||||
});
|
||||
return handleResponse<ConversationWithMessages>(response);
|
||||
},
|
||||
|
||||
async setBot(id: string, botId: string): Promise<Conversation> {
|
||||
const response = await fetch(`${API_URL}/conversations/${id}/set-bot`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ bot_id: botId })
|
||||
});
|
||||
return handleResponse<Conversation>(response);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StrategyConfig {
|
||||
export interface Condition {
|
||||
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
|
||||
token: string;
|
||||
token_address?: string;
|
||||
chain?: string;
|
||||
threshold?: number;
|
||||
price?: number;
|
||||
@@ -37,6 +38,7 @@ export interface Action {
|
||||
type: 'buy' | 'sell' | 'hold';
|
||||
amount_percent?: number;
|
||||
token?: string;
|
||||
token_address?: string;
|
||||
}
|
||||
|
||||
export interface RiskManagement {
|
||||
@@ -62,13 +64,16 @@ export interface Backtest {
|
||||
bot_id: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
status: 'running' | 'completed' | 'failed' | 'stopped';
|
||||
config: BacktestConfig;
|
||||
result: BacktestResult | null;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface BacktestConfig {
|
||||
token: string;
|
||||
token_name?: string;
|
||||
chain: string;
|
||||
timeframe: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
@@ -84,19 +89,63 @@ export interface BacktestResult {
|
||||
sharpe_ratio: number;
|
||||
}
|
||||
|
||||
export interface PaginatedTrades {
|
||||
trades: Trade[];
|
||||
total_trades: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
type: 'buy' | 'sell';
|
||||
token: string;
|
||||
price: number;
|
||||
amount: number;
|
||||
quantity: number;
|
||||
timestamp: number;
|
||||
exit_reason?: 'stop_loss' | 'take_profit' | string;
|
||||
}
|
||||
|
||||
export interface Simulation {
|
||||
id: string;
|
||||
bot_id: string;
|
||||
started_at: string;
|
||||
status: 'running' | 'stopped';
|
||||
status: 'running' | 'stopped' | 'completed';
|
||||
config: SimulationConfig;
|
||||
signals: Signal[] | null;
|
||||
klines?: { time: number; close: number }[];
|
||||
trade_log?: TradeLogEntry[];
|
||||
portfolio?: Portfolio;
|
||||
current_candle_index?: number;
|
||||
total_candles?: number;
|
||||
candles_processed?: number;
|
||||
}
|
||||
|
||||
export interface SimulationConfig {
|
||||
token: string;
|
||||
interval_seconds: number;
|
||||
auto_execute: boolean;
|
||||
chain?: string;
|
||||
kline_interval?: string;
|
||||
}
|
||||
|
||||
export interface TradeLogEntry {
|
||||
time: number;
|
||||
price: number;
|
||||
action: 'buy' | 'sell' | 'hold';
|
||||
reason: string;
|
||||
position: number;
|
||||
entry_price: number | null;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
initial_balance: number;
|
||||
current_balance: number;
|
||||
position: number;
|
||||
position_token: string;
|
||||
entry_price: number;
|
||||
current_price: number;
|
||||
}
|
||||
|
||||
export interface Signal {
|
||||
@@ -123,6 +172,38 @@ export interface BotChatRequest {
|
||||
|
||||
export interface BotChatResponse {
|
||||
response: string;
|
||||
thinking: string | null;
|
||||
strategy_config: StrategyConfig | null;
|
||||
success: boolean;
|
||||
strategy_needs_confirmation?: boolean;
|
||||
strategy_data?: StrategyConfig | null;
|
||||
token_search_results?: TokenSearchResult[] | null;
|
||||
}
|
||||
|
||||
export interface TokenSearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
address: string;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
bot_id: string | null;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConversationWithMessages extends Conversation {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
68
src/frontend/src/lib/components/AnonymousBanner.svelte
Normal file
68
src/frontend/src/lib/components/AnonymousBanner.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
chatCount?: number;
|
||||
}
|
||||
|
||||
let { chatCount = 0 }: Props = $props();
|
||||
|
||||
const showWarning = chatCount >= 40;
|
||||
const limit = 50;
|
||||
</script>
|
||||
|
||||
<div class="anonymous-banner" class:warning={showWarning}>
|
||||
<div class="banner-content">
|
||||
{#if showWarning}
|
||||
<span class="icon">⚠️</span>
|
||||
<span>
|
||||
Warning: You've used {chatCount}/{limit} messages.
|
||||
<a href="/login" class="link">Login to continue</a>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="icon">💬</span>
|
||||
<span>
|
||||
Your progress is not saved.
|
||||
<a href="/login" class="link">Login to save</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.anonymous-banner {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-bottom: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.anonymous-banner.warning {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
border-bottom-color: rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.warning .banner-content {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
115
src/frontend/src/lib/components/AppHeader.svelte
Normal file
115
src/frontend/src/lib/components/AppHeader.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { isAuthenticated, logout, userStore } from '$lib/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<a href="/home" class="logo">
|
||||
<span class="logo-text">Randebu</span>
|
||||
</a>
|
||||
{#if $isAuthenticated}
|
||||
<a href="/dashboard" class="nav-link">Dashboard</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
{#if $isAuthenticated}
|
||||
<span class="user-info">{$userStore?.username || 'User'}</span>
|
||||
<button class="btn btn-ghost" onclick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/login" class="btn btn-ghost">Login</a>
|
||||
<a href="/register" class="btn btn-primary">Register</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 56px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -3,24 +3,21 @@
|
||||
|
||||
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);
|
||||
}
|
||||
let { bot, onDelete, showActions = true }: Props = $props();
|
||||
|
||||
function handleDelete(e: Event) {
|
||||
e.preventDefault();
|
||||
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-card">
|
||||
<a href="/chat/{bot.id}" class="bot-card-link" data-sveltekit-preload-data="hover" aria-label="Open {bot.name}"></a>
|
||||
<div class="bot-info">
|
||||
<h3>{bot.name}</h3>
|
||||
{#if bot.description}
|
||||
@@ -29,8 +26,8 @@
|
||||
<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>
|
||||
<div class="bot-actions" role="group">
|
||||
<a href="/chat/{bot.id}" class="btn btn-primary" data-sveltekit-preload-data="hover">Open</a>
|
||||
<button class="btn btn-danger" onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -38,11 +35,11 @@
|
||||
|
||||
<style>
|
||||
.bot-card {
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -51,13 +48,17 @@
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.bot-card:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
.bot-card-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bot-info h3 {
|
||||
@@ -98,6 +99,8 @@
|
||||
.bot-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -108,6 +111,7 @@
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
||||
434
src/frontend/src/lib/components/BotInfoPanel.svelte
Normal file
434
src/frontend/src/lib/components/BotInfoPanel.svelte
Normal file
@@ -0,0 +1,434 @@
|
||||
<script lang="ts">
|
||||
import type { Bot, Condition, Action, RiskManagement } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
bot?: Bot | null;
|
||||
onSelectBot?: () => void;
|
||||
onBacktest?: () => void;
|
||||
onSimulate?: () => void;
|
||||
}
|
||||
|
||||
let { bot = null, onSelectBot, onBacktest, onSimulate }: Props = $props();
|
||||
|
||||
// Helper to get human-readable condition name
|
||||
function getConditionLabel(condition: Condition): string {
|
||||
const labels: Record<string, string> = {
|
||||
'price_drop': 'Price Drop',
|
||||
'price_rise': 'Price Rise',
|
||||
'volume_spike': 'Volume Spike',
|
||||
'price_level': 'Price Level'
|
||||
};
|
||||
return labels[condition.type] || condition.type;
|
||||
}
|
||||
|
||||
// Format condition to readable string
|
||||
function formatCondition(condition: Condition): string {
|
||||
const parts: string[] = [getConditionLabel(condition)];
|
||||
|
||||
if (condition.token) {
|
||||
parts.push(condition.token.toUpperCase());
|
||||
}
|
||||
|
||||
if (condition.direction) {
|
||||
parts.push(condition.direction);
|
||||
}
|
||||
|
||||
if (condition.threshold) {
|
||||
parts.push(`>${condition.threshold}%`);
|
||||
}
|
||||
|
||||
if (condition.price) {
|
||||
parts.push(`$${condition.price}`);
|
||||
}
|
||||
|
||||
if (condition.timeframe) {
|
||||
parts.push(`(${condition.timeframe})`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Format action to readable string
|
||||
function formatAction(action: Action): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (action.type === 'buy') {
|
||||
parts.push('Buy');
|
||||
if (action.amount_percent) {
|
||||
parts.push(`${action.amount_percent}%`);
|
||||
}
|
||||
if (action.token) {
|
||||
parts.push(`of ${action.token.toUpperCase()}`);
|
||||
}
|
||||
} else if (action.type === 'sell') {
|
||||
parts.push('Sell');
|
||||
if (action.amount_percent) {
|
||||
parts.push(`${action.amount_percent}%`);
|
||||
}
|
||||
} else {
|
||||
parts.push('Hold');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
let hasStrategy = $derived(
|
||||
bot?.strategy_config &&
|
||||
((bot.strategy_config.conditions && bot.strategy_config.conditions.length > 0) ||
|
||||
(bot.strategy_config.actions && bot.strategy_config.actions.length > 0) ||
|
||||
bot.strategy_config.risk_management)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="bot-info-panel">
|
||||
<h3>Bot Details</h3>
|
||||
|
||||
{#if !bot}
|
||||
<div class="no-bot">
|
||||
<p>No bot selected</p>
|
||||
{#if onSelectBot}
|
||||
<button class="btn btn-secondary" onclick={onSelectBot}>
|
||||
Select Bot
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bot-details">
|
||||
<div class="bot-name">{bot.name}</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value" class:active={bot.status === 'active'}>
|
||||
{bot.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="strategy-section">
|
||||
<h4>Strategy</h4>
|
||||
|
||||
{#if hasStrategy}
|
||||
{#if bot.strategy_config?.conditions && bot.strategy_config.conditions.length > 0}
|
||||
<div class="strategy-group">
|
||||
<div class="strategy-group-label">When:</div>
|
||||
{#each bot.strategy_config.conditions as condition}
|
||||
<div class="condition-item">
|
||||
<span class="condition-icon">📊</span>
|
||||
{formatCondition(condition)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if bot.strategy_config?.actions && bot.strategy_config.actions.length > 0}
|
||||
<div class="strategy-group">
|
||||
<div class="strategy-group-label">Then:</div>
|
||||
{#each bot.strategy_config.actions as action}
|
||||
<div class="action-item">
|
||||
<span class="action-icon">{action.type === 'buy' ? '🟢' : action.type === 'sell' ? '🔴' : '⏸️'}</span>
|
||||
{formatAction(action)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if bot.strategy_config?.risk_management}
|
||||
<div class="strategy-group">
|
||||
<div class="strategy-group-label">Risk:</div>
|
||||
<div class="risk-item">
|
||||
{#if bot.strategy_config.risk_management.stop_loss_percent}
|
||||
<span class="risk-tag loss">Stop Loss: {bot.strategy_config.risk_management.stop_loss_percent}%</span>
|
||||
{/if}
|
||||
{#if bot.strategy_config.risk_management.take_profit_percent}
|
||||
<span class="risk-tag profit">Take Profit: {bot.strategy_config.risk_management.take_profit_percent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="no-strategy">
|
||||
<div class="no-strategy-icon">⚙️</div>
|
||||
<p>No strategy configured</p>
|
||||
<span class="no-strategy-hint">Chat with the bot to set up your trading strategy</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onSelectBot}
|
||||
<button class="btn btn-outline" onclick={onSelectBot}>
|
||||
Change Bot
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="action-buttons">
|
||||
<h4>Tools</h4>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-tool btn-backtest"
|
||||
onclick={onBacktest}
|
||||
disabled={!bot || !hasStrategy}
|
||||
>
|
||||
📊 Backtest
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-tool btn-simulate"
|
||||
onclick={onSimulate}
|
||||
disabled={!bot || !hasStrategy}
|
||||
>
|
||||
🎮 Simulate
|
||||
</button>
|
||||
</div>
|
||||
{#if !hasStrategy}
|
||||
<p class="tool-hint">Configure a strategy first to use these tools</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bot-info-panel {
|
||||
padding: 1.25rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #888;
|
||||
margin: 0 0 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #666;
|
||||
margin: 1rem 0 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-bot {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.no-bot p {
|
||||
color: #666;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.bot-details {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.value.active {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.strategy-section {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.strategy-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.strategy-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.strategy-group-label {
|
||||
font-size: 0.75rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.condition-item,
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.condition-icon,
|
||||
.action-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.risk-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.risk-tag.loss {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.risk-tag.profit {
|
||||
background: rgba(63, 185, 80, 0.2);
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.no-strategy {
|
||||
text-align: center;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.no-strategy-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-strategy p {
|
||||
color: #888;
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.no-strategy-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.action-buttons h4 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.btn-tool:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-backtest {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: #667eea;
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-backtest:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.btn-simulate {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: #3fb950;
|
||||
border: 1px solid rgba(63, 185, 80, 0.3);
|
||||
}
|
||||
|
||||
.btn-simulate:hover:not(:disabled) {
|
||||
background: rgba(63, 185, 80, 0.25);
|
||||
}
|
||||
|
||||
.tool-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
margin: 0.5rem 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
</style>
|
||||
47
src/frontend/src/lib/components/CandlestickLoader.svelte
Normal file
47
src/frontend/src/lib/components/CandlestickLoader.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
let { class: className = '' } = $props();
|
||||
|
||||
const bars = [0, 1, 2, 3];
|
||||
const heights = ['12px', '18px', '14px', '20px'];
|
||||
const delays = ['0s', '0.15s', '0.3s', '0.45s'];
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 h-8 {className}">
|
||||
{#each bars as i}
|
||||
<div
|
||||
class="w-2 bg-green-500 rounded-sm animate-pulse"
|
||||
style="height: {heights[i]}; animation-delay: {delays[i]};"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
.w-2 {
|
||||
width: 0.5rem;
|
||||
}
|
||||
.bg-green-500 {
|
||||
background-color: rgb(34 197 94);
|
||||
}
|
||||
.rounded-sm {
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
.animate-pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scaleY(1); }
|
||||
50% { opacity: 1; transform: scaleY(1.2); }
|
||||
}
|
||||
</style>
|
||||
188
src/frontend/src/lib/components/ChatArea.svelte
Normal file
188
src/frontend/src/lib/components/ChatArea.svelte
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import type { Message } from '$lib/api';
|
||||
import { parseMarkdown, type ParsedSegment } from '$lib/utils/markdown';
|
||||
import CandlestickLoader from './CandlestickLoader.svelte';
|
||||
|
||||
interface Props {
|
||||
conversationId?: string;
|
||||
messages?: Message[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { conversationId, messages = [], isLoading = false }: Props = $props();
|
||||
|
||||
let chatContainer: HTMLDivElement;
|
||||
|
||||
function renderContent(content: string): ParsedSegment[] {
|
||||
return parseMarkdown(content);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.length && chatContainer) {
|
||||
setTimeout(() => {
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chat-area" bind:this={chatContainer}>
|
||||
{#if !conversationId}
|
||||
<div class="empty-state">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
{:else if messages.length === 0 && !isLoading}
|
||||
<div class="empty-state">
|
||||
Send a message to start the conversation
|
||||
</div>
|
||||
{:else}
|
||||
<div class="messages">
|
||||
{#each messages as msg (msg.id)}
|
||||
<div class="message" class:user={msg.role === 'user'} class:assistant={msg.role === 'assistant'}>
|
||||
<div class="message-content">
|
||||
{#each renderContent(msg.content) as segment}
|
||||
{#if segment.type === 'bold'}
|
||||
<strong>{segment.content}</strong>
|
||||
{:else if segment.type === 'italic'}
|
||||
<em>{segment.content}</em>
|
||||
{:else if segment.type === 'code'}
|
||||
<code class="inline-code">{segment.content}</code>
|
||||
{:else if segment.type === 'codeBlock'}
|
||||
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||
{:else if segment.type === 'link'}
|
||||
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
|
||||
{:else if segment.type === 'list' && segment.items}
|
||||
<ul>
|
||||
{#each segment.items as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if segment.type === 'lineBreak'}
|
||||
<br />
|
||||
{:else}
|
||||
{segment.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="message assistant">
|
||||
<div class="message-content">
|
||||
<CandlestickLoader />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 12px;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.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.08);
|
||||
color: #e0e0e0;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
pre.code-block code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
113
src/frontend/src/lib/components/ChatInput.svelte
Normal file
113
src/frontend/src/lib/components/ChatInput.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { onSend, disabled = false, placeholder = "Type a message..." }: Props = $props();
|
||||
|
||||
let messageInput = $state('');
|
||||
let textarea: HTMLTextAreaElement;
|
||||
|
||||
function handleSend() {
|
||||
if (!messageInput.trim() || disabled) return;
|
||||
onSend(messageInput);
|
||||
messageInput = '';
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={messageInput}
|
||||
onkeydown={handleKeydown}
|
||||
oninput={handleInput}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button onclick={handleSend} {disabled}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
padding: 0.875rem 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;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
min-height: 48px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
import type { ChatMessage } from '$lib/stores/chatStore';
|
||||
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
|
||||
|
||||
interface ToolGroup {
|
||||
category: string;
|
||||
label: string;
|
||||
requiresBot: boolean;
|
||||
tools: ToolItem[];
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const TOOLS: ToolGroup[] = [
|
||||
{
|
||||
category: 'randebu',
|
||||
label: '🤖 Randebu Built-in',
|
||||
requiresBot: true,
|
||||
tools: [
|
||||
{ name: 'backtest', description: 'Run strategy backtest', command: '/backtest' },
|
||||
{ name: 'simulate', description: 'Start/stop simulation', command: '/simulate' },
|
||||
{ name: 'strategy', description: 'View/update strategy', command: '/strategy' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'ave',
|
||||
label: '☁️ AVE Cloud Skills',
|
||||
requiresBot: false,
|
||||
tools: [
|
||||
{ name: 'search', description: 'Token search', command: '/search' },
|
||||
{ name: 'trending', description: 'Popular tokens', command: '/trending' },
|
||||
{ name: 'risk', description: 'Honeypot detection', command: '/risk' },
|
||||
{ name: 'token', description: 'Token details', command: '/token' },
|
||||
{ name: 'price', description: 'Batch prices', command: '/price' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
interface Props {
|
||||
bot: Bot | null;
|
||||
messages: ChatMessage[];
|
||||
isSending?: boolean;
|
||||
isBlocked?: boolean;
|
||||
blockedReason?: string | null;
|
||||
onSendMessage: (message: string) => void;
|
||||
onSelectBot?: (botId: string) => void;
|
||||
onLogin?: () => void;
|
||||
availableBots?: Bot[];
|
||||
showBotSelector?: boolean;
|
||||
}
|
||||
@@ -16,17 +58,45 @@
|
||||
bot,
|
||||
messages,
|
||||
isSending = false,
|
||||
isBlocked = false,
|
||||
blockedReason = null,
|
||||
onSendMessage,
|
||||
onSelectBot,
|
||||
onLogin,
|
||||
availableBots = [],
|
||||
showBotSelector = false
|
||||
}: Props = $props();
|
||||
|
||||
let messageInput = $state('');
|
||||
let chatContainer: HTMLDivElement;
|
||||
let expandedThinking: Record<string, boolean> = $state({});
|
||||
let showSlashMenu = $state(false);
|
||||
let slashMenuPosition = $state({ top: 0, left: 0 });
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
// Use $derived for filteredTools
|
||||
// Filter tools based on whether user has a bot
|
||||
let availableTools = $derived(
|
||||
TOOLS.flatMap(t => !t.requiresBot || bot ? t.tools : [])
|
||||
);
|
||||
|
||||
let filteredTools = $derived(
|
||||
messageInput.startsWith('/')
|
||||
? availableTools.filter(tool =>
|
||||
tool.name.toLowerCase().startsWith(messageInput.slice(1).toLowerCase()) ||
|
||||
tool.command.toLowerCase().startsWith(messageInput.slice(1).toLowerCase())
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
// Get visible tool groups for the menu
|
||||
let visibleGroups = $derived(
|
||||
TOOLS.filter(group => !group.requiresBot || bot)
|
||||
);
|
||||
|
||||
function handleSend() {
|
||||
if (!messageInput.trim()) return;
|
||||
showSlashMenu = false;
|
||||
onSendMessage(messageInput);
|
||||
messageInput = '';
|
||||
}
|
||||
@@ -34,7 +104,54 @@
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
if (showSlashMenu && filteredTools.length > 0) {
|
||||
selectTool(filteredTools[selectedIndex]);
|
||||
} else {
|
||||
handleSend();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' && showSlashMenu) {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredTools.length - 1);
|
||||
} else if (e.key === 'ArrowUp' && showSlashMenu) {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Escape' && showSlashMenu) {
|
||||
showSlashMenu = false;
|
||||
} else if (e.key === 'Tab' && showSlashMenu && filteredTools.length > 0) {
|
||||
e.preventDefault();
|
||||
selectTool(filteredTools[selectedIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const value = target.value;
|
||||
messageInput = value;
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
selectedIndex = 0;
|
||||
showSlashMenu = filteredTools.length > 0;
|
||||
|
||||
if (showSlashMenu) {
|
||||
// Position menu above the textarea
|
||||
const rect = target.getBoundingClientRect();
|
||||
const menuHeight = 300;
|
||||
slashMenuPosition = {
|
||||
top: Math.max(10, rect.top - menuHeight),
|
||||
left: rect.left
|
||||
};
|
||||
}
|
||||
} else {
|
||||
showSlashMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTool(tool: ToolItem) {
|
||||
messageInput = tool.command + ' ';
|
||||
showSlashMenu = false;
|
||||
const textarea = document.querySelector('.input-container textarea') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +162,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThinkingExpand(messageId: string) {
|
||||
expandedThinking[messageId] = !expandedThinking[messageId];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.length && chatContainer) {
|
||||
setTimeout(() => {
|
||||
@@ -52,8 +173,33 @@
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
function renderContent(content: string) {
|
||||
return parseMarkdown(content);
|
||||
}
|
||||
|
||||
function renderInline(segments: InlineSegment[]): string {
|
||||
return segments.map(seg => {
|
||||
switch (seg.type) {
|
||||
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||
case 'italic': return `<em>${seg.content}</em>`;
|
||||
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||
default: return seg.content;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.slash-menu') && !target.closest('.input-container textarea')) {
|
||||
showSlashMenu = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<div class="chat-interface">
|
||||
{#if showBotSelector && availableBots.length > 0}
|
||||
<div class="bot-selector">
|
||||
@@ -78,8 +224,99 @@
|
||||
|
||||
{#each messages as message}
|
||||
<div class="message {message.role}">
|
||||
{#if message.role === 'assistant' && message.thinking}
|
||||
{@const firstLine = message.thinking.split('\n')[0]}
|
||||
{@const isExpanded = expandedThinking[message.id] ?? false}
|
||||
<div class="thinking-section">
|
||||
<button class="thinking-toggle" onclick={() => toggleThinkingExpand(message.id)}>
|
||||
<span class="thinking-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||
<span class="thinking-label">{isExpanded ? 'Hide reasoning' : 'Show reasoning'}</span>
|
||||
{#if !isExpanded}
|
||||
<span class="thinking-preview"> — {firstLine.slice(0, 60)}{firstLine.length > 60 ? '...' : ''}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{message.thinking}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="message-content">
|
||||
{message.content}
|
||||
{#each renderContent(message.content) as segment}
|
||||
{#if segment.type === 'bold'}
|
||||
<strong>{segment.content}</strong>
|
||||
{:else if segment.type === 'italic'}
|
||||
<em>{segment.content}</em>
|
||||
{:else if segment.type === 'code'}
|
||||
<code class="inline-code">{segment.content}</code>
|
||||
{:else if segment.type === 'codeBlock'}
|
||||
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||
{:else if segment.type === 'link'}
|
||||
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
|
||||
{:else if segment.type === 'list' && segment.items}
|
||||
<ul>
|
||||
{#each segment.items as item}
|
||||
<li>{@html renderInline(parseInlineElements(item))}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if segment.type === 'table' && segment.headers && segment.rows}
|
||||
<div class="table-wrapper">
|
||||
<table class="markdown-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each segment.headers as header}
|
||||
<th>
|
||||
{#each header as cellSeg}
|
||||
{#if cellSeg.type === 'bold'}
|
||||
<strong>{cellSeg.content}</strong>
|
||||
{:else if cellSeg.type === 'italic'}
|
||||
<em>{cellSeg.content}</em>
|
||||
{:else if cellSeg.type === 'code'}
|
||||
<code class="inline-code">{cellSeg.content}</code>
|
||||
{:else if cellSeg.type === 'link'}
|
||||
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||
{:else}
|
||||
{cellSeg.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each segment.rows as row}
|
||||
<tr>
|
||||
{#each row as cell}
|
||||
<td>
|
||||
{#each cell as cellSeg}
|
||||
{#if cellSeg.type === 'bold'}
|
||||
<strong>{cellSeg.content}</strong>
|
||||
{:else if cellSeg.type === 'italic'}
|
||||
<em>{cellSeg.content}</em>
|
||||
{:else if cellSeg.type === 'code'}
|
||||
<code class="inline-code">{cellSeg.content}</code>
|
||||
{:else if cellSeg.type === 'link'}
|
||||
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||
{:else}
|
||||
{cellSeg.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if segment.type === 'heading'}
|
||||
<h4 class="content-heading">{segment.content}</h4>
|
||||
{:else if segment.type === 'lineBreak'}
|
||||
<br />
|
||||
{:else}
|
||||
{segment.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
@@ -89,31 +326,85 @@
|
||||
|
||||
{#if isSending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<div class="message-content">
|
||||
<div class="typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if bot}
|
||||
<div class="input-container">
|
||||
<div class="input-container">
|
||||
{#if isBlocked}
|
||||
<div class="blocked-message">
|
||||
<div class="blocked-icon">🚫</div>
|
||||
<div class="blocked-text">
|
||||
{#if blockedReason === 'message_limit'}
|
||||
<p><strong>Message Limit Reached</strong></p>
|
||||
<p>You've used all 50 messages as an anonymous user.</p>
|
||||
<p>Login to continue chatting with unlimited messages.</p>
|
||||
{:else if blockedReason === 'bot_limit'}
|
||||
<p><strong>Bot Limit Reached</strong></p>
|
||||
<p>You've created the maximum of 1 bot as an anonymous user.</p>
|
||||
<p>Login to create more bots.</p>
|
||||
{:else if blockedReason === 'backtest_limit'}
|
||||
<p><strong>Backtest Limit Reached</strong></p>
|
||||
<p>You've run the maximum of 1 backtest as an anonymous user.</p>
|
||||
<p>Login to run more backtests.</p>
|
||||
{:else}
|
||||
<p><strong>Action Blocked</strong></p>
|
||||
<p>Please login to continue.</p>
|
||||
{/if}
|
||||
<button class="login-button" onclick={() => onLogin?.()}>
|
||||
Login to Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if showSlashMenu && filteredTools.length > 0}
|
||||
<div class="slash-menu" style="top: {slashMenuPosition.top}px; left: {slashMenuPosition.left}px;">
|
||||
<div class="slash-menu-header">Available Commands</div>
|
||||
{#each visibleGroups as group}
|
||||
{#if group.tools.some(t => filteredTools.includes(t))}
|
||||
<div class="slash-menu-category">{group.label}</div>
|
||||
{#each group.tools.filter(t => filteredTools.includes(t)) as tool, i}
|
||||
<button
|
||||
class="slash-menu-item"
|
||||
class:selected={filteredTools.indexOf(tool) === selectedIndex}
|
||||
onclick={() => selectTool(tool)}
|
||||
>
|
||||
<span class="slash-command">{tool.command}</span>
|
||||
<span class="slash-description">{tool.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="slash-menu-hint">
|
||||
{#if !bot}
|
||||
Login to access bot commands
|
||||
{:else}
|
||||
Press Tab to select, Enter to send
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<textarea
|
||||
bind:value={messageInput}
|
||||
value={messageInput}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Describe your trading strategy..."
|
||||
placeholder="Describe your trading strategy... (or type / for commands)"
|
||||
rows="1"
|
||||
disabled={isSending}
|
||||
></textarea>
|
||||
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-interface {
|
||||
display: flex;
|
||||
@@ -206,6 +497,64 @@
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.thinking-section {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 0.6rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.thinking-preview {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message.system .message-content {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
@@ -213,6 +562,92 @@
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.content-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.markdown-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-table th,
|
||||
.markdown-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.markdown-table th {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.markdown-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
@@ -223,7 +658,7 @@
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -253,6 +688,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
.blocked-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blocked-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.blocked-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blocked-text p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.blocked-text p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.blocked-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -297,4 +782,76 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: fixed;
|
||||
background: rgba(20, 20, 20, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.slash-menu-header {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.slash-menu-category {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.slash-command {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slash-description {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.slash-menu-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
82
src/frontend/src/lib/components/ChatLayout.svelte
Normal file
82
src/frontend/src/lib/components/ChatLayout.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
leftPane?: Component;
|
||||
rightPane?: Component;
|
||||
rightPaneProps?: Record<string, any>;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { leftPane: LeftPane, rightPane: RightPane, rightPaneProps = {}, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="chat-layout">
|
||||
{#if LeftPane}
|
||||
<aside class="sidebar-left">
|
||||
<LeftPane />
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="main-content">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if RightPane}
|
||||
<aside class="sidebar-right">
|
||||
<RightPane {...rightPaneProps} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-left {
|
||||
width: 280px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
width: 300px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-left {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
src/frontend/src/lib/components/ConversationList.svelte
Normal file
178
src/frontend/src/lib/components/ConversationList.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { conversationStore, setConversations, addConversation } from '$lib/stores';
|
||||
|
||||
let conversations = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const unsub = conversationStore.subscribe(state => {
|
||||
conversations = state.conversations;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadConversations();
|
||||
});
|
||||
|
||||
async function loadConversations() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await api.conversations.list();
|
||||
setConversations(data);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load conversations';
|
||||
setConversations([]);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createConversation() {
|
||||
try {
|
||||
const newConv = await api.conversations.create();
|
||||
addConversation(newConv);
|
||||
goto(`/chat/${newConv.id}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to create conversation:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function isActiveConversation(convId: string): boolean {
|
||||
return $page.params.conversationId === convId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="conversation-list">
|
||||
<div class="header">
|
||||
<button class="new-chat-btn" onclick={createConversation}>
|
||||
+ New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if conversations.length === 0}
|
||||
<div class="empty">No conversations yet</div>
|
||||
{:else}
|
||||
{#each conversations as conv (conv.id)}
|
||||
<button
|
||||
class="conversation-item"
|
||||
class:active={isActiveConversation(conv.id)}
|
||||
onclick={() => goto(`/chat/${conv.id}`)}
|
||||
>
|
||||
<div class="conv-title">{conv.title || 'New Chat'}</div>
|
||||
<div class="conv-date">{formatDate(conv.updated_at)}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conversation-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-date {
|
||||
color: #666;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
initialBalance?: number;
|
||||
currentBalance?: number;
|
||||
position?: number;
|
||||
positionToken?: string;
|
||||
entryPrice?: number;
|
||||
currentPrice?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
initialBalance = 10000,
|
||||
currentBalance = 10000,
|
||||
position = 0,
|
||||
positionToken = '',
|
||||
entryPrice = 0,
|
||||
currentPrice = 0
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate metrics
|
||||
let positionValue = $derived(position * currentPrice);
|
||||
let totalValue = $derived(currentBalance + positionValue);
|
||||
let pnl = $derived(totalValue - initialBalance);
|
||||
let pnlPercent = $derived((pnl / initialBalance) * 100);
|
||||
let unrealizedPnL = $derived(position > 0 && entryPrice > 0 ? (currentPrice - entryPrice) / entryPrice * 100 : 0);
|
||||
</script>
|
||||
|
||||
<div class="portfolio-summary">
|
||||
<div class="metric">
|
||||
<span class="label">Cash Balance</span>
|
||||
<span class="value">${currentBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{#if position > 0}
|
||||
<div class="metric">
|
||||
<span class="label">Position ({positionToken || 'Token'})</span>
|
||||
<span class="value highlight">{position.toFixed(6)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Position Value</span>
|
||||
<span class="value">${positionValue.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Entry Price</span>
|
||||
<span class="value">${entryPrice.toFixed(8)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Current Price</span>
|
||||
<span class="value">${currentPrice.toFixed(8)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Unrealized P&L</span>
|
||||
<span class="value" class:positive={unrealizedPnL > 0} class:negative={unrealizedPnL < 0}>
|
||||
{unrealizedPnL >= 0 ? '+' : ''}{unrealizedPnL.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="metric total">
|
||||
<span class="label">Total Value</span>
|
||||
<span class="value">${totalValue.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">P&L</span>
|
||||
<span class="value large" class:positive={pnl > 0} class:negative={pnl < 0}>
|
||||
{pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.portfolio-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.metric .value.highlight {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.metric .value.large {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.metric.total {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.metric.total .value {
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,155 +1,241 @@
|
||||
<script lang="ts">
|
||||
import type { Signal } from '$lib/api';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
signals: Signal[];
|
||||
signals?: Signal[];
|
||||
klines?: { time: number; close: number }[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { signals, height = 200 }: Props = $props();
|
||||
let { signals = [], klines = [], height = 200 }: Props = $props();
|
||||
|
||||
let width = $state(800);
|
||||
let containerEl: HTMLDivElement;
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
// Set initial width
|
||||
if (containerEl) {
|
||||
width = containerEl.clientWidth;
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
width = entry.contentRect.width;
|
||||
drawChart();
|
||||
}
|
||||
});
|
||||
|
||||
if (containerEl) {
|
||||
resizeObserver.observe(containerEl);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
// Draw when data changes
|
||||
$effect(() => {
|
||||
// Access reactive values to trigger effect
|
||||
const currentSignals = signals;
|
||||
const currentKlines = klines;
|
||||
const currentWidth = width;
|
||||
|
||||
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 };
|
||||
}
|
||||
// Wait for DOM to be ready
|
||||
tick().then(() => {
|
||||
drawChart();
|
||||
});
|
||||
});
|
||||
|
||||
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 drawChart() {
|
||||
if (!canvasEl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
];
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
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());
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvasEl.width = width * dpr;
|
||||
canvasEl.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Check if we have data
|
||||
if (klines.length === 0 && signals.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get price data
|
||||
let priceData: { time: number; price: number }[] = [];
|
||||
|
||||
if (klines.length > 0) {
|
||||
priceData = klines.map(k => ({
|
||||
time: k.time,
|
||||
price: typeof k.close === 'string' ? parseFloat(k.close) : k.close
|
||||
})).filter(d => !isNaN(d.price) && d.price > 0);
|
||||
} else if (signals.length > 0) {
|
||||
priceData = signals.map(s => ({ time: 0, price: s.price }));
|
||||
}
|
||||
|
||||
if (priceData.length === 0) return;
|
||||
|
||||
const prices = priceData.map(d => d.price);
|
||||
const padding = { top: 20, right: 20, bottom: 45, left: 60 }; // More bottom padding for time labels
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// Price range with padding
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const priceRange = maxPrice - minPrice || 1;
|
||||
const paddedMin = minPrice - priceRange * 0.1;
|
||||
const paddedMax = maxPrice + priceRange * 0.1;
|
||||
|
||||
function priceToY(price: number): number {
|
||||
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
|
||||
}
|
||||
|
||||
function indexToX(index: number): number {
|
||||
return padding.left + (index / Math.max(prices.length - 1, 1)) * chartWidth;
|
||||
}
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding.top + (i / 4) * chartHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw Y axis labels
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
|
||||
const y = padding.top + (i / 4) * chartHeight + 4;
|
||||
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
|
||||
}
|
||||
|
||||
// Draw price line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#667eea';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.moveTo(indexToX(0), priceToY(prices[0]));
|
||||
for (let i = 1; i < prices.length; i++) {
|
||||
ctx.lineTo(indexToX(i), priceToY(prices[i]));
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Fill area under line
|
||||
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
|
||||
ctx.lineTo(indexToX(0), padding.top + chartHeight);
|
||||
ctx.closePath();
|
||||
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
|
||||
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// Draw signal markers
|
||||
if (signals.length > 0) {
|
||||
signals.forEach((signal) => {
|
||||
// Find closest price match
|
||||
const signalPrice = signal.price;
|
||||
let closestIndex = 0;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < priceData.length; i++) {
|
||||
const diff = Math.abs(priceData[i].price - signalPrice);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const x = indexToX(closestIndex);
|
||||
const y = priceToY(signalPrice);
|
||||
const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
|
||||
|
||||
// Vertical dashed line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Signal dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Draw X axis time labels
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const numTimeLabels = Math.min(5, priceData.length);
|
||||
for (let i = 0; i < numTimeLabels; i++) {
|
||||
const dataIndex = Math.floor(i * (priceData.length - 1) / (numTimeLabels - 1 || 1));
|
||||
const x = indexToX(dataIndex);
|
||||
|
||||
// Convert timestamp to readable time
|
||||
let timeLabel = '';
|
||||
if (priceData[dataIndex].time > 0) {
|
||||
const date = new Date(priceData[dataIndex].time * 1000);
|
||||
timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
timeLabel = `${dataIndex + 1}`;
|
||||
}
|
||||
|
||||
ctx.fillText(timeLabel, x, height - 5);
|
||||
}
|
||||
|
||||
// Legend
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
if (signals.length > 0) {
|
||||
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
|
||||
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
|
||||
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${priceData.length} Candles`, width / 2, height - 20);
|
||||
} else {
|
||||
ctx.fillText(`${priceData.length} Candles (No signals generated)`, width / 2, height - 20);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="signal-chart" bind:this={containerEl}>
|
||||
{#if signals.length === 0}
|
||||
{#if klines.length === 0 && signals.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No signals to display</p>
|
||||
<p>No data to display. Start a simulation to see price movements.</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>
|
||||
<canvas
|
||||
bind:this={canvasEl}
|
||||
style="width: 100%; height: {height}px;"
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -169,60 +255,12 @@
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
canvas {
|
||||
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>
|
||||
@@ -10,13 +10,14 @@
|
||||
let { config, editable = false, onUpdate }: Props = $props();
|
||||
|
||||
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
|
||||
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
|
||||
switch (condition.type) {
|
||||
case 'price_drop':
|
||||
return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} drops by ${condition.threshold}%${timeframe}`;
|
||||
case 'price_rise':
|
||||
return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} rises by ${condition.threshold}%${timeframe}`;
|
||||
case 'volume_spike':
|
||||
return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`;
|
||||
case 'price_level':
|
||||
return `${condition.token} crosses ${condition.direction} $${condition.price}`;
|
||||
default:
|
||||
|
||||
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import type { TradeLogEntry } from '$lib/stores/simulationStore';
|
||||
|
||||
interface Props {
|
||||
tradeLog: TradeLogEntry[];
|
||||
}
|
||||
|
||||
let { tradeLog }: Props = $props();
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function getActionColor(action: string): string {
|
||||
switch (action) {
|
||||
case 'buy': return '#22c55e';
|
||||
case 'sell': return '#ef4444';
|
||||
default: return '#666';
|
||||
}
|
||||
}
|
||||
|
||||
function getActionIcon(action: string): string {
|
||||
switch (action) {
|
||||
case 'buy': return '📈';
|
||||
case 'sell': return '📉';
|
||||
default: return '➡️';
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to show only buy/sell actions
|
||||
let tradeActions = $derived(tradeLog.filter(t => t.action !== 'hold'));
|
||||
</script>
|
||||
|
||||
<div class="trade-dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h3>Trade Activity</h3>
|
||||
<span class="trade-count">
|
||||
{tradeActions.length} trades
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if tradeActions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No trades executed yet. Check the strategy configuration.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trade-list">
|
||||
{#each tradeActions as entry}
|
||||
<div class="trade-entry action-{entry.action}">
|
||||
<div class="trade-time">
|
||||
<span class="action-icon">{getActionIcon(entry.action)}</span>
|
||||
<span class="action-badge" style="background: {getActionColor(entry.action)}">
|
||||
{entry.action.toUpperCase()}
|
||||
</span>
|
||||
<span class="time">{formatTime(entry.time)}</span>
|
||||
</div>
|
||||
<div class="trade-details">
|
||||
<div class="price">
|
||||
<span class="label">Price:</span>
|
||||
<span class="value">${entry.price.toFixed(8)}</span>
|
||||
</div>
|
||||
<div class="reason">
|
||||
<span class="label">Reason:</span>
|
||||
<span class="value">{entry.reason}</span>
|
||||
</div>
|
||||
{#if entry.action === 'sell' && entry.position > 0}
|
||||
<div class="pnl">
|
||||
<span class="label">Position:</span>
|
||||
<span class="value">{entry.position.toFixed(6)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trade-dashboard {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dashboard-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trade-count {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.trade-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.trade-entry {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.trade-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.trade-entry.action-buy {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.trade-entry.action-sell {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.trade-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.trade-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.trade-details .label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.trade-details .value {
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.pnl .value {
|
||||
color: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,17 @@ export { default as BotCard } from './BotCard.svelte';
|
||||
export { default as BotSelector } from './BotSelector.svelte';
|
||||
export { default as StrategyPreview } from './StrategyPreview.svelte';
|
||||
export { default as SignalChart } from './SignalChart.svelte';
|
||||
export { default as TradeDashboard } from './TradeDashboard.svelte';
|
||||
export { default as PortfolioSummary } from './PortfolioSummary.svelte';
|
||||
export { default as BacktestChart } from './BacktestChart.svelte';
|
||||
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
||||
export { default as TokenPicker } from './TokenPicker.svelte';
|
||||
export { default as ConditionBuilder } from './ConditionBuilder.svelte';
|
||||
export { default as ChatLayout } from './ChatLayout.svelte';
|
||||
export { default as ConversationList } from './ConversationList.svelte';
|
||||
export { default as ChatArea } from './ChatArea.svelte';
|
||||
export { default as ChatInput } from './ChatInput.svelte';
|
||||
export { default as BotInfoPanel } from './BotInfoPanel.svelte';
|
||||
export { default as AnonymousBanner } from './AnonymousBanner.svelte';
|
||||
export { default as CandlestickLoader } from './CandlestickLoader.svelte';
|
||||
export { default as AppHeader } from './AppHeader.svelte';
|
||||
@@ -5,15 +5,29 @@ export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
thinking: string | null;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Fallback UUID generator for environments where crypto.randomUUID is not available
|
||||
export function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback: simple UUID v4 implementation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export const chatStore = writable<ChatMessage[]>([]);
|
||||
|
||||
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
id: crypto.randomUUID(),
|
||||
id: generateId(),
|
||||
timestamp: new Date()
|
||||
};
|
||||
chatStore.update(messages => [...messages, newMessage]);
|
||||
@@ -24,6 +38,7 @@ export function setMessages(messages: BotConversation[]) {
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
thinking: null,
|
||||
timestamp: new Date(m.created_at)
|
||||
})));
|
||||
}
|
||||
|
||||
96
src/frontend/src/lib/stores/conversationStore.ts
Normal file
96
src/frontend/src/lib/stores/conversationStore.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Conversation, Message } from '$lib/api';
|
||||
|
||||
export interface ConversationState {
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
anonymousChatCount: number;
|
||||
}
|
||||
|
||||
const initialState: ConversationState = {
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
anonymousChatCount: 0
|
||||
};
|
||||
|
||||
export const conversationStore = writable<ConversationState>(initialState);
|
||||
|
||||
export function setConversations(conversations: Conversation[]) {
|
||||
conversationStore.update(state => ({ ...state, conversations }));
|
||||
}
|
||||
|
||||
export function addConversation(conversation: Conversation) {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
conversations: [conversation, ...state.conversations]
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeConversation(conversationId: string) {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
conversations: state.conversations.filter(c => c.id !== conversationId),
|
||||
currentConversationId: state.currentConversationId === conversationId ? null : state.currentConversationId
|
||||
}));
|
||||
}
|
||||
|
||||
export function setCurrentConversation(conversationId: string | null) {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
currentConversationId: conversationId,
|
||||
messages: []
|
||||
}));
|
||||
}
|
||||
|
||||
export function setMessages(messages: Message[]) {
|
||||
conversationStore.update(state => ({ ...state, messages }));
|
||||
}
|
||||
|
||||
export function addMessage(message: Message) {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
messages: [...state.messages, message]
|
||||
}));
|
||||
}
|
||||
|
||||
export function setLoading(isLoading: boolean) {
|
||||
conversationStore.update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
|
||||
export function setError(error: string | null) {
|
||||
conversationStore.update(state => ({ ...state, error }));
|
||||
}
|
||||
|
||||
export function incrementAnonymousChatCount() {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
anonymousChatCount: state.anonymousChatCount + 1
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetAnonymousChatCount() {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
anonymousChatCount: 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateConversationTitle(conversationId: string, title: string) {
|
||||
conversationStore.update(state => ({
|
||||
...state,
|
||||
conversations: state.conversations.map(c =>
|
||||
c.id === conversationId ? { ...c, title, updated_at: new Date().toISOString() } : c
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export const currentConversation = derived(
|
||||
conversationStore,
|
||||
$state => $state.conversations.find(c => c.id === $state.currentConversationId) ?? null
|
||||
);
|
||||
@@ -28,3 +28,18 @@ export {
|
||||
register,
|
||||
logout
|
||||
} from './authStore';
|
||||
export {
|
||||
conversationStore,
|
||||
setConversations,
|
||||
addConversation,
|
||||
removeConversation,
|
||||
setCurrentConversation,
|
||||
setMessages as setConversationMessages,
|
||||
addMessage as addConversationMessage,
|
||||
setLoading as setConversationLoading,
|
||||
setError as setConversationError,
|
||||
incrementAnonymousChatCount,
|
||||
resetAnonymousChatCount,
|
||||
updateConversationTitle,
|
||||
currentConversation
|
||||
} from './conversationStore';
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Simulation, Signal } from '$lib/api';
|
||||
|
||||
export interface KlineData {
|
||||
time: number;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface TradeLogEntry {
|
||||
time: number;
|
||||
price: number;
|
||||
action: 'buy' | 'sell' | 'hold';
|
||||
reason: string;
|
||||
position: number;
|
||||
entry_price: number | null;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
initial_balance: number;
|
||||
current_balance: number;
|
||||
position: number;
|
||||
position_token: string;
|
||||
entry_price: number;
|
||||
current_price: number;
|
||||
}
|
||||
|
||||
export interface SimulationState {
|
||||
currentSimulation: Simulation | null;
|
||||
signals: Signal[];
|
||||
klines: KlineData[];
|
||||
tradeLog: TradeLogEntry[];
|
||||
portfolio: Portfolio;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -11,6 +37,16 @@ export interface SimulationState {
|
||||
const initialState: SimulationState = {
|
||||
currentSimulation: null,
|
||||
signals: [],
|
||||
klines: [],
|
||||
tradeLog: [],
|
||||
portfolio: {
|
||||
initial_balance: 10000,
|
||||
current_balance: 10000,
|
||||
position: 0,
|
||||
position_token: '',
|
||||
entry_price: 0,
|
||||
current_price: 0
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
@@ -18,7 +54,20 @@ const initialState: SimulationState = {
|
||||
export const simulationStore = writable<SimulationState>(initialState);
|
||||
|
||||
export function setCurrentSimulation(simulation: Simulation | null) {
|
||||
simulationStore.update(state => ({ ...state, currentSimulation: simulation }));
|
||||
simulationStore.update(state => ({
|
||||
...state,
|
||||
currentSimulation: simulation,
|
||||
klines: simulation?.klines || [],
|
||||
tradeLog: simulation?.trade_log || [],
|
||||
portfolio: simulation?.portfolio || state.portfolio
|
||||
}));
|
||||
}
|
||||
|
||||
export function updatePortfolio(portfolio: Partial<Portfolio>) {
|
||||
simulationStore.update(state => ({
|
||||
...state,
|
||||
portfolio: { ...state.portfolio, ...portfolio }
|
||||
}));
|
||||
}
|
||||
|
||||
export function addSignals(newSignals: Signal[]) {
|
||||
|
||||
256
src/frontend/src/lib/utils/markdown.ts
Normal file
256
src/frontend/src/lib/utils/markdown.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Simple markdown parser for rendering AI responses
|
||||
* Supports: bold, italic, code blocks, inline code, links, lists, tables, headings, line breaks
|
||||
*/
|
||||
|
||||
export interface InlineSegment {
|
||||
type: 'text' | 'bold' | 'italic' | 'code' | 'link';
|
||||
content: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface ParsedSegment {
|
||||
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list' | 'table' | 'lineBreak' | 'heading';
|
||||
content: string;
|
||||
items?: string[];
|
||||
headers?: InlineSegment[][];
|
||||
rows?: InlineSegment[][][];
|
||||
}
|
||||
|
||||
export function parseMarkdown(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// First, extract code blocks
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
const parts = text.split(codeBlockRegex);
|
||||
const codeBlocks = text.match(codeBlockRegex) || [];
|
||||
|
||||
let partIndex = 0;
|
||||
|
||||
while (partIndex < parts.length) {
|
||||
const part = parts[partIndex];
|
||||
|
||||
if (part.trim()) {
|
||||
// Process non-code content
|
||||
segments.push(...parseInlineContent(part));
|
||||
}
|
||||
|
||||
// Add code block if there's one after this part
|
||||
if (partIndex < codeBlocks.length) {
|
||||
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
|
||||
segments.push({ type: 'codeBlock', content: codeContent });
|
||||
}
|
||||
|
||||
partIndex++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseInlineContent(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Check for tables - match table pattern anywhere in text
|
||||
// Table pattern: | header | ... |\n|---|...|\n| row | ... |
|
||||
const tableRegex = /\|.+\|\n\|[-:\s|]+\|\n((?:\|.+\|\n?)*)/g;
|
||||
let lastIndex = 0;
|
||||
let tableMatch;
|
||||
|
||||
while ((tableMatch = tableRegex.exec(text)) !== null) {
|
||||
// Add content before table
|
||||
const beforeTable = text.substring(lastIndex, tableMatch.index);
|
||||
if (beforeTable.trim()) {
|
||||
segments.push(...parseLines(beforeTable));
|
||||
}
|
||||
|
||||
// Parse table
|
||||
const tableContent = tableMatch[0];
|
||||
const tableSegments = parseTable(tableContent);
|
||||
if (tableSegments.length > 0) {
|
||||
segments.push(...tableSegments);
|
||||
} else {
|
||||
// If table parsing failed, treat as text
|
||||
segments.push(...parseLines(tableContent));
|
||||
}
|
||||
|
||||
lastIndex = tableMatch.index + tableContent.length;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
if (lastIndex < text.length) {
|
||||
const remaining = text.substring(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
segments.push(...parseLines(remaining));
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseTable(tableStr: string): ParsedSegment[] {
|
||||
const lines = tableStr.trim().split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Skip separator line (|---|---|)
|
||||
const dataLines = lines.filter(line => !line.match(/^[\|\s\-:]+$/));
|
||||
if (dataLines.length < 2) return [];
|
||||
|
||||
const headers = parseTableRow(dataLines[0]);
|
||||
const rows = dataLines.slice(1).map(row => parseTableRow(row));
|
||||
|
||||
return [{
|
||||
type: 'table',
|
||||
content: '',
|
||||
headers,
|
||||
rows
|
||||
}];
|
||||
}
|
||||
|
||||
function parseTableRow(row: string): InlineSegment[][] {
|
||||
return row.split('|')
|
||||
.map(cell => cell.trim())
|
||||
.filter(cell => cell !== '')
|
||||
.map(cell => parseInlineElements(cell));
|
||||
}
|
||||
|
||||
export function parseInlineElements(text: string): InlineSegment[] {
|
||||
const segments: InlineSegment[] = [];
|
||||
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const parts = text.split(inlineRegex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('[') && part.includes('](')) {
|
||||
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
segments.push({ type: 'link', content: linkMatch[1], href: linkMatch[2] });
|
||||
}
|
||||
} else if (part) {
|
||||
segments.push({ type: 'text', content: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Render inline segments to HTML string
|
||||
function renderInlineSegments(segments: InlineSegment[]): string {
|
||||
return segments.map(seg => {
|
||||
switch (seg.type) {
|
||||
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||
case 'italic': return `<em>${seg.content}</em>`;
|
||||
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||
default: return seg.content;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function parseLines(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Combined regex for inline formatting
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
// Empty line - add line break for paragraph separation
|
||||
segments.push({ type: 'lineBreak', content: '' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for headings
|
||||
if (line.match(/^#{1,6}\s/)) {
|
||||
segments.push({ type: 'heading', content: line.replace(/^#+\s/, '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
if (line.match(/^[\-\*]\s/)) {
|
||||
const listMatch = line.match(/^([\-\*])\s(.*)/);
|
||||
if (listMatch) {
|
||||
// Parse inline formatting for list item
|
||||
const itemContent = listMatch[2];
|
||||
const inlineSegments = parseInlineElements(itemContent);
|
||||
|
||||
// Check if previous segment is a list
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (lastSeg && lastSeg.type === 'list') {
|
||||
lastSeg.items?.push(itemContent);
|
||||
} else {
|
||||
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for numbered lists
|
||||
if (line.match(/^\d+\.\s/)) {
|
||||
const listMatch = line.match(/^\d+\.\s(.*)/);
|
||||
if (listMatch) {
|
||||
const itemContent = listMatch[1];
|
||||
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (lastSeg && lastSeg.type === 'list') {
|
||||
lastSeg.items?.push(itemContent);
|
||||
} else {
|
||||
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process inline formatting
|
||||
const inlineSegments = parseInlineElementsAsText(line);
|
||||
segments.push(...inlineSegments);
|
||||
|
||||
// Add line break after non-empty lines (except last in a paragraph)
|
||||
if (i < lines.length - 1 && line.trim()) {
|
||||
segments.push({ type: 'lineBreak', content: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseInlineElementsAsText(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const parts = text.split(inlineRegex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('[') && part.includes('](')) {
|
||||
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
segments.push({ type: 'link', content: linkMatch[1] });
|
||||
}
|
||||
} else if (part) {
|
||||
segments.push({ type: 'text', content: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -7,6 +7,13 @@
|
||||
|
||||
onMount(() => {
|
||||
initAuth();
|
||||
|
||||
// Reset anonymous counts on layout load (for debugging)
|
||||
const count = localStorage.getItem('anonymous_chat_count');
|
||||
if (count && parseInt(count) >= 50) {
|
||||
console.log('Resetting anonymous_chat_count from', count, 'to 0');
|
||||
localStorage.setItem('anonymous_chat_count', '0');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
<h1>Randebu</h1>
|
||||
<p class="tagline">Create trading bots through conversation with AI</p>
|
||||
<div class="cta">
|
||||
<a href="/register" class="btn btn-primary">Get Started</a>
|
||||
<a href="/login" class="btn btn-secondary">Login</a>
|
||||
<a href="/chat" class="btn btn-primary">Try Now - Free</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components';
|
||||
import { ChatInterface, StrategyPreview } from '$lib/components';
|
||||
import type { TokenSearchResult } from '$lib/api';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let isSending = $state(false);
|
||||
let showStrategy = $state(false);
|
||||
|
||||
// Token address confirmation modal state
|
||||
let showTokenConfirm = $state(false);
|
||||
let pendingStrategyData = $state<any>(null);
|
||||
let tokenAddressInput = $state('');
|
||||
let confirmingMessage = $state('');
|
||||
let tokenSearchResults = $state<TokenSearchResult[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
goto('/login');
|
||||
@@ -44,16 +52,40 @@
|
||||
|
||||
isSending = true;
|
||||
|
||||
// Add user's message immediately so it shows even before API response
|
||||
addMessage({ role: 'user', content: message, thinking: null });
|
||||
|
||||
try {
|
||||
const response = await api.bots.chat(botId, message);
|
||||
addMessage({ role: 'assistant', content: response.response });
|
||||
// Add timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const response = await api.bots.chat(botId, message, controller.signal);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Check if token address confirmation is needed
|
||||
if (response.strategy_needs_confirmation && response.strategy_data) {
|
||||
// Show token confirmation modal
|
||||
pendingStrategyData = response.strategy_data;
|
||||
confirmingMessage = response.response;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = response.token_search_results || [];
|
||||
showTokenConfirm = true;
|
||||
}
|
||||
|
||||
// Add assistant response with thinking
|
||||
addMessage({ role: 'assistant', content: response.response, thinking: response.thinking || null });
|
||||
|
||||
if (response.strategy_config) {
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
}
|
||||
} catch (e) {
|
||||
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
addMessage({ role: 'assistant', content: 'Request timed out. Please try again.', thinking: null });
|
||||
} else {
|
||||
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.', thinking: null });
|
||||
}
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
@@ -62,6 +94,62 @@
|
||||
function toggleStrategy() {
|
||||
showStrategy = !showStrategy;
|
||||
}
|
||||
|
||||
async function confirmTokenAddress() {
|
||||
if (!tokenAddressInput.trim() || !pendingStrategyData) {
|
||||
showTokenConfirm = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the pending strategy with the token address
|
||||
const updatedStrategy = { ...pendingStrategyData };
|
||||
|
||||
// Update conditions with token address
|
||||
if (updatedStrategy.conditions) {
|
||||
updatedStrategy.conditions = updatedStrategy.conditions.map((cond: any) => ({
|
||||
...cond,
|
||||
token_address: tokenAddressInput.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
// Update actions with token address
|
||||
if (updatedStrategy.actions) {
|
||||
updatedStrategy.actions = updatedStrategy.actions.map((action: any) => ({
|
||||
...action,
|
||||
token_address: tokenAddressInput.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
// Update bot with the strategy
|
||||
await api.bots.update(botId, { strategy_config: updatedStrategy });
|
||||
|
||||
// Refresh bot data
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Add success message
|
||||
addMessage({ role: 'assistant', content: `Perfect! I've saved your strategy with the token address. You can now run backtests!`, thinking: null });
|
||||
} catch (e) {
|
||||
addMessage({ role: 'assistant', content: 'Failed to save strategy. Please try again.', thinking: null });
|
||||
}
|
||||
|
||||
showTokenConfirm = false;
|
||||
pendingStrategyData = null;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = [];
|
||||
}
|
||||
|
||||
function selectTokenResult(result: TokenSearchResult) {
|
||||
tokenAddressInput = result.address;
|
||||
}
|
||||
|
||||
function cancelTokenConfirm() {
|
||||
showTokenConfirm = false;
|
||||
pendingStrategyData = null;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -69,6 +157,34 @@
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
{#if showTokenConfirm}
|
||||
<div class="modal-overlay" onclick={cancelTokenConfirm}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Select Token Address</h3>
|
||||
<p class="modal-message">{confirmingMessage}</p>
|
||||
|
||||
{#if tokenSearchResults.length > 0}
|
||||
<div class="token-results">
|
||||
<p class="modal-hint">Select a token:</p>
|
||||
{#each tokenSearchResults as result}
|
||||
<button class="token-result" onclick={() => selectTokenResult(result)}>
|
||||
<span class="token-symbol">{result.symbol}</span>
|
||||
<span class="token-name">{result.name}</span>
|
||||
<span class="token-address">{result.address.slice(0, 10)}...{result.address.slice(-8)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="modal-divider">or enter manually:</p>
|
||||
{/if}
|
||||
|
||||
<input type="text" class="token-input" bind:value={tokenAddressInput} placeholder="0x..."/>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={cancelTokenConfirm}>Cancel</button>
|
||||
<button class="btn btn-primary" onclick={confirmTokenAddress} disabled={!tokenAddressInput.trim()}>Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||
@@ -95,12 +211,12 @@
|
||||
<ChatInterface
|
||||
bot={$currentBotStore}
|
||||
messages={$chatStore}
|
||||
{isSending}
|
||||
isSending={isSending}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProUpgradeBanner feature="Auto-execute trades with your bot" />
|
||||
<!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> -->
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -186,4 +302,145 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
color: #ccc;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.token-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Token Results */
|
||||
.token-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-result {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.token-result:hover {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.token-result:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.token-symbol {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,14 +8,36 @@
|
||||
import type { Backtest } from '$lib/api';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
let tokenName = $state('');
|
||||
let tokenAddress = $state('');
|
||||
let timeframe = $state('1h');
|
||||
let startDate = $state('');
|
||||
let endDate = $state('');
|
||||
let isRunning = $state(false);
|
||||
let selectedBacktest = $state<Backtest | null>(null);
|
||||
|
||||
// Expandable trades state
|
||||
let expandedTrades = $state<Set<string>>(new Set());
|
||||
|
||||
// Pagination state for each backtest
|
||||
let tradesPage = $state<Record<string, number>>({});
|
||||
let tradesData = $state<Record<string, any>>({});
|
||||
const TRADES_PER_PAGE = 5;
|
||||
|
||||
onMount(async () => {
|
||||
// Set default dates - yesterday only (1 day range for fast testing)
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Set max date to yesterday
|
||||
const maxDate = yesterday.toISOString().split('T')[0];
|
||||
|
||||
// Set end to yesterday, start to day before (1 day range)
|
||||
endDate = maxDate;
|
||||
const dayBefore = new Date(yesterday);
|
||||
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||
startDate = dayBefore.toISOString().split('T')[0];
|
||||
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
goto('/login');
|
||||
return;
|
||||
@@ -30,6 +52,16 @@
|
||||
try {
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Extract token info from strategy config
|
||||
const strategy = bot.strategy_config;
|
||||
if (strategy) {
|
||||
// Try conditions first, then actions
|
||||
const condition = strategy.conditions?.[0];
|
||||
const action = strategy.actions?.[0];
|
||||
tokenName = condition?.token || action?.token || '';
|
||||
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||
}
|
||||
} catch (e) {
|
||||
goto('/dashboard');
|
||||
}
|
||||
@@ -46,13 +78,25 @@
|
||||
|
||||
async function startBacktest() {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
// Validate date range (max 7 days)
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff > 7) {
|
||||
setBacktestError('Maximum backtest duration is 7 days for fast testing');
|
||||
return;
|
||||
}
|
||||
|
||||
setBacktestError(null);
|
||||
setBacktestLoading(true);
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const backtest = await api.backtest.start(botId, {
|
||||
token,
|
||||
token: tokenAddress, // Use token address from strategy
|
||||
token_name: tokenName, // Also send token name for display
|
||||
timeframe,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
@@ -76,15 +120,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setBacktestHistory(backtests: any[]) {
|
||||
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
|
||||
}
|
||||
|
||||
function selectBacktest(backtest: Backtest) {
|
||||
if (backtest.status === 'completed' && backtest.result) {
|
||||
if (backtest.status === 'completed' && backtest.result && !backtest.result.error) {
|
||||
selectedBacktest = backtest;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrades(backtestId: string) {
|
||||
if (expandedTrades.has(backtestId)) {
|
||||
expandedTrades.delete(backtestId);
|
||||
} else {
|
||||
expandedTrades.add(backtestId);
|
||||
// Load first page of trades if not loaded
|
||||
if (!tradesData[backtestId]) {
|
||||
loadTrades(backtestId, 1);
|
||||
}
|
||||
}
|
||||
expandedTrades = new Set(expandedTrades); // Trigger reactivity
|
||||
}
|
||||
|
||||
async function loadTrades(backtestId: string, page: number) {
|
||||
try {
|
||||
const data = await api.backtest.getTrades(botId, backtestId, page, TRADES_PER_PAGE);
|
||||
tradesData[backtestId] = { ...data, currentPage: page };
|
||||
tradesData = { ...tradesData }; // Trigger reactivity
|
||||
} catch (e) {
|
||||
console.error('Failed to load trades:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function nextTradesPage(backtestId: string) {
|
||||
const data = tradesData[backtestId];
|
||||
if (data && data.has_next) {
|
||||
loadTrades(backtestId, data.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function prevTradesPage(backtestId: string) {
|
||||
const data = tradesData[backtestId];
|
||||
if (data && data.has_prev) {
|
||||
loadTrades(backtestId, data.page - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -109,17 +192,19 @@
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="token">Token</label>
|
||||
<input type="text" id="token" bind:value={token} required />
|
||||
<div class="field token-info">
|
||||
<label>Token</label>
|
||||
<div class="token-display">
|
||||
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||
{#if tokenAddress}
|
||||
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="timeframe">Timeframe</label>
|
||||
<select id="timeframe" bind:value={timeframe}>
|
||||
<option value="1m">1 minute</option>
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="1h">1 hour (recommended)</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="1d">1 day</option>
|
||||
</select>
|
||||
@@ -144,7 +229,12 @@
|
||||
</section>
|
||||
|
||||
<section class="results-section">
|
||||
<h2>Backtest History</h2>
|
||||
<div class="section-header">
|
||||
<h2>Backtest History</h2>
|
||||
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
|
||||
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $backtestStore.backtestHistory.length === 0}
|
||||
<p class="empty-state">No backtests yet. Run your first backtest above.</p>
|
||||
@@ -156,7 +246,11 @@
|
||||
<span class="backtest-status status-{backtest.status}">{backtest.status}</span>
|
||||
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{#if backtest.result}
|
||||
{#if backtest.result && backtest.result.error}
|
||||
<div class="backtest-error">
|
||||
<span class="error-label">Error:</span> {typeof backtest.result.error === 'string' ? backtest.result.error : JSON.stringify(backtest.result.error)}
|
||||
</div>
|
||||
{:else if backtest.result}
|
||||
<div class="backtest-results">
|
||||
<div class="result-item">
|
||||
<span class="result-label">Total Return</span>
|
||||
@@ -177,16 +271,68 @@
|
||||
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backtest-config">
|
||||
<span class="config-item">
|
||||
<span class="config-label">Token:</span> {backtest.config.token || 'Unknown'}
|
||||
</span>
|
||||
<span class="config-item">
|
||||
<span class="config-label">TF:</span> {backtest.config.timeframe || '1h'}
|
||||
</span>
|
||||
<span class="config-item">
|
||||
<span class="config-label">Period:</span> {backtest.config.start_date} to {backtest.config.end_date}
|
||||
</span>
|
||||
</div>
|
||||
{#if backtest.result.trades && backtest.result.trades.length > 0}
|
||||
<button class="btn-toggle-trades" onclick={() => toggleTrades(backtest.id)}>
|
||||
{expandedTrades.has(backtest.id) ? 'Hide' : 'Show'} Trade History ({backtest.result.trades.length})
|
||||
</button>
|
||||
{#if expandedTrades.has(backtest.id)}
|
||||
<div class="trades-inline">
|
||||
{#if tradesData[backtest.id]}
|
||||
<div class="trades-pagination-header">
|
||||
<span class="trades-count">
|
||||
Showing {((tradesData[backtest.id].page - 1) * TRADES_PER_PAGE) + 1}-{Math.min(tradesData[backtest.id].page * TRADES_PER_PAGE, tradesData[backtest.id].total_trades)} of {tradesData[backtest.id].total_trades}
|
||||
</span>
|
||||
{#if tradesData[backtest.id].total_pages > 1}
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-pagination" onclick={() => prevTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_prev}>← Prev</button>
|
||||
<span class="page-indicator">Page {tradesData[backtest.id].page} of {tradesData[backtest.id].total_pages}</span>
|
||||
<button class="btn-pagination" onclick={() => nextTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_next}>Next →</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="trades-list">
|
||||
{#each tradesData[backtest.id].trades as trade}
|
||||
<div class="trade-item">
|
||||
<span class="trade-type" class:buy={trade.type === 'buy'} class:sell={trade.type === 'sell'}>
|
||||
{trade.type.toUpperCase()}
|
||||
</span>
|
||||
<span class="trade-price">${trade.price?.toFixed(6)}</span>
|
||||
<span class="trade-amount">${trade.amount?.toFixed(2)}</span>
|
||||
<span class="trade-reason">{trade.exit_reason || 'entry'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trades-loading">Loading trades...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if backtest.status === 'running'}
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {backtest.progress ?? 0}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">{backtest.progress ?? 0}%</span>
|
||||
</div>
|
||||
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if selectedBacktest}
|
||||
<section class="chart-section">
|
||||
<div class="chart-header">
|
||||
@@ -196,6 +342,8 @@
|
||||
<BacktestChart results={selectedBacktest.result} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -237,7 +385,120 @@
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Trades Modal */
|
||||
.trades-modal {
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trades-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.trades-modal h3 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: yellow;
|
||||
color: black;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trades-table-wrapper {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.trades-table th,
|
||||
.trades-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trade-type {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.trade-type.buy {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trade-type.sell {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -262,6 +523,20 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.backtest-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -275,6 +550,27 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
@@ -334,6 +630,83 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Inline Trades */
|
||||
.trades-inline {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-inline h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.trades-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trade-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.trade-item .trade-type {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.trade-item .trade-type.buy {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trade-item .trade-type.sell {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.trade-price {
|
||||
color: #ccc;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trade-amount {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.trade-reason {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-toggle-trades {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #667eea;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-toggle-trades:hover {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.backtest-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -364,6 +737,11 @@
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.backtest-date {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
@@ -375,6 +753,24 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.backtest-config {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.config-item {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -399,6 +795,33 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
margin-top: 0.75rem;
|
||||
width: auto;
|
||||
@@ -439,4 +862,61 @@
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Pagination styles */
|
||||
.trades-pagination-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-count {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-pagination {
|
||||
width: auto;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #667eea;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-pagination:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-pagination:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trades-loading {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { SignalChart, ProUpgradeBanner } from '$lib/components';
|
||||
import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
let intervalSeconds = $state(60);
|
||||
let autoExecute = $state(false);
|
||||
let tokenName = $state('');
|
||||
let tokenAddress = $state('');
|
||||
let klineInterval = $state('1m');
|
||||
let isRunning = $state(false);
|
||||
let isRefreshing = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
@@ -27,26 +28,40 @@
|
||||
try {
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Extract token info from strategy config
|
||||
const strategy = bot.strategy_config;
|
||||
if (strategy) {
|
||||
const condition = strategy.conditions?.[0];
|
||||
const action = strategy.actions?.[0];
|
||||
tokenName = condition?.token || action?.token || '';
|
||||
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||
}
|
||||
} catch (e) {
|
||||
goto('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSimulations() {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const simulations = await api.simulate.list(botId);
|
||||
if (simulations.length > 0) {
|
||||
const latest = simulations[0];
|
||||
setCurrentSimulation(latest);
|
||||
if (latest.signals) {
|
||||
addSignals(latest.signals);
|
||||
}
|
||||
if (latest.status === 'running') {
|
||||
isRunning = true;
|
||||
|
||||
// Find the most recent running simulation, or fall back to most recent
|
||||
let current = simulations.find(s => s.status === 'running') || simulations[0];
|
||||
|
||||
if (current) {
|
||||
setCurrentSimulation(current);
|
||||
clearSignals();
|
||||
if (current.signals && current.signals.length > 0) {
|
||||
addSignals(current.signals);
|
||||
}
|
||||
isRunning = current.status === 'running';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load simulations:', e);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +72,9 @@
|
||||
|
||||
try {
|
||||
const simulation = await api.simulate.start(botId, {
|
||||
token,
|
||||
interval_seconds: intervalSeconds,
|
||||
auto_execute: autoExecute
|
||||
token: tokenAddress,
|
||||
chain: 'bsc',
|
||||
kline_interval: klineInterval
|
||||
});
|
||||
setCurrentSimulation(simulation);
|
||||
clearSignals();
|
||||
@@ -94,11 +109,23 @@
|
||||
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
|
||||
<h1>Simulation</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{#if $simulationStore.currentSimulation}
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-btn"
|
||||
onclick={() => loadSimulations()}
|
||||
class:refreshing={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '⟳ Refreshing...' : '⟳ Refresh'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="notice">
|
||||
<span class="notice-icon">⚠️</span>
|
||||
<span>Simulation Mode - Using REST polling (every {intervalSeconds}s). For real-time signals, consider upgrading to Pro tier.</span>
|
||||
<span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -111,26 +138,26 @@
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="token">Token</label>
|
||||
<input type="text" id="token" bind:value={token} required disabled={isRunning} />
|
||||
<div class="field token-info">
|
||||
<label>Token</label>
|
||||
<div class="token-display">
|
||||
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||
{#if tokenAddress}
|
||||
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="interval">Check Interval (seconds)</label>
|
||||
<select id="interval" bind:value={intervalSeconds} disabled={isRunning}>
|
||||
<option value={30}>30 seconds</option>
|
||||
<option value={60}>60 seconds</option>
|
||||
<option value={120}>2 minutes</option>
|
||||
<option value={300}>5 minutes</option>
|
||||
<label for="klineInterval">Kline Interval</label>
|
||||
<select id="klineInterval" bind:value={klineInterval} disabled={isRunning}>
|
||||
<option value="1m">1 minute</option>
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field checkbox-field">
|
||||
<input type="checkbox" id="autoExecute" bind:checked={autoExecute} disabled={isRunning} />
|
||||
<label for="autoExecute">Auto-execute trades (requires Pro tier)</label>
|
||||
</div>
|
||||
|
||||
{#if isRunning}
|
||||
<button type="button" onclick={stopSimulation} class="btn btn-danger">
|
||||
Stop Simulation
|
||||
@@ -143,16 +170,31 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
|
||||
|
||||
<section class="signals-section">
|
||||
<h2>Signals ({$simulationStore.signals.length})</h2>
|
||||
<h2>Portfolio</h2>
|
||||
|
||||
<PortfolioSummary
|
||||
initialBalance={$simulationStore.currentSimulation?.portfolio?.initial_balance || 10000}
|
||||
currentBalance={$simulationStore.currentSimulation?.portfolio?.current_balance || 10000}
|
||||
position={$simulationStore.currentSimulation?.portfolio?.position || 0}
|
||||
positionToken={$simulationStore.currentSimulation?.portfolio?.position_token || ''}
|
||||
entryPrice={$simulationStore.currentSimulation?.portfolio?.entry_price || 0}
|
||||
currentPrice={$simulationStore.currentSimulation?.portfolio?.current_price || 0}
|
||||
/>
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Price Chart</h2>
|
||||
|
||||
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={250} />
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Trade Activity</h2>
|
||||
|
||||
<TradeDashboard tradeLog={$simulationStore.currentSimulation?.trade_log || []} />
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Signals ({$simulationStore.signals.length})</h2>
|
||||
|
||||
{#if $simulationStore.signals.length === 0}
|
||||
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
|
||||
<p class="empty-state">No signals generated. The chart above shows price movement.</p>
|
||||
{:else}
|
||||
<SignalChart signals={$simulationStore.signals} height={200} />
|
||||
|
||||
<div class="signals-list">
|
||||
{#each $simulationStore.signals as signal}
|
||||
<div class="signal-card">
|
||||
@@ -198,6 +240,42 @@
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.refresh-btn.refreshing {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -276,6 +354,27 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
110
src/frontend/src/routes/chat/+page.svelte
Normal file
110
src/frontend/src/routes/chat/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { isAuthenticated } from '$lib/stores';
|
||||
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
|
||||
let anonymousChatCount = $state(0);
|
||||
|
||||
async function createNewChat() {
|
||||
try {
|
||||
const conv = await api.conversations.create();
|
||||
goto(`/chat/${conv.id}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to create conversation:', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chat - Randebu</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<AppHeader />
|
||||
|
||||
{#if !$isAuthenticated}
|
||||
<AnonymousBanner chatCount={anonymousChatCount} />
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<ChatLayout leftPane={ConversationList}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-content">
|
||||
<h2>Start a Conversation</h2>
|
||||
<p>Select a conversation from the sidebar or create a new one</p>
|
||||
<button class="btn btn-primary" onclick={createNewChat}>
|
||||
+ New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
585
src/frontend/src/routes/chat/[conversationId]/+page.svelte
Normal file
585
src/frontend/src/routes/chat/[conversationId]/+page.svelte
Normal file
@@ -0,0 +1,585 @@
|
||||
<script lang="ts">
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { isAuthenticated } from '$lib/stores';
|
||||
import type { Message, Bot, ConversationWithMessages } from '$lib/api';
|
||||
import { generateId, type ChatMessage } from '$lib/stores/chatStore';
|
||||
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||
import ChatInterface from '$lib/components/ChatInterface.svelte';
|
||||
import BotInfoPanel from '$lib/components/BotInfoPanel.svelte';
|
||||
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import CandlestickLoader from '$lib/components/CandlestickLoader.svelte';
|
||||
|
||||
let conversationId = $derived($page.params.conversationId);
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let currentBot = $state<Bot | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let isSending = $state(false);
|
||||
let anonymousChatCount = $state(0);
|
||||
let hasShownLoginPrompt = $state(false); // Track if we've already shown the login prompt
|
||||
let isBlocked = $state(false); // Track if user is blocked due to limits
|
||||
let blockedReason = $state<string | null>(null); // Reason for being blocked
|
||||
|
||||
// Initialize from localStorage
|
||||
$effect(() => {
|
||||
const storedCount = localStorage.getItem('anonymous_chat_count');
|
||||
if (storedCount) {
|
||||
anonymousChatCount = parseInt(storedCount, 10);
|
||||
}
|
||||
const shownPrompt = localStorage.getItem('shown_login_prompt');
|
||||
hasShownLoginPrompt = shownPrompt === 'true';
|
||||
});
|
||||
|
||||
let currentConversation = $state<ConversationWithMessages | null>(null);
|
||||
let showBacktestModal = $state(false); // Show backtest configuration modal
|
||||
let showSimulateModal = $state(false); // Show simulation configuration modal
|
||||
|
||||
// Convert Message[] to ChatMessage[] for ChatInterface
|
||||
function convertMessages(apiMessages: Message[]): ChatMessage[] {
|
||||
return apiMessages.map(msg => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
thinking: null,
|
||||
timestamp: new Date(msg.created_at)
|
||||
}));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (conversationId) {
|
||||
loadConversation(conversationId);
|
||||
} else {
|
||||
messages = [];
|
||||
currentBot = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadConversation(id: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
// First, try to load as bot ID (direct bot link from dashboard)
|
||||
try {
|
||||
const bot = await api.bots.get(id);
|
||||
currentBot = bot;
|
||||
messages = [];
|
||||
currentConversation = null;
|
||||
isLoading = false;
|
||||
return;
|
||||
} catch {
|
||||
// Not a bot, continue to try conversation
|
||||
}
|
||||
|
||||
// Try to load as conversation
|
||||
const conv = await api.conversations.get(id);
|
||||
currentConversation = conv;
|
||||
messages = convertMessages(conv.messages);
|
||||
|
||||
if (conv.bot_id) {
|
||||
try {
|
||||
currentBot = await api.bots.get(conv.bot_id);
|
||||
} catch (e) {
|
||||
console.error('Failed to load bot:', e);
|
||||
currentBot = null;
|
||||
}
|
||||
} else {
|
||||
currentBot = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load conversation';
|
||||
messages = [];
|
||||
currentBot = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(message: string) {
|
||||
if (!conversationId) return;
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
if (anonymousChatCount >= 50) {
|
||||
// Show confirmation dialog before redirecting
|
||||
isBlocked = true;
|
||||
blockedReason = 'message_limit';
|
||||
error = 'You have reached the maximum of 50 messages. Login to continue with unlimited messages.';
|
||||
return; // Don't redirect automatically, show blocked state instead
|
||||
}
|
||||
// Show login prompt only once when reaching warning threshold (40 messages)
|
||||
if (anonymousChatCount >= 40 && !hasShownLoginPrompt) {
|
||||
hasShownLoginPrompt = true;
|
||||
localStorage.setItem('shown_login_prompt', 'true');
|
||||
const proceed = confirm('You are about to reach your message limit (40/50). Login now to save your progress and continue chatting.');
|
||||
if (proceed) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
|
||||
// Optimistically add user's message to the chat
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
thinking: null,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, userMessage];
|
||||
|
||||
try {
|
||||
let updatedConv;
|
||||
|
||||
// If we have a bot loaded directly (from dashboard), use bot chat
|
||||
// Otherwise, use conversation chat
|
||||
console.log('handleSendMessage: currentBot=', currentBot, 'conversationId=', conversationId);
|
||||
if (currentBot) {
|
||||
console.log('Using bot chat for bot:', currentBot.id);
|
||||
updatedConv = await api.bots.chat(currentBot.id, message);
|
||||
// Bot chat returns the assistant response directly
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: updatedConv.response,
|
||||
thinking: updatedConv.thinking || null,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, assistantMessage];
|
||||
} else {
|
||||
updatedConv = await api.conversations.chat(conversationId, message);
|
||||
messages = convertMessages(updatedConv.messages);
|
||||
currentConversation = updatedConv;
|
||||
|
||||
if (updatedConv.bot_id && (!currentBot || currentBot.id !== updatedConv.bot_id)) {
|
||||
try {
|
||||
currentBot = await api.bots.get(updatedConv.bot_id);
|
||||
} catch (e) {
|
||||
console.error('Failed to load bot:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
anonymousChatCount++;
|
||||
localStorage.setItem('anonymous_chat_count', String(anonymousChatCount));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('=== CHAT ERROR CAUGHT ===', e);
|
||||
console.error('Error status:', e.status);
|
||||
console.error('Error message:', e.message);
|
||||
|
||||
// Remove the optimistic user message if the request failed
|
||||
messages = messages.filter(m => m.id !== userMessage.id);
|
||||
|
||||
const status = e.status || (e.response && e.response.status);
|
||||
const errorMsg = e.message || (e.response && e.response.data && e.response.data.detail) || 'Unknown error';
|
||||
|
||||
if (status === 429) {
|
||||
error = 'Rate limited from the agent service. Please come back later.';
|
||||
} else if (status === 403 || status === 401) {
|
||||
// Check which limit was hit based on error message
|
||||
if (errorMsg.includes('bot')) {
|
||||
isBlocked = true;
|
||||
blockedReason = 'bot_limit';
|
||||
error = "You've reached the maximum number of bots (1) as an anonymous user. Login to create more bots.";
|
||||
} else if (errorMsg.includes('backtest')) {
|
||||
isBlocked = true;
|
||||
blockedReason = 'backtest_limit';
|
||||
error = "You've reached the maximum number of backtests (1) as an anonymous user. Login to run more.";
|
||||
} else {
|
||||
isBlocked = true;
|
||||
blockedReason = 'message_limit';
|
||||
error = "You've reached the maximum number of messages (50) as an anonymous user. Login to continue chatting.";
|
||||
}
|
||||
} else {
|
||||
error = errorMsg || 'Failed to send message';
|
||||
}
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectBot() {
|
||||
goto('/dashboard');
|
||||
}
|
||||
|
||||
function handleBacktest() {
|
||||
showBacktestModal = true;
|
||||
}
|
||||
|
||||
function handleSimulate() {
|
||||
showSimulateModal = true;
|
||||
}
|
||||
|
||||
async function runBacktest(config: { token_address: string; timeframe: string; start_date: string; end_date: string }) {
|
||||
if (!currentBot) return;
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
showBacktestModal = false;
|
||||
|
||||
// Add user message
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: `/backtest ${config.token_address} ${config.timeframe} ${config.start_date} ${config.end_date}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, userMessage];
|
||||
|
||||
try {
|
||||
const result = await api.backtest.start(currentBot.id, {
|
||||
token: config.token_address,
|
||||
timeframe: config.timeframe,
|
||||
start_date: config.start_date,
|
||||
end_date: config.end_date
|
||||
});
|
||||
|
||||
// Add bot response
|
||||
const botMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: `Backtest started! ID: ${result.id}\nStatus: ${result.status}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, botMessage];
|
||||
} catch (e: any) {
|
||||
messages = messages.filter(m => m.id !== userMessage.id);
|
||||
error = e.message || 'Failed to start backtest';
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSimulate(config: { token_address: string; kline_interval: string; action: string }) {
|
||||
if (!currentBot) return;
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
showSimulateModal = false;
|
||||
|
||||
// Add user message
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: `/simulate ${config.action} ${config.token_address} ${config.kline_interval}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, userMessage];
|
||||
|
||||
try {
|
||||
const result = await api.simulate.start(currentBot.id, {
|
||||
token: config.token_address,
|
||||
kline_interval: config.kline_interval
|
||||
});
|
||||
|
||||
// Add bot response
|
||||
const botMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: `Simulation ${config.action}! Token: ${config.token_address}\nInterval: ${config.kline_interval}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
messages = [...messages, botMessage];
|
||||
} catch (e: any) {
|
||||
messages = messages.filter(m => m.id !== userMessage.id);
|
||||
error = e.message || 'Failed to ${config.action} simulation';
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chat - Randebu</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<AppHeader />
|
||||
|
||||
{#if !$isAuthenticated}
|
||||
<AnonymousBanner chatCount={anonymousChatCount} />
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<ChatLayout
|
||||
leftPane={ConversationList}
|
||||
rightPane={BotInfoPanel}
|
||||
rightPaneProps={{
|
||||
bot: currentBot,
|
||||
onSelectBot: handleSelectBot,
|
||||
onBacktest: handleBacktest,
|
||||
onSimulate: handleSimulate
|
||||
}}
|
||||
>
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<CandlestickLoader />
|
||||
</div>
|
||||
{:else if conversationId}
|
||||
<ChatInterface
|
||||
bot={currentBot}
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
isBlocked={isBlocked}
|
||||
blockedReason={blockedReason}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSelectBot={handleSelectBot}
|
||||
onLogin={() => goto('/login')}
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
{/if}
|
||||
</ChatLayout>
|
||||
|
||||
<!-- Backtest Modal -->
|
||||
{#if showBacktestModal}
|
||||
<div class="modal-overlay" onclick={() => showBacktestModal = false}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Run Backtest</h3>
|
||||
<form onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
runBacktest({
|
||||
token_address: fd.get('token_address') as string,
|
||||
timeframe: fd.get('timeframe') as string,
|
||||
start_date: fd.get('start_date') as string,
|
||||
end_date: fd.get('end_date') as string
|
||||
});
|
||||
}}>
|
||||
<div class="form-group">
|
||||
<label for="bt-token">Token Contract Address</label>
|
||||
<input type="text" id="bt-token" name="token_address" required placeholder="0x..." />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="bt-timeframe">Timeframe</label>
|
||||
<select id="bt-timeframe" name="timeframe">
|
||||
<option value="1d">1 Day</option>
|
||||
<option value="4h">4 Hours</option>
|
||||
<option value="1h">1 Hour</option>
|
||||
<option value="15m">15 Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bt-start">Start Date</label>
|
||||
<input type="date" id="bt-start" name="start_date" value="2025-01-01" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bt-end">End Date</label>
|
||||
<input type="date" id="bt-end" name="end_date" value="2026-01-01" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="btn-cancel" onclick={() => showBacktestModal = false}>Cancel</button>
|
||||
<button type="submit" class="btn-submit">Run Backtest</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Simulation Modal -->
|
||||
{#if showSimulateModal}
|
||||
<div class="modal-overlay" onclick={() => showSimulateModal = false}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Run Simulation</h3>
|
||||
<form onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
runSimulate({
|
||||
token_address: fd.get('token_address') as string,
|
||||
kline_interval: fd.get('kline_interval') as string,
|
||||
action: 'start'
|
||||
});
|
||||
}}>
|
||||
<div class="form-group">
|
||||
<label for="sim-token">Token Contract Address</label>
|
||||
<input type="text" id="sim-token" name="token_address" required placeholder="0x..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-interval">Kline Interval</label>
|
||||
<select id="sim-interval" name="kline_interval">
|
||||
<option value="1m">1 Minute</option>
|
||||
<option value="5m">5 Minutes</option>
|
||||
<option value="15m">15 Minutes</option>
|
||||
<option value="1h">1 Hour</option>
|
||||
<option value="4h">4 Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="btn-cancel" onclick={() => showSimulateModal = false}>Cancel</button>
|
||||
<button type="submit" class="btn-submit">Start Simulation</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
flex: 1;
|
||||
padding: 0.7rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(220, 38, 38, 0.2);
|
||||
border-bottom: 1px solid rgba(220, 38, 38, 0.3);
|
||||
color: #fca5a5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@
|
||||
showCreateModal = false;
|
||||
newBotName = '';
|
||||
newBotDescription = '';
|
||||
goto(`/bot/${bot.id}`);
|
||||
goto(`/chat/${bot.id}`);
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Failed to create bot';
|
||||
} finally {
|
||||
@@ -96,7 +96,7 @@
|
||||
{:else}
|
||||
<div class="bots-grid">
|
||||
{#each $botsStore as bot}
|
||||
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
|
||||
<BotCard {bot} onDelete={deleteBot} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
166
src/frontend/src/routes/home/+page.svelte
Normal file
166
src/frontend/src/routes/home/+page.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { isAuthenticated } from '$lib/stores';
|
||||
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
|
||||
let anonymousChatCount = $state(0);
|
||||
|
||||
onMount(async () => {
|
||||
// Check if there's an anonymous token in cookies
|
||||
const cookies = document.cookie.split(';');
|
||||
const anonToken = cookies.find(c => c.trim().startsWith('anonymous_token='));
|
||||
if (anonToken) {
|
||||
// Count would be tracked server-side, for now just track locally
|
||||
const stored = localStorage.getItem('anonymous_chat_count');
|
||||
anonymousChatCount = stored ? parseInt(stored, 10) : 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Randebu - AI Trading Bot Platform</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<AppHeader />
|
||||
|
||||
{#if !$isAuthenticated}
|
||||
<AnonymousBanner chatCount={anonymousChatCount} />
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<ChatLayout leftPane={ConversationList}>
|
||||
<div class="welcome-screen">
|
||||
<div class="welcome-content">
|
||||
<h1>Welcome to Randebu</h1>
|
||||
<p class="subtitle">Create trading bots through conversation with AI</p>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<h3>Describe Your Strategy</h3>
|
||||
<p>Tell our AI what kind of trading you want in plain English</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<h3>Backtest & Validate</h3>
|
||||
<p>Test your strategy against historical data</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-content">
|
||||
<h3>Simulate & Monitor</h3>
|
||||
<p>Run real-time simulations and watch for signals</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">Select a conversation from the left or start a new one to begin</p>
|
||||
</div>
|
||||
</div>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #888;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user