Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
4cc0d982d6 fix: update MiniMax API endpoint from api.minimax.chat to api.minimax.io
The MiniMax API endpoint should be api.minimax.io, not api.minimax.chat.

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

Fixes #43
2026-04-10 02:59:24 +00:00
42 changed files with 477 additions and 6282 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "ave-cloud-skill"]
path = ave-cloud-skill
url = https://github.com/AveCloud/ave-cloud-skill.git

Submodule ave-cloud-skill deleted from 5eaef99e15

View File

@@ -34,9 +34,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
} }
location /ws/ { location /ws/ {

View File

@@ -10,8 +10,6 @@ Environment="PATH=/var/www/bot/src/backend/venv/bin"
ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
Restart=always Restart=always
RestartSec=10 RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=300
EnvironmentFile=/var/www/bot/data/.env EnvironmentFile=/var/www/bot/data/.env

View File

@@ -1,27 +0,0 @@
# Open Issues
## Frontend
### Token Address Confirmation Dialog
- **Priority**: High
- **Status**: Open
- **Description**: When user configures a trading strategy via chat and mentions a token (e.g., "buy PEPE"), the AI asks for the token contract address. The frontend should show a confirmation dialog allowing user to:
1. See the token the AI detected (PEPE)
2. Enter/confirm the BSC contract address
3. Save the strategy with the confirmed address
**Related Files**:
- Frontend: `src/frontend/src/routes/bot/[id]/+page.svelte`
- Backend: `src/backend/app/services/ai_agent/conversational.py`
**Acceptance Criteria**:
- [ ] Modal/dialog appears when AI detects a token without address
- [ ] User can enter the contract address (0x...)
- [ ] Strategy is saved only after user confirmation
- [ ] Clear error handling if address is invalid
---
## Backend
*No open backend issues*

View File

@@ -32,7 +32,7 @@ MINIMAX_API_KEY=your-minimax-api-key
# MiniMax model to use # MiniMax model to use
# Common options: MiniMax-Text-01, MiniMax-M2.1 # Common options: MiniMax-Text-01, MiniMax-M2.1
MINIMAX_MODEL=MiniMax-M2.7 MINIMAX_MODEL=MiniMax-Text-01
# ============================================================================= # =============================================================================
# AVE CLOUD API # AVE CLOUD API

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Annotated from typing import Annotated
@@ -14,7 +14,6 @@ from ..core.config import get_settings
from ..core.limiter import limiter from ..core.limiter import limiter
from ..db.schemas import ( from ..db.schemas import (
UserCreate, UserCreate,
LoginRequest,
UserResponse, UserResponse,
Token, Token,
UserSettings, UserSettings,
@@ -59,7 +58,7 @@ def get_current_user(
@router.post( @router.post(
"/register", response_model=Token, status_code=status.HTTP_201_CREATED "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
) )
def register(user: UserCreate, db: Session = Depends(get_db)): def register(user: UserCreate, db: Session = Depends(get_db)):
existing_user = db.query(User).filter(User.email == user.email).first() existing_user = db.query(User).filter(User.email == user.email).first()
@@ -76,21 +75,18 @@ def register(user: UserCreate, db: Session = Depends(get_db)):
db.add(db_user) db.add(db_user)
db.commit() db.commit()
db.refresh(db_user) db.refresh(db_user)
return db_user
# Generate and return access token so frontend can proceed immediately
access_token = create_access_token(data={"sub": db_user.id})
return Token(access_token=access_token, token_type="bearer")
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute") @limiter.limit("5/minute")
def login( def login(
request: Request, request: Request,
login_data: LoginRequest, form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = db.query(User).filter(User.email == login_data.username).first() user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(login_data.password, user.password_hash): if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password", detail="Incorrect email or password",

View File

@@ -22,7 +22,6 @@ def run_backtest_sync(
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any] backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
): ):
import asyncio import asyncio
import json
from ..services.backtest.engine import BacktestEngine from ..services.backtest.engine import BacktestEngine
from ..core.database import SessionLocal from ..core.database import SessionLocal
@@ -32,19 +31,6 @@ def run_backtest_sync(
running_backtests[backtest_id] = engine running_backtests[backtest_id] = engine
try: try:
results = await engine.run() results = await engine.run()
# Convert datetime objects to ISO strings for JSON serialization
def convert_datetime(obj):
if isinstance(obj, datetime):
return obj.isoformat()
elif isinstance(obj, dict):
return {k: convert_datetime(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_datetime(i) for i in obj]
return obj
results = convert_datetime(results)
db = SessionLocal() db = SessionLocal()
try: try:
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
@@ -55,18 +41,17 @@ def run_backtest_sync(
db.commit() db.commit()
for signal in engine.signals: for signal in engine.signals:
signal_data = convert_datetime(signal)
db_signal = Signal( db_signal = Signal(
id=signal_data["id"], id=signal["id"],
bot_id=signal_data["bot_id"], bot_id=signal["bot_id"],
run_id=signal_data["run_id"], run_id=signal["run_id"],
signal_type=signal_data["signal_type"], signal_type=signal["signal_type"],
token=signal_data["token"], token=signal["token"],
price=signal_data["price"], price=signal["price"],
confidence=signal_data.get("confidence"), confidence=signal.get("confidence"),
reasoning=signal_data.get("reasoning"), reasoning=signal.get("reasoning"),
executed=signal_data.get("executed", False), executed=signal.get("executed", False),
created_at=signal["created_at"], # Use original datetime, not converted string created_at=signal["created_at"],
) )
db.add(db_signal) db.add(db_signal)
db.commit() db.commit()
@@ -169,81 +154,9 @@ def get_backtest(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found" status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
) )
# Add progress from running engine if available
if backtest.status == "running" and run_id in running_backtests:
engine = running_backtests[run_id]
backtest.progress = engine.progress
return backtest return backtest
@router.get("/bots/{bot_id}/backtest/{run_id}/trades")
def get_backtest_trades(
bot_id: str,
run_id: str,
page: int = 1,
per_page: int = 5,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get paginated trade history for a specific backtest.
Args:
page: Page number (1-indexed)
per_page: Number of trades per page (default 5, max 20)
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
backtest = (
db.query(Backtest)
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
.first()
)
if not backtest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
)
# Get trades from result
result = backtest.result or {}
# Handle case where result might be a JSON string
if isinstance(result, str):
import json
result = json.loads(result)
all_trades = result.get("trades", []) or []
total_trades = len(all_trades)
# Validate pagination params
per_page = min(max(per_page, 1), 20) # Clamp between 1 and 20
page = max(page, 1)
# Calculate pagination
total_pages = max(1, (total_trades + per_page - 1) // per_page) if total_trades > 0 else 1
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Get page of trades (return empty list if start_idx >= total_trades)
paginated_trades = all_trades[start_idx:end_idx] if start_idx < total_trades else []
return {
"backtest_id": run_id,
"trades": paginated_trades,
"total_trades": total_trades,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
}
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse]) @router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
def list_backtests( def list_backtests(
bot_id: str, bot_id: str,
@@ -264,7 +177,6 @@ def list_backtests(
db.query(Backtest) db.query(Backtest)
.filter(Backtest.bot_id == bot_id) .filter(Backtest.bot_id == bot_id)
.order_by(Backtest.started_at.desc()) .order_by(Backtest.started_at.desc())
.limit(5)
.all() .all()
) )
return backtests return backtests
@@ -299,12 +211,7 @@ def stop_backtest(
if run_id in running_backtests: if run_id in running_backtests:
engine = running_backtests[run_id] engine = running_backtests[run_id]
engine.running = False # Direct sync access to running flag asyncio.create_task(engine.stop())
backtest.status = "stopped"
backtest.ended_at = datetime.utcnow()
db.commit()
elif backtest.status == "running":
# Engine already finished but status not updated
backtest.status = "stopped" backtest.status = "stopped"
backtest.ended_at = datetime.utcnow() backtest.ended_at = datetime.utcnow()
db.commit() db.commit()

View File

@@ -16,7 +16,6 @@ from ..db.schemas import (
) )
from ..db.models import Bot, BotConversation, User from ..db.models import Bot, BotConversation, User
from ..services.ai_agent.crew import get_trading_crew from ..services.ai_agent.crew import get_trading_crew
from ..services.ai_agent import get_conversational_agent
router = APIRouter() router = APIRouter()
MAX_BOTS_PER_USER = 3 MAX_BOTS_PER_USER = 3
@@ -184,20 +183,21 @@ def chat(
.order_by(BotConversation.created_at) .order_by(BotConversation.created_at)
.all() .all()
) )
history_for_agent = [ history_for_crew = [
{"role": conv.role, "content": conv.content} {"role": conv.role, "content": conv.content}
for conv in conversation_history[-10:] for conv in conversation_history[-10:]
] ]
user_message = request.message user_message = request.message
if request.strategy_config:
# Use ConversationalAgent for natural chat with tool-calling crew = get_trading_crew()
agent = get_conversational_agent(bot_id=bot_id) result = crew.chat(user_message, history_for_crew)
result = agent.chat(user_message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.") assistant_content = result.get("response", "I couldn't process your request.")
if result.get("success") and result.get("strategy_config"):
bot.strategy_config = result["strategy_config"]
db.commit()
# Save conversation
db_conversation = BotConversation( db_conversation = BotConversation(
bot_id=bot_id, bot_id=bot_id,
role="user", role="user",
@@ -214,20 +214,37 @@ def chat(
db.commit() db.commit()
db.refresh(db_assistant) db.refresh(db_assistant)
# If strategy was updated via tool, refresh bot data return BotChatResponse(
if result.get("strategy_updated"): response=assistant_content,
db.refresh(bot) strategy_config=result.get("strategy_config"),
success=result.get("success", False),
)
else:
crew = get_trading_crew()
result = crew.chat(user_message, history_for_crew)
assistant_content = result.get("response", "I couldn't process your request.")
db_conversation = BotConversation(
bot_id=bot_id,
role="user",
content=user_message,
)
db.add(db_conversation)
db_assistant = BotConversation(
bot_id=bot_id,
role="assistant",
content=assistant_content,
)
db.add(db_assistant)
db.commit()
db.refresh(db_assistant)
return BotChatResponse( return BotChatResponse(
response=assistant_content, response=assistant_content,
thinking=result.get("thinking"), strategy_config=result.get("strategy_config"),
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
success=result.get("success", False), success=result.get("success", False),
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
strategy_data=result.get("strategy_data")
if result.get("strategy_needs_confirmation")
else None,
token_search_results=result.get("token_search_results"),
) )

View File

@@ -1,6 +1,5 @@
import uuid import uuid
import asyncio import asyncio
import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,9 +11,6 @@ from ..core.database import get_db
from ..core.config import get_settings from ..core.config import get_settings
from ..db.schemas import SimulationCreate, SimulationResponse from ..db.schemas import SimulationCreate, SimulationResponse
from ..db.models import Bot, Simulation, Signal, User from ..db.models import Bot, Simulation, Signal, User
from ..services.ave.client import AveCloudClient
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -26,7 +22,6 @@ def run_simulation_sync(
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any] simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
): ):
import asyncio import asyncio
import time
from ..services.simulate.engine import SimulateEngine from ..services.simulate.engine import SimulateEngine
from ..core.database import SessionLocal from ..core.database import SessionLocal
@@ -34,19 +29,8 @@ def run_simulation_sync(
engine = SimulateEngine(config) engine = SimulateEngine(config)
engine.run_id = simulation_id engine.run_id = simulation_id
running_simulations[simulation_id] = engine running_simulations[simulation_id] = engine
try:
# Serialize signals for JSON storage (convert datetime to string) results = await engine.run()
def serialize_signal(s):
created = s.get("created_at")
if hasattr(created, "isoformat"):
created = created.isoformat()
return {
**s,
"created_at": created
}
def save_progress():
"""Save current progress to database."""
db = SessionLocal() db = SessionLocal()
try: try:
simulation = ( simulation = (
@@ -54,50 +38,27 @@ def run_simulation_sync(
) )
if simulation: if simulation:
simulation.status = engine.status simulation.status = engine.status
simulation.signals = [serialize_signal(s) for s in engine.signals] simulation.signals = engine.signals
simulation.klines = [ db.commit()
{"time": k.get("time"), "close": k.get("close")}
for k in engine.klines for signal in engine.signals:
] db_signal = Signal(
simulation.trade_log = engine.trade_log id=signal["id"],
# Save portfolio data bot_id=signal["bot_id"],
simulation.portfolio = { run_id=signal["run_id"],
"initial_balance": engine.config.get("initial_balance", 10000), signal_type=signal["signal_type"],
"current_balance": engine.current_balance, token=signal["token"],
"position": engine.position, price=signal["price"],
"position_token": engine.position_token, confidence=signal.get("confidence"),
"entry_price": engine.entry_price, reasoning=signal.get("reasoning"),
"current_price": engine.last_close, executed=signal.get("executed", False),
} created_at=signal["created_at"],
)
db.add(db_signal)
db.commit() db.commit()
finally: finally:
db.close() db.close()
async def run_with_progress_save():
"""Run simulation and save progress periodically."""
last_save_time = time.time()
save_interval = 5 # Save every 5 seconds
while engine.running and engine.status == "running":
await asyncio.sleep(1) # Check every second
current_time = time.time()
if current_time - last_save_time >= save_interval:
save_progress()
last_save_time = current_time
# Final save when done
save_progress()
try:
# Run both simulation and progress saving concurrently
await asyncio.gather(
engine.run(),
run_with_progress_save()
)
finally: finally:
# Save final state
save_progress()
if simulation_id in running_simulations: if simulation_id in running_simulations:
del running_simulations[simulation_id] del running_simulations[simulation_id]
@@ -126,35 +87,20 @@ async def start_simulation(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
) )
# Check if there's already a running simulation for this bot
existing_simulation = (
db.query(Simulation)
.filter(Simulation.bot_id == bot_id, Simulation.status == "running")
.first()
)
if existing_simulation:
# Stop the existing simulation first
if existing_simulation.id in running_simulations:
running_simulations[existing_simulation.id].stop()
del running_simulations[existing_simulation.id]
existing_simulation.status = "stopped"
db.commit()
settings = get_settings() settings = get_settings()
simulation_id = str(uuid.uuid4()) simulation_id = str(uuid.uuid4())
# Create AVE client for klines fetching check_interval = config.check_interval
ave_client = AveCloudClient( if settings.AVE_API_PLAN != "pro" and check_interval < 60:
api_key=settings.AVE_API_KEY, check_interval = 60
plan=settings.AVE_API_PLAN,
)
simulation_config = { simulation_config = {
"bot_id": bot_id, "bot_id": bot_id,
"token": config.token, "token": config.token,
"chain": config.chain, "chain": config.chain,
"kline_interval": config.kline_interval, "duration_seconds": config.duration_seconds,
"auto_execute": False, # Always paper trade "check_interval": check_interval,
"auto_execute": config.auto_execute,
"strategy_config": bot.strategy_config, "strategy_config": bot.strategy_config,
"ave_api_key": settings.AVE_API_KEY, "ave_api_key": settings.AVE_API_KEY,
"ave_api_plan": settings.AVE_API_PLAN, "ave_api_plan": settings.AVE_API_PLAN,
@@ -168,46 +114,19 @@ async def start_simulation(
config={ config={
"token": config.token, "token": config.token,
"chain": config.chain, "chain": config.chain,
"kline_interval": config.kline_interval, "duration_seconds": config.duration_seconds,
"check_interval": check_interval,
"auto_execute": config.auto_execute,
}, },
signals=[], signals=[],
klines=[],
) )
db.add(simulation) db.add(simulation)
db.commit() db.commit()
db.refresh(simulation) db.refresh(simulation)
# Fetch klines SYNCHRONOUSLY so user can see chart immediately db_url = str(settings.DATABASE_URL)
try:
token_id = f"{config.token}-{config.chain}"
# Calculate time range (last 1 hour)
import time
end_time = int(time.time() * 1000)
start_time = end_time - (60 * 60 * 1000) # 1 hour ago
klines_data = await ave_client.get_klines(
token_id,
interval=config.kline_interval,
start_time=start_time,
end_time=end_time,
limit=500
)
klines_for_chart = [
{"time": k.get("time"), "close": k.get("close")}
for k in sorted(klines_data, key=lambda x: x.get("time", 0))
]
# Update simulation with klines
simulation.klines = klines_for_chart
db.commit()
db.refresh(simulation)
logger.info(f"Fetched {len(klines_for_chart)} klines for simulation {simulation_id}")
except Exception as e:
logger.error(f"Failed to fetch klines: {e}")
# Run simulation in background for signal processing
background_tasks.add_task( background_tasks.add_task(
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config run_simulation_sync, simulation_id, db_url, bot_id, simulation_config
) )
return simulation return simulation
@@ -274,9 +193,6 @@ def list_simulations(
if sim.id in running_simulations: if sim.id in running_simulations:
engine = running_simulations[sim.id] engine = running_simulations[sim.id]
sim.signals = engine.get_signals() sim.signals = engine.get_signals()
# Include klines from running engine for chart display
if hasattr(engine, 'klines'):
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
return simulations return simulations
@@ -308,15 +224,10 @@ def stop_simulation(
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found" status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
) )
# Always update status to stopped, even if engine is not in memory
simulation.status = "stopped"
# Try to stop the engine if it's still in memory
if run_id in running_simulations: if run_id in running_simulations:
engine = running_simulations[run_id] engine = running_simulations[run_id]
engine.stop() asyncio.create_task(engine.stop())
del running_simulations[run_id] simulation.status = "stopped"
db.commit() db.commit()
return {"status": "stopped", "run_id": run_id} return {"status": "stopping", "run_id": run_id}

View File

@@ -1 +0,0 @@
../../ave-cloud-skill/scripts/ave

View File

@@ -93,9 +93,6 @@ class Simulation(Base):
status = Column(String, nullable=False) status = Column(String, nullable=False)
config = Column(JSON, nullable=False) config = Column(JSON, nullable=False)
signals = Column(JSON) signals = Column(JSON)
klines = Column(JSON) # Price data for chart display
trade_log = Column(JSON) # Trade activity log
portfolio = Column(JSON) # Portfolio data
bot = relationship("Bot", back_populates="simulations") bot = relationship("Bot", back_populates="simulations")

View File

@@ -8,11 +8,6 @@ class UserCreate(BaseModel):
password: str password: str
class LoginRequest(BaseModel):
username: EmailStr
password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: str
email: str email: str
@@ -69,7 +64,6 @@ class BotResponse(BaseModel):
class BacktestCreate(BaseModel): class BacktestCreate(BaseModel):
token: str token: str
token_name: Optional[str] = None
chain: str chain: str
timeframe: str timeframe: str
start_date: str start_date: str
@@ -91,7 +85,6 @@ class BacktestResponse(BaseModel):
status: str status: str
config: dict config: dict
result: Optional[dict] result: Optional[dict]
progress: Optional[int] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -100,7 +93,9 @@ class BacktestResponse(BaseModel):
class SimulationCreate(BaseModel): class SimulationCreate(BaseModel):
token: str token: str
chain: str chain: str
kline_interval: str = "1m" duration_seconds: int = 3600
check_interval: int = 60
auto_execute: bool = False
@field_validator("chain") @field_validator("chain")
@classmethod @classmethod
@@ -117,12 +112,6 @@ class SimulationResponse(BaseModel):
status: str status: str
config: dict config: dict
signals: Optional[List[dict]] signals: Optional[List[dict]]
klines: Optional[List[dict]] = None # Price data for chart
trade_log: Optional[List[dict]] = None # Trade activity log
portfolio: Optional[dict] = None # Portfolio data
current_candle_index: Optional[int] = None # Progress: current candle
total_candles: Optional[int] = None # Progress: total candles
candles_processed: Optional[int] = None # Progress: candles processed
class Config: class Config:
from_attributes = True from_attributes = True
@@ -151,12 +140,8 @@ class BotChatRequest(BaseModel):
class BotChatResponse(BaseModel): class BotChatResponse(BaseModel):
response: str response: str
thinking: Optional[str] = None
strategy_config: Optional[dict] = None strategy_config: Optional[dict] = None
success: bool = False success: bool = False
strategy_needs_confirmation: Optional[bool] = False
strategy_data: Optional[dict] = None
token_search_results: Optional[List[dict]] = None
class SignalResponse(BaseModel): class SignalResponse(BaseModel):

View File

@@ -1,29 +1,4 @@
"""AI Agent module for conversational trading."""
from .agent import ConversationalAgent, get_conversational_agent
from .client import MiniMaxClient
from .tools import get_tool_registry, TOOL_REGISTRY
from .help import (
format_tools_list,
format_general_help,
format_tool_help,
format_skill_acknowledgment,
)
from .crew import TradingCrew, get_trading_crew from .crew import TradingCrew, get_trading_crew
from .llm_connector import MiniMaxLLM, MiniMaxConnector from .llm_connector import MiniMaxLLM, MiniMaxConnector
__all__ = [ __all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"]
"ConversationalAgent",
"get_conversational_agent",
"MiniMaxClient",
"get_tool_registry",
"TOOL_REGISTRY",
"format_tools_list",
"format_general_help",
"format_tool_help",
"format_skill_acknowledgment",
"TradingCrew",
"get_trading_crew",
"MiniMaxLLM",
"MiniMaxConnector",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,308 +0,0 @@
"""MiniMax API client for the conversational agent."""
import requests
from typing import Dict, Any, Optional, List
SYSTEM_PROMPT = """You are a helpful AI trading assistant named Randebu. You help users manage their trading bots.
IMPORTANT CHAIN LIMITATION:
- We ONLY support BSC (Binance Smart Chain) blockchain
- If user asks about any other chain (Solana, ETH, Base, etc.), respond with: "Currently we only support BSC (Binance Smart Chain). All trading strategies and token searches are performed on BSC."
- Never search or recommend tokens on other chains
- The search_tokens tool defaults to BSC, never change this
Your response must be valid JSON with exactly this structure:
{
"thinking": "Your internal reasoning and analysis (what you're thinking about)",
"response": "Your actual response to the user (be concise and helpful)",
"strategy_update": null or {
"conditions": [{"type": "price_drop" | "price_rise" | "volume_spike" | "price_level", "token": "TOKEN_SYMBOL", "token_address": null, "threshold": number, ...}],
"actions": [{"type": "buy" | "sell" | "hold", "amount_percent": number, ...}],
"risk_management": {"stop_loss_percent": number, "take_profit_percent": number}
}
}
Guidelines:
- "thinking" should be detailed reasoning about the user's request
- "response" should be conversational and clear
- "strategy_update" should be populated ONLY when the user provides specific trading parameters (percentages, tokens, conditions, etc.)
- IMPORTANT: When a token is mentioned, set "token_address": null and ask user to confirm the token address before saving. Your response should say something like: "I need to confirm the token address. Could you provide the contract address for [TOKEN]?"
- If no strategy parameters are provided, set "strategy_update" to null
- Be friendly, concise, and helpful in your response
Example 1 (no strategy update):
User: "What can this bot do?"
{
"thinking": "The user is asking about the bot's capabilities. I should explain the main features.",
"response": "Randebu is your AI trading assistant! It can monitor cryptocurrency prices and execute trades based on your configured strategies. Tell me your trading parameters and I'll set them up for you.",
"strategy_update": null
}
Example 2 (token needs confirmation):
User: "I want to buy PEPE when it drops 10%"
{
"thinking": "User wants to buy PEPE. I need the token contract address to proceed. I should ask for confirmation.",
"response": "I'd be happy to set up a buy order for PEPE! However, I need to confirm the token contract address. Could you provide the BSC contract address for PEPE? (It usually starts with 0x...)",
"strategy_update": {
"conditions": [{"type": "price_drop", "token": "PEPE", "token_address": null, "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": null
}
}
Example 3 (with token address provided by user):
User: "Buy 0x6982508145454Ce125dDE157d8d64a26D53f60a2 when it drops 10%"
{
"thinking": "User provided a contract address, I can use it directly.",
"response": "Perfect! I've configured your strategy to buy the token when it drops 10%.",
"strategy_update": {
"conditions": [{"type": "price_drop", "token": "TOKEN", "token_address": "0x6982508145454Ce125dDE157d8d64a26D53f60a2", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": null
}
}"""
TOOLS = [
{
"type": "function",
"function": {
"name": "search_tokens",
"description": "Search for tokens by keyword on BSC blockchain. Use this when user asks to search for a specific token or find tokens by name/symbol.",
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Token symbol or name to search for (e.g., 'PEPE', 'BTC')",
},
"limit": {
"type": "integer",
"description": "Number of tokens to return (default: 10)",
"default": 10,
},
},
"required": ["keyword"],
},
},
},
{
"type": "function",
"function": {
"name": "get_token",
"description": "Get detailed information about a specific token including price, market cap, and pairs. Use when user asks for token details or wants to find a specific token by contract address.",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
},
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
},
"required": ["address"],
},
},
},
{
"type": "function",
"function": {
"name": "get_price",
"description": "Get current price(s) for tokens. Use when user asks for token price or wants to compare prices of multiple tokens.",
"parameters": {
"type": "object",
"properties": {
"token_ids": {
"type": "string",
"description": "Comma-separated list of token IDs with chain suffix (e.g., 'PEPE-bsc,TRUMP-bsc')",
}
},
"required": ["token_ids"],
},
},
},
{
"type": "function",
"function": {
"name": "get_risk",
"description": "Get risk analysis for a token contract. Use when user asks about token risk, honeypot analysis, or safety assessment before trading.",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
},
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
},
"required": ["address"],
},
},
},
{
"type": "function",
"function": {
"name": "get_trending",
"description": "Get trending tokens on a blockchain. Use when user asks what's trending, top tokens, or popular tokens right now.",
"parameters": {
"type": "object",
"properties": {
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
"limit": {
"type": "integer",
"description": "Number of trending tokens to return (default: 10, max: 50)",
"default": 10,
},
},
},
},
},
{
"type": "function",
"function": {
"name": "run_backtest",
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically. Returns key metrics like ROI, win rate, max drawdown, etc. Use this when user asks to backtest, test strategy, or check historical performance.",
"parameters": {
"type": "object",
"properties": {
"token_address": {
"type": "string",
"description": "The BSC contract address of the token to backtest (required)",
},
"timeframe": {
"type": "string",
"description": "Timeframe for klines: '1d' (1 day), '4h' (4 hours), '1h' (1 hour), '15m' (15 minutes)",
"default": "1d",
},
"start_date": {
"type": "string",
"description": "Start date for backtest in YYYY-MM-DD format (e.g., '2024-01-01')",
},
"end_date": {
"type": "string",
"description": "End date for backtest in YYYY-MM-DD format (e.g., '2024-12-01')",
},
},
"required": ["token_address"],
},
},
},
{
"type": "function",
"function": {
"name": "manage_simulation",
"description": "Manage trading simulations: start, stop, or check status. Simulations run on real-time klines and show live portfolio updates. Use when user asks to run simulation, check simulation status, or stop simulation.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["start", "stop", "status", "results"],
"description": "Action to perform: 'start' (begin new simulation), 'stop' (stop running simulation), 'status' (check if simulation is running), 'results' (get results from current or latest simulation)",
},
"token_address": {
"type": "string",
"description": "Token contract address for simulation (required for 'start' action)",
},
"kline_interval": {
"type": "string",
"description": "Kline interval: '1m', '5m', '15m', '1h' (default: '1m')",
"default": "1m",
},
},
"required": ["action"],
},
},
},
]
SYSTEM_PROMPT_WITH_TOOLS = (
SYSTEM_PROMPT
+ """
You have access to tools:
- 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 information about a specific token. Use when user asks for token details.
- get_price(token_ids): Get current price(s) for tokens. Use when user asks for token price.
- 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 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 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 trading simulations. Actions: 'start' (begin new), 'stop' (stop running), 'status' (check if running), 'results' (get current/latest results).
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"}}
}
"""
)
class MiniMaxClient:
"""Client for MiniMax extended thinking API."""
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
self.api_key = api_key
self.model = model
self.endpoint = "https://api.minimax.io/v1/text/chatcompletion_v2"
def chat(
self,
messages: List[Dict[str, str]],
system_prompt: str,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
thinking_budget: int = 1500,
) -> Dict[str, Any]:
"""Send a chat request to MiniMax API."""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
all_messages = [{"role": "system", "content": system_prompt}] + messages
payload = {
"model": self.model,
"messages": all_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"thinking": {"type": "human", "budget_tokens": thinking_budget},
}
if tools:
payload["tools"] = tools
resp = requests.post(self.endpoint, headers=headers, json=payload)
return resp.json() or {}
def check_connection(self) -> bool:
"""Check if API is reachable."""
try:
resp = requests.post(
self.endpoint,
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "ping"}],
},
timeout=10,
)
return resp.status_code == 200
except Exception:
return False

View File

@@ -1,6 +1,6 @@
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from crewai import Agent, Task, Crew, LLM from crewai import Agent, Task, Crew
from .llm_connector import MiniMaxConnector from .llm_connector import MiniMaxConnector, MiniMaxLLM
from ...core.config import get_settings from ...core.config import get_settings
@@ -120,7 +120,7 @@ class StrategyExplainer:
def create_trading_designer_agent( def create_trading_designer_agent(
api_key: str, model: str = "MiniMax-M2.7" api_key: str, model: str = "MiniMax-Text-01"
) -> Agent: ) -> Agent:
connector = MiniMaxConnector(api_key=api_key, model=model) connector = MiniMaxConnector(api_key=api_key, model=model)
@@ -141,13 +141,13 @@ def create_trading_designer_agent(
role="Trading Strategy Designer", role="Trading Strategy Designer",
goal="Convert natural language trading requests into precise strategy configurations", goal="Convert natural language trading requests into precise strategy configurations",
backstory=system_prompt, backstory=system_prompt,
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"), llm=MiniMaxLLM(api_key=api_key, model=model),
verbose=True, verbose=True,
) )
def create_strategy_validator_agent( def create_strategy_validator_agent(
api_key: str, model: str = "MiniMax-M2.7" api_key: str, model: str = "MiniMax-Text-01"
) -> Agent: ) -> Agent:
return Agent( return Agent(
role="Strategy Validator", role="Strategy Validator",
@@ -155,13 +155,13 @@ def create_strategy_validator_agent(
backstory="""You are a meticulous strategy validator with expertise in trading systems. backstory="""You are a meticulous strategy validator with expertise in trading systems.
You check that all required parameters are present, values are reasonable, and the You check that all required parameters are present, values are reasonable, and the
strategy makes logical sense. You never approve strategies with missing or invalid data.""", strategy makes logical sense. You never approve strategies with missing or invalid data.""",
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"), llm=MiniMaxLLM(api_key=api_key, model=model),
verbose=True, verbose=True,
) )
def create_strategy_explainer_agent( def create_strategy_explainer_agent(
api_key: str, model: str = "MiniMax-M2.7" api_key: str, model: str = "MiniMax-Text-01"
) -> Agent: ) -> Agent:
return Agent( return Agent(
role="Strategy Explainer", role="Strategy Explainer",
@@ -169,13 +169,13 @@ def create_strategy_explainer_agent(
backstory="""You are a patient trading strategy explainer. You translate complex backstory="""You are a patient trading strategy explainer. You translate complex
strategy configurations into easy-to-understand language. You help users understand strategy configurations into easy-to-understand language. You help users understand
exactly what their strategies will do when triggered.""", exactly what their strategies will do when triggered.""",
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"), llm=MiniMaxLLM(api_key=api_key, model=model),
verbose=True, verbose=True,
) )
class TradingCrew: class TradingCrew:
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"): def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
self.validator = StrategyValidator() self.validator = StrategyValidator()

View File

@@ -1,83 +0,0 @@
"""Help formatters for slash commands and tool documentation."""
from typing import Optional
from .tools import get_tool_registry, SKILL_EMOJIS
def format_tools_list() -> str:
"""Format the tool registry as a help message."""
message = "📋 Available Tools\n\n"
for category in ["randebu", "ave"]:
tools = get_tool_registry().get(category, [])
if category == "randebu":
message += "🤖 Randebu Built-in:\n"
else:
message += "☁️ AVE Cloud Skills:\n"
for tool in tools:
message += f"{tool['command']} - {tool['description']}\n"
message += "\n"
message = (
message.rstrip() + "\n\nType /<tool-name> for detailed help on a specific tool."
)
return message
def format_skill_acknowledgment(tool_name: str, description: str) -> str:
"""Format a brief acknowledgment when a skill is activated."""
emoji = SKILL_EMOJIS.get(tool_name.lower(), "")
return f"{emoji} **{tool_name}** loaded. Ready for *{description}*, ask me away!"
def format_tool_help(tool_name: str) -> str:
"""Format detailed help for a specific tool."""
tool_name = tool_name.lstrip("/")
for category in ["randebu", "ave"]:
for tool in get_tool_registry().get(category, []):
if tool["name"].lower() == tool_name.lower():
cat_label = (
"Randebu Built-in" if category == "randebu" else "AVE Cloud Skill"
)
details = tool["details"]
message = (
f"🔍 {tool['command']} - {details['description']} ({cat_label})\n\n"
)
message += f"**Description:** {details['description']}\n"
message += f"**Commands:**\n {details['usage']}\n\n"
message += f"**Example:**\n```\n{details['example']}\n```"
return message
return f"Tool '{tool_name}' not found. Type / to see all available tools."
def format_general_help() -> str:
"""Format general help about Randebu."""
return """🤖 **Randebu - AI Trading Assistant**
Randebu is your AI trading assistant that helps you manage your trading bots on BSC (Binance Smart Chain).
**Getting Started:**
1. Create a bot on the dashboard
2. Describe your trading strategy in plain English
3. Run backtests to validate your strategy
4. Start simulations to see live trading
**Example Strategies:**
- "Buy PEPE when it drops 5%"
- "Sell if price rises 10% within 1 hour"
- "Buy when volume spikes by 200%"
**Slash Commands:**
- `/` - Show all available tools
- `/help` - Show this help message
- `/<tool-name>` - Get help on a specific tool
**Natural Language:**
You can also just describe what you want in natural language. For example:
- "What's the price of PEPE?"
- "Run a backtest on 0x... token"
- "Start a simulation on TRUMP"
"""

View File

@@ -1,9 +1,11 @@
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
import httpx import httpx
from crewai import LLM
class MiniMaxLLM: class MiniMaxLLM(LLM):
def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs): def __init__(self, api_key: str, model: str = "MiniMax-Text-01", **kwargs):
super().__init__(**kwargs)
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
self.base_url = "https://api.minimax.io/v1" self.base_url = "https://api.minimax.io/v1"
@@ -21,7 +23,7 @@ class MiniMaxLLM:
} }
with httpx.Client(timeout=60.0) as client: with httpx.Client(timeout=60.0) as client:
response = client.post( response = client.post(
f"{self.base_url}/text/chatcompletion_v2", f"{self.base_url}/chat/completions",
headers=headers, headers=headers,
json=payload, json=payload,
) )
@@ -33,7 +35,7 @@ class MiniMaxLLM:
class MiniMaxConnector: class MiniMaxConnector:
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"): def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model

View File

@@ -1,128 +0,0 @@
"""Tool registry and definitions for the conversational agent."""
from typing import Dict, Any, List
TOOL_REGISTRY: Dict[str, Any] = {
"randebu": [
{
"name": "backtest",
"description": "Run strategy backtest",
"category": "Randebu Built-in",
"command": "/backtest",
"details": {
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically.",
"usage": "/backtest [token_address] [--timeframe 1d|4h|1h|15m] [--start YYYY-MM-DD] [--end YYYY-MM-DD]",
"example": "Run a backtest on PEPE for the last 30 days",
},
},
{
"name": "simulate",
"description": "Start/stop simulation",
"category": "Randebu Built-in",
"command": "/simulate",
"details": {
"description": "Start or stop trading simulations that run on real-time klines.",
"usage": "/simulate start|stop|status|results [token_address]",
"example": "Start a simulation on PEPE",
},
},
{
"name": "strategy",
"description": "View/update strategy",
"category": "Randebu Built-in",
"command": "/strategy",
"details": {
"description": "View your current trading strategy or update it with new parameters.",
"usage": "Describe your strategy in plain English, e.g., 'Buy PEPE when price drops 5%'",
"example": "Buy PEPE when it drops 10% within 1 hour",
},
},
],
"ave": [
{
"name": "search",
"description": "Token search",
"category": "AVE Cloud Skills",
"command": "/search",
"details": {
"description": "Find tokens by keyword, symbol, or contract address on BSC.",
"usage": "search <keyword> [--chain bsc] [--limit 20]",
"example": "search PEPE\nsearch 0x1234... --chain bsc",
},
},
{
"name": "trending",
"description": "Popular tokens",
"category": "AVE Cloud Skills",
"command": "/trending",
"details": {
"description": "Get list of trending/popular tokens on BSC.",
"usage": "trending [--chain bsc] [--limit 20]",
"example": "trending --chain bsc\ntrending --limit 10",
},
},
{
"name": "risk",
"description": "Honeypot detection",
"category": "AVE Cloud Skills",
"command": "/risk",
"details": {
"description": "Get risk analysis for a token contract including honeypot assessment.",
"usage": "risk <token_address> [--chain bsc]",
"example": "risk 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
},
},
{
"name": "token",
"description": "Token details",
"category": "AVE Cloud Skills",
"command": "/token",
"details": {
"description": "Get detailed information about a specific token including price, market cap, and pairs.",
"usage": "token <address> [--chain bsc]",
"example": "token 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
},
},
{
"name": "price",
"description": "Batch prices",
"category": "AVE Cloud Skills",
"command": "/price",
"details": {
"description": "Get current price(s) for multiple tokens.",
"usage": "price <token_id>,<token_id>,... (e.g., PEPE-bsc,TRUMP-bsc)",
"example": "price PEPE-bsc,TRUMP-bsc",
},
},
],
}
SKILL_EMOJIS: Dict[str, str] = {
"backtest": "📊",
"simulate": "🎮",
"strategy": "📝",
"search": "🔍",
"trending": "📈",
"risk": "📉",
"token": "🪙",
"price": "💰",
}
def get_tool_registry() -> Dict[str, Any]:
"""Return the tool registry for slash command help."""
return TOOL_REGISTRY
def get_tools_by_category(category: str) -> List[Dict[str, Any]]:
"""Get tools filtered by category."""
return TOOL_REGISTRY.get(category, [])
def get_tool_by_name(tool_name: str) -> Dict[str, Any]:
"""Get a tool by its name."""
for category in ["randebu", "ave"]:
for tool in TOOL_REGISTRY.get(category, []):
if tool["name"].lower() == tool_name.lower():
return tool
return None

View File

@@ -23,9 +23,10 @@ class AveCloudClient:
chain: Optional[str] = None, chain: Optional[str] = None,
limit: int = 20, limit: int = 20,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
# Use trending endpoint which supports chain filter url = f"{self.DATA_API_URL}/v2/tokens"
url = f"{self.DATA_API_URL}/v2/tokens/trending" params = {"limit": limit}
params = {"limit": min(limit, 100)} # API returns max 100 if query:
params["query"] = query
if chain: if chain:
params["chain"] = chain params["chain"] = chain
@@ -35,18 +36,8 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get("status") == 1: # 1 = SUCCESS if data.get("status") == 200:
tokens = data.get("data", {}).get("tokens", []) return data.get("data", [])
# Filter by query if provided
if query:
query_lower = query.lower()
tokens = [
t for t in tokens
if query_lower in t.get("symbol", "").lower()
or query_lower in t.get("name", "").lower()
]
return tokens[:limit]
return []
raise Exception(f"Failed to fetch tokens: {data}") raise Exception(f"Failed to fetch tokens: {data}")
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]: async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
@@ -82,10 +73,6 @@ class AveCloudClient:
start_time: Optional[int] = None, start_time: Optional[int] = None,
end_time: Optional[int] = None, end_time: Optional[int] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
# Token ID must be in format "{contract_address}-bsc" for the AVE API
if not token_id.endswith("-bsc") and token_id.startswith("0x"):
token_id = f"{token_id}-bsc"
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}" url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
params = {"interval": interval, "limit": limit} params = {"interval": interval, "limit": limit}
if start_time: if start_time:
@@ -99,9 +86,8 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# AVE API returns status: 1 for success, not 200 if data.get("status") == 200:
if data.get("status") == 1: return data.get("data", [])
return data.get("data", {}).get("points", [])
raise Exception(f"Failed to fetch klines: {data}") raise Exception(f"Failed to fetch klines: {data}")
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]: async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
@@ -115,7 +101,7 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get("status") == 1: if data.get("status") == 200:
prices = data.get("data", {}) prices = data.get("data", {})
return prices.get(token_id) return prices.get(token_id)
return None return None

View File

@@ -28,13 +28,9 @@ class BacktestEngine:
self.position = 0.0 self.position = 0.0
self.position_token = "" self.position_token = ""
self.entry_price: Optional[float] = None self.entry_price: Optional[float] = None
self.cost_basis = 0.0 # Track total amount spent on current position for average price calc
self.entry_time: Optional[int] = None self.entry_time: Optional[int] = None
self.trades: List[Dict[str, Any]] = [] self.trades: List[Dict[str, Any]] = []
self.running = False self.running = False
self.progress = 0
self.total_klines = 0
self.last_kline_price: Optional[float] = None # Track last price for open position valuation
async def run(self) -> Dict[str, Any]: async def run(self) -> Dict[str, Any]:
self.running = True self.running = True
@@ -42,28 +38,20 @@ class BacktestEngine:
started_at = datetime.utcnow() started_at = datetime.utcnow()
try: try:
token = self.config.get("token", "")
chain = self.config.get("chain", "bsc") chain = self.config.get("chain", "bsc")
timeframe = self.config.get("timeframe", "1h") timeframe = self.config.get("timeframe", "1h")
start_date = self.config.get("start_date", "") start_date = self.config.get("start_date", "")
end_date = self.config.get("end_date", "") end_date = self.config.get("end_date", "")
# Get token address from strategy config (saved when user confirmed token) token_id = (
token_address = None f"{token}-{chain}"
token_symbol = None if token and not token.endswith(f"-{chain}")
else token
)
# Try to get from conditions first if not token_id or token_id == f"-{chain}":
if self.conditions: raise ValueError("Token ID is required")
token_address = self.conditions[0].get("token_address")
token_symbol = self.conditions[0].get("token")
# Fallback to actions
if not token_address and self.actions:
token_address = self.actions[0].get("token_address")
token_symbol = self.actions[0].get("token") or token_symbol
if not token_address:
raise ValueError("Token address not found in strategy. Please update your strategy with a valid token.")
token_id = token_address
start_ts = None start_ts = None
end_ts = None end_ts = None
@@ -109,48 +97,15 @@ class BacktestEngine:
return self.results return self.results
async def run_with_klines(self, klines: List[Dict[str, Any]]):
"""Test helper method that runs backtest with provided klines (bypasses API call)."""
self.running = True
self.status = "running"
started_at = datetime.utcnow()
try:
if not klines:
self.status = "failed"
self.results = {"error": "No kline data available"}
return self.results
await self._process_klines(klines)
self._calculate_metrics()
self.status = "completed"
except Exception as e:
self.status = "failed"
self.results = {"error": str(e)}
ended_at = datetime.utcnow()
self.results = self.results or {}
self.results["started_at"] = started_at
self.results["ended_at"] = ended_at
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
return self.results
async def _process_klines(self, klines: List[Dict[str, Any]]): async def _process_klines(self, klines: List[Dict[str, Any]]):
self.total_klines = len(klines)
for i, kline in enumerate(klines): for i, kline in enumerate(klines):
if not self.running: if not self.running:
break break
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
price = float(kline.get("close", 0)) price = float(kline.get("close", 0))
if price <= 0: if price <= 0:
continue continue
self.last_kline_price = price # Track last price for open position valuation
timestamp = kline.get("timestamp", 0) timestamp = kline.get("timestamp", 0)
if self.position > 0 and self.entry_price is not None: if self.position > 0 and self.entry_price is not None:
@@ -164,28 +119,20 @@ class BacktestEngine:
await self._execute_actions(price, timestamp, condition) await self._execute_actions(price, timestamp, condition)
break break
@property
def average_entry_price(self) -> Optional[float]:
"""Calculate weighted average entry price based on cost basis."""
if self.position <= 0 or self.cost_basis <= 0:
return None
return self.cost_basis / self.position
def _check_risk_management( def _check_risk_management(
self, current_price: float, timestamp: int self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if self.position <= 0 or self.average_entry_price is None: if self.position <= 0 or self.entry_price is None:
return None return None
if self.stop_loss_percent is not None: if self.stop_loss_percent is not None:
stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100) stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price: if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price} return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None: if self.take_profit_percent is not None:
take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100) take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
# Use small epsilon to handle floating point precision if current_price >= take_profit_price:
if current_price >= take_profit_price - 0.001:
return {"reason": "take_profit", "price": take_profit_price} return {"reason": "take_profit", "price": take_profit_price}
return None return None
@@ -226,7 +173,6 @@ class BacktestEngine:
) )
self.position = 0 self.position = 0
self.entry_price = None self.entry_price = None
self.cost_basis = 0.0
self.entry_time = None self.entry_time = None
def _check_condition( def _check_condition(
@@ -291,12 +237,10 @@ 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:
quantity = amount / price self.position += amount / price
self.position += quantity
self.current_balance -= amount self.current_balance -= amount
self.cost_basis += amount # Track total cost for average price
self.position_token = token self.position_token = token
self.entry_price = price # Keep last entry price for reference self.entry_price = price
self.entry_time = timestamp self.entry_time = timestamp
self.trades.append( self.trades.append(
{ {
@@ -304,7 +248,7 @@ class BacktestEngine:
"token": token, "token": token,
"price": price, "price": price,
"amount": amount, "amount": amount,
"quantity": quantity, "quantity": amount / price,
"timestamp": timestamp, "timestamp": timestamp,
} }
) )
@@ -356,17 +300,11 @@ class BacktestEngine:
) )
def _calculate_metrics(self): def _calculate_metrics(self):
# For open positions, use the last kline price to mark to market final_balance = self.current_balance + (
# If no last kline price, fall back to entry price self.position * self.trades[-1]["price"]
position_price = self.last_kline_price if self.trades and self.position > 0
if position_price is None and self.trades and self.position > 0: else 0
position_price = self.trades[-1]["price"] # Fall back to entry price )
# Calculate final balance: use marked-to-market value if position open, otherwise current balance
if self.position > 0 and position_price:
final_balance = self.current_balance + self.position * position_price
else:
final_balance = self.current_balance
total_return = ( total_return = (
(final_balance - self.initial_balance) / self.initial_balance (final_balance - self.initial_balance) / self.initial_balance
) * 100 ) * 100
@@ -393,23 +331,18 @@ class BacktestEngine:
for trade in self.trades: for trade in self.trades:
if trade["type"] == "buy": if trade["type"] == "buy":
running_position += trade["quantity"] # Add to existing position (DCA) running_position = trade["quantity"]
running_balance -= trade["amount"] # Subtract amount spent running_balance = trade["amount"]
current_token = trade["token"] current_token = trade["token"]
last_price = trade["price"] last_price = trade["price"]
else: # sell else:
running_balance += trade["amount"] # Add amount received running_balance = trade["amount"]
running_position = 0 # Close entire position running_position = 0
last_price = trade["price"] last_price = trade["price"]
portfolio_value = running_balance + (running_position * last_price) portfolio_value = running_balance + (running_position * last_price)
portfolio_values.append(portfolio_value) portfolio_values.append(portfolio_value)
# If there's an open position, add final marked-to-market value
if self.position > 0 and self.last_kline_price:
final_portfolio_value = self.current_balance + (self.position * self.last_kline_price)
portfolio_values.append(final_portfolio_value)
max_value = self.initial_balance max_value = self.initial_balance
max_drawdown = 0.0 max_drawdown = 0.0
for value in portfolio_values: for value in portfolio_values:
@@ -447,13 +380,10 @@ class BacktestEngine:
"sharpe_ratio": round(sharpe_ratio, 2), "sharpe_ratio": round(sharpe_ratio, 2),
"final_balance": round(final_balance, 2), "final_balance": round(final_balance, 2),
"signals": self.signals, "signals": self.signals,
"trades": self.trades, # Include trades in results for storage
} }
async def stop(self): async def stop(self):
self.running = False self.running = False
self.progress = 0
self.total_klines = 0
self.status = "stopped" self.status = "stopped"
self._calculate_metrics() self._calculate_metrics()
@@ -463,13 +393,4 @@ class BacktestEngine:
"status": self.status, "status": self.status,
"results": self.results, "results": self.results,
"signals": self.signals, "signals": self.signals,
"progress": self.progress,
"total_klines": self.total_klines,
}
def get_status(self) -> Dict[str, Any]:
return {
"status": self.status,
"progress": self.progress,
"total_klines": self.total_klines,
} }

View File

@@ -3,7 +3,6 @@ import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from ..ave.client import AveCloudClient from ..ave.client import AveCloudClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,66 +26,23 @@ class SimulateEngine:
self.risk_management = self.strategy_config.get("risk_management", {}) self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent") self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent") self.take_profit_percent = self.risk_management.get("take_profit_percent")
self.check_interval = config.get("check_interval", 60)
# Kline-based settings self.duration_seconds = config.get("duration_seconds", 3600)
self.kline_interval = config.get("kline_interval", "1m")
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
# Delay between candles (in seconds) to simulate real-time
# e.g., 1m interval -> 30s delay between candles
# Use config value if provided, otherwise calculate
if "candle_delay" in config and config["candle_delay"] is not None:
self.candle_delay = config["candle_delay"]
else:
self.candle_delay = self._get_interval_seconds(self.kline_interval) / 2
self.auto_execute = config.get("auto_execute", False) self.auto_execute = config.get("auto_execute", False)
self.token = config.get("token", "") self.token = config.get("token", "")
self.chain = config.get("chain", "bsc") self.chain = config.get("chain", "bsc")
self.running = False self.running = False
self.started_at: Optional[datetime] = None self.started_at: Optional[datetime] = None
self.last_price: Optional[float] = None
# Price tracking (for conditions)
self.last_close: Optional[float] = None
self.last_volume: Optional[float] = None self.last_volume: Optional[float] = None
# Position tracking (for risk management)
self.position: float = 0.0 self.position: float = 0.0
self.position_token: str = "" self.position_token: str = ""
self.entry_price: Optional[float] = None self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None self.entry_time: Optional[int] = None
# Portfolio
self.current_balance: float = config.get("initial_balance", 10000.0) self.current_balance: float = config.get("initial_balance", 10000.0)
self.trades: List[Dict[str, Any]] = [] self.trades: List[Dict[str, Any]] = []
# Error tracking
self.errors: List[str] = [] self.errors: List[str] = []
# Kline data
self.klines: List[Dict[str, Any]] = []
self.last_processed_time: Optional[int] = None
# Trade log - tracks what happened at each candle
self.trade_log: List[Dict[str, Any]] = []
# Current candle being processed (for frontend to show progress)
self.current_candle_index = 0
self.total_candles = 0
def _get_interval_seconds(self, interval: str) -> int:
"""Convert kline interval to seconds."""
mapping = {
"1m": 60,
"5m": 300,
"15m": 900,
"30m": 1800,
"1h": 3600,
"4h": 14400,
"1d": 86400,
}
return mapping.get(interval, 60)
async def run(self) -> Dict[str, Any]: async def run(self) -> Dict[str, Any]:
self.running = True self.running = True
self.status = "running" self.status = "running"
@@ -103,174 +59,72 @@ class SimulateEngine:
self.results = {"error": "Token ID is required"} self.results = {"error": "Token ID is required"}
return self.results return self.results
end_time = datetime.utcnow().timestamp() + self.duration_seconds
try: try:
# Step 1: Fetch klines (only once for simulation) while self.running and datetime.utcnow().timestamp() < end_time:
self.klines = await self._fetch_klines(token_id) try:
price_data = await self.ave_client.get_token_price(token_id)
if price_data:
current_price = float(price_data.get("price", 0))
current_volume = float(price_data.get("volume", 0))
if not self.klines: if current_price > 0:
self.status = "failed" await self._check_conditions(
self.results = {"error": "No kline data available"} current_price, current_volume, price_data
return self.results )
logger.info(f"Fetched {len(self.klines)} klines for {token_id}") self.last_price = current_price
self.last_volume = current_volume
# Step 2: Process candles (with limit)
candles_processed = 0 except Exception as e:
self.total_candles = min(len(self.klines), self.max_candles) logger.warning(f"Failed to get price for {token_id}: {e}")
self.current_candle_index = 0 self.errors.append(f"Price fetch failed for {token_id}: {str(e)}")
continue
for i, candle in enumerate(self.klines):
if not self.running: for _ in range(self.check_interval):
break if not self.running:
if candles_processed >= self.max_candles: break
logger.info(f"Reached max candles limit ({self.max_candles})") await asyncio.sleep(1)
break
if self.running:
self.current_candle_index = candles_processed self.status = "completed"
candle_time = int(candle.get("time", 0)) else:
self.status = "stopped"
# Get OHLCV data from candle
close_price = float(candle.get("close", 0))
volume = float(candle.get("volume", 0))
if close_price > 0:
# Process candle
await self._process_candle(close_price, volume, candle_time)
# Update last close for next iteration
self.last_close = close_price
self.last_volume = volume
# Track last processed time
self.last_processed_time = candle_time
candles_processed += 1
# Delay to simulate real-time (only for visible candles, not initial batch)
if candles_processed > 1 and self.candle_delay > 0:
await asyncio.sleep(self.candle_delay)
self.status = "completed"
except Exception as e: except Exception as e:
logger.error(f"Simulation error: {e}")
self.status = "failed" self.status = "failed"
self.results = {"error": str(e)} self.results = {"error": str(e)}
self.errors.append(str(e))
self.results = self.results or {} self.results = self.results or {}
self.results["total_signals"] = len(self.signals) self.results["total_signals"] = len(self.signals)
self.results["total_trades"] = len(self.trades)
self.results["total_errors"] = len(self.errors) self.results["total_errors"] = len(self.errors)
self.results["errors"] = self.errors self.results["errors"] = self.errors
self.results["signals"] = self.signals self.results["signals"] = self.signals
self.results["candles_processed"] = candles_processed
self.results["current_candle_index"] = self.current_candle_index
self.results["total_candles"] = self.total_candles
self.results["klines"] = self.klines # Include klines for chart display
self.results["trade_log"] = self.trade_log # Include trade log for dashboard
self.results["portfolio"] = {
"initial_balance": self.config.get("initial_balance", 10000),
"current_balance": self.current_balance,
"position": self.position,
"position_token": self.position_token,
"entry_price": self.entry_price,
"current_price": self.last_close,
}
self.results["started_at"] = self.started_at self.results["started_at"] = self.started_at
self.results["ended_at"] = datetime.utcnow() self.results["ended_at"] = datetime.utcnow()
return self.results return self.results
async def _fetch_klines( async def _check_conditions(
self, self, current_price: float, current_volume: float, price_data: Dict[str, Any]
token_id: str,
limit: int = 500
) -> List[Dict[str, Any]]:
"""Fetch klines from AVE API."""
try:
klines = await self.ave_client.get_klines(
token_id,
interval=self.kline_interval,
limit=limit
)
# Sort by time ascending (oldest first)
klines = sorted(klines, key=lambda x: x.get("time", 0))
return klines
except Exception as e:
logger.warning(f"Failed to fetch klines for {token_id}: {e}")
self.errors.append(f"Kline fetch failed: {str(e)}")
return []
async def _process_candle(
self,
close_price: float,
volume: float,
timestamp: int
): ):
"""Process a single candle - check conditions and risk management.""" timestamp = int(datetime.utcnow().timestamp() * 1000)
action = "hold" # Default action
reason = ""
# Check risk management first (for open positions)
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(close_price, timestamp) exit_info = self._check_risk_management(current_price, timestamp)
if exit_info: if exit_info:
await self._execute_risk_exit(close_price, timestamp, exit_info) await self._execute_risk_exit(current_price, timestamp, exit_info)
action = "sell"
reason = exit_info["reason"]
# Log the action
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": action,
"reason": reason,
"position": self.position,
"entry_price": self.entry_price,
})
return return
# Check conditions (only if no open position)
if self.position == 0:
for condition in self.conditions: for condition in self.conditions:
if self._check_condition(condition, close_price, volume): if self._check_condition(condition, current_price, current_volume):
await self._execute_actions(close_price, timestamp, condition) await self._execute_actions(current_price, timestamp, condition)
action = "buy"
reason = f"{condition.get('type')} {condition.get('threshold')}%".format(
type=condition.get('type'),
threshold=condition.get('threshold')
)
# Log the action
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": action,
"reason": reason,
"position": self.position,
"entry_price": self.entry_price,
})
break break
# Log hold action (no signal)
if action == "hold":
# Only log every 10th candle to reduce data
if len(self.trade_log) == 0 or (len(self.klines) - len(self.trade_log) > 10):
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": "hold",
"reason": "no_signal",
"position": self.position,
"entry_price": self.entry_price,
})
def _check_risk_management( def _check_risk_management(
self, current_price: float, timestamp: int self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Check if stop loss or take profit is triggered."""
if self.position <= 0 or self.entry_price is None: if self.position <= 0 or self.entry_price is None:
return None return None
@@ -289,24 +143,16 @@ class SimulateEngine:
async def _execute_risk_exit( async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any] self, price: float, timestamp: int, exit_info: Dict[str, Any]
): ):
"""Execute stop loss or take profit."""
if self.position <= 0: if self.position <= 0:
return return
reason = exit_info["reason"] reason = exit_info["reason"]
quantity = self.position
sale_proceeds = quantity * price
# Add sale proceeds to cash balance
self.current_balance += sale_proceeds
self.trades.append( self.trades.append(
{ {
"type": "sell", "type": "sell",
"token": self.position_token, "token": self.position_token,
"price": price, "price": price,
"quantity": quantity, "quantity": self.position,
"amount": sale_proceeds,
"timestamp": timestamp, "timestamp": timestamp,
"exit_reason": reason, "exit_reason": reason,
} }
@@ -335,34 +181,32 @@ class SimulateEngine:
current_price: float, current_price: float,
current_volume: float, current_volume: float,
) -> bool: ) -> bool:
"""Check if a condition is met based on price movement."""
cond_type = condition.get("type", "") cond_type = condition.get("type", "")
threshold = condition.get("threshold", 0) threshold = condition.get("threshold", 0)
price_level = condition.get("price")
direction = condition.get("direction", "above")
if cond_type == "price_drop": if cond_type == "price_drop":
# Price dropped by threshold % from last close if self.last_price is None or self.last_price <= 0:
if self.last_close is None or self.last_close <= 0:
return False return False
drop_pct = ((self.last_close - current_price) / self.last_close) * 100 drop_pct = ((self.last_price - current_price) / self.last_price) * 100
return drop_pct >= threshold return drop_pct >= threshold
elif cond_type == "price_rise": elif cond_type == "price_rise":
# Price rose by threshold % from last close if self.last_price is None or self.last_price <= 0:
if self.last_close is None or self.last_close <= 0:
return False return False
rise_pct = ((current_price - self.last_close) / self.last_close) * 100 rise_pct = ((current_price - self.last_price) / self.last_price) * 100
return rise_pct >= threshold return rise_pct >= threshold
elif cond_type == "volume_spike": elif cond_type == "volume_spike":
# Volume increased significantly
if self.last_volume is None or self.last_volume <= 0: if self.last_volume is None or self.last_volume <= 0:
return False return False
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100 volume_increase = (
(current_volume - self.last_volume) / self.last_volume
) * 100
return volume_increase >= threshold return volume_increase >= threshold
elif cond_type == "price_level": elif cond_type == "price_level":
price_level = condition.get("price")
direction = condition.get("direction", "above")
if price_level is None: if price_level is None:
return False return False
if direction == "above": if direction == "above":
@@ -375,7 +219,6 @@ class SimulateEngine:
async def _execute_actions( async def _execute_actions(
self, price: float, timestamp: int, matched_condition: Dict[str, Any] self, price: float, timestamp: int, matched_condition: Dict[str, Any]
): ):
"""Execute buy/sell actions based on matched condition."""
token = matched_condition.get("token", self.token) token = matched_condition.get("token", self.token)
reasoning = f"Condition {matched_condition.get('type')} triggered" reasoning = f"Condition {matched_condition.get('type')} triggered"
@@ -384,21 +227,18 @@ class SimulateEngine:
if action_type == "buy": if action_type == "buy":
amount_percent = action.get("amount_percent", 10) amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100) amount = self.current_balance * (amount_percent / 100)
quantity = amount / price self.position += amount / price
self.position += quantity
self.position_token = token self.position_token = token
self.entry_price = price self.entry_price = price
self.entry_time = timestamp self.entry_time = timestamp
self.current_balance -= amount self.current_balance -= amount
self.trades.append( self.trades.append(
{ {
"type": "buy", "type": "buy",
"token": token, "token": token,
"price": price, "price": price,
"amount": amount, "amount": amount,
"quantity": quantity, "quantity": amount / price,
"timestamp": timestamp, "timestamp": timestamp,
} }
) )
@@ -418,13 +258,11 @@ class SimulateEngine:
self.signals.append(signal) self.signals.append(signal)
def stop(self): async def stop(self):
"""Stop the simulation."""
self.running = False self.running = False
self.status = "stopped" self.status = "stopped"
def get_results(self) -> Dict[str, Any]: def get_results(self) -> Dict[str, Any]:
"""Get simulation results."""
return { return {
"id": self.run_id, "id": self.run_id,
"status": self.status, "status": self.status,
@@ -433,5 +271,4 @@ class SimulateEngine:
} }
def get_signals(self) -> List[Dict[str, Any]]: def get_signals(self) -> List[Dict[str, Any]]:
"""Get current signals."""
return self.signals return self.signals

View File

@@ -8,5 +8,4 @@ if __name__ == "__main__":
host=settings.HOST, host=settings.HOST,
port=settings.PORT, port=settings.PORT,
reload=settings.DEBUG, reload=settings.DEBUG,
timeout_keep_alive=300,
) )

View File

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

View File

@@ -1,386 +0,0 @@
import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import sys
sys.path.insert(0, 'src/backend')
from app.services.simulate.engine import SimulateEngine
class MockAveClient:
"""Mock AVE client for testing."""
def __init__(self, klines_data=None):
self.klines_data = klines_data or []
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
return self.klines_data
def create_engine(config_override=None, klines_data=None):
"""Create a test engine with mock client."""
config = {
"bot_id": "test-bot",
"token": "0x1234567890123456789012345678901234567890",
"chain": "bsc",
"kline_interval": "1m",
"max_candles": 10, # Small number for fast tests
"candle_delay": 0, # No delay in tests
"auto_execute": False,
"strategy_config": {
"conditions": [
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5,
"take_profit_percent": 10
}
},
"ave_api_key": "test",
"ave_api_plan": "free",
}
if config_override:
config.update(config_override)
engine = SimulateEngine(config)
engine.ave_client = MockAveClient(klines_data)
return engine
class TestSimulateEngine:
"""Unit tests for SimulateEngine."""
# ==================== Kline Fetching Tests ====================
@pytest.mark.asyncio
async def test_fetches_klines_on_start(self):
"""Engine should fetch klines when run is called."""
klines = [
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert engine.status == "completed"
assert results["candles_processed"] == 2
@pytest.mark.asyncio
async def test_handles_no_klines_data(self):
"""Engine should handle empty klines gracefully."""
engine = create_engine(klines_data=[])
engine.running = True
results = await engine.run()
assert engine.status == "failed"
assert "error" in results
assert "No kline data" in results["error"]
# ==================== Price Drop Condition Tests ====================
@pytest.mark.asyncio
async def test_price_drop_condition_triggers_buy(self):
"""Price drop >= threshold should trigger BUY signal."""
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] >= 1
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
assert len(buy_signals) >= 1
assert buy_signals[0]["price"] == 90.0
@pytest.mark.asyncio
async def test_price_drop_below_threshold_no_signal(self):
"""Price drop < threshold should NOT trigger signal."""
# Price drops from 100 to 98 (2% drop) - below 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] == 0
# ==================== Risk Management Tests ====================
@pytest.mark.asyncio
async def test_stop_loss_triggers_after_buy(self):
"""Stop loss should trigger SELL after price drops below threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
assert "stop_loss" in sell_signals[0]["reasoning"]
@pytest.mark.asyncio
async def test_take_profit_triggers_after_buy(self):
"""Take profit should trigger SELL after price rises above threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
assert "take_profit" in sell_signals[0]["reasoning"]
# ==================== Multiple Conditions Tests ====================
@pytest.mark.asyncio
async def test_no_buy_if_already_in_position(self):
"""Should not trigger another BUY if already holding position."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
# Should only have 1 buy, not multiple
assert len(buy_signals) == 1, "Should only have one BUY signal"
@pytest.mark.asyncio
async def test_can_buy_again_after_sell(self):
"""Should be able to BUY again after position is closed by risk management."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
# First trade
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
# Second trade
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) == 2, "Should have two BUY signals"
assert len(sell_signals) == 2, "Should have two SELL signals"
# ==================== Edge Cases ====================
@pytest.mark.asyncio
async def test_handles_zero_price(self):
"""Should skip processing for candles with zero price but still count them."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
# All 3 candles counted, but only 2 valid for condition checking
assert results["candles_processed"] == 3
# Only 1 signal (the valid candle that dropped 10%)
assert results["total_signals"] == 1
@pytest.mark.asyncio
async def test_max_candles_limit(self):
"""Should respect max_candles limit."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 201) # 200 candles
]
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
engine.running = True
results = await engine.run()
assert results["candles_processed"] == 50
@pytest.mark.asyncio
async def test_stop_interrupts_processing(self):
"""Should stop processing when stop() is called."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 101)
]
engine = create_engine(klines_data=klines)
engine.running = True
engine.run_id = "test"
# Stop after a few candles
async def stop_after_delay():
await asyncio.sleep(0.1)
engine.stop()
await asyncio.gather(engine.run(), stop_after_delay())
assert engine.status == "stopped"
# Should have processed some candles before stopping
assert engine.last_processed_time is not None
# ==================== Price Movement Display Tests ====================
@pytest.mark.asyncio
async def test_records_all_processed_prices(self):
"""Should track last processed time for display purposes."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Should have tracked the last candle's time
assert engine.last_processed_time == 3000
@pytest.mark.asyncio
async def test_tracks_price_changes(self):
"""Should track price changes for potential chart display."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Last close should be the last candle's close
assert engine.last_close == 104.0
# ==================== Integration Tests ====================
@pytest.mark.asyncio
async def test_full_simulation_workflow_generates_signals_and_trades(self):
"""
Full integration test: provides klines with clear price movements
and verifies signals and trade_log are populated.
This test ensures the simulation is working by:
1. Creating klines with obvious price movements (drops > 0.1%)
2. Using a very low threshold (0.1%)
3. Verifying signals are generated
4. Verifying trade_log is populated
5. Verifying we have buy/sell actions
"""
# Create klines with clear price drops and rises
klines = [
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}, # Flat
{"time": 2000, "open": 100, "high": 101, "low": 99.9, "close": 99.95, "volume": 1000}, # 0.05% drop
{"time": 3000, "open": 99.95, "high": 100, "low": 99.5, "close": 99.5, "volume": 1000}, # 0.45% drop
{"time": 4000, "open": 99.5, "high": 100, "low": 99, "close": 99.2, "volume": 1000}, # 0.30% drop
{"time": 5000, "open": 99.2, "high": 100, "low": 98, "close": 98.5, "volume": 1000}, # 0.71% drop
{"time": 6000, "open": 98.5, "high": 99, "low": 98, "close": 98.8, "volume": 1000}, # 0.30% rise
{"time": 7000, "open": 98.8, "high": 99, "low": 98, "close": 98.3, "volume": 1000}, # 0.51% drop
{"time": 8000, "open": 98.3, "high": 99, "low": 97, "close": 97.5, "volume": 1000}, # 0.81% drop
{"time": 9000, "open": 97.5, "high": 98, "low": 96, "close": 96.5, "volume": 1000}, # 1.03% drop
]
# Use very low threshold to ensure signals are generated
config_override = {
"max_candles": 100,
"strategy_config": {
"conditions": [
{"type": "price_drop", "threshold": 0.1, "token": "TEST", "token_address": "0x1234"}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5,
"take_profit_percent": 5
}
}
}
engine = create_engine(config_override=config_override, klines_data=klines)
engine.running = True
engine.run_id = "integration-test"
results = await engine.run()
# Verify results
print(f"\n=== Integration Test Results ===")
print(f"Status: {engine.status}")
print(f"Candles processed: {results.get('candles_processed')}")
print(f"Signals count: {len(engine.signals)}")
print(f"Trade log count: {len(engine.trade_log)}")
# ASSERTIONS - These should NEVER fail if simulation is working
assert engine.status == "completed", "Simulation should complete successfully"
assert results.get("candles_processed") == len(klines), f"Should process all {len(klines)} candles"
# Critical: signals should NOT be empty
assert len(engine.signals) > 0, "SIGNALS SHOULD NOT BE EMPTY! Simulation is not generating signals."
print(f"Signals: {[s['signal_type'] for s in engine.signals]}")
# Critical: trade_log should NOT be empty
assert len(engine.trade_log) > 0, "TRADE_LOG SHOULD NOT BE EMPTY! No activity logged."
print(f"Trade log: {[t['action'] for t in engine.trade_log]}")
# Should have at least one BUY signal
buy_signals = [s for s in engine.signals if s['signal_type'] == 'buy']
assert len(buy_signals) > 0, "Should have at least one BUY signal"
print(f"Buy signals: {len(buy_signals)}")
# Verify trade_log has BUY action
buy_trades = [t for t in engine.trade_log if t['action'] == 'buy']
assert len(buy_trades) > 0, "Trade log should contain BUY actions"
# Verify results contain the data
assert "signals" in results, "Results should contain signals"
assert "trade_log" in results, "Results should contain trade_log"
print("\n=== Integration Test PASSED ===")
print(f"Simulation working correctly!")
print(f"Generated {len(engine.signals)} signals and {len(engine.trade_log)} trade log entries")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -7,9 +7,6 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
@@ -104,12 +101,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@@ -578,18 +569,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View File

@@ -19,8 +19,5 @@
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.7" "vite": "^8.0.7"
},
"dependencies": {
"chart.js": "^4.5.1"
} }
} }

View File

@@ -21,20 +21,7 @@ function getAuthHeaders(): HeadersInit {
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' }));
let errorMessage = 'An error occurred'; throw new Error(error.detail || `HTTP error ${response.status}`);
if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else if (Array.isArray(error.detail)) {
// Handle FastAPI validation error format: [{type, loc, msg, input}]
errorMessage = error.detail.map((e: any) => e.msg || JSON.stringify(e)).join(', ');
} else if (error.message) {
errorMessage = error.message;
} else {
errorMessage = `HTTP error ${response.status}`;
}
throw new Error(errorMessage);
} }
return response.json(); return response.json();
} }
@@ -54,7 +41,7 @@ export const api = {
const response = await fetch(`${API_URL}/auth/login`, { const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password }) body: JSON.stringify({ email, password })
}); });
return handleResponse<AuthResponse>(response); return handleResponse<AuthResponse>(response);
}, },
@@ -117,12 +104,11 @@ export const api = {
} }
}, },
async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> { async chat(id: string, message: string): Promise<BotChatResponse> {
const response = await fetch(`${API_URL}/bots/${id}/chat`, { const response = await fetch(`${API_URL}/bots/${id}/chat`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ message } as BotChatRequest), body: JSON.stringify({ message } as BotChatRequest)
signal
}); });
return handleResponse<BotChatResponse>(response); return handleResponse<BotChatResponse>(response);
}, },
@@ -140,7 +126,7 @@ export const api = {
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, { const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ ...config, chain: 'bsc' }) body: JSON.stringify(config)
}); });
return handleResponse<Backtest>(response); return handleResponse<Backtest>(response);
}, },
@@ -167,29 +153,11 @@ export const api = {
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error ${response.status}`); throw new Error(`HTTP error ${response.status}`);
} }
},
async getTrades(botId: string, runId: string, page: number = 1, perPage: number = 5): Promise<{
trades: any[];
total_trades: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/trades?page=${page}&per_page=${perPage}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return response.json();
} }
}, },
simulate: { simulate: {
async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> { async start(botId: string, config: { token: string; interval_seconds: number; auto_execute: boolean }): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, { const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),

View File

@@ -26,7 +26,6 @@ export interface StrategyConfig {
export interface Condition { export interface Condition {
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level'; type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
token: string; token: string;
token_address?: string;
chain?: string; chain?: string;
threshold?: number; threshold?: number;
price?: number; price?: number;
@@ -38,7 +37,6 @@ export interface Action {
type: 'buy' | 'sell' | 'hold'; type: 'buy' | 'sell' | 'hold';
amount_percent?: number; amount_percent?: number;
token?: string; token?: string;
token_address?: string;
} }
export interface RiskManagement { export interface RiskManagement {
@@ -64,16 +62,13 @@ export interface Backtest {
bot_id: string; bot_id: string;
started_at: string; started_at: string;
ended_at: string | null; ended_at: string | null;
status: 'running' | 'completed' | 'failed' | 'stopped'; status: 'running' | 'completed' | 'failed';
config: BacktestConfig; config: BacktestConfig;
result: BacktestResult | null; result: BacktestResult | null;
progress?: number;
} }
export interface BacktestConfig { export interface BacktestConfig {
token: string; token: string;
token_name?: string;
chain: string;
timeframe: string; timeframe: string;
start_date: string; start_date: string;
end_date: string; end_date: string;
@@ -89,63 +84,19 @@ export interface BacktestResult {
sharpe_ratio: number; sharpe_ratio: number;
} }
export interface PaginatedTrades {
trades: Trade[];
total_trades: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
export interface Trade {
type: 'buy' | 'sell';
token: string;
price: number;
amount: number;
quantity: number;
timestamp: number;
exit_reason?: 'stop_loss' | 'take_profit' | string;
}
export interface Simulation { export interface Simulation {
id: string; id: string;
bot_id: string; bot_id: string;
started_at: string; started_at: string;
status: 'running' | 'stopped' | 'completed'; status: 'running' | 'stopped';
config: SimulationConfig; config: SimulationConfig;
signals: Signal[] | null; signals: Signal[] | null;
klines?: { time: number; close: number }[];
trade_log?: TradeLogEntry[];
portfolio?: Portfolio;
current_candle_index?: number;
total_candles?: number;
candles_processed?: number;
} }
export interface SimulationConfig { export interface SimulationConfig {
token: string; token: string;
chain?: string; interval_seconds: number;
kline_interval?: string; auto_execute: boolean;
}
export interface TradeLogEntry {
time: number;
price: number;
action: 'buy' | 'sell' | 'hold';
reason: string;
position: number;
entry_price: number | null;
}
export interface Portfolio {
initial_balance: number;
current_balance: number;
position: number;
position_token: string;
entry_price: number;
current_price: number;
} }
export interface Signal { export interface Signal {
@@ -172,17 +123,6 @@ export interface BotChatRequest {
export interface BotChatResponse { export interface BotChatResponse {
response: string; response: string;
thinking: string | null;
strategy_config: StrategyConfig | null; strategy_config: StrategyConfig | null;
success: boolean; success: boolean;
strategy_needs_confirmation?: boolean;
strategy_data?: StrategyConfig | null;
token_search_results?: TokenSearchResult[] | null;
}
export interface TokenSearchResult {
symbol: string;
name: string;
address: string;
chain: string;
} }

View File

@@ -1,36 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Bot } from '$lib/api'; import type { Bot } from '$lib/api';
import type { ChatMessage } from '$lib/stores/chatStore'; import type { ChatMessage } from '$lib/stores/chatStore';
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
interface ToolItem {
name: string;
description: string;
command: string;
}
const TOOLS: { category: string; label: string; tools: ToolItem[] }[] = [
{
category: 'randebu',
label: '🤖 Randebu Built-in',
tools: [
{ name: 'backtest', description: 'Run strategy backtest', command: '/backtest' },
{ name: 'simulate', description: 'Start/stop simulation', command: '/simulate' },
{ name: 'strategy', description: 'View/update strategy', command: '/strategy' },
]
},
{
category: 'ave',
label: '☁️ AVE Cloud Skills',
tools: [
{ name: 'search', description: 'Token search', command: '/search' },
{ name: 'trending', description: 'Popular tokens', command: '/trending' },
{ name: 'risk', description: 'Honeypot detection', command: '/risk' },
{ name: 'token', description: 'Token details', command: '/token' },
{ name: 'price', description: 'Batch prices', command: '/price' },
]
}
];
interface Props { interface Props {
bot: Bot | null; bot: Bot | null;
@@ -54,17 +24,9 @@
let messageInput = $state(''); let messageInput = $state('');
let chatContainer: HTMLDivElement; let chatContainer: HTMLDivElement;
let expandedThinking: Record<string, boolean> = $state({});
let showSlashMenu = $state(false);
let slashMenuPosition = $state({ top: 0, left: 0 });
let selectedIndex = $state(0);
// Use $derived for filteredTools
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())) : []);
function handleSend() { function handleSend() {
if (!messageInput.trim()) return; if (!messageInput.trim()) return;
showSlashMenu = false;
onSendMessage(messageInput); onSendMessage(messageInput);
messageInput = ''; messageInput = '';
} }
@@ -72,55 +34,8 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (showSlashMenu && filteredTools.length > 0) {
selectTool(filteredTools[selectedIndex]);
} else {
handleSend(); handleSend();
} }
} else if (e.key === 'ArrowDown' && showSlashMenu) {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredTools.length - 1);
} else if (e.key === 'ArrowUp' && showSlashMenu) {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === 'Escape' && showSlashMenu) {
showSlashMenu = false;
} else if (e.key === 'Tab' && showSlashMenu && filteredTools.length > 0) {
e.preventDefault();
selectTool(filteredTools[selectedIndex]);
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
const value = target.value;
messageInput = value;
if (value.startsWith('/')) {
selectedIndex = 0;
showSlashMenu = filteredTools.length > 0;
if (showSlashMenu) {
// Position menu above the textarea
const rect = target.getBoundingClientRect();
const menuHeight = 300;
slashMenuPosition = {
top: Math.max(10, rect.top - menuHeight),
left: rect.left
};
}
} else {
showSlashMenu = false;
}
}
function selectTool(tool: ToolItem) {
messageInput = tool.command + ' ';
showSlashMenu = false;
const textarea = document.querySelector('.input-container textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.focus();
}
} }
function handleBotChange(e: Event) { function handleBotChange(e: Event) {
@@ -130,10 +45,6 @@
} }
} }
function toggleThinkingExpand(messageId: string) {
expandedThinking[messageId] = !expandedThinking[messageId];
}
$effect(() => { $effect(() => {
if (messages.length && chatContainer) { if (messages.length && chatContainer) {
setTimeout(() => { setTimeout(() => {
@@ -141,33 +52,8 @@
}, 50); }, 50);
} }
}); });
function renderContent(content: string) {
return parseMarkdown(content);
}
function renderInline(segments: InlineSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'bold': return `<strong>${seg.content}</strong>`;
case 'italic': return `<em>${seg.content}</em>`;
case 'code': return `<code class="inline-code">${seg.content}</code>`;
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
default: return seg.content;
}
}).join('');
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.slash-menu') && !target.closest('.input-container textarea')) {
showSlashMenu = false;
}
}
</script> </script>
<svelte:window on:click={handleClickOutside} />
<div class="chat-interface"> <div class="chat-interface">
{#if showBotSelector && availableBots.length > 0} {#if showBotSelector && availableBots.length > 0}
<div class="bot-selector"> <div class="bot-selector">
@@ -192,99 +78,8 @@
{#each messages as message} {#each messages as message}
<div class="message {message.role}"> <div class="message {message.role}">
{#if message.role === 'assistant' && message.thinking}
{@const firstLine = message.thinking.split('\n')[0]}
{@const isExpanded = expandedThinking[message.id] ?? false}
<div class="thinking-section">
<button class="thinking-toggle" onclick={() => toggleThinkingExpand(message.id)}>
<span class="thinking-icon">{isExpanded ? '▼' : '▶'}</span>
<span class="thinking-label">{isExpanded ? 'Hide reasoning' : 'Show reasoning'}</span>
{#if !isExpanded}
<span class="thinking-preview">{firstLine.slice(0, 60)}{firstLine.length > 60 ? '...' : ''}</span>
{/if}
</button>
{#if isExpanded}
<div class="thinking-content">
{message.thinking}
</div>
{/if}
</div>
{/if}
<div class="message-content"> <div class="message-content">
{#each renderContent(message.content) as segment} {message.content}
{#if segment.type === 'bold'}
<strong>{segment.content}</strong>
{:else if segment.type === 'italic'}
<em>{segment.content}</em>
{:else if segment.type === 'code'}
<code class="inline-code">{segment.content}</code>
{:else if segment.type === 'codeBlock'}
<pre class="code-block"><code>{segment.content}</code></pre>
{:else if segment.type === 'link'}
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
{:else if segment.type === 'list' && segment.items}
<ul>
{#each segment.items as item}
<li>{@html renderInline(parseInlineElements(item))}</li>
{/each}
</ul>
{:else if segment.type === 'table' && segment.headers && segment.rows}
<div class="table-wrapper">
<table class="markdown-table">
<thead>
<tr>
{#each segment.headers as header}
<th>
{#each header as cellSeg}
{#if cellSeg.type === 'bold'}
<strong>{cellSeg.content}</strong>
{:else if cellSeg.type === 'italic'}
<em>{cellSeg.content}</em>
{:else if cellSeg.type === 'code'}
<code class="inline-code">{cellSeg.content}</code>
{:else if cellSeg.type === 'link'}
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
{:else}
{cellSeg.content}
{/if}
{/each}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each segment.rows as row}
<tr>
{#each row as cell}
<td>
{#each cell as cellSeg}
{#if cellSeg.type === 'bold'}
<strong>{cellSeg.content}</strong>
{:else if cellSeg.type === 'italic'}
<em>{cellSeg.content}</em>
{:else if cellSeg.type === 'code'}
<code class="inline-code">{cellSeg.content}</code>
{:else if cellSeg.type === 'link'}
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
{:else}
{cellSeg.content}
{/if}
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else if segment.type === 'heading'}
<h4 class="content-heading">{segment.content}</h4>
{:else if segment.type === 'lineBreak'}
<br />
{:else}
{segment.content}
{/if}
{/each}
</div> </div>
<div class="message-time"> <div class="message-time">
{message.timestamp.toLocaleTimeString()} {message.timestamp.toLocaleTimeString()}
@@ -294,48 +89,25 @@
{#if isSending} {#if isSending}
<div class="message assistant"> <div class="message assistant">
<div class="message-content"> <div class="message-content typing">
<div class="typing">
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
{#if bot} {#if bot}
<div class="input-container"> <div class="input-container">
{#if showSlashMenu && filteredTools.length > 0}
<div class="slash-menu" style="top: {slashMenuPosition.top}px; left: {slashMenuPosition.left}px;">
<div class="slash-menu-header">Available Commands</div>
{#each TOOLS as group}
{#if group.tools.some(t => filteredTools.includes(t))}
<div class="slash-menu-category">{group.label}</div>
{#each group.tools.filter(t => filteredTools.includes(t)) as tool, i}
<button
class="slash-menu-item"
class:selected={filteredTools.indexOf(tool) === selectedIndex}
onclick={() => selectTool(tool)}
>
<span class="slash-command">{tool.command}</span>
<span class="slash-description">{tool.description}</span>
</button>
{/each}
{/if}
{/each}
<div class="slash-menu-hint">Press Tab to select, Enter to send</div>
</div>
{/if}
<textarea <textarea
value={messageInput} bind:value={messageInput}
oninput={handleInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Describe your trading strategy... (or type / for commands)" placeholder="Describe your trading strategy..."
rows="1" rows="1"
disabled={isSending}
></textarea> ></textarea>
<button onclick={handleSend}> <button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
Send Send
</button> </button>
</div> </div>
@@ -434,64 +206,6 @@
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.thinking-section {
margin-bottom: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
transition: background 0.2s;
width: 100%;
text-align: left;
}
.thinking-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.thinking-icon {
font-size: 0.6rem;
color: #667eea;
}
.thinking-label {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #667eea;
}
.thinking-preview {
color: #666;
font-style: italic;
font-weight: normal;
text-transform: none;
letter-spacing: normal;
}
.thinking-content {
color: #888;
font-size: 0.85rem;
padding: 0.75rem 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0.5rem;
white-space: pre-wrap;
line-height: 1.6;
}
.message.system .message-content { .message.system .message-content {
background: rgba(251, 191, 36, 0.1); background: rgba(251, 191, 36, 0.1);
color: #fbbf24; color: #fbbf24;
@@ -499,92 +213,6 @@
border: 1px solid rgba(251, 191, 36, 0.3); border: 1px solid rgba(251, 191, 36, 0.3);
} }
.inline-code {
background: rgba(0, 0, 0, 0.3);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85em;
}
.code-block {
background: rgba(0, 0, 0, 0.4);
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85rem;
margin: 0.5rem 0;
}
.code-block code {
white-space: pre;
}
ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
li {
margin: 0.25rem 0;
}
.content-heading {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem;
color: #fff;
}
.content-heading:first-child {
margin-top: 0;
}
.table-wrapper {
overflow-x: auto;
margin: 0.75rem 0;
}
.markdown-table {
border-collapse: collapse;
width: 100%;
font-size: 0.85rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
overflow: hidden;
}
.markdown-table th,
.markdown-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.markdown-table th {
background: rgba(102, 126, 234, 0.2);
font-weight: 600;
color: #667eea;
}
.markdown-table tr:last-child td {
border-bottom: none;
}
.markdown-table tr:hover td {
background: rgba(255, 255, 255, 0.05);
}
a {
color: #667eea;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.message-time { .message-time {
font-size: 0.7rem; font-size: 0.7rem;
color: #666; color: #666;
@@ -595,7 +223,7 @@
.typing { .typing {
display: flex; display: flex;
gap: 4px; gap: 4px;
padding: 0.5rem; padding: 1rem 1.25rem;
} }
.dot { .dot {
@@ -669,76 +297,4 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.slash-menu {
position: fixed;
background: rgba(20, 20, 20, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 0.5rem;
min-width: 280px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.slash-menu-header {
font-size: 0.75rem;
color: #888;
padding: 0.5rem 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 0.5rem;
}
.slash-menu-category {
font-size: 0.75rem;
color: #666;
padding: 0.5rem 0.75rem 0.25rem;
}
.slash-menu-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
margin: 0.15rem 0;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: rgba(102, 126, 234, 0.2);
}
.slash-command {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
color: #667eea;
font-weight: 500;
}
.slash-description {
font-size: 0.8rem;
color: #888;
margin-top: 0.15rem;
}
.slash-menu-hint {
font-size: 0.7rem;
color: #555;
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0.5rem;
text-align: center;
}
</style> </style>

View File

@@ -1,139 +0,0 @@
<script lang="ts">
interface Props {
initialBalance?: number;
currentBalance?: number;
position?: number;
positionToken?: string;
entryPrice?: number;
currentPrice?: number;
}
let {
initialBalance = 10000,
currentBalance = 10000,
position = 0,
positionToken = '',
entryPrice = 0,
currentPrice = 0
}: Props = $props();
// Calculate metrics
let positionValue = $derived(position * currentPrice);
let totalValue = $derived(currentBalance + positionValue);
let pnl = $derived(totalValue - initialBalance);
let pnlPercent = $derived((pnl / initialBalance) * 100);
let unrealizedPnL = $derived(position > 0 && entryPrice > 0 ? (currentPrice - entryPrice) / entryPrice * 100 : 0);
</script>
<div class="portfolio-summary">
<div class="metric">
<span class="label">Cash Balance</span>
<span class="value">${currentBalance.toFixed(2)}</span>
</div>
{#if position > 0}
<div class="metric">
<span class="label">Position ({positionToken || 'Token'})</span>
<span class="value highlight">{position.toFixed(6)}</span>
</div>
<div class="metric">
<span class="label">Position Value</span>
<span class="value">${positionValue.toFixed(2)}</span>
</div>
<div class="metric">
<span class="label">Entry Price</span>
<span class="value">${entryPrice.toFixed(8)}</span>
</div>
<div class="metric">
<span class="label">Current Price</span>
<span class="value">${currentPrice.toFixed(8)}</span>
</div>
<div class="metric">
<span class="label">Unrealized P&L</span>
<span class="value" class:positive={unrealizedPnL > 0} class:negative={unrealizedPnL < 0}>
{unrealizedPnL >= 0 ? '+' : ''}{unrealizedPnL.toFixed(2)}%
</span>
</div>
{/if}
<div class="divider"></div>
<div class="metric total">
<span class="label">Total Value</span>
<span class="value">${totalValue.toFixed(2)}</span>
</div>
<div class="metric">
<span class="label">P&L</span>
<span class="value large" class:positive={pnl > 0} class:negative={pnl < 0}>
{pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
</span>
</div>
</div>
<style>
.portfolio-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.metric {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric .label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric .value {
font-size: 1rem;
font-weight: 600;
color: #fff;
font-family: monospace;
}
.metric .value.highlight {
color: #fbbf24;
}
.metric .value.large {
font-size: 1.25rem;
}
.metric.total {
grid-column: 1 / -1;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.metric.total .value {
font-size: 1.5rem;
color: #667eea;
}
.positive {
color: #22c55e !important;
}
.negative {
color: #ef4444 !important;
}
.divider {
display: none;
}
</style>

View File

@@ -1,241 +1,155 @@
<script lang="ts"> <script lang="ts">
import type { Signal } from '$lib/api'; import type { Signal } from '$lib/api';
import { onMount, tick } from 'svelte';
interface Props { interface Props {
signals?: Signal[]; signals: Signal[];
klines?: { time: number; close: number }[];
height?: number; height?: number;
} }
let { signals = [], klines = [], height = 200 }: Props = $props(); let { signals, height = 200 }: Props = $props();
let width = $state(800); let width = $state(800);
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
let canvasEl: HTMLCanvasElement;
let initialized = $state(false);
onMount(() => { $effect(() => {
// Set initial width
if (containerEl) { if (containerEl) {
width = containerEl.clientWidth; width = containerEl.clientWidth;
} }
// Resize observer
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
width = entry.contentRect.width;
drawChart();
}
}); });
if (containerEl) { function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
resizeObserver.observe(containerEl); const padding = 30;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
const priceRange = getPriceRange();
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
const y = padding + (1 - normalizedPrice) * chartHeight;
return { x, y };
} }
initialized = true; function getPriceRange(): { min: number; max: number } {
if (signals.length === 0) return { min: 0, max: 1 };
return () => { const prices = signals.map(s => s.price);
resizeObserver.disconnect(); const min = Math.min(...prices);
}; const max = Math.max(...prices);
}); const padding = (max - min) * 0.1 || 1;
return { min: min - padding, max: max + padding };
// Draw when data changes
$effect(() => {
// Access reactive values to trigger effect
const currentSignals = signals;
const currentKlines = klines;
const currentWidth = width;
// Wait for DOM to be ready
tick().then(() => {
drawChart();
});
});
function drawChart() {
if (!canvasEl) {
return;
} }
const ctx = canvasEl.getContext('2d'); function getSignalColor(signal: Signal): string {
if (!ctx) return; switch (signal.signal_type) {
case 'buy': return '#22c55e';
const dpr = window.devicePixelRatio || 1; case 'sell': return '#ef4444';
canvasEl.width = width * dpr; case 'hold': return '#fbbf24';
canvasEl.height = height * dpr; default: return '#888';
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Check if we have data
if (klines.length === 0 && signals.length === 0) {
return;
}
// Get price data
let priceData: { time: number; price: number }[] = [];
if (klines.length > 0) {
priceData = klines.map(k => ({
time: k.time,
price: typeof k.close === 'string' ? parseFloat(k.close) : k.close
})).filter(d => !isNaN(d.price) && d.price > 0);
} else if (signals.length > 0) {
priceData = signals.map(s => ({ time: 0, price: s.price }));
}
if (priceData.length === 0) return;
const prices = priceData.map(d => d.price);
const padding = { top: 20, right: 20, bottom: 45, left: 60 }; // More bottom padding for time labels
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Price range with padding
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceRange = maxPrice - minPrice || 1;
const paddedMin = minPrice - priceRange * 0.1;
const paddedMax = maxPrice + priceRange * 0.1;
function priceToY(price: number): number {
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
}
function indexToX(index: number): number {
return padding.left + (index / Math.max(prices.length - 1, 1)) * chartWidth;
}
// Draw grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (i / 4) * chartHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Draw Y axis labels
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
const y = padding.top + (i / 4) * chartHeight + 4;
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
}
// Draw price line
ctx.beginPath();
ctx.strokeStyle = '#667eea';
ctx.lineWidth = 2;
ctx.moveTo(indexToX(0), priceToY(prices[0]));
for (let i = 1; i < prices.length; i++) {
ctx.lineTo(indexToX(i), priceToY(prices[i]));
}
ctx.stroke();
// Fill area under line
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
ctx.lineTo(indexToX(0), padding.top + chartHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
ctx.fillStyle = gradient;
ctx.fill();
// Draw signal markers
if (signals.length > 0) {
signals.forEach((signal) => {
// Find closest price match
const signalPrice = signal.price;
let closestIndex = 0;
let closestDiff = Infinity;
for (let i = 0; i < priceData.length; i++) {
const diff = Math.abs(priceData[i].price - signalPrice);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
} }
} }
const x = indexToX(closestIndex); function getYAxisLabels(): string[] {
const y = priceToY(signalPrice); const range = getPriceRange();
const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444'; const step = (range.max - range.min) / 4;
return [
// Vertical dashed line range.max.toFixed(6),
ctx.beginPath(); (range.max - step).toFixed(6),
ctx.strokeStyle = color; (range.min + step).toFixed(6),
ctx.setLineDash([4, 4]); range.min.toFixed(6)
ctx.moveTo(x, padding.top); ];
ctx.lineTo(x, y);
ctx.stroke();
ctx.setLineDash([]);
// Signal dot
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
});
} }
// Draw X axis time labels function getXAxisLabels(): string[] {
ctx.fillStyle = '#666'; if (signals.length === 0) return [];
ctx.font = '9px monospace'; const step = Math.max(1, Math.floor(signals.length / 5));
ctx.textAlign = 'center'; const labels: string[] = [];
for (let i = 0; i < signals.length; i += step) {
const numTimeLabels = Math.min(5, priceData.length); labels.push(new Date(signals[i].created_at).toLocaleTimeString());
for (let i = 0; i < numTimeLabels; i++) {
const dataIndex = Math.floor(i * (priceData.length - 1) / (numTimeLabels - 1 || 1));
const x = indexToX(dataIndex);
// Convert timestamp to readable time
let timeLabel = '';
if (priceData[dataIndex].time > 0) {
const date = new Date(priceData[dataIndex].time * 1000);
timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
timeLabel = `${dataIndex + 1}`;
}
ctx.fillText(timeLabel, x, height - 5);
}
// Legend
ctx.fillStyle = '#888';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
if (signals.length > 0) {
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${priceData.length} Candles`, width / 2, height - 20);
} else {
ctx.fillText(`${priceData.length} Candles (No signals generated)`, width / 2, height - 20);
} }
return labels;
} }
</script> </script>
<div class="signal-chart" bind:this={containerEl}> <div class="signal-chart" bind:this={containerEl}>
{#if klines.length === 0 && signals.length === 0} {#if signals.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>No data to display. Start a simulation to see price movements.</p> <p>No signals to display</p>
</div> </div>
{:else} {:else}
<canvas <svg {width} {height} viewBox="0 0 {width} {height}">
bind:this={canvasEl} <defs>
style="width: 100%; height: {height}px;" <linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
></canvas> <stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
</linearGradient>
</defs>
<g class="grid-lines">
{#each [0, 1, 2, 3] as i}
{@const y = 30 + (i / 3) * (height - 60)}
<line
x1="30" y1={y}
x2={width - 30} y2={y}
stroke="rgba(255,255,255,0.1)"
stroke-dasharray="4,4"
/>
{/each}
</g>
<g class="y-axis">
{#each getYAxisLabels() as label, i}
{@const y = 30 + (i / 3) * (height - 60)}
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
{/each}
</g>
<g class="x-axis">
{#each getXAxisLabels() as label, i}
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
{/each}
</g>
<path
d={signals.map((s, i) => {
const pos = getSignalPosition(s, i, signals.length);
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
}).join(' ')}
fill="none"
stroke="#667eea"
stroke-width="2"
/>
{#each signals as signal, i}
{@const pos = getSignalPosition(signal, i, signals.length)}
{@const color = getSignalColor(signal)}
<circle
cx={pos.x}
cy={pos.y}
r="6"
fill={color}
stroke={color}
stroke-width="2"
class="signal-dot"
>
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
</circle>
{/each}
</svg>
<div class="legend">
<div class="legend-item">
<span class="legend-dot buy"></span>
<span>Buy</span>
</div>
<div class="legend-item">
<span class="legend-dot sell"></span>
<span>Sell</span>
</div>
<div class="legend-item">
<span class="legend-dot hold"></span>
<span>Hold</span>
</div>
</div>
{/if} {/if}
</div> </div>
@@ -255,12 +169,60 @@
justify-content: center; justify-content: center;
height: 200px; height: 200px;
color: #666; color: #666;
text-align: center;
padding: 1rem;
} }
canvas { svg {
display: block; display: block;
width: 100%; width: 100%;
height: auto;
}
.axis-label {
font-size: 10px;
fill: #666;
}
.signal-dot {
cursor: pointer;
transition: r 0.2s;
}
.signal-dot:hover {
r: 8;
}
.legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.buy {
background: #22c55e;
}
.legend-dot.sell {
background: #ef4444;
}
.legend-dot.hold {
background: #fbbf24;
} }
</style> </style>

View File

@@ -10,14 +10,13 @@
let { config, editable = false, onUpdate }: Props = $props(); let { config, editable = false, onUpdate }: Props = $props();
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string { function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
switch (condition.type) { switch (condition.type) {
case 'price_drop': case 'price_drop':
return `${condition.token} drops by ${condition.threshold}%${timeframe}`; return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`;
case 'price_rise': case 'price_rise':
return `${condition.token} rises by ${condition.threshold}%${timeframe}`; return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`;
case 'volume_spike': case 'volume_spike':
return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`; return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.timeframe}`;
case 'price_level': case 'price_level':
return `${condition.token} crosses ${condition.direction} $${condition.price}`; return `${condition.token} crosses ${condition.direction} $${condition.price}`;
default: default:

View File

@@ -1,180 +0,0 @@
<script lang="ts">
import type { TradeLogEntry } from '$lib/stores/simulationStore';
interface Props {
tradeLog: TradeLogEntry[];
}
let { tradeLog }: Props = $props();
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
function getActionColor(action: string): string {
switch (action) {
case 'buy': return '#22c55e';
case 'sell': return '#ef4444';
default: return '#666';
}
}
function getActionIcon(action: string): string {
switch (action) {
case 'buy': return '📈';
case 'sell': return '📉';
default: return '➡️';
}
}
// Filter to show only buy/sell actions
let tradeActions = $derived(tradeLog.filter(t => t.action !== 'hold'));
</script>
<div class="trade-dashboard">
<div class="dashboard-header">
<h3>Trade Activity</h3>
<span class="trade-count">
{tradeActions.length} trades
</span>
</div>
{#if tradeActions.length === 0}
<div class="empty-state">
<p>No trades executed yet. Check the strategy configuration.</p>
</div>
{:else}
<div class="trade-list">
{#each tradeActions as entry}
<div class="trade-entry action-{entry.action}">
<div class="trade-time">
<span class="action-icon">{getActionIcon(entry.action)}</span>
<span class="action-badge" style="background: {getActionColor(entry.action)}">
{entry.action.toUpperCase()}
</span>
<span class="time">{formatTime(entry.time)}</span>
</div>
<div class="trade-details">
<div class="price">
<span class="label">Price:</span>
<span class="value">${entry.price.toFixed(8)}</span>
</div>
<div class="reason">
<span class="label">Reason:</span>
<span class="value">{entry.reason}</span>
</div>
{#if entry.action === 'sell' && entry.position > 0}
<div class="pnl">
<span class="label">Position:</span>
<span class="value">{entry.position.toFixed(6)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.trade-dashboard {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.dashboard-header h3 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.trade-count {
font-size: 0.85rem;
color: #888;
}
.empty-state {
padding: 2rem;
text-align: center;
color: #666;
}
.trade-list {
max-height: 300px;
overflow-y: auto;
}
.trade-entry {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s;
}
.trade-entry:hover {
background: rgba(255, 255, 255, 0.02);
}
.trade-entry.action-buy {
border-left: 3px solid #22c55e;
}
.trade-entry.action-sell {
border-left: 3px solid #ef4444;
}
.trade-time {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.action-icon {
font-size: 1rem;
}
.action-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
color: #fff;
}
.time {
font-size: 0.85rem;
color: #888;
}
.trade-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
font-size: 0.85rem;
}
.trade-details .label {
color: #666;
}
.trade-details .value {
color: #fff;
font-family: monospace;
}
.pnl .value {
color: #fbbf24;
}
</style>

View File

@@ -3,8 +3,6 @@ export { default as BotCard } from './BotCard.svelte';
export { default as BotSelector } from './BotSelector.svelte'; export { default as BotSelector } from './BotSelector.svelte';
export { default as StrategyPreview } from './StrategyPreview.svelte'; export { default as StrategyPreview } from './StrategyPreview.svelte';
export { default as SignalChart } from './SignalChart.svelte'; export { default as SignalChart } from './SignalChart.svelte';
export { default as TradeDashboard } from './TradeDashboard.svelte';
export { default as PortfolioSummary } from './PortfolioSummary.svelte';
export { default as BacktestChart } from './BacktestChart.svelte'; export { default as 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';

View File

@@ -5,29 +5,15 @@ export interface ChatMessage {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
thinking: string | null;
timestamp: Date; timestamp: Date;
} }
// Fallback UUID generator for environments where crypto.randomUUID is not available
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: simple UUID v4 implementation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export const chatStore = writable<ChatMessage[]>([]); export const chatStore = writable<ChatMessage[]>([]);
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) { export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
const newMessage: ChatMessage = { const newMessage: ChatMessage = {
...message, ...message,
id: generateId(), id: crypto.randomUUID(),
timestamp: new Date() timestamp: new Date()
}; };
chatStore.update(messages => [...messages, newMessage]); chatStore.update(messages => [...messages, newMessage]);
@@ -38,7 +24,6 @@ export function setMessages(messages: BotConversation[]) {
id: m.id, id: m.id,
role: m.role, role: m.role,
content: m.content, content: m.content,
thinking: null,
timestamp: new Date(m.created_at) timestamp: new Date(m.created_at)
}))); })));
} }

View File

@@ -1,35 +1,9 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Simulation, Signal } from '$lib/api'; import type { Simulation, Signal } from '$lib/api';
export interface KlineData {
time: number;
close: number;
}
export interface TradeLogEntry {
time: number;
price: number;
action: 'buy' | 'sell' | 'hold';
reason: string;
position: number;
entry_price: number | null;
}
export interface Portfolio {
initial_balance: number;
current_balance: number;
position: number;
position_token: string;
entry_price: number;
current_price: number;
}
export interface SimulationState { export interface SimulationState {
currentSimulation: Simulation | null; currentSimulation: Simulation | null;
signals: Signal[]; signals: Signal[];
klines: KlineData[];
tradeLog: TradeLogEntry[];
portfolio: Portfolio;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@@ -37,16 +11,6 @@ export interface SimulationState {
const initialState: SimulationState = { const initialState: SimulationState = {
currentSimulation: null, currentSimulation: null,
signals: [], signals: [],
klines: [],
tradeLog: [],
portfolio: {
initial_balance: 10000,
current_balance: 10000,
position: 0,
position_token: '',
entry_price: 0,
current_price: 0
},
isLoading: false, isLoading: false,
error: null error: null
}; };
@@ -54,20 +18,7 @@ const initialState: SimulationState = {
export const simulationStore = writable<SimulationState>(initialState); export const simulationStore = writable<SimulationState>(initialState);
export function setCurrentSimulation(simulation: Simulation | null) { export function setCurrentSimulation(simulation: Simulation | null) {
simulationStore.update(state => ({ simulationStore.update(state => ({ ...state, currentSimulation: simulation }));
...state,
currentSimulation: simulation,
klines: simulation?.klines || [],
tradeLog: simulation?.trade_log || [],
portfolio: simulation?.portfolio || state.portfolio
}));
}
export function updatePortfolio(portfolio: Partial<Portfolio>) {
simulationStore.update(state => ({
...state,
portfolio: { ...state.portfolio, ...portfolio }
}));
} }
export function addSignals(newSignals: Signal[]) { export function addSignals(newSignals: Signal[]) {

View File

@@ -1,256 +0,0 @@
/**
* Simple markdown parser for rendering AI responses
* Supports: bold, italic, code blocks, inline code, links, lists, tables, headings, line breaks
*/
export interface InlineSegment {
type: 'text' | 'bold' | 'italic' | 'code' | 'link';
content: string;
href?: string;
}
export interface ParsedSegment {
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list' | 'table' | 'lineBreak' | 'heading';
content: string;
items?: string[];
headers?: InlineSegment[][];
rows?: InlineSegment[][];
}
export function parseMarkdown(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Normalize line endings
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// First, extract code blocks
const codeBlockRegex = /```[\s\S]*?```/g;
const parts = text.split(codeBlockRegex);
const codeBlocks = text.match(codeBlockRegex) || [];
let partIndex = 0;
while (partIndex < parts.length) {
const part = parts[partIndex];
if (part.trim()) {
// Process non-code content
segments.push(...parseInlineContent(part));
}
// Add code block if there's one after this part
if (partIndex < codeBlocks.length) {
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
segments.push({ type: 'codeBlock', content: codeContent });
}
partIndex++;
}
return segments;
}
function parseInlineContent(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Check for tables - match table pattern anywhere in text
// Table pattern: | header | ... |\n|---|...|\n| row | ... |
const tableRegex = /\|.+\|\n\|[-:\s|]+\|\n((?:\|.+\|\n?)*)/g;
let lastIndex = 0;
let tableMatch;
while ((tableMatch = tableRegex.exec(text)) !== null) {
// Add content before table
const beforeTable = text.substring(lastIndex, tableMatch.index);
if (beforeTable.trim()) {
segments.push(...parseLines(beforeTable));
}
// Parse table
const tableContent = tableMatch[0];
const tableSegments = parseTable(tableContent);
if (tableSegments.length > 0) {
segments.push(...tableSegments);
} else {
// If table parsing failed, treat as text
segments.push(...parseLines(tableContent));
}
lastIndex = tableMatch.index + tableContent.length;
}
// Add remaining content
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex);
if (remaining.trim()) {
segments.push(...parseLines(remaining));
}
}
return segments;
}
function parseTable(tableStr: string): ParsedSegment[] {
const lines = tableStr.trim().split('\n').filter(line => line.trim());
if (lines.length < 2) return [];
// Skip separator line (|---|---|)
const dataLines = lines.filter(line => !line.match(/^[\|\s\-:]+$/));
if (dataLines.length < 2) return [];
const headers = parseTableRow(dataLines[0]);
const rows = dataLines.slice(1).map(row => parseTableRow(row));
return [{
type: 'table',
content: '',
headers,
rows
}];
}
function parseTableRow(row: string): InlineSegment[][] {
return row.split('|')
.map(cell => cell.trim())
.filter(cell => cell !== '')
.map(cell => parseInlineElements(cell));
}
export function parseInlineElements(text: string): InlineSegment[] {
const segments: InlineSegment[] = [];
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const parts = text.split(inlineRegex);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
segments.push({ type: 'bold', content: part.slice(2, -2) });
} else if (part.startsWith('*') && part.endsWith('*')) {
segments.push({ type: 'italic', content: part.slice(1, -1) });
} else if (part.startsWith('`') && part.endsWith('`')) {
segments.push({ type: 'code', content: part.slice(1, -1) });
} else if (part.startsWith('[') && part.includes('](')) {
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
segments.push({ type: 'link', content: linkMatch[1], href: linkMatch[2] });
}
} else if (part) {
segments.push({ type: 'text', content: part });
}
}
return segments;
}
// Render inline segments to HTML string
function renderInlineSegments(segments: InlineSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'bold': return `<strong>${seg.content}</strong>`;
case 'italic': return `<em>${seg.content}</em>`;
case 'code': return `<code class="inline-code">${seg.content}</code>`;
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
default: return seg.content;
}
}).join('');
}
function parseLines(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Combined regex for inline formatting
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) {
// Empty line - add line break for paragraph separation
segments.push({ type: 'lineBreak', content: '' });
continue;
}
// Check for headings
if (line.match(/^#{1,6}\s/)) {
segments.push({ type: 'heading', content: line.replace(/^#+\s/, '') });
continue;
}
// Check for list items
if (line.match(/^[\-\*]\s/)) {
const listMatch = line.match(/^([\-\*])\s(.*)/);
if (listMatch) {
// Parse inline formatting for list item
const itemContent = listMatch[2];
const inlineSegments = parseInlineElements(itemContent);
// Check if previous segment is a list
const lastSeg = segments[segments.length - 1];
if (lastSeg && lastSeg.type === 'list') {
lastSeg.items?.push(itemContent);
} else {
segments.push({ type: 'list', content: '', items: [itemContent] });
}
}
continue;
}
// Check for numbered lists
if (line.match(/^\d+\.\s/)) {
const listMatch = line.match(/^\d+\.\s(.*)/);
if (listMatch) {
const itemContent = listMatch[1];
const lastSeg = segments[segments.length - 1];
if (lastSeg && lastSeg.type === 'list') {
lastSeg.items?.push(itemContent);
} else {
segments.push({ type: 'list', content: '', items: [itemContent] });
}
}
continue;
}
// Process inline formatting
const inlineSegments = parseInlineElementsAsText(line);
segments.push(...inlineSegments);
// Add line break after non-empty lines (except last in a paragraph)
if (i < lines.length - 1 && line.trim()) {
segments.push({ type: 'lineBreak', content: '' });
}
}
return segments;
}
function parseInlineElementsAsText(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const parts = text.split(inlineRegex);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
segments.push({ type: 'bold', content: part.slice(2, -2) });
} else if (part.startsWith('*') && part.endsWith('*')) {
segments.push({ type: 'italic', content: part.slice(1, -1) });
} else if (part.startsWith('`') && part.endsWith('`')) {
segments.push({ type: 'code', content: part.slice(1, -1) });
} else if (part.startsWith('[') && part.includes('](')) {
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
segments.push({ type: 'link', content: linkMatch[1] });
}
} else if (part) {
segments.push({ type: 'text', content: part });
}
}
return segments;
}

View File

@@ -4,20 +4,12 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores'; import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { ChatInterface, StrategyPreview } from '$lib/components'; import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components';
import type { TokenSearchResult } from '$lib/api';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let isSending = $state(false); let isSending = $state(false);
let showStrategy = $state(false); let showStrategy = $state(false);
// Token address confirmation modal state
let showTokenConfirm = $state(false);
let pendingStrategyData = $state<any>(null);
let tokenAddressInput = $state('');
let confirmingMessage = $state('');
let tokenSearchResults = $state<TokenSearchResult[]>([]);
onMount(async () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
goto('/login'); goto('/login');
@@ -52,40 +44,16 @@
isSending = true; isSending = true;
// Add user's message immediately so it shows even before API response
addMessage({ role: 'user', content: message });
try { try {
// Add timeout to prevent hanging requests const response = await api.bots.chat(botId, message);
const controller = new AbortController(); addMessage({ role: 'assistant', content: response.response });
const timeoutId = setTimeout(() => controller.abort(), 30000);
const response = await api.bots.chat(botId, message, controller.signal);
clearTimeout(timeoutId);
// Check if token address confirmation is needed
if (response.strategy_needs_confirmation && response.strategy_data) {
// Show token confirmation modal
pendingStrategyData = response.strategy_data;
confirmingMessage = response.response;
tokenAddressInput = '';
tokenSearchResults = response.token_search_results || [];
showTokenConfirm = true;
}
// Add assistant response with thinking
addMessage({ role: 'assistant', content: response.response, thinking: response.thinking || null });
if (response.strategy_config) { if (response.strategy_config) {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); setCurrentBot(bot);
} }
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
addMessage({ role: 'assistant', content: 'Request timed out. Please try again.', thinking: null });
} else {
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.', thinking: null });
}
} finally { } finally {
isSending = false; isSending = false;
} }
@@ -94,62 +62,6 @@
function toggleStrategy() { function toggleStrategy() {
showStrategy = !showStrategy; showStrategy = !showStrategy;
} }
async function confirmTokenAddress() {
if (!tokenAddressInput.trim() || !pendingStrategyData) {
showTokenConfirm = false;
return;
}
// Update the pending strategy with the token address
const updatedStrategy = { ...pendingStrategyData };
// Update conditions with token address
if (updatedStrategy.conditions) {
updatedStrategy.conditions = updatedStrategy.conditions.map((cond: any) => ({
...cond,
token_address: tokenAddressInput.trim()
}));
}
// Update actions with token address
if (updatedStrategy.actions) {
updatedStrategy.actions = updatedStrategy.actions.map((action: any) => ({
...action,
token_address: tokenAddressInput.trim()
}));
}
try {
// Update bot with the strategy
await api.bots.update(botId, { strategy_config: updatedStrategy });
// Refresh bot data
const bot = await api.bots.get(botId);
setCurrentBot(bot);
// Add success message
addMessage({ role: 'assistant', content: `Perfect! I've saved your strategy with the token address. You can now run backtests!`, thinking: null });
} catch (e) {
addMessage({ role: 'assistant', content: 'Failed to save strategy. Please try again.', thinking: null });
}
showTokenConfirm = false;
pendingStrategyData = null;
tokenAddressInput = '';
tokenSearchResults = [];
}
function selectTokenResult(result: TokenSearchResult) {
tokenAddressInput = result.address;
}
function cancelTokenConfirm() {
showTokenConfirm = false;
pendingStrategyData = null;
tokenAddressInput = '';
tokenSearchResults = [];
}
</script> </script>
<svelte:head> <svelte:head>
@@ -157,34 +69,6 @@
</svelte:head> </svelte:head>
<main> <main>
{#if showTokenConfirm}
<div class="modal-overlay" onclick={cancelTokenConfirm}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<h3>Select Token Address</h3>
<p class="modal-message">{confirmingMessage}</p>
{#if tokenSearchResults.length > 0}
<div class="token-results">
<p class="modal-hint">Select a token:</p>
{#each tokenSearchResults as result}
<button class="token-result" onclick={() => selectTokenResult(result)}>
<span class="token-symbol">{result.symbol}</span>
<span class="token-name">{result.name}</span>
<span class="token-address">{result.address.slice(0, 10)}...{result.address.slice(-8)}</span>
</button>
{/each}
</div>
<p class="modal-divider">or enter manually:</p>
{/if}
<input type="text" class="token-input" bind:value={tokenAddressInput} placeholder="0x..."/>
<div class="modal-actions">
<button class="btn btn-secondary" onclick={cancelTokenConfirm}>Cancel</button>
<button class="btn btn-primary" onclick={confirmTokenAddress} disabled={!tokenAddressInput.trim()}>Confirm</button>
</div>
</div>
</div>
{/if}
<header> <header>
<div class="header-left"> <div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a> <a href="/dashboard" class="back-link">← Dashboard</a>
@@ -211,12 +95,12 @@
<ChatInterface <ChatInterface
bot={$currentBotStore} bot={$currentBotStore}
messages={$chatStore} messages={$chatStore}
isSending={isSending} {isSending}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
/> />
</div> </div>
<!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> --> <ProUpgradeBanner feature="Auto-execute trades with your bot" />
</main> </main>
<style> <style>
@@ -302,145 +186,4 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 1.5rem;
max-width: 450px;
width: 90%;
}
.modal-content h3 {
margin: 0 0 1rem;
color: #667eea;
}
.modal-message {
color: #ccc;
margin-bottom: 0.5rem;
line-height: 1.5;
}
.modal-hint {
color: #888;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.token-input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
font-family: 'Monaco', 'Menlo', monospace;
box-sizing: border-box;
}
.token-input:focus {
outline: none;
border-color: #667eea;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
justify-content: flex-end;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Token Results */
.token-results {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
.token-result {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-bottom: 0.5rem;
cursor: pointer;
text-align: left;
color: #fff;
transition: background 0.2s;
}
.token-result:hover {
background: rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.5);
}
.token-result:last-child {
margin-bottom: 0;
}
.token-symbol {
font-weight: 600;
color: #667eea;
min-width: 60px;
}
.token-name {
flex: 1;
color: #ccc;
font-size: 0.9rem;
}
.token-address {
font-size: 0.75rem;
color: #666;
font-family: 'Monaco', 'Menlo', monospace;
}
.modal-divider {
text-align: center;
color: #666;
font-size: 0.85rem;
margin: 1rem 0;
}
</style> </style>

View File

@@ -8,36 +8,14 @@
import type { Backtest } from '$lib/api'; import type { Backtest } from '$lib/api';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let tokenName = $state(''); let token = $state('PEPE');
let tokenAddress = $state('');
let timeframe = $state('1h'); let timeframe = $state('1h');
let startDate = $state(''); let startDate = $state('');
let endDate = $state(''); let endDate = $state('');
let isRunning = $state(false); let isRunning = $state(false);
let selectedBacktest = $state<Backtest | null>(null); let selectedBacktest = $state<Backtest | null>(null);
// Expandable trades state
let expandedTrades = $state<Set<string>>(new Set());
// Pagination state for each backtest
let tradesPage = $state<Record<string, number>>({});
let tradesData = $state<Record<string, any>>({});
const TRADES_PER_PAGE = 5;
onMount(async () => { onMount(async () => {
// Set default dates - yesterday only (1 day range for fast testing)
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// Set max date to yesterday
const maxDate = yesterday.toISOString().split('T')[0];
// Set end to yesterday, start to day before (1 day range)
endDate = maxDate;
const dayBefore = new Date(yesterday);
dayBefore.setDate(dayBefore.getDate() - 1);
startDate = dayBefore.toISOString().split('T')[0];
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
goto('/login'); goto('/login');
return; return;
@@ -52,16 +30,6 @@
try { try {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); setCurrentBot(bot);
// Extract token info from strategy config
const strategy = bot.strategy_config;
if (strategy) {
// Try conditions first, then actions
const condition = strategy.conditions?.[0];
const action = strategy.actions?.[0];
tokenName = condition?.token || action?.token || '';
tokenAddress = condition?.token_address || action?.token_address || '';
}
} catch (e) { } catch (e) {
goto('/dashboard'); goto('/dashboard');
} }
@@ -78,25 +46,13 @@
async function startBacktest() { async function startBacktest() {
if (!startDate || !endDate) return; if (!startDate || !endDate) return;
// Validate date range (max 7 days)
const start = new Date(startDate);
const end = new Date(endDate);
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 7) {
setBacktestError('Maximum backtest duration is 7 days for fast testing');
return;
}
setBacktestError(null); setBacktestError(null);
setBacktestLoading(true); setBacktestLoading(true);
isRunning = true; isRunning = true;
try { try {
const backtest = await api.backtest.start(botId, { const backtest = await api.backtest.start(botId, {
token: tokenAddress, // Use token address from strategy token,
token_name: tokenName, // Also send token name for display
timeframe, timeframe,
start_date: startDate, start_date: startDate,
end_date: endDate end_date: endDate
@@ -120,54 +76,15 @@
} }
} }
function setBacktestHistory(backtests: any[]) { function setBacktestHistory(backtests: any[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests })); backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
} }
function selectBacktest(backtest: Backtest) { function selectBacktest(backtest: Backtest) {
if (backtest.status === 'completed' && backtest.result && !backtest.result.error) { if (backtest.status === 'completed' && backtest.result) {
selectedBacktest = backtest; selectedBacktest = backtest;
} }
} }
function toggleTrades(backtestId: string) {
if (expandedTrades.has(backtestId)) {
expandedTrades.delete(backtestId);
} else {
expandedTrades.add(backtestId);
// Load first page of trades if not loaded
if (!tradesData[backtestId]) {
loadTrades(backtestId, 1);
}
}
expandedTrades = new Set(expandedTrades); // Trigger reactivity
}
async function loadTrades(backtestId: string, page: number) {
try {
const data = await api.backtest.getTrades(botId, backtestId, page, TRADES_PER_PAGE);
tradesData[backtestId] = { ...data, currentPage: page };
tradesData = { ...tradesData }; // Trigger reactivity
} catch (e) {
console.error('Failed to load trades:', e);
}
}
function nextTradesPage(backtestId: string) {
const data = tradesData[backtestId];
if (data && data.has_next) {
loadTrades(backtestId, data.page + 1);
}
}
function prevTradesPage(backtestId: string) {
const data = tradesData[backtestId];
if (data && data.has_prev) {
loadTrades(backtestId, data.page - 1);
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -192,19 +109,17 @@
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}> <form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
<div class="form-row"> <div class="form-row">
<div class="field token-info"> <div class="field">
<label>Token</label> <label for="token">Token</label>
<div class="token-display"> <input type="text" id="token" bind:value={token} required />
<span class="token-name">{tokenName || 'Not configured'}</span>
{#if tokenAddress}
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
{/if}
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="timeframe">Timeframe</label> <label for="timeframe">Timeframe</label>
<select id="timeframe" bind:value={timeframe}> <select id="timeframe" bind:value={timeframe}>
<option value="1h">1 hour (recommended)</option> <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> <option value="4h">4 hours</option>
<option value="1d">1 day</option> <option value="1d">1 day</option>
</select> </select>
@@ -229,12 +144,7 @@
</section> </section>
<section class="results-section"> <section class="results-section">
<div class="section-header">
<h2>Backtest History</h2> <h2>Backtest History</h2>
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{#if $backtestStore.backtestHistory.length === 0} {#if $backtestStore.backtestHistory.length === 0}
<p class="empty-state">No backtests yet. Run your first backtest above.</p> <p class="empty-state">No backtests yet. Run your first backtest above.</p>
@@ -246,11 +156,7 @@
<span class="backtest-status status-{backtest.status}">{backtest.status}</span> <span class="backtest-status status-{backtest.status}">{backtest.status}</span>
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span> <span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
</div> </div>
{#if backtest.result && backtest.result.error} {#if backtest.result}
<div class="backtest-error">
<span class="error-label">Error:</span> {typeof backtest.result.error === 'string' ? backtest.result.error : JSON.stringify(backtest.result.error)}
</div>
{:else if backtest.result}
<div class="backtest-results"> <div class="backtest-results">
<div class="result-item"> <div class="result-item">
<span class="result-label">Total Return</span> <span class="result-label">Total Return</span>
@@ -271,68 +177,16 @@
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span> <span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
</div> </div>
</div> </div>
<div class="backtest-config">
<span class="config-item">
<span class="config-label">Token:</span> {backtest.config.token || 'Unknown'}
</span>
<span class="config-item">
<span class="config-label">TF:</span> {backtest.config.timeframe || '1h'}
</span>
<span class="config-item">
<span class="config-label">Period:</span> {backtest.config.start_date} to {backtest.config.end_date}
</span>
</div>
{#if backtest.result.trades && backtest.result.trades.length > 0}
<button class="btn-toggle-trades" onclick={() => toggleTrades(backtest.id)}>
{expandedTrades.has(backtest.id) ? 'Hide' : 'Show'} Trade History ({backtest.result.trades.length})
</button>
{#if expandedTrades.has(backtest.id)}
<div class="trades-inline">
{#if tradesData[backtest.id]}
<div class="trades-pagination-header">
<span class="trades-count">
Showing {((tradesData[backtest.id].page - 1) * TRADES_PER_PAGE) + 1}-{Math.min(tradesData[backtest.id].page * TRADES_PER_PAGE, tradesData[backtest.id].total_trades)} of {tradesData[backtest.id].total_trades}
</span>
{#if tradesData[backtest.id].total_pages > 1}
<div class="pagination-controls">
<button class="btn-pagination" onclick={() => prevTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_prev}>← Prev</button>
<span class="page-indicator">Page {tradesData[backtest.id].page} of {tradesData[backtest.id].total_pages}</span>
<button class="btn-pagination" onclick={() => nextTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_next}>Next →</button>
</div>
{/if}
</div>
<div class="trades-list">
{#each tradesData[backtest.id].trades as trade}
<div class="trade-item">
<span class="trade-type" class:buy={trade.type === 'buy'} class:sell={trade.type === 'sell'}>
{trade.type.toUpperCase()}
</span>
<span class="trade-price">${trade.price?.toFixed(6)}</span>
<span class="trade-amount">${trade.amount?.toFixed(2)}</span>
<span class="trade-reason">{trade.exit_reason || 'entry'}</span>
</div>
{/each}
</div>
{:else}
<div class="trades-loading">Loading trades...</div>
{/if}
</div>
{/if}
{/if}
{/if} {/if}
{#if backtest.status === 'running'} {#if backtest.status === 'running'}
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: {backtest.progress ?? 0}%"></div>
</div>
<span class="progress-text">{backtest.progress ?? 0}%</span>
</div>
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button> <button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</section>
{#if selectedBacktest} {#if selectedBacktest}
<section class="chart-section"> <section class="chart-section">
<div class="chart-header"> <div class="chart-header">
@@ -342,8 +196,6 @@
<BacktestChart results={selectedBacktest.result} /> <BacktestChart results={selectedBacktest.result} />
</section> </section>
{/if} {/if}
</div> </div>
</main> </main>
@@ -385,120 +237,7 @@
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
margin: 0; margin: 0 0 1rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
.btn-refresh {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
width: auto;
}
.btn-refresh:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
transform: none;
}
.btn-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
/* Trades Modal */
.trades-modal {
max-width: 800px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.trades-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.trades-modal h3 {
margin: 0;
color: #667eea;
}
.debug-info {
background: yellow;
color: black;
padding: 0.5rem;
margin-bottom: 1rem;
font-family: monospace;
}
.trades-table-wrapper {
overflow-y: auto;
flex: 1;
}
.trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.trades-table th,
.trades-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-table th {
background: rgba(255, 255, 255, 0.05);
font-weight: 600;
color: #ccc;
position: sticky;
top: 0;
}
.trades-table td {
color: #fff;
}
.trade-type {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
}
.trade-type.buy {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.trade-type.sell {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
} }
.content { .content {
@@ -523,20 +262,6 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.backtest-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.error-label {
font-weight: 600;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -550,27 +275,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
.token-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.token-name {
font-weight: 600;
color: #667eea;
}
.token-address {
font-size: 0.8rem;
color: #888;
font-family: 'Monaco', 'Menlo', monospace;
}
label { label {
font-size: 0.9rem; font-size: 0.9rem;
color: #ccc; color: #ccc;
@@ -630,83 +334,6 @@
padding: 1rem; padding: 1rem;
} }
/* Inline Trades */
.trades-inline {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-inline h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #667eea;
}
.trades-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trade-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 0.85rem;
}
.trade-item .trade-type {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.75rem;
}
.trade-item .trade-type.buy {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.trade-item .trade-type.sell {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.trade-price {
color: #ccc;
font-family: monospace;
}
.trade-amount {
color: #888;
}
.trade-reason {
color: #666;
font-size: 0.8rem;
margin-left: auto;
}
.btn-toggle-trades {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 6px;
color: #667eea;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-trades:hover {
background: rgba(102, 126, 234, 0.2);
}
.backtest-header { .backtest-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -737,11 +364,6 @@
color: #fca5a5; color: #fca5a5;
} }
.status-stopped {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.backtest-date { .backtest-date {
color: #888; color: #888;
font-size: 0.85rem; font-size: 0.85rem;
@@ -753,24 +375,6 @@
gap: 1rem; gap: 1rem;
} }
.backtest-config {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding: 0.5rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.config-item {
font-size: 0.8rem;
color: #888;
}
.config-label {
color: #666;
}
.result-item { .result-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -795,33 +399,6 @@
color: #ef4444; color: #ef4444;
} }
.progress-container {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85rem;
color: #888;
min-width: 40px;
}
.btn-danger { .btn-danger {
margin-top: 0.75rem; margin-top: 0.75rem;
width: auto; width: auto;
@@ -862,61 +439,4 @@
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
color: #fff; color: #fff;
} }
/* Pagination styles */
.trades-pagination-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-count {
font-size: 0.85rem;
color: #888;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-pagination {
width: auto;
padding: 0.35rem 0.75rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 6px;
color: #667eea;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-pagination:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.2);
transform: none;
}
.btn-pagination:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-indicator {
font-size: 0.8rem;
color: #888;
min-width: 80px;
text-align: center;
}
.trades-loading {
text-align: center;
color: #888;
padding: 1rem;
font-size: 0.9rem;
}
</style> </style>

View File

@@ -4,14 +4,13 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores'; import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components'; import { SignalChart, ProUpgradeBanner } from '$lib/components';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let tokenName = $state(''); let token = $state('PEPE');
let tokenAddress = $state(''); let intervalSeconds = $state(60);
let klineInterval = $state('1m'); let autoExecute = $state(false);
let isRunning = $state(false); let isRunning = $state(false);
let isRefreshing = $state(false);
onMount(async () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
@@ -28,40 +27,26 @@
try { try {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); setCurrentBot(bot);
// Extract token info from strategy config
const strategy = bot.strategy_config;
if (strategy) {
const condition = strategy.conditions?.[0];
const action = strategy.actions?.[0];
tokenName = condition?.token || action?.token || '';
tokenAddress = condition?.token_address || action?.token_address || '';
}
} catch (e) { } catch (e) {
goto('/dashboard'); goto('/dashboard');
} }
} }
async function loadSimulations() { async function loadSimulations() {
isRefreshing = true;
try { try {
const simulations = await api.simulate.list(botId); const simulations = await api.simulate.list(botId);
if (simulations.length > 0) {
// Find the most recent running simulation, or fall back to most recent const latest = simulations[0];
let current = simulations.find(s => s.status === 'running') || simulations[0]; setCurrentSimulation(latest);
if (latest.signals) {
if (current) { addSignals(latest.signals);
setCurrentSimulation(current); }
clearSignals(); if (latest.status === 'running') {
if (current.signals && current.signals.length > 0) { isRunning = true;
addSignals(current.signals);
} }
isRunning = current.status === 'running';
} }
} catch (e) { } catch (e) {
console.error('Failed to load simulations:', e); console.error('Failed to load simulations:', e);
} finally {
isRefreshing = false;
} }
} }
@@ -72,9 +57,9 @@
try { try {
const simulation = await api.simulate.start(botId, { const simulation = await api.simulate.start(botId, {
token: tokenAddress, token,
chain: 'bsc', interval_seconds: intervalSeconds,
kline_interval: klineInterval auto_execute: autoExecute
}); });
setCurrentSimulation(simulation); setCurrentSimulation(simulation);
clearSignals(); clearSignals();
@@ -109,23 +94,11 @@
<a href="/bot/{botId}" class="back-link">← Back to Chat</a> <a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Simulation</h1> <h1>Simulation</h1>
</div> </div>
<div class="header-right">
{#if $simulationStore.currentSimulation}
<button
type="button"
class="refresh-btn"
onclick={() => loadSimulations()}
class:refreshing={isRefreshing}
>
{isRefreshing ? '⟳ Refreshing...' : '⟳ Refresh'}
</button>
{/if}
</div>
</header> </header>
<div class="notice"> <div class="notice">
<span class="notice-icon">⚠️</span> <span class="notice-icon">⚠️</span>
<span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span> <span>Simulation Mode - Using REST polling (every {intervalSeconds}s). For real-time signals, consider upgrading to Pro tier.</span>
</div> </div>
<div class="content"> <div class="content">
@@ -138,26 +111,26 @@
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}> <form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
<div class="form-row"> <div class="form-row">
<div class="field token-info"> <div class="field">
<label>Token</label> <label for="token">Token</label>
<div class="token-display"> <input type="text" id="token" bind:value={token} required disabled={isRunning} />
<span class="token-name">{tokenName || 'Not configured'}</span>
{#if tokenAddress}
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
{/if}
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="klineInterval">Kline Interval</label> <label for="interval">Check Interval (seconds)</label>
<select id="klineInterval" bind:value={klineInterval} disabled={isRunning}> <select id="interval" bind:value={intervalSeconds} disabled={isRunning}>
<option value="1m">1 minute</option> <option value={30}>30 seconds</option>
<option value="5m">5 minutes</option> <option value={60}>60 seconds</option>
<option value="15m">15 minutes</option> <option value={120}>2 minutes</option>
<option value="1h">1 hour</option> <option value={300}>5 minutes</option>
</select> </select>
</div> </div>
</div> </div>
<div class="field checkbox-field">
<input type="checkbox" id="autoExecute" bind:checked={autoExecute} disabled={isRunning} />
<label for="autoExecute">Auto-execute trades (requires Pro tier)</label>
</div>
{#if isRunning} {#if isRunning}
<button type="button" onclick={stopSimulation} class="btn btn-danger"> <button type="button" onclick={stopSimulation} class="btn btn-danger">
Stop Simulation Stop Simulation
@@ -170,31 +143,16 @@
</form> </form>
</section> </section>
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
<section class="signals-section"> <section class="signals-section">
<h2>Portfolio</h2> <h2>Signals ({$simulationStore.signals.length})</h2>
<PortfolioSummary
initialBalance={$simulationStore.currentSimulation?.portfolio?.initial_balance || 10000}
currentBalance={$simulationStore.currentSimulation?.portfolio?.current_balance || 10000}
position={$simulationStore.currentSimulation?.portfolio?.position || 0}
positionToken={$simulationStore.currentSimulation?.portfolio?.position_token || ''}
entryPrice={$simulationStore.currentSimulation?.portfolio?.entry_price || 0}
currentPrice={$simulationStore.currentSimulation?.portfolio?.current_price || 0}
/>
<h2 style="margin-top: 1.5rem;">Price Chart</h2>
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={250} />
<h2 style="margin-top: 1.5rem;">Trade Activity</h2>
<TradeDashboard tradeLog={$simulationStore.currentSimulation?.trade_log || []} />
<h2 style="margin-top: 1.5rem;">Signals ({$simulationStore.signals.length})</h2>
{#if $simulationStore.signals.length === 0} {#if $simulationStore.signals.length === 0}
<p class="empty-state">No signals generated. The chart above shows price movement.</p> <p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
{:else} {:else}
<SignalChart signals={$simulationStore.signals} height={200} />
<div class="signals-list"> <div class="signals-list">
{#each $simulationStore.signals as signal} {#each $simulationStore.signals as signal}
<div class="signal-card"> <div class="signal-card">
@@ -240,42 +198,6 @@
padding: 2rem; padding: 2rem;
} }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-right {
display: flex;
gap: 0.5rem;
}
.refresh-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.refresh-btn.refreshing {
opacity: 0.7;
cursor: not-allowed;
}
header { header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -354,27 +276,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
.token-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.token-name {
font-weight: 600;
color: #667eea;
}
.token-address {
font-size: 0.8rem;
color: #888;
font-family: 'Monaco', 'Menlo', monospace;
}
.checkbox-field { .checkbox-field {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;