Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
fccdbb4cca docs: add README.md 2026-04-15 15:46:05 +00:00
38 changed files with 809 additions and 5341 deletions

230
README.md Normal file
View File

@@ -0,0 +1,230 @@
# Randebu
**Create Trading Bots Through Natural Conversation**
Randebu is a web-based platform that allows traders to create and manage automated trading bots through simple chat interactions. No coding required.
---
## The Problem
Trading bots like **OpenClaw** and similar platforms are powerful but come with a steep learning curve:
- Complex configuration files
- Requires understanding of trading strategies
- CLI-based interfaces
- Steep technical barrier for non-developers
## The Solution
Randebu lets you create trading bots by simply chatting:
> "Create a bot that buys PEPE when it drops 5% and sells when it profits 10%"
That's it. Randebu handles the rest.
---
## How It Works
1. **Chat** - Tell Randebu what you want in plain English
2. **Bot Created** - Randebu creates a configured trading bot
3. **Backtest** - Test your strategy with historical data
4. **Simulate** - Run a simulation with real-time data
5. **Deploy** - Activate your bot on the blockchain
---
## Built on AVE Cloud
Randebu is powered by **AVE Cloud Skills** - the same infrastructure used by professional trading teams.
### AVE Skills Currently Integrated
Randebu uses **AVE Cloud Skills** (the skill scripts from `ave-cloud-skill` repository) for data fetching:
| Skill Script | Command | Purpose | Line in agent.py |
|-------------|---------|---------|------------------|
| `ave_data_rest.py` | `trending` | Get trending tokens | 218 |
| `ave_data_rest.py` | `search` | Search tokens by keyword | 285 |
| `ave_data_rest.py` | `risk` | Honeypot/risk analysis | 367 |
| `ave_data_rest.py` | `token` | Get token details | 487 |
| `ave_data_rest.py` | `price` | Get token prices | 509 |
### AVE Integration Points
The AVE skills are called through `_call_ave_script()` in `agent.py`:
```python
# agent.py - Calling AVE skill scripts
def _call_ave_script(self, command: str, args: list) -> tuple[int, str]:
ave_skill_path = os.path.join(
repo_root, "ave-cloud-skill", "scripts", "ave_data_rest.py"
)
result = subprocess.run(
["python3", ave_skill_path, command] + args,
...
)
```
### Direct API Usage (Not Skills)
These components use the AVE API directly (not through skills):
- `backtest/engine.py` - Uses `AveCloudClient.get_klines()` for historical kline data
- `simulate/engine.py` - Uses `AveCloudClient.get_klines()` for real-time kline data
---
## Further AVE Integration Opportunities
### 1. Trading Execution (Priority: High)
- **AVE Skills**: `trade-chain-wallet`, `trade-proxy-wallet`
- **Use**: Execute trades directly from the bot (market orders, limit orders, TP/SL)
- **Status**: Not yet integrated - this is the next major feature
### 2. Real-Time Alerts (Priority: Medium)
- **AVE Skills**: WebSocket streams (`data-wss`)
- **Use**: Notify users when price hits targets
### 3. Portfolio Tracking (Priority: Medium)
- **AVE API**: `address/walletinfo` endpoint
- **Use**: Show user's complete portfolio across chains
### 4. Advanced Risk Analysis (Priority: Low)
- **AVE Skills**: Extended token analysis
- **Use**: More detailed honeypot detection, liquidity analysis
---
## Tech Stack
| Component | Technology |
|-----------|------------|
| Frontend | SvelteKit, TypeScript |
| Backend | FastAPI, Python |
| Database | PostgreSQL |
| AI | MiniMax (extended thinking) |
| Trading Data | AVE Cloud API |
---
## Future Development Plan
### Phase 1: Core MVP (Current)
- [x] Chat-based bot creation
- [x] Strategy configuration via conversation
- [x] Backtest historical data
- [x] Simulation with real-time data
- [x] Bot management (create, list, set)
### Phase 2: Trading Execution
- [ ] AVE Trading API integration
- [ ] Chain wallet support
- [ ] Proxy wallet (bot-managed) support
- [ ] TP/SL automation
### Phase 3: Advanced Features
- [ ] Portfolio dashboard
- [ ] Multi-chain support (Solana, Base, ETH)
- [ ] Copy trading (follow other traders)
- [ ] Strategy marketplace
### Phase 4: Platform Growth
- [ ] Strategy templates
- [ ] Community strategies
- [ ] Premium features (for fees)
---
## Business Opportunity
### Target Market
1. **Retail Traders** - People who want to automate trading but can't code
2. **Crypto Enthusiasts** - Active traders looking for easier tools
3. **Small Funds** - Need automation without expensive developers
### Revenue Model
| Tier | Price | Features |
|------|-------|----------|
| Free | $0 | 1 bot, 50 chats, basic features |
| Pro | $19/mo | Unlimited bots, backtests, simulations |
| Enterprise | Custom | API access, priority support, custom integrations |
### Competitive Advantage
- **No-code** - Unlike OpenClaw, 3Commas, Cryptohopper
- **Natural Language** - Describe strategy in plain English
- **AVE Integration** - Built on professional-grade infrastructure
- **Focused UX** - Simple, clean interface designed for beginners
### Market Size
- Crypto traders: 100M+ globally
- Trading bot market: $1.5B+ by 2027
- No-code platform market: Growing rapidly
---
## Getting Started
### Prerequisites
- Python 3.10+
- Node.js 18+
- PostgreSQL
### Installation
```bash
# Clone the repo
git clone https://github.com/shoko/randebu.git
cd randebu
# Setup backend
cd src/backend
pip install -r requirements.txt
# Setup frontend
cd ../frontend
npm install
# Configure environment
cp .env.example .env
# Edit .env with your API keys
# Run
# Backend: uvicorn main:app
# Frontend: npm run dev
```
### Configuration
Required environment variables:
- `MINIMAX_API_KEY` - For AI chat
- `AVE_API_KEY` - For trading data
- `DATABASE_URL` - PostgreSQL connection
---
## Contributing
Contributions welcome! Please read our contributing guidelines before submitting PRs.
---
## License
MIT
---
## Links
- Website: [randebu.com](https://randebu.com)
- AVE Cloud: [cloud.ave.ai](https://cloud.ave.ai)
- Hackathon: [clawhackathon.aveai.trade](https://clawhackathon.aveai.trade)
---
*Built with ❤️ for traders, by traders*

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional, Annotated from typing import Annotated
from ..core.database import get_db from ..core.database import get_db
from ..core.security import ( from ..core.security import (
@@ -26,14 +26,6 @@ router = APIRouter()
settings = get_settings() settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") 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() TOKEN_BLACKLIST = set()
@@ -66,31 +58,6 @@ def get_current_user(
return 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( @router.post(
"/register", response_model=Token, status_code=status.HTTP_201_CREATED "/register", response_model=Token, status_code=status.HTTP_201_CREATED
) )

View File

@@ -1,17 +1,16 @@
import uuid import uuid
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Request from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional, Union from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from .auth import get_optional_user, get_current_user from .auth import get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings from ..core.config import get_settings
from ..db.schemas import BacktestCreate, BacktestResponse from ..db.schemas import BacktestCreate, BacktestResponse
from ..db.models import Bot, Backtest, Signal, User, AnonymousUser from ..db.models import Bot, Backtest, Signal, User
from ..services.rate_limiter import RateLimiter
router = APIRouter() router = APIRouter()
@@ -89,41 +88,18 @@ async def start_backtest(
bot_id: str, bot_id: str,
config: BacktestCreate, config: BacktestCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: Optional[User] = Depends(get_optional_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
request: Request = None,
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot: if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
# Check authorization raise HTTPException(
if current_user: status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
# 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() settings = get_settings()
backtest_id = str(uuid.uuid4()) backtest_id = str(uuid.uuid4())
@@ -158,10 +134,6 @@ async def start_backtest(
db.commit() db.commit()
db.refresh(backtest) 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) db_url = str(settings.DATABASE_URL)
background_tasks.add_task( background_tasks.add_task(
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config run_backtest_sync, backtest_id, db_url, bot_id, backtest_config

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Annotated, Optional from typing import List, Annotated
from .auth import get_current_user, get_optional_user from .auth import get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings from ..core.config import get_settings
from ..db.schemas import ( from ..db.schemas import (
@@ -71,7 +71,7 @@ def create_bot(
@router.get("/{bot_id}", response_model=BotResponse) @router.get("/{bot_id}", response_model=BotResponse)
def get_bot( def get_bot(
bot_id: str, bot_id: str,
current_user: Optional[User] = Depends(get_optional_user), current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -80,22 +80,11 @@ def get_bot(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
if bot.user_id != current_user.id:
# Check authorization raise HTTPException(
if current_user: status_code=status.HTTP_403_FORBIDDEN,
# Authenticated user - must own the bot detail="Not authorized to access this 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 return bot
@@ -174,7 +163,7 @@ def delete_bot(
def chat( def chat(
bot_id: str, bot_id: str,
request: BotChatRequest, request: BotChatRequest,
current_user: Optional[User] = Depends(get_optional_user), current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -183,21 +172,11 @@ def chat(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
# Check authorization if bot.user_id != current_user.id:
if current_user: raise HTTPException(
# Authenticated user - must own the bot status_code=status.HTTP_403_FORBIDDEN,
if bot.user_id != current_user.id: detail="Not authorized to chat with this bot",
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 = ( conversation_history = (
db.query(BotConversation) db.query(BotConversation)
@@ -212,17 +191,8 @@ def chat(
user_message = request.message user_message = request.message
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}")
# Use ConversationalAgent for natural chat with tool-calling # Use ConversationalAgent for natural chat with tool-calling
agent = get_conversational_agent( agent = get_conversational_agent(bot_id=bot_id)
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}")
result = agent.chat(user_message, history_for_agent) result = agent.chat(user_message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.") assistant_content = result.get("response", "I couldn't process your request.")
@@ -264,7 +234,7 @@ def chat(
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse]) @router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
def get_history( def get_history(
bot_id: str, bot_id: str,
current_user: Optional[User] = Depends(get_optional_user), current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -273,21 +243,11 @@ def get_history(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
# Check authorization if bot.user_id != current_user.id:
if current_user: raise HTTPException(
# Authenticated user - must own the bot status_code=status.HTTP_403_FORBIDDEN,
if bot.user_id != current_user.id: detail="Not authorized to access this bot's history",
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 = ( conversations = (
db.query(BotConversation) db.query(BotConversation)

View File

@@ -5,8 +5,7 @@ from typing import List, Optional, Annotated
from ..core.database import get_db from ..core.database import get_db
from ..db.models import Conversation, Message, User, AnonymousUser, Bot from ..db.models import Conversation, Message, User, AnonymousUser, Bot
from ..db.schemas import ChatRequest from ..services.auth import get_current_user
from ..api.auth import get_optional_user
from ..services.rate_limiter import RateLimiter from ..services.rate_limiter import RateLimiter
from ..services.ai_agent import get_conversational_agent from ..services.ai_agent import get_conversational_agent
@@ -34,7 +33,7 @@ def get_or_create_anonymous_token(
@router.get("") @router.get("")
def list_conversations( def list_conversations(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
): ):
if current_user: if current_user:
return ( return (
@@ -49,7 +48,7 @@ def list_conversations(
@router.post("") @router.post("")
def create_conversation( def create_conversation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
request: Request = None, request: Request = None,
response: Response = None, response: Response = None,
): ):
@@ -71,7 +70,7 @@ def create_conversation(
def get_conversation( def get_conversation(
conversation_id: str, conversation_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
): ):
conversation = ( conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first() db.query(Conversation).filter(Conversation.id == conversation_id).first()
@@ -79,43 +78,17 @@ def get_conversation(
if not conversation: if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id: if conversation.user_id and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
# Get messages for this conversation return 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) @router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_conversation( def delete_conversation(
conversation_id: str, conversation_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
): ):
conversation = ( conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first() db.query(Conversation).filter(Conversation.id == conversation_id).first()
@@ -123,7 +96,7 @@ def delete_conversation(
if not conversation: if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id: if conversation.user_id and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
db.delete(conversation) db.delete(conversation)
@@ -135,7 +108,7 @@ def set_bot_for_conversation(
conversation_id: str, conversation_id: str,
bot_id: str, bot_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
request: Request = None, request: Request = None,
): ):
conversation = ( conversation = (
@@ -144,7 +117,7 @@ def set_bot_for_conversation(
if not conversation: if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id: if conversation.user_id and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
if not current_user: if not current_user:
@@ -173,9 +146,9 @@ def set_bot_for_conversation(
@router.post("/{conversation_id}/chat") @router.post("/{conversation_id}/chat")
def chat_in_conversation( def chat_in_conversation(
conversation_id: str, conversation_id: str,
body: ChatRequest, message: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_current_user),
request: Request = None, request: Request = None,
response: Response = None, response: Response = None,
): ):
@@ -185,53 +158,23 @@ def chat_in_conversation(
if not conversation: if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id: if conversation.user_id and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
warning = None 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: if not current_user:
RateLimiter.check_system_limit(db) RateLimiter.check_system_limit(db)
# First try to get from conversation (more reliable) anon_token = get_or_create_anonymous_token(request, response, db)
anonymous_token = conversation.anonymous_token
# If not on conversation, try cookies anon = RateLimiter.check_anonymous_limit(db, anon_token)
if not anonymous_token and request:
anonymous_token = request.cookies.get("anonymous_token")
# If still not found, create new one RateLimiter.increment_chat_count(db, anon_token)
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: if anon and anon.chat_count > 40:
warning = "Your progress is not saved." 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 = ( conversation_history = (
db.query(Message) db.query(Message)
.filter(Message.conversation_id == conversation_id) .filter(Message.conversation_id == conversation_id)
@@ -242,77 +185,27 @@ def chat_in_conversation(
{"role": msg.role, "content": msg.content} for msg in conversation_history[-10:] {"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: 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 { return {
"id": conversation.id, "response": "No bot selected for this conversation. Please set a bot first.",
"user_id": conversation.user_id, "thinking": None,
"bot_id": conversation.bot_id, "strategy_config": None,
"title": conversation.title, "success": False,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None, "warning": warning,
"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)
agent = get_conversational_agent( result = agent.chat(message, history_for_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.") assistant_content = result.get("response", "I couldn't process your request.")
# Save the assistant's response user_msg = Message(
conversation_id=conversation_id,
role="user",
content=message,
)
db.add(user_msg)
assistant_msg = Message( assistant_msg = Message(
conversation_id=conversation_id, conversation_id=conversation_id,
role="assistant", role="assistant",
@@ -320,31 +213,13 @@ def chat_in_conversation(
) )
db.add(assistant_msg) db.add(assistant_msg)
conversation.updated_at = conversation.updated_at
db.commit() 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 { return {
"id": conversation.id, "response": assistant_content,
"user_id": conversation.user_id, "thinking": result.get("thinking"),
"bot_id": conversation.bot_id, "strategy_config": result.get("strategy_config"),
"title": conversation.title, "success": result.get("success", False),
"created_at": conversation.created_at.isoformat() if conversation.created_at else None, "warning": warning,
"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
],
} }

View File

@@ -40,7 +40,7 @@ class Bot(Base):
__tablename__ = "bots" __tablename__ = "bots"
id = Column(String, primary_key=True, default=generate_uuid) id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(String, ForeignKey("users.id"), nullable=True) # nullable for anonymous bots user_id = Column(String, ForeignKey("users.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(Text) description = Column(Text)
strategy_config = Column(JSON, nullable=False) strategy_config = Column(JSON, nullable=False)
@@ -53,9 +53,6 @@ class Bot(Base):
conversations = relationship( conversations = relationship(
"Conversation", back_populates="bot", cascade="all, delete-orphan" "Conversation", back_populates="bot", cascade="all, delete-orphan"
) )
bot_conversations = relationship(
"BotConversation", back_populates="bot", cascade="all, delete-orphan"
)
backtests = relationship( backtests = relationship(
"Backtest", back_populates="bot", cascade="all, delete-orphan" "Backtest", back_populates="bot", cascade="all, delete-orphan"
) )
@@ -115,7 +112,7 @@ class BotConversation(Base):
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
bot = relationship("Bot", back_populates="bot_conversations") bot = relationship("Bot", back_populates="conversations")
class Backtest(Base): class Backtest(Base):
@@ -169,7 +166,7 @@ Index("idx_bots_user_id", Bot.user_id)
Index("idx_conversations_user_id", Conversation.user_id) Index("idx_conversations_user_id", Conversation.user_id)
Index("idx_conversations_bot_id", Conversation.bot_id) Index("idx_conversations_bot_id", Conversation.bot_id)
Index("idx_messages_conversation_id", Message.conversation_id) Index("idx_messages_conversation_id", Message.conversation_id)
Index("idx_bot_conversations_bot_id", BotConversation.bot_id) Index("idx_conversations_bot_id", BotConversation.bot_id)
Index("idx_backtests_bot_id", Backtest.bot_id) Index("idx_backtests_bot_id", Backtest.bot_id)
Index("idx_simulations_bot_id", Simulation.bot_id) Index("idx_simulations_bot_id", Simulation.bot_id)
Index("idx_signals_bot_id", Signal.bot_id) Index("idx_signals_bot_id", Signal.bot_id)

View File

@@ -54,7 +54,7 @@ class BotUpdate(BaseModel):
class BotResponse(BaseModel): class BotResponse(BaseModel):
id: str id: str
user_id: Optional[str] # None for anonymous bots user_id: str
name: str name: str
description: Optional[str] description: Optional[str]
strategy_config: dict strategy_config: dict

View File

@@ -54,7 +54,9 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(bots.router, prefix="/api/bots", tags=["bots"]) app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
app.include_router(conversations.router, tags=["conversations"]) app.include_router(
conversations.router, prefix="/api/conversations", tags=["conversations"]
)
app.include_router(backtest.router, prefix="/api", tags=["backtest"]) app.include_router(backtest.router, prefix="/api", tags=["backtest"])
app.include_router(simulate.router, prefix="/api", tags=["simulate"]) app.include_router(simulate.router, prefix="/api", tags=["simulate"])
app.include_router(config.router, prefix="/api/config", tags=["config"]) app.include_router(config.router, prefix="/api/config", tags=["config"])

File diff suppressed because it is too large Load Diff

View File

@@ -227,163 +227,27 @@ TOOLS = [
}, },
}, },
}, },
{
"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_WITH_TOOLS = (
SYSTEM_PROMPT 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: You have access to tools:
- search_tokens(keyword, limit): Search for tokens by keyword/symbol. - search_tokens(keyword, limit): Search for tokens by keyword. Use it when user asks to search for a token or find tokens by name/symbol.
- get_token(address, chain): Get detailed token info. - get_token(address, chain): Get detailed information about a specific token. Use when user asks for token details.
- get_price(token_ids): Get current token prices. - get_price(token_ids): Get current price(s) for tokens. Use when user asks for token price.
- get_risk(address, chain): Get risk/honeypot analysis. - get_risk(address, chain): Get risk analysis for a token. Use when user asks about token safety or honeypot analysis.
- get_trending(chain, limit): Get trending tokens. - get_trending(chain, limit): Get trending tokens on a blockchain. Use when user asks what's trending, top tokens, or popular tokens.
- run_backtest(token_address, timeframe, start_date, end_date): Run backtest. REQUIRES a bot to be set first. - run_backtest(token_address, timeframe, start_date, end_date): Run a backtest on historical data. Returns performance metrics. Use when user asks to backtest or check historical performance.
- manage_simulation(action, token_address, kline_interval): Manage simulations. REQUIRES a bot. - manage_simulation(action, token_address, kline_interval): Manage trading simulations. Actions: 'start' (begin new), 'stop' (stop running), 'status' (check if running), 'results' (get current/latest results).
- 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. When you want to use a tool, respond with:
{
"thinking": "...",
"response": "Running backtest...",
"tool_call": {"name": "run_backtest", "arguments": {"token_address": "0x...", "timeframe": "1d", "start_date": "2024-01-01", "end_date": "2024-12-01"}}
}
""" """
) )
@@ -402,7 +266,7 @@ class MiniMaxClient:
system_prompt: str, system_prompt: str,
tools: Optional[List[Dict[str, Any]]] = None, tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.7, temperature: float = 0.7,
max_tokens: int = 3000, max_tokens: int = 2000,
thinking_budget: int = 1500, thinking_budget: int = 1500,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Send a chat request to MiniMax API.""" """Send a chat request to MiniMax API."""
@@ -425,13 +289,6 @@ class MiniMaxClient:
payload["tools"] = tools payload["tools"] = tools
resp = requests.post(self.endpoint, headers=headers, json=payload) 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 {} return resp.json() or {}
def check_connection(self) -> bool: def check_connection(self) -> bool:

View File

@@ -1,164 +0,0 @@
"""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",
}
}]
}

View File

@@ -93,32 +93,6 @@ class BacktestEngine:
self.results = {"error": "No kline data available"} self.results = {"error": "No kline data available"}
return self.results 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) await self._process_klines(klines)
self._calculate_metrics() self._calculate_metrics()
self.status = "completed" self.status = "completed"
@@ -165,17 +139,6 @@ class BacktestEngine:
async def _process_klines(self, klines: List[Dict[str, Any]]): async def _process_klines(self, klines: List[Dict[str, Any]]):
self.total_klines = len(klines) 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): for i, kline in enumerate(klines):
if not self.running: if not self.running:
break break
@@ -186,29 +149,21 @@ class BacktestEngine:
if price <= 0: if price <= 0:
continue continue
self.last_kline_price = price # Track last price for mark to market self.last_kline_price = price # Track last price for open position valuation
timestamp = kline.get("timestamp", 0) timestamp = kline.get("timestamp", 0)
if self.position > 0 and self.entry_price is not None: if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(price, timestamp) exit_info = self._check_risk_management(price, timestamp)
if exit_info: 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) await self._execute_risk_exit(price, timestamp, exit_info)
continue continue
# Check each condition
for condition in self.conditions: for condition in self.conditions:
cond_result = self._check_condition(condition, klines, i, price) if 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) await self._execute_actions(price, timestamp, condition)
break break
print(f"DEBUG _process_klines: Total dip opportunities: {dip_opportunities}")
@property @property
def average_entry_price(self) -> Optional[float]: def average_entry_price(self) -> Optional[float]:
"""Calculate weighted average entry price based on cost basis.""" """Calculate weighted average entry price based on cost basis."""
@@ -294,9 +249,6 @@ class BacktestEngine:
if prev_price <= 0: if prev_price <= 0:
return False return False
drop_pct = ((prev_price - current_price) / prev_price) * 100 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 return drop_pct >= threshold
elif cond_type == "price_rise": elif cond_type == "price_rise":
@@ -339,8 +291,6 @@ class BacktestEngine:
amount = self.current_balance * (amount_percent / 100) amount = self.current_balance * (amount_percent / 100)
if action_type == "buy" and self.current_balance >= amount: if action_type == "buy" and self.current_balance >= amount:
# 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 quantity = amount / price
self.position += quantity self.position += quantity
self.current_balance -= amount self.current_balance -= amount
@@ -348,7 +298,6 @@ class BacktestEngine:
self.position_token = token self.position_token = token
self.entry_price = price # Keep last entry price for reference self.entry_price = price # Keep last entry price for reference
self.entry_time = timestamp self.entry_time = timestamp
print(f"DEBUG _execute_actions: BUY - amount=${amount:.2f}, price={price}, quantity={quantity}, position={self.position}")
self.trades.append( self.trades.append(
{ {
"type": "buy", "type": "buy",
@@ -375,36 +324,22 @@ class BacktestEngine:
) )
elif action_type == "sell" and self.position > 0: elif action_type == "sell" and self.position > 0:
# Sell amount_percent of current position (default 100% if not specified) sell_amount = self.position * price
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 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( self.trades.append(
{ {
"type": "sell", "type": "sell",
"token": self.position_token, "token": self.position_token,
"price": price, "price": price,
"amount": sell_amount, "amount": sell_amount,
"quantity": sell_quantity, "quantity": self.position,
"timestamp": timestamp, "timestamp": timestamp,
"exit_reason": "manual", "exit_reason": "manual",
} }
) )
self.position = 0
# Update remaining position self.entry_price = None
self.position -= sell_quantity self.entry_time = None
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( self.signals.append(
{ {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
@@ -421,49 +356,21 @@ class BacktestEngine:
) )
def _calculate_metrics(self): def _calculate_metrics(self):
# 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 # For open positions, use the last kline price to mark to market
# If no last kline price, fall back to entry price # If no last kline price, fall back to entry price
position_price = self.last_kline_price position_price = self.last_kline_price
if position_price is None and self.trades and self.position > 0: if position_price is None and self.trades and self.position > 0:
position_price = self.trades[-1]["price"] # Fall back to entry price 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 # Calculate final balance: use marked-to-market value if position open, otherwise current balance
if self.position > 0 and position_price: if self.position > 0 and position_price:
final_balance = self.current_balance + self.position * position_price final_balance = self.current_balance + self.position * position_price
else: else:
final_balance = self.current_balance final_balance = self.current_balance
print(f" final_balance calculated: {final_balance}")
total_return = ( total_return = (
(final_balance - self.initial_balance) / self.initial_balance (final_balance - self.initial_balance) / self.initial_balance
) * 100 ) * 100
print(f" total_return calculated: {total_return}%")
buy_trades = [t for t in self.trades if t["type"] == "buy"] buy_trades = [t for t in self.trades if t["type"] == "buy"]
sell_trades = [t for t in self.trades if t["type"] == "sell"] sell_trades = [t for t in self.trades if t["type"] == "sell"]
total_trades = len(buy_trades) + len(sell_trades) total_trades = len(buy_trades) + len(sell_trades)

View File

@@ -1,609 +0,0 @@
"""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"])

View File

@@ -1,505 +1,457 @@
import pytest """
Unit tests for BacktestEngine
Tests stop loss, take profit, and max drawdown calculations
"""
import asyncio 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 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: class TestBacktestEngine:
"""Test cases for BacktestEngine.""" """Test suite for BacktestEngine"""
@pytest.fixture def _run_backtest(self, config, klines):
def base_config(self): """Helper to run backtest with given klines"""
"""Base config for backtest.""" engine = BacktestEngine(config)
return { result = asyncio.run(engine.run_with_klines(klines))
"bot_id": "test-bot-123", return engine, result
"token": "0xtest",
"chain": "bsc", def _trace_portfolio(self, engine, initial_balance):
"timeframe": "1h", """Print portfolio trace for debugging"""
"start_date": "2024-01-01", running_balance = initial_balance
"end_date": "2024-01-02", running_position = 0.0
"ave_api_key": "test-key",
print("\nPortfolio Trace:")
for i, trade in enumerate(engine.trades):
if trade["type"] == "buy":
running_position = trade["quantity"]
running_balance -= trade["amount"]
portfolio = running_balance + (running_position * trade["price"])
print(f" BUY #{i+1}: @${trade['price']} - portfolio=${portfolio:.2f}")
else:
running_balance += trade["amount"]
running_position = 0
portfolio = running_balance
print(f" SELL #{i+1}: @${trade['price']} ({trade.get('exit_reason', '')}) - portfolio=${portfolio:.2f}")
if engine.position > 0 and engine.last_kline_price:
final = running_balance + (engine.position * engine.last_kline_price)
print(f" FINAL: position={engine.position:.2f} @ ${engine.last_kline_price} = ${final:.2f}")
print()
def test_stop_loss_triggers_correctly(self):
"""Test stop loss triggers at configured percentage"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free", "ave_api_plan": "free",
"initial_balance": 10000.0, "initial_balance": 10000.0,
} }
@pytest.fixture # Price sequence that triggers buy then stop loss:
def simple_strategy(self): # $110 -> $100 (9% drop, BUY)
"""Simple strategy: buy on 1% drop, no auto sell.""" # $100 -> $95 (5% drop, STOP LOSS at 5% from $100 = $95)
return { klines = [
"conditions": [ {"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0} {"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
], {"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
"actions": [ ]
{"type": "buy", "amount_percent": 10}
], engine, result = self._run_backtest(config, klines)
"risk_management": {} self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)} (expected 2)")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
assert len(engine.trades) == 2
assert engine.trades[0]["type"] == "buy"
assert engine.trades[1]["type"] == "sell"
assert engine.trades[1]["exit_reason"] == "stop_loss"
# Max drawdown should be ~5% (stop loss percentage)
assert 3 < result['max_drawdown'] < 8
# Total return should be ~-5%
assert -8 < result['total_return'] < -3
def test_take_profit_triggers(self):
"""Test take profit triggers at configured percentage"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
} }
@pytest.fixture # $100 -> $95 (5% drop, BUY) -> $104.5 (10% rise, TAKE PROFIT)
def partial_sell_strategy(self): klines = [
"""Strategy with partial sells: buy on 1% drop, sell 50% on rise (via risk management take profit).""" {"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
return { {"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
"conditions": [ {"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"},
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0} ]
],
"actions": [ engine, result = self._run_backtest(config, klines)
{"type": "buy", "amount_percent": 10} self._trace_portfolio(engine, 10000.0)
],
"risk_management": { print(f"Results:")
"take_profit_percent": 1.5 # 1.5% take profit to trigger on price rise print(f" Trades: {len(engine.trades)} (expected 2)")
} print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
assert len(engine.trades) == 2
assert engine.trades[1]["exit_reason"] == "take_profit"
assert result['total_return'] > 0
def test_max_drawdown_bounded_by_stop_loss(self):
"""Test that max drawdown is bounded by stop loss when position is properly closed"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
} }
@pytest.fixture # $110 -> $100 -> $95 (BUY) -> $90 (STOP LOSS)
def stop_loss_strategy(self): klines = [
"""Strategy with stop loss and take profit.""" {"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
return { {"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
"conditions": [ {"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0} {"close": "90.0", "timestamp": 4000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
], ]
"actions": [
{"type": "buy", "amount_percent": 10} engine, result = self._run_backtest(config, klines)
], self._trace_portfolio(engine, 10000.0)
"risk_management": {
"stop_loss_percent": 5, # 5% stop loss print(f"Results:")
"take_profit_percent": 10 # 10% take profit print(f" Trades: {len(engine.trades)}")
} print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# With 5% stop loss, max drawdown should be around 5%
assert 3 < result['max_drawdown'] < 8
def test_open_position_not_closed(self):
"""Test scenario where last kline has an open position"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
} }
def test_single_buy_and_hold(self, base_config, simple_strategy): # $100 -> $90 (10% drop, BUY) - and backtest ends here
"""Test buying once and holding (no sell triggers).""" # Position is open, marked to market at $90
# Create klines that drop 0.5% each (below 1% threshold, no buy) klines = [
# Then rise 0.5% each (still no sell since no position) {"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
klines = create_klines(100, 10, interval=0.5) # Rising trend {"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
]
config = {**base_config, "strategy_config": simple_strategy} engine, result = self._run_backtest(config, klines)
engine = BacktestEngine(config) self._trace_portfolio(engine, 10000.0)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run()) print(f"Results:")
print(f" Trades: {len(engine.trades)}")
print(f" Position open: {engine.position > 0}")
print(f" Entry price: ${engine.entry_price}")
print(f" Last kline price: ${engine.last_kline_price}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
print(f"Results: {results}") # Position should be open
# 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 assert engine.position > 0
# Entry should be $90
assert engine.entry_price == 90.0
# Since entry = last kline price, no unrealized loss
# Max drawdown should be 0%
assert result['max_drawdown'] == 0.0
def test_partial_sells(self, base_config, partial_sell_strategy): def test_open_position_with_loss(self):
"""Test partial sells - selling some portion via take profit.""" """Test open position where price dropped but stop loss didn't trigger"""
# Create: drop 1.5% (buy), rise 1.5% (sell via take profit), drop 1.5% (buy), rise 1.5% (sell via take profit) config = {
klines = [] "bot_id": "test",
price = 100.0 "strategy_config": {
base_time = 1704067200 "conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
for i in range(8): "risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
if i % 2 == 0: },
# Even: drop 1.5% (should trigger buy) "ave_api_key": "test",
price = price * 0.985 "ave_api_plan": "free",
else: "initial_balance": 10000.0,
# 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 = [] # $100 -> $90 (10% drop, BUY at $90) -> $85 (stop loss at 5% from $90 = $85.5)
base_time = 1704067200 # $85 > $85.5? No, $85 < $85.5, so stop loss WOULD trigger
# Let me use $86 instead - $86 > $85.5 so no stop loss
klines = [
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
{"close": "86.0", "timestamp": 3000, "open": "86.0", "high": "86.0", "low": "86.0", "volume": "1000"},
]
# Klines: engine, result = self._run_backtest(config, klines)
# 0: price 100 (reference) self._trace_portfolio(engine, 10000.0)
# 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} print(f"Results:")
engine = BacktestEngine(config) print(f" Trades: {len(engine.trades)}")
engine.ave_client = MockAveClient(klines) print(f" Position open: {engine.position > 0}")
print(f" Entry price: ${engine.entry_price}")
print(f" Last kline price: ${engine.last_kline_price}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
results = asyncio.run(engine.run()) # Position should be open
assert engine.position > 0
# Entry = $90, stop = $85.50, last = $86 (above stop)
# Portfolio: $0 + position * $86
# Position: 10000/90 = 111.11 tokens
# Portfolio at $86: 111.11 * 86 = $9,555.56
# But we only track portfolio at trade points, so max was $10,000
# drawdown = (10000 - 9555.56) / 10000 = 4.44%
print(f" Expected max drawdown: ~4.4% (marked to market at $86)")
print(f"Results: {results}") def test_multiple_buy_sell_cycles(self):
print(f"Trades: {engine.trades}") """Test multiple buy/sell cycles"""
config = {
# Should have 2 trades: 1 buy, 1 sell (take profit) "bot_id": "test",
assert results.get("total_trades") == 2 "strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
buy_trades = [t for t in engine.trades if t["type"] == "buy"] "actions": [{"type": "buy", "amount_percent": 50}], # 50% of balance
sell_trades = [t for t in engine.trades if t["type"] == "sell"] "risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
assert len(buy_trades) == 1 "ave_api_key": "test",
assert len(sell_trades) == 1 "ave_api_plan": "free",
assert sell_trades[0]["exit_reason"] == "take_profit" "initial_balance": 10000.0,
# 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 # $100 -> $95 (BUY) -> $104.5 (TAKE PROFIT) -> $95 (BUY) -> $90 (STOP LOSS)
klines = [] klines = [
base_time = 1704067200 {"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
price = 100.0 {"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # BUY at $95
{"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"}, # TAKE PROFIT
{"close": "95.0", "timestamp": 4000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # 9% drop - no buy
{"close": "90.0", "timestamp": 5000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"}, # 10.5% drop from $100 - BUY at $90
{"close": "85.5", "timestamp": 6000, "open": "85.5", "high": "85.5", "low": "85.5", "volume": "1000"}, # STOP LOSS at 5% from $90 = $85.5
]
for i in range(12): engine, result = self._run_backtest(config, klines)
if i % 2 == 0: self._trace_portfolio(engine, 10000.0)
price = price * 0.975 # Drop 2.5% - triggers buy
else:
price = price * 1.026 # Rise 2.5% - triggers take profit sell
klines.append({ print(f"Results:")
"open": str(price * 0.99), print(f" Trades: {len(engine.trades)}")
"high": str(price * 1.01), print(f" Buy count: {len([t for t in engine.trades if t['type'] == 'buy'])}")
"low": str(price * 0.98), print(f" Sell count: {len([t for t in engine.trades if t['type'] == 'sell'])}")
"close": str(price), print(f" Max drawdown: {result['max_drawdown']}%")
"volume": "1000", print(f" Total return: {result['total_return']}%")
"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()) def run_tests():
tests = TestBacktestEngine()
print(f"Results: {results}") print("=" * 60)
print(f"Trades: {engine.trades}") print("TEST 1: Stop Loss Triggers Correctly")
print("=" * 60)
try:
tests.test_stop_loss_triggers_correctly()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
# Price oscillates creating multiple dip/sell cycles print("=" * 60)
# Should have 10 trades: 5 buys, 5 sells (via take profit) print("TEST 2: Take Profit Triggers")
assert results.get("total_trades") == 10 print("=" * 60)
try:
tests.test_take_profit_triggers()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
buy_trades = [t for t in engine.trades if t["type"] == "buy"] print("=" * 60)
sell_trades = [t for t in engine.trades if t["type"] == "sell"] print("TEST 3: Max Drawdown Bounded by Stop Loss")
print("=" * 60)
try:
tests.test_max_drawdown_bounded_by_stop_loss()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
assert len(buy_trades) == 5 print("=" * 60)
assert len(sell_trades) == 5 print("TEST 4: Open Position Not Closed")
print("=" * 60)
try:
tests.test_open_position_not_closed()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
# Position should be 0 after all sells print("=" * 60)
assert engine.position == 0 print("TEST 5: Open Position With Loss")
print("=" * 60)
try:
tests.test_open_position_with_loss()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
# Should have profitable trades print("=" * 60)
assert results.get("total_return") > 0 print("TEST 6: Multiple Buy/Sell Cycles")
print("=" * 60)
try:
tests.test_multiple_buy_sell_cycles()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
def test_dca_multiple_buys():
"""Test that DCA with multiple consecutive buys uses weighted average for stop loss."""
print("\n" + "=" * 60)
print("TEST 7: DCA With Multiple Consecutive Buys")
print("=" * 60)
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
"actions": [{"type": "buy", "amount_percent": 20}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
},
"initial_balance": 10000.0,
"ave_api_key": "test",
"ave_api_plan": "free",
}
# 3 consecutive 2% drops = 3 buys at $0.58, $0.57, $0.56
# Then drop to $0.50 which is below 5% from average (~$0.57 * 0.95 = $0.54)
klines = [
{"close": "0.60", "timestamp": 1000, "open": "0.60", "high": "0.60", "low": "0.60", "volume": "1000"},
{"close": "0.588", "timestamp": 2000}, # 2% drop -> BUY 1 @ $0.588
{"close": "0.576", "timestamp": 3000}, # 2% drop -> BUY 2 @ $0.576
{"close": "0.565", "timestamp": 4000}, # 2% drop -> BUY 3 @ $0.565
{"close": "0.50", "timestamp": 5000}, # Below 5% from avg -> STOP LOSS
]
test = TestBacktestEngine()
engine, result = test._run_backtest(config, klines)
test._trace_portfolio(engine, 10000.0)
print(f"\nResults:")
print(f" Trades: {len(engine.trades)} (expected 3: 2 buys + stop loss)")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# Verify: 2 buys + 1 sell (stop loss) = 3 trades
# The 3rd buy @ $0.565 doesn't happen because stop loss triggers at $0.5 first
assert len(engine.trades) == 3, f"Expected 3 trades, got {len(engine.trades)}"
# Verify last trade is stop loss
last_trade = engine.trades[-1]
assert last_trade["type"] == "sell", "Last trade should be sell"
assert last_trade.get("exit_reason") == "stop_loss", f"Last trade should be stop_loss, got {last_trade.get('exit_reason')}"
# Verify max drawdown is reasonable (close to stop loss %)
# Actual loss should be around 5% from weighted average
assert result['max_drawdown'] < 10, f"Max drawdown {result['max_drawdown']}% is too high for 5% stop loss"
# Position is now 0 after stop loss, so avg_entry_price is None
print(f" Position closed: {engine.position == 0}")
print(f" Final balance: ${engine.current_balance:.2f}")
print("PASSED")
return True
def test_stop_loss_always_results_in_loss():
"""Test that stop loss ALWAYS results in a loss, never a gain.
This tests the scenario where:
- You start with $10,000
- Price keeps dropping, triggering multiple buys
- Stop loss triggers, selling your entire position
- Final balance MUST be less than initial balance
"""
print("\n" + "=" * 60)
print("TEST 8: Stop Loss Always Results In Loss")
print("=" * 60)
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
"actions": [{"type": "buy", "amount_percent": 20}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
},
"initial_balance": 10000.0,
"ave_api_key": "test",
"ave_api_plan": "free",
}
# Price scenario: drops each kline, triggering multiple buys
# Final drop triggers stop loss
#
# $0.60 -> $0.588 (2% drop) -> BUY 1 @ $0.588
# $0.588 -> $0.576 (2% drop) -> BUY 2 @ $0.576
# $0.576 -> $0.565 (2% drop) -> BUY 3 @ $0.565
# $0.565 -> $0.535 (5.3% drop) -> STOP LOSS @ $0.535 (5% from weighted avg ~$0.576)
klines = [
{"close": "0.60", "timestamp": 1000},
{"close": "0.588", "timestamp": 2000}, # BUY 1
{"close": "0.576", "timestamp": 3000}, # BUY 2
{"close": "0.565", "timestamp": 4000}, # BUY 3
{"close": "0.535", "timestamp": 5000}, # STOP LOSS
]
test = TestBacktestEngine()
engine, result = test._run_backtest(config, klines)
print(f"\nSetup:")
print(f" Initial balance: $10,000")
print(f" Stop loss: 5%")
print(f" Each buy: 20% of current balance")
print(f"\nTrades:")
for i, trade in enumerate(engine.trades):
exit_info = f" ({trade.get('exit_reason', '')})" if 'exit_reason' in trade else ""
print(f" {i+1}. {trade['type']} @ ${trade['price']} - ${trade['amount']:.2f}{exit_info}")
print(f"\nResults:")
print(f" Final balance: ${engine.current_balance:.2f}")
print(f" Total return: {result['total_return']:.2f}%")
print(f" Max drawdown: {result['max_drawdown']:.2f}%")
# CRITICAL ASSERTION: Stop loss MUST result in loss
assert engine.current_balance < 10000.0, \
f"BUG: Stop loss resulted in GAIN! Balance went from $10,000 to ${engine.current_balance:.2f}"
# Also verify total return is negative
assert result['total_return'] < 0, \
f"BUG: Total return is positive ({result['total_return']:.2f}%) after stop loss!"
# Max drawdown should reflect the actual loss (close to stop loss %)
assert result['max_drawdown'] < 10, \
f"Max drawdown ({result['max_drawdown']:.2f}%) seems too high"
print(f"\n✓ PASSED: Stop loss correctly resulted in ${10000 - engine.current_balance:.2f} loss")
return True
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) run_tests()
test_dca_multiple_buys()
test_stop_loss_always_results_in_loss()

View File

@@ -8,10 +8,7 @@ import type {
AuthResponse, AuthResponse,
BotChatRequest, BotChatRequest,
BotChatResponse, BotChatResponse,
StrategyConfig, StrategyConfig
Conversation,
ConversationWithMessages,
Message
} from './types'; } from './types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
@@ -21,16 +18,6 @@ function getAuthHeaders(): HeadersInit {
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }; 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> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'An error occurred' })); const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
@@ -47,7 +34,7 @@ async function handleResponse<T>(response: Response): Promise<T> {
errorMessage = `HTTP error ${response.status}`; errorMessage = `HTTP error ${response.status}`;
} }
throw new ApiError(errorMessage, response.status); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} }
@@ -250,58 +237,5 @@ export const api = {
}); });
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response); 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);
}
} }
}; };

View File

@@ -186,24 +186,3 @@ export interface TokenSearchResult {
address: string; address: string;
chain: 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[];
}

View File

@@ -1,68 +0,0 @@
<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>

View File

@@ -1,115 +0,0 @@
<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>

View File

@@ -3,21 +3,24 @@
interface Props { interface Props {
bot: Bot; bot: Bot;
onOpen?: (botId: string) => void;
onDelete?: (botId: string) => void; onDelete?: (botId: string) => void;
showActions?: boolean; showActions?: boolean;
} }
let { bot, onDelete, showActions = true }: Props = $props(); let { bot, onOpen, onDelete, showActions = true }: Props = $props();
function handleOpen() {
onOpen?.(bot.id);
}
function handleDelete(e: Event) { function handleDelete(e: Event) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDelete?.(bot.id); onDelete?.(bot.id);
} }
</script> </script>
<div class="bot-card"> <div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}>
<a href="/chat/{bot.id}" class="bot-card-link" data-sveltekit-preload-data="hover" aria-label="Open {bot.name}"></a>
<div class="bot-info"> <div class="bot-info">
<h3>{bot.name}</h3> <h3>{bot.name}</h3>
{#if bot.description} {#if bot.description}
@@ -26,8 +29,8 @@
<span class="bot-status status-{bot.status}">{bot.status}</span> <span class="bot-status status-{bot.status}">{bot.status}</span>
</div> </div>
{#if showActions} {#if showActions}
<div class="bot-actions" role="group"> <div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group">
<a href="/chat/{bot.id}" class="btn btn-primary" data-sveltekit-preload-data="hover">Open</a> <button class="btn btn-primary" onclick={handleOpen}>Open</button>
<button class="btn btn-danger" onclick={handleDelete}>Delete</button> <button class="btn btn-danger" onclick={handleDelete}>Delete</button>
</div> </div>
{/if} {/if}
@@ -35,11 +38,11 @@
<style> <style>
.bot-card { .bot-card {
position: relative;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
cursor: pointer;
transition: transform 0.2s, border-color 0.2s; transition: transform 0.2s, border-color 0.2s;
} }
@@ -48,17 +51,13 @@
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
} }
.bot-card-link { .bot-card:focus {
position: absolute; outline: 2px solid #667eea;
inset: 0; outline-offset: 2px;
border-radius: 12px;
z-index: 0;
} }
.bot-info { .bot-info {
margin-bottom: 1rem; margin-bottom: 1rem;
position: relative;
z-index: 1;
} }
.bot-info h3 { .bot-info h3 {
@@ -99,8 +98,6 @@
.bot-actions { .bot-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
position: relative;
z-index: 2;
} }
.btn { .btn {
@@ -111,7 +108,6 @@
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, opacity 0.2s;
text-decoration: none;
} }
.btn-primary { .btn-primary {

View File

@@ -1,434 +0,0 @@
<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>

View File

@@ -1,47 +0,0 @@
<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>

View File

@@ -1,188 +0,0 @@
<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>

View File

@@ -1,113 +0,0 @@
<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>

View File

@@ -3,24 +3,16 @@
import type { ChatMessage } from '$lib/stores/chatStore'; import type { ChatMessage } from '$lib/stores/chatStore';
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown'; import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
interface ToolGroup {
category: string;
label: string;
requiresBot: boolean;
tools: ToolItem[];
}
interface ToolItem { interface ToolItem {
name: string; name: string;
description: string; description: string;
command: string; command: string;
} }
const TOOLS: ToolGroup[] = [ const TOOLS: { category: string; label: string; tools: ToolItem[] }[] = [
{ {
category: 'randebu', category: 'randebu',
label: '🤖 Randebu Built-in', label: '🤖 Randebu Built-in',
requiresBot: true,
tools: [ tools: [
{ name: 'backtest', description: 'Run strategy backtest', command: '/backtest' }, { name: 'backtest', description: 'Run strategy backtest', command: '/backtest' },
{ name: 'simulate', description: 'Start/stop simulation', command: '/simulate' }, { name: 'simulate', description: 'Start/stop simulation', command: '/simulate' },
@@ -30,7 +22,6 @@
{ {
category: 'ave', category: 'ave',
label: '☁️ AVE Cloud Skills', label: '☁️ AVE Cloud Skills',
requiresBot: false,
tools: [ tools: [
{ name: 'search', description: 'Token search', command: '/search' }, { name: 'search', description: 'Token search', command: '/search' },
{ name: 'trending', description: 'Popular tokens', command: '/trending' }, { name: 'trending', description: 'Popular tokens', command: '/trending' },
@@ -45,11 +36,8 @@
bot: Bot | null; bot: Bot | null;
messages: ChatMessage[]; messages: ChatMessage[];
isSending?: boolean; isSending?: boolean;
isBlocked?: boolean;
blockedReason?: string | null;
onSendMessage: (message: string) => void; onSendMessage: (message: string) => void;
onSelectBot?: (botId: string) => void; onSelectBot?: (botId: string) => void;
onLogin?: () => void;
availableBots?: Bot[]; availableBots?: Bot[];
showBotSelector?: boolean; showBotSelector?: boolean;
} }
@@ -58,11 +46,8 @@
bot, bot,
messages, messages,
isSending = false, isSending = false,
isBlocked = false,
blockedReason = null,
onSendMessage, onSendMessage,
onSelectBot, onSelectBot,
onLogin,
availableBots = [], availableBots = [],
showBotSelector = false showBotSelector = false
}: Props = $props(); }: Props = $props();
@@ -75,24 +60,7 @@
let selectedIndex = $state(0); let selectedIndex = $state(0);
// Use $derived for filteredTools // Use $derived for filteredTools
// Filter tools based on whether user has a bot let filteredTools = $derived(messageInput.startsWith('/') ? TOOLS.flatMap(t => t.tools).filter(tool => tool.name.toLowerCase().startsWith(messageInput.slice(1).toLowerCase()) || tool.command.toLowerCase().startsWith(messageInput.slice(1).toLowerCase())) : []);
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() { function handleSend() {
if (!messageInput.trim()) return; if (!messageInput.trim()) return;
@@ -337,37 +305,12 @@
{/if} {/if}
</div> </div>
<div class="input-container"> {#if bot}
{#if isBlocked} <div class="input-container">
<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} {#if showSlashMenu && filteredTools.length > 0}
<div class="slash-menu" style="top: {slashMenuPosition.top}px; left: {slashMenuPosition.left}px;"> <div class="slash-menu" style="top: {slashMenuPosition.top}px; left: {slashMenuPosition.left}px;">
<div class="slash-menu-header">Available Commands</div> <div class="slash-menu-header">Available Commands</div>
{#each visibleGroups as group} {#each TOOLS as group}
{#if group.tools.some(t => filteredTools.includes(t))} {#if group.tools.some(t => filteredTools.includes(t))}
<div class="slash-menu-category">{group.label}</div> <div class="slash-menu-category">{group.label}</div>
{#each group.tools.filter(t => filteredTools.includes(t)) as tool, i} {#each group.tools.filter(t => filteredTools.includes(t)) as tool, i}
@@ -382,13 +325,7 @@
{/each} {/each}
{/if} {/if}
{/each} {/each}
<div class="slash-menu-hint"> <div class="slash-menu-hint">Press Tab to select, Enter to send</div>
{#if !bot}
Login to access bot commands
{:else}
Press Tab to select, Enter to send
{/if}
</div>
</div> </div>
{/if} {/if}
<textarea <textarea
@@ -397,14 +334,14 @@
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Describe your trading strategy... (or type / for commands)" placeholder="Describe your trading strategy... (or type / for commands)"
rows="1" rows="1"
disabled={isSending}
></textarea> ></textarea>
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}> <button onclick={handleSend}>
Send Send
</button> </button>
{/if} </div>
</div> {/if}
</div> </div>
<style> <style>
.chat-interface { .chat-interface {
display: flex; display: flex;
@@ -688,56 +625,6 @@
} }
} }
.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 { .input-container {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -1,82 +0,0 @@
<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>

View File

@@ -1,178 +0,0 @@
<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>

View File

@@ -9,11 +9,3 @@ export { default as BacktestChart } from './BacktestChart.svelte';
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte'; export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
export { default as TokenPicker } from './TokenPicker.svelte'; export { default as TokenPicker } from './TokenPicker.svelte';
export { default as ConditionBuilder } from './ConditionBuilder.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';

View File

@@ -10,7 +10,7 @@ export interface ChatMessage {
} }
// Fallback UUID generator for environments where crypto.randomUUID is not available // Fallback UUID generator for environments where crypto.randomUUID is not available
export function generateId(): string { function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
} }

View File

@@ -1,96 +0,0 @@
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
);

View File

@@ -28,18 +28,3 @@ export {
register, register,
logout logout
} from './authStore'; } 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';

View File

@@ -14,7 +14,7 @@ export interface ParsedSegment {
content: string; content: string;
items?: string[]; items?: string[];
headers?: InlineSegment[][]; headers?: InlineSegment[][];
rows?: InlineSegment[][][]; rows?: InlineSegment[][];
} }
export function parseMarkdown(text: string): ParsedSegment[] { export function parseMarkdown(text: string): ParsedSegment[] {

View File

@@ -7,13 +7,6 @@
onMount(() => { onMount(() => {
initAuth(); 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> </script>

View File

@@ -16,7 +16,8 @@
<h1>Randebu</h1> <h1>Randebu</h1>
<p class="tagline">Create trading bots through conversation with AI</p> <p class="tagline">Create trading bots through conversation with AI</p>
<div class="cta"> <div class="cta">
<a href="/chat" class="btn btn-primary">Try Now - Free</a> <a href="/register" class="btn btn-primary">Get Started</a>
<a href="/login" class="btn btn-secondary">Login</a>
</div> </div>
</div> </div>

View File

@@ -53,7 +53,7 @@
isSending = true; isSending = true;
// Add user's message immediately so it shows even before API response // Add user's message immediately so it shows even before API response
addMessage({ role: 'user', content: message, thinking: null }); addMessage({ role: 'user', content: message });
try { try {
// Add timeout to prevent hanging requests // Add timeout to prevent hanging requests

View File

@@ -1,110 +0,0 @@
<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>

View File

@@ -1,585 +0,0 @@
<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>

View File

@@ -40,7 +40,7 @@
showCreateModal = false; showCreateModal = false;
newBotName = ''; newBotName = '';
newBotDescription = ''; newBotDescription = '';
goto(`/chat/${bot.id}`); goto(`/bot/${bot.id}`);
} catch (e) { } catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create bot'; createError = e instanceof Error ? e.message : 'Failed to create bot';
} finally { } finally {
@@ -96,7 +96,7 @@
{:else} {:else}
<div class="bots-grid"> <div class="bots-grid">
{#each $botsStore as bot} {#each $botsStore as bot}
<BotCard {bot} onDelete={deleteBot} /> <BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -1,166 +0,0 @@
<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>