Compare commits
98 Commits
feat/conve
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70dfba2ffc | ||
|
|
6d204b537d | ||
|
|
2b7f54703e | ||
|
|
99dded8d16 | ||
|
|
29b7634c34 | ||
|
|
fd5c2b56d7 | ||
|
|
632e1bf524 | ||
|
|
5ae1165ad9 | ||
|
|
283573f5a8 | ||
|
|
90fa66bd39 | ||
|
|
84d8a6f4a6 | ||
|
|
a8e0baf0c0 | ||
|
|
6c39e4e89d | ||
|
|
bba773251a | ||
|
|
3013326ded | ||
|
|
a82185de60 | ||
|
|
cadea23e40 | ||
|
|
984656c83c | ||
|
|
1505bc9913 | ||
|
|
dd61c32ea7 | ||
|
|
01ec8bc539 | ||
|
|
a253aae766 | ||
|
|
13e899c851 | ||
|
|
384f84e772 | ||
|
|
cd1a41d1d7 | ||
|
|
6a20cc174f | ||
|
|
ce8a29c0a4 | ||
|
|
f425ae08d7 | ||
|
|
d4400f5dcd | ||
|
|
1591fcb1ca | ||
|
|
b0131aa566 | ||
|
|
52adc93b25 | ||
|
|
79c3ec7d16 | ||
|
|
3505cf4ade | ||
|
|
1b1358353f | ||
|
|
726e579f5f | ||
|
|
b111e4d79f | ||
|
|
0d63a10ac8 | ||
|
|
19f28fc599 | ||
|
|
5f7667992e | ||
|
|
cd4583ca90 | ||
|
|
6cadb7a67b | ||
|
|
02e0b0ccab | ||
|
|
29ec67cced | ||
|
|
c86e71c3a3 | ||
|
|
44fb840731 | ||
|
|
6a5694f74b | ||
|
|
680a9322e3 | ||
|
|
9973b8f6e2 | ||
|
|
30476e782b | ||
|
|
02ca452655 | ||
|
|
cb9558d54f | ||
|
|
638e17eb73 | ||
|
|
69a8b06462 | ||
|
|
15e72b009c | ||
|
|
19ba0c7cc6 | ||
|
|
847890b634 | ||
|
|
6658a418cc | ||
|
|
5c9e46e693 | ||
|
|
194c4f8a62 | ||
|
|
7afcb983e8 | ||
|
|
caef4b36ed | ||
|
|
3bf2877df2 | ||
|
|
145c6710d1 | ||
|
|
3c8c85aefc | ||
|
|
39b2b558a5 | ||
|
|
7795753aaa | ||
|
|
36dcfdb6e2 | ||
|
|
48fc323dac | ||
|
|
0af2de7209 | ||
|
|
e82b8b3549 | ||
|
|
6f23b322d3 | ||
|
|
297a185215 | ||
|
|
f86ff75525 | ||
|
|
6f9564790f | ||
|
|
f43eb11f6f | ||
|
|
446da96ce4 | ||
|
|
922ef89c1e | ||
|
|
a601ebb08b | ||
|
|
bb40193fc3 | ||
|
|
3a7d3a3732 | ||
|
|
0f558a5e8e | ||
|
|
9e9ff6fa7f | ||
|
|
4c48932ece | ||
|
|
bfc85648db | ||
|
|
925920eee1 | ||
|
|
299e74cffa | ||
|
|
2b875cfa27 | ||
|
|
ae612ad725 | ||
|
|
08912019c2 | ||
|
|
44453877b3 | ||
|
|
ad4a1e89d5 | ||
|
|
57fa200ba9 | ||
|
|
db4fb83243 | ||
|
|
560b61c431 | ||
|
|
c6baadf8b8 | ||
|
|
937cc2da60 | ||
| 32cd7184ea |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "ave-cloud-skill"]
|
||||
path = ave-cloud-skill
|
||||
url = https://github.com/AveCloud/ave-cloud-skill.git
|
||||
1
ave-cloud-skill
Submodule
1
ave-cloud-skill
Submodule
Submodule ave-cloud-skill added at 5eaef99e15
@@ -34,6 +34,9 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
|
||||
@@ -10,6 +10,8 @@ Environment="PATH=/var/www/bot/src/backend/venv/bin"
|
||||
ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=300
|
||||
|
||||
EnvironmentFile=/var/www/bot/data/.env
|
||||
|
||||
|
||||
27
docs/ISSUES.md
Normal file
27
docs/ISSUES.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Open Issues
|
||||
|
||||
## Frontend
|
||||
|
||||
### Token Address Confirmation Dialog
|
||||
- **Priority**: High
|
||||
- **Status**: Open
|
||||
- **Description**: When user configures a trading strategy via chat and mentions a token (e.g., "buy PEPE"), the AI asks for the token contract address. The frontend should show a confirmation dialog allowing user to:
|
||||
1. See the token the AI detected (PEPE)
|
||||
2. Enter/confirm the BSC contract address
|
||||
3. Save the strategy with the confirmed address
|
||||
|
||||
**Related Files**:
|
||||
- Frontend: `src/frontend/src/routes/bot/[id]/+page.svelte`
|
||||
- Backend: `src/backend/app/services/ai_agent/conversational.py`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Modal/dialog appears when AI detects a token without address
|
||||
- [ ] User can enter the contract address (0x...)
|
||||
- [ ] Strategy is saved only after user confirmation
|
||||
- [ ] Clear error handling if address is invalid
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
*No open backend issues*
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Annotated
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..core.config import get_settings
|
||||
from ..core.limiter import limiter
|
||||
from ..db.schemas import (
|
||||
UserCreate,
|
||||
LoginRequest,
|
||||
UserResponse,
|
||||
Token,
|
||||
UserSettings,
|
||||
@@ -85,11 +86,11 @@ def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
@limiter.limit("5/minute")
|
||||
def login(
|
||||
request: Request,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
login_data: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
user = db.query(User).filter(User.email == login_data.username).first()
|
||||
if not user or not verify_password(login_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
|
||||
@@ -22,6 +22,7 @@ def run_backtest_sync(
|
||||
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
import asyncio
|
||||
import json
|
||||
from ..services.backtest.engine import BacktestEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
@@ -31,6 +32,19 @@ def run_backtest_sync(
|
||||
running_backtests[backtest_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
|
||||
# Convert datetime objects to ISO strings for JSON serialization
|
||||
def convert_datetime(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, dict):
|
||||
return {k: convert_datetime(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_datetime(i) for i in obj]
|
||||
return obj
|
||||
|
||||
results = convert_datetime(results)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
||||
@@ -41,17 +55,18 @@ def run_backtest_sync(
|
||||
db.commit()
|
||||
|
||||
for signal in engine.signals:
|
||||
signal_data = convert_datetime(signal)
|
||||
db_signal = Signal(
|
||||
id=signal["id"],
|
||||
bot_id=signal["bot_id"],
|
||||
run_id=signal["run_id"],
|
||||
signal_type=signal["signal_type"],
|
||||
token=signal["token"],
|
||||
price=signal["price"],
|
||||
confidence=signal.get("confidence"),
|
||||
reasoning=signal.get("reasoning"),
|
||||
executed=signal.get("executed", False),
|
||||
created_at=signal["created_at"],
|
||||
id=signal_data["id"],
|
||||
bot_id=signal_data["bot_id"],
|
||||
run_id=signal_data["run_id"],
|
||||
signal_type=signal_data["signal_type"],
|
||||
token=signal_data["token"],
|
||||
price=signal_data["price"],
|
||||
confidence=signal_data.get("confidence"),
|
||||
reasoning=signal_data.get("reasoning"),
|
||||
executed=signal_data.get("executed", False),
|
||||
created_at=signal["created_at"], # Use original datetime, not converted string
|
||||
)
|
||||
db.add(db_signal)
|
||||
db.commit()
|
||||
@@ -154,9 +169,81 @@ def get_backtest(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||
)
|
||||
|
||||
# Add progress from running engine if available
|
||||
if backtest.status == "running" and run_id in running_backtests:
|
||||
engine = running_backtests[run_id]
|
||||
backtest.progress = engine.progress
|
||||
|
||||
return backtest
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/backtest/{run_id}/trades")
|
||||
def get_backtest_trades(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
page: int = 1,
|
||||
per_page: int = 5,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get paginated trade history for a specific backtest.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
per_page: Number of trades per page (default 5, max 20)
|
||||
"""
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
|
||||
)
|
||||
if bot.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
backtest = (
|
||||
db.query(Backtest)
|
||||
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
|
||||
.first()
|
||||
)
|
||||
if not backtest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
|
||||
)
|
||||
|
||||
# Get trades from result
|
||||
result = backtest.result or {}
|
||||
# Handle case where result might be a JSON string
|
||||
if isinstance(result, str):
|
||||
import json
|
||||
result = json.loads(result)
|
||||
all_trades = result.get("trades", []) or []
|
||||
total_trades = len(all_trades)
|
||||
|
||||
# Validate pagination params
|
||||
per_page = min(max(per_page, 1), 20) # Clamp between 1 and 20
|
||||
page = max(page, 1)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = max(1, (total_trades + per_page - 1) // per_page) if total_trades > 0 else 1
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
|
||||
# Get page of trades (return empty list if start_idx >= total_trades)
|
||||
paginated_trades = all_trades[start_idx:end_idx] if start_idx < total_trades else []
|
||||
|
||||
return {
|
||||
"backtest_id": run_id,
|
||||
"trades": paginated_trades,
|
||||
"total_trades": total_trades,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
}
|
||||
|
||||
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
|
||||
def list_backtests(
|
||||
bot_id: str,
|
||||
@@ -177,6 +264,7 @@ def list_backtests(
|
||||
db.query(Backtest)
|
||||
.filter(Backtest.bot_id == bot_id)
|
||||
.order_by(Backtest.started_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return backtests
|
||||
@@ -211,7 +299,12 @@ def stop_backtest(
|
||||
|
||||
if run_id in running_backtests:
|
||||
engine = running_backtests[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
engine.running = False # Direct sync access to running flag
|
||||
backtest.status = "stopped"
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
elif backtest.status == "running":
|
||||
# Engine already finished but status not updated
|
||||
backtest.status = "stopped"
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
@@ -220,8 +220,12 @@ def chat(
|
||||
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
thinking=result.get("thinking"),
|
||||
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
|
||||
success=result.get("success", False),
|
||||
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
|
||||
strategy_data=result.get("strategy_data") if result.get("strategy_needs_confirmation") else None,
|
||||
token_search_results=result.get("token_search_results"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,6 +12,9 @@ from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import SimulationCreate, SimulationResponse
|
||||
from ..db.models import Bot, Simulation, Signal, User
|
||||
from ..services.ave.client import AveCloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +26,7 @@ def run_simulation_sync(
|
||||
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
import asyncio
|
||||
import time
|
||||
from ..services.simulate.engine import SimulateEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
@@ -29,8 +34,19 @@ def run_simulation_sync(
|
||||
engine = SimulateEngine(config)
|
||||
engine.run_id = simulation_id
|
||||
running_simulations[simulation_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
|
||||
# Serialize signals for JSON storage (convert datetime to string)
|
||||
def serialize_signal(s):
|
||||
created = s.get("created_at")
|
||||
if hasattr(created, "isoformat"):
|
||||
created = created.isoformat()
|
||||
return {
|
||||
**s,
|
||||
"created_at": created
|
||||
}
|
||||
|
||||
def save_progress():
|
||||
"""Save current progress to database."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
simulation = (
|
||||
@@ -38,27 +54,50 @@ def run_simulation_sync(
|
||||
)
|
||||
if simulation:
|
||||
simulation.status = engine.status
|
||||
simulation.signals = engine.signals
|
||||
simulation.signals = [serialize_signal(s) for s in engine.signals]
|
||||
simulation.klines = [
|
||||
{"time": k.get("time"), "close": k.get("close")}
|
||||
for k in engine.klines
|
||||
]
|
||||
simulation.trade_log = engine.trade_log
|
||||
# Save portfolio data
|
||||
simulation.portfolio = {
|
||||
"initial_balance": engine.config.get("initial_balance", 10000),
|
||||
"current_balance": engine.current_balance,
|
||||
"position": engine.position,
|
||||
"position_token": engine.position_token,
|
||||
"entry_price": engine.entry_price,
|
||||
"current_price": engine.last_close,
|
||||
}
|
||||
db.commit()
|
||||
|
||||
for signal in engine.signals:
|
||||
db_signal = Signal(
|
||||
id=signal["id"],
|
||||
bot_id=signal["bot_id"],
|
||||
run_id=signal["run_id"],
|
||||
signal_type=signal["signal_type"],
|
||||
token=signal["token"],
|
||||
price=signal["price"],
|
||||
confidence=signal.get("confidence"),
|
||||
reasoning=signal.get("reasoning"),
|
||||
executed=signal.get("executed", False),
|
||||
created_at=signal["created_at"],
|
||||
)
|
||||
db.add(db_signal)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def run_with_progress_save():
|
||||
"""Run simulation and save progress periodically."""
|
||||
last_save_time = time.time()
|
||||
save_interval = 5 # Save every 5 seconds
|
||||
|
||||
while engine.running and engine.status == "running":
|
||||
await asyncio.sleep(1) # Check every second
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_save_time >= save_interval:
|
||||
save_progress()
|
||||
last_save_time = current_time
|
||||
|
||||
# Final save when done
|
||||
save_progress()
|
||||
|
||||
try:
|
||||
# Run both simulation and progress saving concurrently
|
||||
await asyncio.gather(
|
||||
engine.run(),
|
||||
run_with_progress_save()
|
||||
)
|
||||
finally:
|
||||
# Save final state
|
||||
save_progress()
|
||||
if simulation_id in running_simulations:
|
||||
del running_simulations[simulation_id]
|
||||
|
||||
@@ -87,20 +126,35 @@ async def start_simulation(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||
)
|
||||
|
||||
# Check if there's already a running simulation for this bot
|
||||
existing_simulation = (
|
||||
db.query(Simulation)
|
||||
.filter(Simulation.bot_id == bot_id, Simulation.status == "running")
|
||||
.first()
|
||||
)
|
||||
if existing_simulation:
|
||||
# Stop the existing simulation first
|
||||
if existing_simulation.id in running_simulations:
|
||||
running_simulations[existing_simulation.id].stop()
|
||||
del running_simulations[existing_simulation.id]
|
||||
existing_simulation.status = "stopped"
|
||||
db.commit()
|
||||
|
||||
settings = get_settings()
|
||||
simulation_id = str(uuid.uuid4())
|
||||
|
||||
check_interval = config.check_interval
|
||||
if settings.AVE_API_PLAN != "pro" and check_interval < 60:
|
||||
check_interval = 60
|
||||
# Create AVE client for klines fetching
|
||||
ave_client = AveCloudClient(
|
||||
api_key=settings.AVE_API_KEY,
|
||||
plan=settings.AVE_API_PLAN,
|
||||
)
|
||||
|
||||
simulation_config = {
|
||||
"bot_id": bot_id,
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"duration_seconds": config.duration_seconds,
|
||||
"check_interval": check_interval,
|
||||
"auto_execute": config.auto_execute,
|
||||
"kline_interval": config.kline_interval,
|
||||
"auto_execute": False, # Always paper trade
|
||||
"strategy_config": bot.strategy_config,
|
||||
"ave_api_key": settings.AVE_API_KEY,
|
||||
"ave_api_plan": settings.AVE_API_PLAN,
|
||||
@@ -114,19 +168,46 @@ async def start_simulation(
|
||||
config={
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"duration_seconds": config.duration_seconds,
|
||||
"check_interval": check_interval,
|
||||
"auto_execute": config.auto_execute,
|
||||
"kline_interval": config.kline_interval,
|
||||
},
|
||||
signals=[],
|
||||
klines=[],
|
||||
)
|
||||
db.add(simulation)
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
|
||||
db_url = str(settings.DATABASE_URL)
|
||||
# Fetch klines SYNCHRONOUSLY so user can see chart immediately
|
||||
try:
|
||||
token_id = f"{config.token}-{config.chain}"
|
||||
|
||||
# Calculate time range (last 1 hour)
|
||||
import time
|
||||
end_time = int(time.time() * 1000)
|
||||
start_time = end_time - (60 * 60 * 1000) # 1 hour ago
|
||||
|
||||
klines_data = await ave_client.get_klines(
|
||||
token_id,
|
||||
interval=config.kline_interval,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=500
|
||||
)
|
||||
klines_for_chart = [
|
||||
{"time": k.get("time"), "close": k.get("close")}
|
||||
for k in sorted(klines_data, key=lambda x: x.get("time", 0))
|
||||
]
|
||||
# Update simulation with klines
|
||||
simulation.klines = klines_for_chart
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
logger.info(f"Fetched {len(klines_for_chart)} klines for simulation {simulation_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch klines: {e}")
|
||||
|
||||
# Run simulation in background for signal processing
|
||||
background_tasks.add_task(
|
||||
run_simulation_sync, simulation_id, db_url, bot_id, simulation_config
|
||||
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
|
||||
)
|
||||
|
||||
return simulation
|
||||
@@ -193,6 +274,9 @@ def list_simulations(
|
||||
if sim.id in running_simulations:
|
||||
engine = running_simulations[sim.id]
|
||||
sim.signals = engine.get_signals()
|
||||
# Include klines from running engine for chart display
|
||||
if hasattr(engine, 'klines'):
|
||||
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
|
||||
|
||||
return simulations
|
||||
|
||||
@@ -224,10 +308,15 @@ def stop_simulation(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||
)
|
||||
|
||||
# Always update status to stopped, even if engine is not in memory
|
||||
simulation.status = "stopped"
|
||||
|
||||
# Try to stop the engine if it's still in memory
|
||||
if run_id in running_simulations:
|
||||
engine = running_simulations[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
simulation.status = "stopped"
|
||||
db.commit()
|
||||
engine.stop()
|
||||
del running_simulations[run_id]
|
||||
|
||||
return {"status": "stopping", "run_id": run_id}
|
||||
db.commit()
|
||||
|
||||
return {"status": "stopped", "run_id": run_id}
|
||||
|
||||
1
src/backend/app/ave
Symbolic link
1
src/backend/app/ave
Symbolic link
@@ -0,0 +1 @@
|
||||
../../ave-cloud-skill/scripts/ave
|
||||
@@ -93,6 +93,9 @@ class Simulation(Base):
|
||||
status = Column(String, nullable=False)
|
||||
config = Column(JSON, nullable=False)
|
||||
signals = Column(JSON)
|
||||
klines = Column(JSON) # Price data for chart display
|
||||
trade_log = Column(JSON) # Trade activity log
|
||||
portfolio = Column(JSON) # Portfolio data
|
||||
|
||||
bot = relationship("Bot", back_populates="simulations")
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
@@ -64,6 +69,7 @@ class BotResponse(BaseModel):
|
||||
|
||||
class BacktestCreate(BaseModel):
|
||||
token: str
|
||||
token_name: Optional[str] = None
|
||||
chain: str
|
||||
timeframe: str
|
||||
start_date: str
|
||||
@@ -85,6 +91,7 @@ class BacktestResponse(BaseModel):
|
||||
status: str
|
||||
config: dict
|
||||
result: Optional[dict]
|
||||
progress: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -93,9 +100,7 @@ class BacktestResponse(BaseModel):
|
||||
class SimulationCreate(BaseModel):
|
||||
token: str
|
||||
chain: str
|
||||
duration_seconds: int = 3600
|
||||
check_interval: int = 60
|
||||
auto_execute: bool = False
|
||||
kline_interval: str = "1m"
|
||||
|
||||
@field_validator("chain")
|
||||
@classmethod
|
||||
@@ -112,6 +117,12 @@ class SimulationResponse(BaseModel):
|
||||
status: str
|
||||
config: dict
|
||||
signals: Optional[List[dict]]
|
||||
klines: Optional[List[dict]] = None # Price data for chart
|
||||
trade_log: Optional[List[dict]] = None # Trade activity log
|
||||
portfolio: Optional[dict] = None # Portfolio data
|
||||
current_candle_index: Optional[int] = None # Progress: current candle
|
||||
total_candles: Optional[int] = None # Progress: total candles
|
||||
candles_processed: Optional[int] = None # Progress: candles processed
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -140,8 +151,12 @@ class BotChatRequest(BaseModel):
|
||||
|
||||
class BotChatResponse(BaseModel):
|
||||
response: str
|
||||
thinking: Optional[str] = None
|
||||
strategy_config: Optional[dict] = None
|
||||
success: bool = False
|
||||
strategy_needs_confirmation: Optional[bool] = False
|
||||
strategy_data: Optional[dict] = None
|
||||
token_search_results: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class SignalResponse(BaseModel):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,9 @@ class AveCloudClient:
|
||||
chain: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens"
|
||||
params = {"limit": limit}
|
||||
if query:
|
||||
params["query"] = query
|
||||
# Use trending endpoint which supports chain filter
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/trending"
|
||||
params = {"limit": min(limit, 100)} # API returns max 100
|
||||
if chain:
|
||||
params["chain"] = chain
|
||||
|
||||
@@ -36,8 +35,18 @@ class AveCloudClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
if data.get("status") == 1: # 1 = SUCCESS
|
||||
tokens = data.get("data", {}).get("tokens", [])
|
||||
# Filter by query if provided
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
tokens = [
|
||||
t for t in tokens
|
||||
if query_lower in t.get("symbol", "").lower()
|
||||
or query_lower in t.get("name", "").lower()
|
||||
]
|
||||
return tokens[:limit]
|
||||
return []
|
||||
raise Exception(f"Failed to fetch tokens: {data}")
|
||||
|
||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -73,6 +82,10 @@ class AveCloudClient:
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Token ID must be in format "{contract_address}-bsc" for the AVE API
|
||||
if not token_id.endswith("-bsc") and token_id.startswith("0x"):
|
||||
token_id = f"{token_id}-bsc"
|
||||
|
||||
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
|
||||
params = {"interval": interval, "limit": limit}
|
||||
if start_time:
|
||||
@@ -86,8 +99,9 @@ class AveCloudClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
# AVE API returns status: 1 for success, not 200
|
||||
if data.get("status") == 1:
|
||||
return data.get("data", {}).get("points", [])
|
||||
raise Exception(f"Failed to fetch klines: {data}")
|
||||
|
||||
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -101,7 +115,7 @@ class AveCloudClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
if data.get("status") == 1:
|
||||
prices = data.get("data", {})
|
||||
return prices.get(token_id)
|
||||
return None
|
||||
|
||||
@@ -28,9 +28,13 @@ class BacktestEngine:
|
||||
self.position = 0.0
|
||||
self.position_token = ""
|
||||
self.entry_price: Optional[float] = None
|
||||
self.cost_basis = 0.0 # Track total amount spent on current position for average price calc
|
||||
self.entry_time: Optional[int] = None
|
||||
self.trades: List[Dict[str, Any]] = []
|
||||
self.running = False
|
||||
self.progress = 0
|
||||
self.total_klines = 0
|
||||
self.last_kline_price: Optional[float] = None # Track last price for open position valuation
|
||||
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
self.running = True
|
||||
@@ -38,20 +42,28 @@ class BacktestEngine:
|
||||
started_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
token = self.config.get("token", "")
|
||||
chain = self.config.get("chain", "bsc")
|
||||
timeframe = self.config.get("timeframe", "1h")
|
||||
start_date = self.config.get("start_date", "")
|
||||
end_date = self.config.get("end_date", "")
|
||||
|
||||
token_id = (
|
||||
f"{token}-{chain}"
|
||||
if token and not token.endswith(f"-{chain}")
|
||||
else token
|
||||
)
|
||||
# Get token address from strategy config (saved when user confirmed token)
|
||||
token_address = None
|
||||
token_symbol = None
|
||||
|
||||
if not token_id or token_id == f"-{chain}":
|
||||
raise ValueError("Token ID is required")
|
||||
# Try to get from conditions first
|
||||
if self.conditions:
|
||||
token_address = self.conditions[0].get("token_address")
|
||||
token_symbol = self.conditions[0].get("token")
|
||||
# Fallback to actions
|
||||
if not token_address and self.actions:
|
||||
token_address = self.actions[0].get("token_address")
|
||||
token_symbol = self.actions[0].get("token") or token_symbol
|
||||
|
||||
if not token_address:
|
||||
raise ValueError("Token address not found in strategy. Please update your strategy with a valid token.")
|
||||
|
||||
token_id = token_address
|
||||
|
||||
start_ts = None
|
||||
end_ts = None
|
||||
@@ -97,15 +109,48 @@ class BacktestEngine:
|
||||
|
||||
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]]):
|
||||
self.total_klines = len(klines)
|
||||
for i, kline in enumerate(klines):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
|
||||
|
||||
price = float(kline.get("close", 0))
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
self.last_kline_price = price # Track last price for open position valuation
|
||||
|
||||
timestamp = kline.get("timestamp", 0)
|
||||
|
||||
if self.position > 0 and self.entry_price is not None:
|
||||
@@ -119,20 +164,28 @@ class BacktestEngine:
|
||||
await self._execute_actions(price, timestamp, condition)
|
||||
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(
|
||||
self, current_price: float, timestamp: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if self.position <= 0 or self.entry_price is None:
|
||||
if self.position <= 0 or self.average_entry_price is None:
|
||||
return None
|
||||
|
||||
if self.stop_loss_percent is not None:
|
||||
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
|
||||
stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100)
|
||||
if current_price <= stop_loss_price:
|
||||
return {"reason": "stop_loss", "price": stop_loss_price}
|
||||
|
||||
if self.take_profit_percent is not None:
|
||||
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
|
||||
if current_price >= take_profit_price:
|
||||
take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100)
|
||||
# Use small epsilon to handle floating point precision
|
||||
if current_price >= take_profit_price - 0.001:
|
||||
return {"reason": "take_profit", "price": take_profit_price}
|
||||
|
||||
return None
|
||||
@@ -173,6 +226,7 @@ class BacktestEngine:
|
||||
)
|
||||
self.position = 0
|
||||
self.entry_price = None
|
||||
self.cost_basis = 0.0
|
||||
self.entry_time = None
|
||||
|
||||
def _check_condition(
|
||||
@@ -237,10 +291,12 @@ class BacktestEngine:
|
||||
amount = self.current_balance * (amount_percent / 100)
|
||||
|
||||
if action_type == "buy" and self.current_balance >= amount:
|
||||
self.position += amount / price
|
||||
quantity = amount / price
|
||||
self.position += quantity
|
||||
self.current_balance -= amount
|
||||
self.cost_basis += amount # Track total cost for average price
|
||||
self.position_token = token
|
||||
self.entry_price = price
|
||||
self.entry_price = price # Keep last entry price for reference
|
||||
self.entry_time = timestamp
|
||||
self.trades.append(
|
||||
{
|
||||
@@ -248,7 +304,7 @@ class BacktestEngine:
|
||||
"token": token,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"quantity": amount / price,
|
||||
"quantity": quantity,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
@@ -300,11 +356,17 @@ class BacktestEngine:
|
||||
)
|
||||
|
||||
def _calculate_metrics(self):
|
||||
final_balance = self.current_balance + (
|
||||
self.position * self.trades[-1]["price"]
|
||||
if self.trades and self.position > 0
|
||||
else 0
|
||||
)
|
||||
# For open positions, use the last kline price to mark to market
|
||||
# If no last kline price, fall back to entry price
|
||||
position_price = self.last_kline_price
|
||||
if position_price is None and self.trades and self.position > 0:
|
||||
position_price = self.trades[-1]["price"] # Fall back to entry price
|
||||
|
||||
# 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 = (
|
||||
(final_balance - self.initial_balance) / self.initial_balance
|
||||
) * 100
|
||||
@@ -331,18 +393,23 @@ class BacktestEngine:
|
||||
|
||||
for trade in self.trades:
|
||||
if trade["type"] == "buy":
|
||||
running_position = trade["quantity"]
|
||||
running_balance = trade["amount"]
|
||||
running_position += trade["quantity"] # Add to existing position (DCA)
|
||||
running_balance -= trade["amount"] # Subtract amount spent
|
||||
current_token = trade["token"]
|
||||
last_price = trade["price"]
|
||||
else:
|
||||
running_balance = trade["amount"]
|
||||
running_position = 0
|
||||
else: # sell
|
||||
running_balance += trade["amount"] # Add amount received
|
||||
running_position = 0 # Close entire position
|
||||
last_price = trade["price"]
|
||||
|
||||
portfolio_value = running_balance + (running_position * last_price)
|
||||
portfolio_values.append(portfolio_value)
|
||||
|
||||
# If there's an open position, add final marked-to-market value
|
||||
if self.position > 0 and self.last_kline_price:
|
||||
final_portfolio_value = self.current_balance + (self.position * self.last_kline_price)
|
||||
portfolio_values.append(final_portfolio_value)
|
||||
|
||||
max_value = self.initial_balance
|
||||
max_drawdown = 0.0
|
||||
for value in portfolio_values:
|
||||
@@ -380,10 +447,13 @@ class BacktestEngine:
|
||||
"sharpe_ratio": round(sharpe_ratio, 2),
|
||||
"final_balance": round(final_balance, 2),
|
||||
"signals": self.signals,
|
||||
"trades": self.trades, # Include trades in results for storage
|
||||
}
|
||||
|
||||
async def stop(self):
|
||||
self.running = False
|
||||
self.progress = 0
|
||||
self.total_klines = 0
|
||||
self.status = "stopped"
|
||||
self._calculate_metrics()
|
||||
|
||||
@@ -393,4 +463,13 @@ class BacktestEngine:
|
||||
"status": self.status,
|
||||
"results": self.results,
|
||||
"signals": self.signals,
|
||||
"progress": self.progress,
|
||||
"total_klines": self.total_klines,
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"progress": self.progress,
|
||||
"total_klines": self.total_klines,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from ..ave.client import AveCloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,23 +27,66 @@ class SimulateEngine:
|
||||
self.risk_management = self.strategy_config.get("risk_management", {})
|
||||
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
|
||||
self.take_profit_percent = self.risk_management.get("take_profit_percent")
|
||||
self.check_interval = config.get("check_interval", 60)
|
||||
self.duration_seconds = config.get("duration_seconds", 3600)
|
||||
|
||||
# Kline-based settings
|
||||
self.kline_interval = config.get("kline_interval", "1m")
|
||||
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
|
||||
|
||||
# Delay between candles (in seconds) to simulate real-time
|
||||
# e.g., 1m interval -> 30s delay between candles
|
||||
# Use config value if provided, otherwise calculate
|
||||
if "candle_delay" in config and config["candle_delay"] is not None:
|
||||
self.candle_delay = config["candle_delay"]
|
||||
else:
|
||||
self.candle_delay = self._get_interval_seconds(self.kline_interval) / 2
|
||||
|
||||
self.auto_execute = config.get("auto_execute", False)
|
||||
self.token = config.get("token", "")
|
||||
self.chain = config.get("chain", "bsc")
|
||||
self.running = False
|
||||
self.started_at: Optional[datetime] = None
|
||||
self.last_price: Optional[float] = None
|
||||
|
||||
# Price tracking (for conditions)
|
||||
self.last_close: Optional[float] = None
|
||||
self.last_volume: Optional[float] = None
|
||||
|
||||
# Position tracking (for risk management)
|
||||
self.position: float = 0.0
|
||||
self.position_token: str = ""
|
||||
self.entry_price: Optional[float] = None
|
||||
self.entry_time: Optional[int] = None
|
||||
|
||||
# Portfolio
|
||||
self.current_balance: float = config.get("initial_balance", 10000.0)
|
||||
self.trades: List[Dict[str, Any]] = []
|
||||
|
||||
# Error tracking
|
||||
self.errors: List[str] = []
|
||||
|
||||
# Kline data
|
||||
self.klines: List[Dict[str, Any]] = []
|
||||
self.last_processed_time: Optional[int] = None
|
||||
|
||||
# Trade log - tracks what happened at each candle
|
||||
self.trade_log: List[Dict[str, Any]] = []
|
||||
|
||||
# Current candle being processed (for frontend to show progress)
|
||||
self.current_candle_index = 0
|
||||
self.total_candles = 0
|
||||
|
||||
def _get_interval_seconds(self, interval: str) -> int:
|
||||
"""Convert kline interval to seconds."""
|
||||
mapping = {
|
||||
"1m": 60,
|
||||
"5m": 300,
|
||||
"15m": 900,
|
||||
"30m": 1800,
|
||||
"1h": 3600,
|
||||
"4h": 14400,
|
||||
"1d": 86400,
|
||||
}
|
||||
return mapping.get(interval, 60)
|
||||
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
@@ -59,72 +103,174 @@ class SimulateEngine:
|
||||
self.results = {"error": "Token ID is required"}
|
||||
return self.results
|
||||
|
||||
end_time = datetime.utcnow().timestamp() + self.duration_seconds
|
||||
|
||||
try:
|
||||
while self.running and datetime.utcnow().timestamp() < end_time:
|
||||
try:
|
||||
price_data = await self.ave_client.get_token_price(token_id)
|
||||
if price_data:
|
||||
current_price = float(price_data.get("price", 0))
|
||||
current_volume = float(price_data.get("volume", 0))
|
||||
# Step 1: Fetch klines (only once for simulation)
|
||||
self.klines = await self._fetch_klines(token_id)
|
||||
|
||||
if current_price > 0:
|
||||
await self._check_conditions(
|
||||
current_price, current_volume, price_data
|
||||
)
|
||||
if not self.klines:
|
||||
self.status = "failed"
|
||||
self.results = {"error": "No kline data available"}
|
||||
return self.results
|
||||
|
||||
self.last_price = current_price
|
||||
self.last_volume = current_volume
|
||||
logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get price for {token_id}: {e}")
|
||||
self.errors.append(f"Price fetch failed for {token_id}: {str(e)}")
|
||||
continue
|
||||
# Step 2: Process candles (with limit)
|
||||
candles_processed = 0
|
||||
self.total_candles = min(len(self.klines), self.max_candles)
|
||||
self.current_candle_index = 0
|
||||
|
||||
for _ in range(self.check_interval):
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
for i, candle in enumerate(self.klines):
|
||||
if not self.running:
|
||||
break
|
||||
if candles_processed >= self.max_candles:
|
||||
logger.info(f"Reached max candles limit ({self.max_candles})")
|
||||
break
|
||||
|
||||
if self.running:
|
||||
self.status = "completed"
|
||||
else:
|
||||
self.status = "stopped"
|
||||
self.current_candle_index = candles_processed
|
||||
candle_time = int(candle.get("time", 0))
|
||||
|
||||
# Get OHLCV data from candle
|
||||
close_price = float(candle.get("close", 0))
|
||||
volume = float(candle.get("volume", 0))
|
||||
|
||||
if close_price > 0:
|
||||
# Process candle
|
||||
await self._process_candle(close_price, volume, candle_time)
|
||||
|
||||
# Update last close for next iteration
|
||||
self.last_close = close_price
|
||||
self.last_volume = volume
|
||||
|
||||
# Track last processed time
|
||||
self.last_processed_time = candle_time
|
||||
|
||||
candles_processed += 1
|
||||
|
||||
# Delay to simulate real-time (only for visible candles, not initial batch)
|
||||
if candles_processed > 1 and self.candle_delay > 0:
|
||||
await asyncio.sleep(self.candle_delay)
|
||||
|
||||
self.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Simulation error: {e}")
|
||||
self.status = "failed"
|
||||
self.results = {"error": str(e)}
|
||||
self.errors.append(str(e))
|
||||
|
||||
self.results = self.results or {}
|
||||
self.results["total_signals"] = len(self.signals)
|
||||
self.results["total_trades"] = len(self.trades)
|
||||
self.results["total_errors"] = len(self.errors)
|
||||
self.results["errors"] = self.errors
|
||||
self.results["signals"] = self.signals
|
||||
self.results["candles_processed"] = candles_processed
|
||||
self.results["current_candle_index"] = self.current_candle_index
|
||||
self.results["total_candles"] = self.total_candles
|
||||
self.results["klines"] = self.klines # Include klines for chart display
|
||||
self.results["trade_log"] = self.trade_log # Include trade log for dashboard
|
||||
self.results["portfolio"] = {
|
||||
"initial_balance": self.config.get("initial_balance", 10000),
|
||||
"current_balance": self.current_balance,
|
||||
"position": self.position,
|
||||
"position_token": self.position_token,
|
||||
"entry_price": self.entry_price,
|
||||
"current_price": self.last_close,
|
||||
}
|
||||
self.results["started_at"] = self.started_at
|
||||
self.results["ended_at"] = datetime.utcnow()
|
||||
|
||||
return self.results
|
||||
|
||||
async def _check_conditions(
|
||||
self, current_price: float, current_volume: float, price_data: Dict[str, Any]
|
||||
):
|
||||
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
||||
async def _fetch_klines(
|
||||
self,
|
||||
token_id: str,
|
||||
limit: int = 500
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch klines from AVE API."""
|
||||
try:
|
||||
klines = await self.ave_client.get_klines(
|
||||
token_id,
|
||||
interval=self.kline_interval,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# Sort by time ascending (oldest first)
|
||||
klines = sorted(klines, key=lambda x: x.get("time", 0))
|
||||
return klines
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch klines for {token_id}: {e}")
|
||||
self.errors.append(f"Kline fetch failed: {str(e)}")
|
||||
return []
|
||||
|
||||
async def _process_candle(
|
||||
self,
|
||||
close_price: float,
|
||||
volume: float,
|
||||
timestamp: int
|
||||
):
|
||||
"""Process a single candle - check conditions and risk management."""
|
||||
|
||||
action = "hold" # Default action
|
||||
reason = ""
|
||||
|
||||
# Check risk management first (for open positions)
|
||||
if self.position > 0 and self.entry_price is not None:
|
||||
exit_info = self._check_risk_management(current_price, timestamp)
|
||||
exit_info = self._check_risk_management(close_price, timestamp)
|
||||
if exit_info:
|
||||
await self._execute_risk_exit(current_price, timestamp, exit_info)
|
||||
await self._execute_risk_exit(close_price, timestamp, exit_info)
|
||||
action = "sell"
|
||||
reason = exit_info["reason"]
|
||||
# Log the action
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
return
|
||||
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, current_price, current_volume):
|
||||
await self._execute_actions(current_price, timestamp, condition)
|
||||
break
|
||||
# Check conditions (only if no open position)
|
||||
if self.position == 0:
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, close_price, volume):
|
||||
await self._execute_actions(close_price, timestamp, condition)
|
||||
action = "buy"
|
||||
reason = f"{condition.get('type')} {condition.get('threshold')}%".format(
|
||||
type=condition.get('type'),
|
||||
threshold=condition.get('threshold')
|
||||
)
|
||||
# Log the action
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
break
|
||||
|
||||
# Log hold action (no signal)
|
||||
if action == "hold":
|
||||
# Only log every 10th candle to reduce data
|
||||
if len(self.trade_log) == 0 or (len(self.klines) - len(self.trade_log) > 10):
|
||||
self.trade_log.append({
|
||||
"time": timestamp,
|
||||
"price": close_price,
|
||||
"action": "hold",
|
||||
"reason": "no_signal",
|
||||
"position": self.position,
|
||||
"entry_price": self.entry_price,
|
||||
})
|
||||
|
||||
def _check_risk_management(
|
||||
self, current_price: float, timestamp: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if stop loss or take profit is triggered."""
|
||||
if self.position <= 0 or self.entry_price is None:
|
||||
return None
|
||||
|
||||
@@ -143,16 +289,24 @@ class SimulateEngine:
|
||||
async def _execute_risk_exit(
|
||||
self, price: float, timestamp: int, exit_info: Dict[str, Any]
|
||||
):
|
||||
"""Execute stop loss or take profit."""
|
||||
if self.position <= 0:
|
||||
return
|
||||
|
||||
reason = exit_info["reason"]
|
||||
quantity = self.position
|
||||
sale_proceeds = quantity * price
|
||||
|
||||
# Add sale proceeds to cash balance
|
||||
self.current_balance += sale_proceeds
|
||||
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"quantity": self.position,
|
||||
"quantity": quantity,
|
||||
"amount": sale_proceeds,
|
||||
"timestamp": timestamp,
|
||||
"exit_reason": reason,
|
||||
}
|
||||
@@ -181,32 +335,34 @@ class SimulateEngine:
|
||||
current_price: float,
|
||||
current_volume: float,
|
||||
) -> bool:
|
||||
"""Check if a condition is met based on price movement."""
|
||||
cond_type = condition.get("type", "")
|
||||
threshold = condition.get("threshold", 0)
|
||||
price_level = condition.get("price")
|
||||
direction = condition.get("direction", "above")
|
||||
|
||||
if cond_type == "price_drop":
|
||||
if self.last_price is None or self.last_price <= 0:
|
||||
# Price dropped by threshold % from last close
|
||||
if self.last_close is None or self.last_close <= 0:
|
||||
return False
|
||||
drop_pct = ((self.last_price - current_price) / self.last_price) * 100
|
||||
drop_pct = ((self.last_close - current_price) / self.last_close) * 100
|
||||
return drop_pct >= threshold
|
||||
|
||||
elif cond_type == "price_rise":
|
||||
if self.last_price is None or self.last_price <= 0:
|
||||
# Price rose by threshold % from last close
|
||||
if self.last_close is None or self.last_close <= 0:
|
||||
return False
|
||||
rise_pct = ((current_price - self.last_price) / self.last_price) * 100
|
||||
rise_pct = ((current_price - self.last_close) / self.last_close) * 100
|
||||
return rise_pct >= threshold
|
||||
|
||||
elif cond_type == "volume_spike":
|
||||
# Volume increased significantly
|
||||
if self.last_volume is None or self.last_volume <= 0:
|
||||
return False
|
||||
volume_increase = (
|
||||
(current_volume - self.last_volume) / self.last_volume
|
||||
) * 100
|
||||
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
|
||||
return volume_increase >= threshold
|
||||
|
||||
elif cond_type == "price_level":
|
||||
price_level = condition.get("price")
|
||||
direction = condition.get("direction", "above")
|
||||
if price_level is None:
|
||||
return False
|
||||
if direction == "above":
|
||||
@@ -219,6 +375,7 @@ class SimulateEngine:
|
||||
async def _execute_actions(
|
||||
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||
):
|
||||
"""Execute buy/sell actions based on matched condition."""
|
||||
token = matched_condition.get("token", self.token)
|
||||
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
||||
|
||||
@@ -227,18 +384,21 @@ class SimulateEngine:
|
||||
if action_type == "buy":
|
||||
amount_percent = action.get("amount_percent", 10)
|
||||
amount = self.current_balance * (amount_percent / 100)
|
||||
self.position += amount / price
|
||||
quantity = amount / price
|
||||
|
||||
self.position += quantity
|
||||
self.position_token = token
|
||||
self.entry_price = price
|
||||
self.entry_time = timestamp
|
||||
self.current_balance -= amount
|
||||
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "buy",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"quantity": amount / price,
|
||||
"quantity": quantity,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
@@ -258,11 +418,13 @@ class SimulateEngine:
|
||||
|
||||
self.signals.append(signal)
|
||||
|
||||
async def stop(self):
|
||||
def stop(self):
|
||||
"""Stop the simulation."""
|
||||
self.running = False
|
||||
self.status = "stopped"
|
||||
|
||||
def get_results(self) -> Dict[str, Any]:
|
||||
"""Get simulation results."""
|
||||
return {
|
||||
"id": self.run_id,
|
||||
"status": self.status,
|
||||
@@ -271,4 +433,5 @@ class SimulateEngine:
|
||||
}
|
||||
|
||||
def get_signals(self) -> List[Dict[str, Any]]:
|
||||
"""Get current signals."""
|
||||
return self.signals
|
||||
|
||||
@@ -8,4 +8,5 @@ if __name__ == "__main__":
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
timeout_keep_alive=300,
|
||||
)
|
||||
|
||||
457
src/backend/tests/test_backtest_engine.py
Normal file
457
src/backend/tests/test_backtest_engine.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
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()
|
||||
386
src/backend/tests/test_simulate_engine.py
Normal file
386
src/backend/tests/test_simulate_engine.py
Normal file
@@ -0,0 +1,386 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'src/backend')
|
||||
|
||||
from app.services.simulate.engine import SimulateEngine
|
||||
|
||||
|
||||
class MockAveClient:
|
||||
"""Mock AVE client for testing."""
|
||||
|
||||
def __init__(self, klines_data=None):
|
||||
self.klines_data = klines_data or []
|
||||
|
||||
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
|
||||
return self.klines_data
|
||||
|
||||
|
||||
def create_engine(config_override=None, klines_data=None):
|
||||
"""Create a test engine with mock client."""
|
||||
config = {
|
||||
"bot_id": "test-bot",
|
||||
"token": "0x1234567890123456789012345678901234567890",
|
||||
"chain": "bsc",
|
||||
"kline_interval": "1m",
|
||||
"max_candles": 10, # Small number for fast tests
|
||||
"candle_delay": 0, # No delay in tests
|
||||
"auto_execute": False,
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 10
|
||||
}
|
||||
},
|
||||
"ave_api_key": "test",
|
||||
"ave_api_plan": "free",
|
||||
}
|
||||
if config_override:
|
||||
config.update(config_override)
|
||||
|
||||
engine = SimulateEngine(config)
|
||||
engine.ave_client = MockAveClient(klines_data)
|
||||
return engine
|
||||
|
||||
|
||||
class TestSimulateEngine:
|
||||
"""Unit tests for SimulateEngine."""
|
||||
|
||||
# ==================== Kline Fetching Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetches_klines_on_start(self):
|
||||
"""Engine should fetch klines when run is called."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
|
||||
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert engine.status == "completed"
|
||||
assert results["candles_processed"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_no_klines_data(self):
|
||||
"""Engine should handle empty klines gracefully."""
|
||||
engine = create_engine(klines_data=[])
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert engine.status == "failed"
|
||||
assert "error" in results
|
||||
assert "No kline data" in results["error"]
|
||||
|
||||
# ==================== Price Drop Condition Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_drop_condition_triggers_buy(self):
|
||||
"""Price drop >= threshold should trigger BUY signal."""
|
||||
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["total_signals"] >= 1
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
assert len(buy_signals) >= 1
|
||||
assert buy_signals[0]["price"] == 90.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_drop_below_threshold_no_signal(self):
|
||||
"""Price drop < threshold should NOT trigger signal."""
|
||||
# Price drops from 100 to 98 (2% drop) - below 5% threshold
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["total_signals"] == 0
|
||||
|
||||
# ==================== Risk Management Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_loss_triggers_after_buy(self):
|
||||
"""Stop loss should trigger SELL after price drops below threshold."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
|
||||
assert "stop_loss" in sell_signals[0]["reasoning"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_take_profit_triggers_after_buy(self):
|
||||
"""Take profit should trigger SELL after price rises above threshold."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
|
||||
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
|
||||
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
|
||||
assert "take_profit" in sell_signals[0]["reasoning"]
|
||||
|
||||
# ==================== Multiple Conditions Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_buy_if_already_in_position(self):
|
||||
"""Should not trigger another BUY if already holding position."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
|
||||
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
|
||||
# Should only have 1 buy, not multiple
|
||||
assert len(buy_signals) == 1, "Should only have one BUY signal"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_buy_again_after_sell(self):
|
||||
"""Should be able to BUY again after position is closed by risk management."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
# First trade
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
|
||||
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
|
||||
# Second trade
|
||||
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
|
||||
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
|
||||
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
|
||||
|
||||
assert len(buy_signals) == 2, "Should have two BUY signals"
|
||||
assert len(sell_signals) == 2, "Should have two SELL signals"
|
||||
|
||||
# ==================== Edge Cases ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_zero_price(self):
|
||||
"""Should skip processing for candles with zero price but still count them."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
|
||||
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
# All 3 candles counted, but only 2 valid for condition checking
|
||||
assert results["candles_processed"] == 3
|
||||
# Only 1 signal (the valid candle that dropped 10%)
|
||||
assert results["total_signals"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_candles_limit(self):
|
||||
"""Should respect max_candles limit."""
|
||||
klines = [
|
||||
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||
for i in range(1, 201) # 200 candles
|
||||
]
|
||||
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
|
||||
engine.running = True
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
assert results["candles_processed"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_interrupts_processing(self):
|
||||
"""Should stop processing when stop() is called."""
|
||||
klines = [
|
||||
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
|
||||
for i in range(1, 101)
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
engine.run_id = "test"
|
||||
|
||||
# Stop after a few candles
|
||||
async def stop_after_delay():
|
||||
await asyncio.sleep(0.1)
|
||||
engine.stop()
|
||||
|
||||
await asyncio.gather(engine.run(), stop_after_delay())
|
||||
|
||||
assert engine.status == "stopped"
|
||||
# Should have processed some candles before stopping
|
||||
assert engine.last_processed_time is not None
|
||||
|
||||
# ==================== Price Movement Display Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_all_processed_prices(self):
|
||||
"""Should track last processed time for display purposes."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
|
||||
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
await engine.run()
|
||||
|
||||
# Should have tracked the last candle's time
|
||||
assert engine.last_processed_time == 3000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracks_price_changes(self):
|
||||
"""Should track price changes for potential chart display."""
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
|
||||
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
|
||||
]
|
||||
engine = create_engine(klines_data=klines)
|
||||
engine.running = True
|
||||
|
||||
await engine.run()
|
||||
|
||||
# Last close should be the last candle's close
|
||||
assert engine.last_close == 104.0
|
||||
|
||||
# ==================== Integration Tests ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_simulation_workflow_generates_signals_and_trades(self):
|
||||
"""
|
||||
Full integration test: provides klines with clear price movements
|
||||
and verifies signals and trade_log are populated.
|
||||
|
||||
This test ensures the simulation is working by:
|
||||
1. Creating klines with obvious price movements (drops > 0.1%)
|
||||
2. Using a very low threshold (0.1%)
|
||||
3. Verifying signals are generated
|
||||
4. Verifying trade_log is populated
|
||||
5. Verifying we have buy/sell actions
|
||||
"""
|
||||
# Create klines with clear price drops and rises
|
||||
klines = [
|
||||
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}, # Flat
|
||||
{"time": 2000, "open": 100, "high": 101, "low": 99.9, "close": 99.95, "volume": 1000}, # 0.05% drop
|
||||
{"time": 3000, "open": 99.95, "high": 100, "low": 99.5, "close": 99.5, "volume": 1000}, # 0.45% drop
|
||||
{"time": 4000, "open": 99.5, "high": 100, "low": 99, "close": 99.2, "volume": 1000}, # 0.30% drop
|
||||
{"time": 5000, "open": 99.2, "high": 100, "low": 98, "close": 98.5, "volume": 1000}, # 0.71% drop
|
||||
{"time": 6000, "open": 98.5, "high": 99, "low": 98, "close": 98.8, "volume": 1000}, # 0.30% rise
|
||||
{"time": 7000, "open": 98.8, "high": 99, "low": 98, "close": 98.3, "volume": 1000}, # 0.51% drop
|
||||
{"time": 8000, "open": 98.3, "high": 99, "low": 97, "close": 97.5, "volume": 1000}, # 0.81% drop
|
||||
{"time": 9000, "open": 97.5, "high": 98, "low": 96, "close": 96.5, "volume": 1000}, # 1.03% drop
|
||||
]
|
||||
|
||||
# Use very low threshold to ensure signals are generated
|
||||
config_override = {
|
||||
"max_candles": 100,
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "threshold": 0.1, "token": "TEST", "token_address": "0x1234"}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 10}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine = create_engine(config_override=config_override, klines_data=klines)
|
||||
engine.running = True
|
||||
engine.run_id = "integration-test"
|
||||
|
||||
results = await engine.run()
|
||||
|
||||
# Verify results
|
||||
print(f"\n=== Integration Test Results ===")
|
||||
print(f"Status: {engine.status}")
|
||||
print(f"Candles processed: {results.get('candles_processed')}")
|
||||
print(f"Signals count: {len(engine.signals)}")
|
||||
print(f"Trade log count: {len(engine.trade_log)}")
|
||||
|
||||
# ASSERTIONS - These should NEVER fail if simulation is working
|
||||
assert engine.status == "completed", "Simulation should complete successfully"
|
||||
assert results.get("candles_processed") == len(klines), f"Should process all {len(klines)} candles"
|
||||
|
||||
# Critical: signals should NOT be empty
|
||||
assert len(engine.signals) > 0, "SIGNALS SHOULD NOT BE EMPTY! Simulation is not generating signals."
|
||||
print(f"Signals: {[s['signal_type'] for s in engine.signals]}")
|
||||
|
||||
# Critical: trade_log should NOT be empty
|
||||
assert len(engine.trade_log) > 0, "TRADE_LOG SHOULD NOT BE EMPTY! No activity logged."
|
||||
print(f"Trade log: {[t['action'] for t in engine.trade_log]}")
|
||||
|
||||
# Should have at least one BUY signal
|
||||
buy_signals = [s for s in engine.signals if s['signal_type'] == 'buy']
|
||||
assert len(buy_signals) > 0, "Should have at least one BUY signal"
|
||||
print(f"Buy signals: {len(buy_signals)}")
|
||||
|
||||
# Verify trade_log has BUY action
|
||||
buy_trades = [t for t in engine.trade_log if t['action'] == 'buy']
|
||||
assert len(buy_trades) > 0, "Trade log should contain BUY actions"
|
||||
|
||||
# Verify results contain the data
|
||||
assert "signals" in results, "Results should contain signals"
|
||||
assert "trade_log" in results, "Results should contain trade_log"
|
||||
|
||||
print("\n=== Integration Test PASSED ===")
|
||||
print(f"Simulation working correctly!")
|
||||
print(f"Generated {len(engine.signals)} signals and {len(engine.trade_log)} trade log entries")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
21
src/frontend/package-lock.json
generated
21
src/frontend/package-lock.json
generated
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
@@ -101,6 +104,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
@@ -569,6 +578,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,20 @@ function getAuthHeaders(): HeadersInit {
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
|
||||
throw new Error(error.detail || `HTTP error ${response.status}`);
|
||||
let errorMessage = 'An error occurred';
|
||||
|
||||
if (typeof error.detail === 'string') {
|
||||
errorMessage = error.detail;
|
||||
} else if (Array.isArray(error.detail)) {
|
||||
// Handle FastAPI validation error format: [{type, loc, msg, input}]
|
||||
errorMessage = error.detail.map((e: any) => e.msg || JSON.stringify(e)).join(', ');
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
} else {
|
||||
errorMessage = `HTTP error ${response.status}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -41,7 +54,7 @@ export const api = {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({ username: email, password })
|
||||
});
|
||||
return handleResponse<AuthResponse>(response);
|
||||
},
|
||||
@@ -127,7 +140,7 @@ export const api = {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(config)
|
||||
body: JSON.stringify({ ...config, chain: 'bsc' })
|
||||
});
|
||||
return handleResponse<Backtest>(response);
|
||||
},
|
||||
@@ -154,11 +167,29 @@ export const api = {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
async getTrades(botId: string, runId: string, page: number = 1, perPage: number = 5): Promise<{
|
||||
trades: any[];
|
||||
total_trades: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}> {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/trades?page=${page}&per_page=${perPage}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
},
|
||||
|
||||
simulate: {
|
||||
async start(botId: string, config: { token: string; interval_seconds: number; auto_execute: boolean }): Promise<Simulation> {
|
||||
async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> {
|
||||
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StrategyConfig {
|
||||
export interface Condition {
|
||||
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
|
||||
token: string;
|
||||
token_address?: string;
|
||||
chain?: string;
|
||||
threshold?: number;
|
||||
price?: number;
|
||||
@@ -37,6 +38,7 @@ export interface Action {
|
||||
type: 'buy' | 'sell' | 'hold';
|
||||
amount_percent?: number;
|
||||
token?: string;
|
||||
token_address?: string;
|
||||
}
|
||||
|
||||
export interface RiskManagement {
|
||||
@@ -62,13 +64,16 @@ export interface Backtest {
|
||||
bot_id: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
status: 'running' | 'completed' | 'failed' | 'stopped';
|
||||
config: BacktestConfig;
|
||||
result: BacktestResult | null;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface BacktestConfig {
|
||||
token: string;
|
||||
token_name?: string;
|
||||
chain: string;
|
||||
timeframe: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
@@ -84,19 +89,63 @@ export interface BacktestResult {
|
||||
sharpe_ratio: number;
|
||||
}
|
||||
|
||||
export interface PaginatedTrades {
|
||||
trades: Trade[];
|
||||
total_trades: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
type: 'buy' | 'sell';
|
||||
token: string;
|
||||
price: number;
|
||||
amount: number;
|
||||
quantity: number;
|
||||
timestamp: number;
|
||||
exit_reason?: 'stop_loss' | 'take_profit' | string;
|
||||
}
|
||||
|
||||
export interface Simulation {
|
||||
id: string;
|
||||
bot_id: string;
|
||||
started_at: string;
|
||||
status: 'running' | 'stopped';
|
||||
status: 'running' | 'stopped' | 'completed';
|
||||
config: SimulationConfig;
|
||||
signals: Signal[] | null;
|
||||
klines?: { time: number; close: number }[];
|
||||
trade_log?: TradeLogEntry[];
|
||||
portfolio?: Portfolio;
|
||||
current_candle_index?: number;
|
||||
total_candles?: number;
|
||||
candles_processed?: number;
|
||||
}
|
||||
|
||||
export interface SimulationConfig {
|
||||
token: string;
|
||||
interval_seconds: number;
|
||||
auto_execute: boolean;
|
||||
chain?: string;
|
||||
kline_interval?: string;
|
||||
}
|
||||
|
||||
export interface TradeLogEntry {
|
||||
time: number;
|
||||
price: number;
|
||||
action: 'buy' | 'sell' | 'hold';
|
||||
reason: string;
|
||||
position: number;
|
||||
entry_price: number | null;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
initial_balance: number;
|
||||
current_balance: number;
|
||||
position: number;
|
||||
position_token: string;
|
||||
entry_price: number;
|
||||
current_price: number;
|
||||
}
|
||||
|
||||
export interface Signal {
|
||||
@@ -123,6 +172,17 @@ export interface BotChatRequest {
|
||||
|
||||
export interface BotChatResponse {
|
||||
response: string;
|
||||
thinking: string | null;
|
||||
strategy_config: StrategyConfig | null;
|
||||
success: boolean;
|
||||
strategy_needs_confirmation?: boolean;
|
||||
strategy_data?: StrategyConfig | null;
|
||||
token_search_results?: TokenSearchResult[] | null;
|
||||
}
|
||||
|
||||
export interface TokenSearchResult {
|
||||
symbol: string;
|
||||
name: string;
|
||||
address: string;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
import type { ChatMessage } from '$lib/stores/chatStore';
|
||||
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
|
||||
|
||||
interface Props {
|
||||
bot: Bot | null;
|
||||
@@ -24,6 +25,7 @@
|
||||
|
||||
let messageInput = $state('');
|
||||
let chatContainer: HTMLDivElement;
|
||||
let expandedThinking: Record<string, boolean> = $state({});
|
||||
|
||||
function handleSend() {
|
||||
if (!messageInput.trim()) return;
|
||||
@@ -45,6 +47,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThinkingExpand(messageId: string) {
|
||||
expandedThinking[messageId] = !expandedThinking[messageId];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.length && chatContainer) {
|
||||
setTimeout(() => {
|
||||
@@ -52,6 +58,22 @@
|
||||
}, 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('');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-interface">
|
||||
@@ -78,8 +100,99 @@
|
||||
|
||||
{#each messages as message}
|
||||
<div class="message {message.role}">
|
||||
{#if message.role === 'assistant' && message.thinking}
|
||||
{@const firstLine = message.thinking.split('\n')[0]}
|
||||
{@const isExpanded = expandedThinking[message.id] ?? false}
|
||||
<div class="thinking-section">
|
||||
<button class="thinking-toggle" onclick={() => toggleThinkingExpand(message.id)}>
|
||||
<span class="thinking-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||
<span class="thinking-label">{isExpanded ? 'Hide reasoning' : 'Show reasoning'}</span>
|
||||
{#if !isExpanded}
|
||||
<span class="thinking-preview"> — {firstLine.slice(0, 60)}{firstLine.length > 60 ? '...' : ''}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{message.thinking}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="message-content">
|
||||
{message.content}
|
||||
{#each renderContent(message.content) as segment}
|
||||
{#if segment.type === 'bold'}
|
||||
<strong>{segment.content}</strong>
|
||||
{:else if segment.type === 'italic'}
|
||||
<em>{segment.content}</em>
|
||||
{:else if segment.type === 'code'}
|
||||
<code class="inline-code">{segment.content}</code>
|
||||
{:else if segment.type === 'codeBlock'}
|
||||
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||
{:else if segment.type === 'link'}
|
||||
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
|
||||
{:else if segment.type === 'list' && segment.items}
|
||||
<ul>
|
||||
{#each segment.items as item}
|
||||
<li>{@html renderInline(parseInlineElements(item))}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if segment.type === 'table' && segment.headers && segment.rows}
|
||||
<div class="table-wrapper">
|
||||
<table class="markdown-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each segment.headers as header}
|
||||
<th>
|
||||
{#each header as cellSeg}
|
||||
{#if cellSeg.type === 'bold'}
|
||||
<strong>{cellSeg.content}</strong>
|
||||
{:else if cellSeg.type === 'italic'}
|
||||
<em>{cellSeg.content}</em>
|
||||
{:else if cellSeg.type === 'code'}
|
||||
<code class="inline-code">{cellSeg.content}</code>
|
||||
{:else if cellSeg.type === 'link'}
|
||||
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||
{:else}
|
||||
{cellSeg.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each segment.rows as row}
|
||||
<tr>
|
||||
{#each row as cell}
|
||||
<td>
|
||||
{#each cell as cellSeg}
|
||||
{#if cellSeg.type === 'bold'}
|
||||
<strong>{cellSeg.content}</strong>
|
||||
{:else if cellSeg.type === 'italic'}
|
||||
<em>{cellSeg.content}</em>
|
||||
{:else if cellSeg.type === 'code'}
|
||||
<code class="inline-code">{cellSeg.content}</code>
|
||||
{:else if cellSeg.type === 'link'}
|
||||
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
|
||||
{:else}
|
||||
{cellSeg.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if segment.type === 'heading'}
|
||||
<h4 class="content-heading">{segment.content}</h4>
|
||||
{:else if segment.type === 'lineBreak'}
|
||||
<br />
|
||||
{:else}
|
||||
{segment.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
@@ -89,10 +202,12 @@
|
||||
|
||||
{#if isSending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<div class="message-content">
|
||||
<div class="typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -105,9 +220,8 @@
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Describe your trading strategy..."
|
||||
rows="1"
|
||||
disabled={isSending}
|
||||
></textarea>
|
||||
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
|
||||
<button onclick={handleSend}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
@@ -206,6 +320,64 @@
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.thinking-section {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 0.6rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.thinking-preview {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message.system .message-content {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
@@ -213,6 +385,92 @@
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.content-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.markdown-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-table th,
|
||||
.markdown-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.markdown-table th {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.markdown-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
@@ -223,7 +481,7 @@
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
139
src/frontend/src/lib/components/PortfolioSummary.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
initialBalance?: number;
|
||||
currentBalance?: number;
|
||||
position?: number;
|
||||
positionToken?: string;
|
||||
entryPrice?: number;
|
||||
currentPrice?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
initialBalance = 10000,
|
||||
currentBalance = 10000,
|
||||
position = 0,
|
||||
positionToken = '',
|
||||
entryPrice = 0,
|
||||
currentPrice = 0
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate metrics
|
||||
let positionValue = $derived(position * currentPrice);
|
||||
let totalValue = $derived(currentBalance + positionValue);
|
||||
let pnl = $derived(totalValue - initialBalance);
|
||||
let pnlPercent = $derived((pnl / initialBalance) * 100);
|
||||
let unrealizedPnL = $derived(position > 0 && entryPrice > 0 ? (currentPrice - entryPrice) / entryPrice * 100 : 0);
|
||||
</script>
|
||||
|
||||
<div class="portfolio-summary">
|
||||
<div class="metric">
|
||||
<span class="label">Cash Balance</span>
|
||||
<span class="value">${currentBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{#if position > 0}
|
||||
<div class="metric">
|
||||
<span class="label">Position ({positionToken || 'Token'})</span>
|
||||
<span class="value highlight">{position.toFixed(6)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Position Value</span>
|
||||
<span class="value">${positionValue.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Entry Price</span>
|
||||
<span class="value">${entryPrice.toFixed(8)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Current Price</span>
|
||||
<span class="value">${currentPrice.toFixed(8)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">Unrealized P&L</span>
|
||||
<span class="value" class:positive={unrealizedPnL > 0} class:negative={unrealizedPnL < 0}>
|
||||
{unrealizedPnL >= 0 ? '+' : ''}{unrealizedPnL.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="metric total">
|
||||
<span class="label">Total Value</span>
|
||||
<span class="value">${totalValue.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="label">P&L</span>
|
||||
<span class="value large" class:positive={pnl > 0} class:negative={pnl < 0}>
|
||||
{pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.portfolio-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.metric .value.highlight {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.metric .value.large {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.metric.total {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.metric.total .value {
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,155 +1,241 @@
|
||||
<script lang="ts">
|
||||
import type { Signal } from '$lib/api';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
signals: Signal[];
|
||||
signals?: Signal[];
|
||||
klines?: { time: number; close: number }[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { signals, height = 200 }: Props = $props();
|
||||
let { signals = [], klines = [], height = 200 }: Props = $props();
|
||||
|
||||
let width = $state(800);
|
||||
let containerEl: HTMLDivElement;
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
// Set initial width
|
||||
if (containerEl) {
|
||||
width = containerEl.clientWidth;
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
width = entry.contentRect.width;
|
||||
drawChart();
|
||||
}
|
||||
});
|
||||
|
||||
if (containerEl) {
|
||||
resizeObserver.observe(containerEl);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
|
||||
const padding = 30;
|
||||
const chartWidth = width - padding * 2;
|
||||
const chartHeight = height - padding * 2;
|
||||
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
|
||||
const priceRange = getPriceRange();
|
||||
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
|
||||
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
|
||||
const y = padding + (1 - normalizedPrice) * chartHeight;
|
||||
return { x, y };
|
||||
}
|
||||
// Draw when data changes
|
||||
$effect(() => {
|
||||
// Access reactive values to trigger effect
|
||||
const currentSignals = signals;
|
||||
const currentKlines = klines;
|
||||
const currentWidth = width;
|
||||
|
||||
function getPriceRange(): { min: number; max: number } {
|
||||
if (signals.length === 0) return { min: 0, max: 1 };
|
||||
const prices = signals.map(s => s.price);
|
||||
const min = Math.min(...prices);
|
||||
const max = Math.max(...prices);
|
||||
const padding = (max - min) * 0.1 || 1;
|
||||
return { min: min - padding, max: max + padding };
|
||||
}
|
||||
// Wait for DOM to be ready
|
||||
tick().then(() => {
|
||||
drawChart();
|
||||
});
|
||||
});
|
||||
|
||||
function getSignalColor(signal: Signal): string {
|
||||
switch (signal.signal_type) {
|
||||
case 'buy': return '#22c55e';
|
||||
case 'sell': return '#ef4444';
|
||||
case 'hold': return '#fbbf24';
|
||||
default: return '#888';
|
||||
function drawChart() {
|
||||
if (!canvasEl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function getYAxisLabels(): string[] {
|
||||
const range = getPriceRange();
|
||||
const step = (range.max - range.min) / 4;
|
||||
return [
|
||||
range.max.toFixed(6),
|
||||
(range.max - step).toFixed(6),
|
||||
(range.min + step).toFixed(6),
|
||||
range.min.toFixed(6)
|
||||
];
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function getXAxisLabels(): string[] {
|
||||
if (signals.length === 0) return [];
|
||||
const step = Math.max(1, Math.floor(signals.length / 5));
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < signals.length; i += step) {
|
||||
labels.push(new Date(signals[i].created_at).toLocaleTimeString());
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvasEl.width = width * dpr;
|
||||
canvasEl.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Check if we have data
|
||||
if (klines.length === 0 && signals.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get price data
|
||||
let priceData: { time: number; price: number }[] = [];
|
||||
|
||||
if (klines.length > 0) {
|
||||
priceData = klines.map(k => ({
|
||||
time: k.time,
|
||||
price: typeof k.close === 'string' ? parseFloat(k.close) : k.close
|
||||
})).filter(d => !isNaN(d.price) && d.price > 0);
|
||||
} else if (signals.length > 0) {
|
||||
priceData = signals.map(s => ({ time: 0, price: s.price }));
|
||||
}
|
||||
|
||||
if (priceData.length === 0) return;
|
||||
|
||||
const prices = priceData.map(d => d.price);
|
||||
const padding = { top: 20, right: 20, bottom: 45, left: 60 }; // More bottom padding for time labels
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// Price range with padding
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const priceRange = maxPrice - minPrice || 1;
|
||||
const paddedMin = minPrice - priceRange * 0.1;
|
||||
const paddedMax = maxPrice + priceRange * 0.1;
|
||||
|
||||
function priceToY(price: number): number {
|
||||
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
|
||||
}
|
||||
|
||||
function indexToX(index: number): number {
|
||||
return padding.left + (index / Math.max(prices.length - 1, 1)) * chartWidth;
|
||||
}
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding.top + (i / 4) * chartHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw Y axis labels
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
|
||||
const y = padding.top + (i / 4) * chartHeight + 4;
|
||||
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
|
||||
}
|
||||
|
||||
// Draw price line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#667eea';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.moveTo(indexToX(0), priceToY(prices[0]));
|
||||
for (let i = 1; i < prices.length; i++) {
|
||||
ctx.lineTo(indexToX(i), priceToY(prices[i]));
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Fill area under line
|
||||
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
|
||||
ctx.lineTo(indexToX(0), padding.top + chartHeight);
|
||||
ctx.closePath();
|
||||
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
|
||||
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// Draw signal markers
|
||||
if (signals.length > 0) {
|
||||
signals.forEach((signal) => {
|
||||
// Find closest price match
|
||||
const signalPrice = signal.price;
|
||||
let closestIndex = 0;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < priceData.length; i++) {
|
||||
const diff = Math.abs(priceData[i].price - signalPrice);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const x = indexToX(closestIndex);
|
||||
const y = priceToY(signalPrice);
|
||||
const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
|
||||
|
||||
// Vertical dashed line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Signal dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Draw X axis time labels
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const numTimeLabels = Math.min(5, priceData.length);
|
||||
for (let i = 0; i < numTimeLabels; i++) {
|
||||
const dataIndex = Math.floor(i * (priceData.length - 1) / (numTimeLabels - 1 || 1));
|
||||
const x = indexToX(dataIndex);
|
||||
|
||||
// Convert timestamp to readable time
|
||||
let timeLabel = '';
|
||||
if (priceData[dataIndex].time > 0) {
|
||||
const date = new Date(priceData[dataIndex].time * 1000);
|
||||
timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
timeLabel = `${dataIndex + 1}`;
|
||||
}
|
||||
|
||||
ctx.fillText(timeLabel, x, height - 5);
|
||||
}
|
||||
|
||||
// Legend
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
if (signals.length > 0) {
|
||||
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
|
||||
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
|
||||
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${priceData.length} Candles`, width / 2, height - 20);
|
||||
} else {
|
||||
ctx.fillText(`${priceData.length} Candles (No signals generated)`, width / 2, height - 20);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="signal-chart" bind:this={containerEl}>
|
||||
{#if signals.length === 0}
|
||||
{#if klines.length === 0 && signals.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No signals to display</p>
|
||||
<p>No data to display. Start a simulation to see price movements.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<svg {width} {height} viewBox="0 0 {width} {height}">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
|
||||
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="grid-lines">
|
||||
{#each [0, 1, 2, 3] as i}
|
||||
{@const y = 30 + (i / 3) * (height - 60)}
|
||||
<line
|
||||
x1="30" y1={y}
|
||||
x2={width - 30} y2={y}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke-dasharray="4,4"
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="y-axis">
|
||||
{#each getYAxisLabels() as label, i}
|
||||
{@const y = 30 + (i / 3) * (height - 60)}
|
||||
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="x-axis">
|
||||
{#each getXAxisLabels() as label, i}
|
||||
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
|
||||
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<path
|
||||
d={signals.map((s, i) => {
|
||||
const pos = getSignalPosition(s, i, signals.length);
|
||||
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke="#667eea"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
{#each signals as signal, i}
|
||||
{@const pos = getSignalPosition(signal, i, signals.length)}
|
||||
{@const color = getSignalColor(signal)}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
class="signal-dot"
|
||||
>
|
||||
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
|
||||
</circle>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot buy"></span>
|
||||
<span>Buy</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot sell"></span>
|
||||
<span>Sell</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot hold"></span>
|
||||
<span>Hold</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas
|
||||
bind:this={canvasEl}
|
||||
style="width: 100%; height: {height}px;"
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -169,60 +255,12 @@
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
font-size: 10px;
|
||||
fill: #666;
|
||||
}
|
||||
|
||||
.signal-dot {
|
||||
cursor: pointer;
|
||||
transition: r 0.2s;
|
||||
}
|
||||
|
||||
.signal-dot:hover {
|
||||
r: 8;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.buy {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.legend-dot.sell {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.legend-dot.hold {
|
||||
background: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
@@ -10,13 +10,14 @@
|
||||
let { config, editable = false, onUpdate }: Props = $props();
|
||||
|
||||
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
|
||||
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
|
||||
switch (condition.type) {
|
||||
case 'price_drop':
|
||||
return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} drops by ${condition.threshold}%${timeframe}`;
|
||||
case 'price_rise':
|
||||
return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} rises by ${condition.threshold}%${timeframe}`;
|
||||
case 'volume_spike':
|
||||
return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`;
|
||||
case 'price_level':
|
||||
return `${condition.token} crosses ${condition.direction} $${condition.price}`;
|
||||
default:
|
||||
|
||||
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
180
src/frontend/src/lib/components/TradeDashboard.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import type { TradeLogEntry } from '$lib/stores/simulationStore';
|
||||
|
||||
interface Props {
|
||||
tradeLog: TradeLogEntry[];
|
||||
}
|
||||
|
||||
let { tradeLog }: Props = $props();
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function getActionColor(action: string): string {
|
||||
switch (action) {
|
||||
case 'buy': return '#22c55e';
|
||||
case 'sell': return '#ef4444';
|
||||
default: return '#666';
|
||||
}
|
||||
}
|
||||
|
||||
function getActionIcon(action: string): string {
|
||||
switch (action) {
|
||||
case 'buy': return '📈';
|
||||
case 'sell': return '📉';
|
||||
default: return '➡️';
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to show only buy/sell actions
|
||||
let tradeActions = $derived(tradeLog.filter(t => t.action !== 'hold'));
|
||||
</script>
|
||||
|
||||
<div class="trade-dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h3>Trade Activity</h3>
|
||||
<span class="trade-count">
|
||||
{tradeActions.length} trades
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if tradeActions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No trades executed yet. Check the strategy configuration.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trade-list">
|
||||
{#each tradeActions as entry}
|
||||
<div class="trade-entry action-{entry.action}">
|
||||
<div class="trade-time">
|
||||
<span class="action-icon">{getActionIcon(entry.action)}</span>
|
||||
<span class="action-badge" style="background: {getActionColor(entry.action)}">
|
||||
{entry.action.toUpperCase()}
|
||||
</span>
|
||||
<span class="time">{formatTime(entry.time)}</span>
|
||||
</div>
|
||||
<div class="trade-details">
|
||||
<div class="price">
|
||||
<span class="label">Price:</span>
|
||||
<span class="value">${entry.price.toFixed(8)}</span>
|
||||
</div>
|
||||
<div class="reason">
|
||||
<span class="label">Reason:</span>
|
||||
<span class="value">{entry.reason}</span>
|
||||
</div>
|
||||
{#if entry.action === 'sell' && entry.position > 0}
|
||||
<div class="pnl">
|
||||
<span class="label">Position:</span>
|
||||
<span class="value">{entry.position.toFixed(6)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trade-dashboard {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dashboard-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trade-count {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.trade-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.trade-entry {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.trade-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.trade-entry.action-buy {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.trade-entry.action-sell {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.trade-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.trade-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.trade-details .label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.trade-details .value {
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.pnl .value {
|
||||
color: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ export { default as BotCard } from './BotCard.svelte';
|
||||
export { default as BotSelector } from './BotSelector.svelte';
|
||||
export { default as StrategyPreview } from './StrategyPreview.svelte';
|
||||
export { default as SignalChart } from './SignalChart.svelte';
|
||||
export { default as TradeDashboard } from './TradeDashboard.svelte';
|
||||
export { default as PortfolioSummary } from './PortfolioSummary.svelte';
|
||||
export { default as BacktestChart } from './BacktestChart.svelte';
|
||||
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
||||
export { default as TokenPicker } from './TokenPicker.svelte';
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
thinking: string | null;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export function setMessages(messages: BotConversation[]) {
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
thinking: null,
|
||||
timestamp: new Date(m.created_at)
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Simulation, Signal } from '$lib/api';
|
||||
|
||||
export interface KlineData {
|
||||
time: number;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface TradeLogEntry {
|
||||
time: number;
|
||||
price: number;
|
||||
action: 'buy' | 'sell' | 'hold';
|
||||
reason: string;
|
||||
position: number;
|
||||
entry_price: number | null;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
initial_balance: number;
|
||||
current_balance: number;
|
||||
position: number;
|
||||
position_token: string;
|
||||
entry_price: number;
|
||||
current_price: number;
|
||||
}
|
||||
|
||||
export interface SimulationState {
|
||||
currentSimulation: Simulation | null;
|
||||
signals: Signal[];
|
||||
klines: KlineData[];
|
||||
tradeLog: TradeLogEntry[];
|
||||
portfolio: Portfolio;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -11,6 +37,16 @@ export interface SimulationState {
|
||||
const initialState: SimulationState = {
|
||||
currentSimulation: null,
|
||||
signals: [],
|
||||
klines: [],
|
||||
tradeLog: [],
|
||||
portfolio: {
|
||||
initial_balance: 10000,
|
||||
current_balance: 10000,
|
||||
position: 0,
|
||||
position_token: '',
|
||||
entry_price: 0,
|
||||
current_price: 0
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
@@ -18,7 +54,20 @@ const initialState: SimulationState = {
|
||||
export const simulationStore = writable<SimulationState>(initialState);
|
||||
|
||||
export function setCurrentSimulation(simulation: Simulation | null) {
|
||||
simulationStore.update(state => ({ ...state, currentSimulation: simulation }));
|
||||
simulationStore.update(state => ({
|
||||
...state,
|
||||
currentSimulation: simulation,
|
||||
klines: simulation?.klines || [],
|
||||
tradeLog: simulation?.trade_log || [],
|
||||
portfolio: simulation?.portfolio || state.portfolio
|
||||
}));
|
||||
}
|
||||
|
||||
export function updatePortfolio(portfolio: Partial<Portfolio>) {
|
||||
simulationStore.update(state => ({
|
||||
...state,
|
||||
portfolio: { ...state.portfolio, ...portfolio }
|
||||
}));
|
||||
}
|
||||
|
||||
export function addSignals(newSignals: Signal[]) {
|
||||
|
||||
256
src/frontend/src/lib/utils/markdown.ts
Normal file
256
src/frontend/src/lib/utils/markdown.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Simple markdown parser for rendering AI responses
|
||||
* Supports: bold, italic, code blocks, inline code, links, lists, tables, headings, line breaks
|
||||
*/
|
||||
|
||||
export interface InlineSegment {
|
||||
type: 'text' | 'bold' | 'italic' | 'code' | 'link';
|
||||
content: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface ParsedSegment {
|
||||
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list' | 'table' | 'lineBreak' | 'heading';
|
||||
content: string;
|
||||
items?: string[];
|
||||
headers?: InlineSegment[][];
|
||||
rows?: InlineSegment[][];
|
||||
}
|
||||
|
||||
export function parseMarkdown(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// First, extract code blocks
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
const parts = text.split(codeBlockRegex);
|
||||
const codeBlocks = text.match(codeBlockRegex) || [];
|
||||
|
||||
let partIndex = 0;
|
||||
|
||||
while (partIndex < parts.length) {
|
||||
const part = parts[partIndex];
|
||||
|
||||
if (part.trim()) {
|
||||
// Process non-code content
|
||||
segments.push(...parseInlineContent(part));
|
||||
}
|
||||
|
||||
// Add code block if there's one after this part
|
||||
if (partIndex < codeBlocks.length) {
|
||||
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
|
||||
segments.push({ type: 'codeBlock', content: codeContent });
|
||||
}
|
||||
|
||||
partIndex++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseInlineContent(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Check for tables - match table pattern anywhere in text
|
||||
// Table pattern: | header | ... |\n|---|...|\n| row | ... |
|
||||
const tableRegex = /\|.+\|\n\|[-:\s|]+\|\n((?:\|.+\|\n?)*)/g;
|
||||
let lastIndex = 0;
|
||||
let tableMatch;
|
||||
|
||||
while ((tableMatch = tableRegex.exec(text)) !== null) {
|
||||
// Add content before table
|
||||
const beforeTable = text.substring(lastIndex, tableMatch.index);
|
||||
if (beforeTable.trim()) {
|
||||
segments.push(...parseLines(beforeTable));
|
||||
}
|
||||
|
||||
// Parse table
|
||||
const tableContent = tableMatch[0];
|
||||
const tableSegments = parseTable(tableContent);
|
||||
if (tableSegments.length > 0) {
|
||||
segments.push(...tableSegments);
|
||||
} else {
|
||||
// If table parsing failed, treat as text
|
||||
segments.push(...parseLines(tableContent));
|
||||
}
|
||||
|
||||
lastIndex = tableMatch.index + tableContent.length;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
if (lastIndex < text.length) {
|
||||
const remaining = text.substring(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
segments.push(...parseLines(remaining));
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseTable(tableStr: string): ParsedSegment[] {
|
||||
const lines = tableStr.trim().split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Skip separator line (|---|---|)
|
||||
const dataLines = lines.filter(line => !line.match(/^[\|\s\-:]+$/));
|
||||
if (dataLines.length < 2) return [];
|
||||
|
||||
const headers = parseTableRow(dataLines[0]);
|
||||
const rows = dataLines.slice(1).map(row => parseTableRow(row));
|
||||
|
||||
return [{
|
||||
type: 'table',
|
||||
content: '',
|
||||
headers,
|
||||
rows
|
||||
}];
|
||||
}
|
||||
|
||||
function parseTableRow(row: string): InlineSegment[][] {
|
||||
return row.split('|')
|
||||
.map(cell => cell.trim())
|
||||
.filter(cell => cell !== '')
|
||||
.map(cell => parseInlineElements(cell));
|
||||
}
|
||||
|
||||
export function parseInlineElements(text: string): InlineSegment[] {
|
||||
const segments: InlineSegment[] = [];
|
||||
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const parts = text.split(inlineRegex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('[') && part.includes('](')) {
|
||||
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
segments.push({ type: 'link', content: linkMatch[1], href: linkMatch[2] });
|
||||
}
|
||||
} else if (part) {
|
||||
segments.push({ type: 'text', content: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Render inline segments to HTML string
|
||||
function renderInlineSegments(segments: InlineSegment[]): string {
|
||||
return segments.map(seg => {
|
||||
switch (seg.type) {
|
||||
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||
case 'italic': return `<em>${seg.content}</em>`;
|
||||
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||
default: return seg.content;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function parseLines(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Combined regex for inline formatting
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
// Empty line - add line break for paragraph separation
|
||||
segments.push({ type: 'lineBreak', content: '' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for headings
|
||||
if (line.match(/^#{1,6}\s/)) {
|
||||
segments.push({ type: 'heading', content: line.replace(/^#+\s/, '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
if (line.match(/^[\-\*]\s/)) {
|
||||
const listMatch = line.match(/^([\-\*])\s(.*)/);
|
||||
if (listMatch) {
|
||||
// Parse inline formatting for list item
|
||||
const itemContent = listMatch[2];
|
||||
const inlineSegments = parseInlineElements(itemContent);
|
||||
|
||||
// Check if previous segment is a list
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (lastSeg && lastSeg.type === 'list') {
|
||||
lastSeg.items?.push(itemContent);
|
||||
} else {
|
||||
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for numbered lists
|
||||
if (line.match(/^\d+\.\s/)) {
|
||||
const listMatch = line.match(/^\d+\.\s(.*)/);
|
||||
if (listMatch) {
|
||||
const itemContent = listMatch[1];
|
||||
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
if (lastSeg && lastSeg.type === 'list') {
|
||||
lastSeg.items?.push(itemContent);
|
||||
} else {
|
||||
segments.push({ type: 'list', content: '', items: [itemContent] });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process inline formatting
|
||||
const inlineSegments = parseInlineElementsAsText(line);
|
||||
segments.push(...inlineSegments);
|
||||
|
||||
// Add line break after non-empty lines (except last in a paragraph)
|
||||
if (i < lines.length - 1 && line.trim()) {
|
||||
segments.push({ type: 'lineBreak', content: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseInlineElementsAsText(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
|
||||
const parts = text.split(inlineRegex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('[') && part.includes('](')) {
|
||||
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
segments.push({ type: 'link', content: linkMatch[1] });
|
||||
}
|
||||
} else if (part) {
|
||||
segments.push({ type: 'text', content: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -4,12 +4,20 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components';
|
||||
import { ChatInterface, StrategyPreview } from '$lib/components';
|
||||
import type { TokenSearchResult } from '$lib/api';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let isSending = $state(false);
|
||||
let showStrategy = $state(false);
|
||||
|
||||
// Token address confirmation modal state
|
||||
let showTokenConfirm = $state(false);
|
||||
let pendingStrategyData = $state<any>(null);
|
||||
let tokenAddressInput = $state('');
|
||||
let confirmingMessage = $state('');
|
||||
let tokenSearchResults = $state<TokenSearchResult[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
goto('/login');
|
||||
@@ -55,7 +63,18 @@
|
||||
const response = await api.bots.chat(botId, message, controller.signal);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
addMessage({ role: 'assistant', content: response.response });
|
||||
// Check if token address confirmation is needed
|
||||
if (response.strategy_needs_confirmation && response.strategy_data) {
|
||||
// Show token confirmation modal
|
||||
pendingStrategyData = response.strategy_data;
|
||||
confirmingMessage = response.response;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = response.token_search_results || [];
|
||||
showTokenConfirm = true;
|
||||
}
|
||||
|
||||
// Add assistant response with thinking
|
||||
addMessage({ role: 'assistant', content: response.response, thinking: response.thinking || null });
|
||||
|
||||
if (response.strategy_config) {
|
||||
const bot = await api.bots.get(botId);
|
||||
@@ -63,9 +82,9 @@
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
addMessage({ role: 'assistant', content: 'Request timed out. 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.' });
|
||||
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.', thinking: null });
|
||||
}
|
||||
} finally {
|
||||
isSending = false;
|
||||
@@ -75,6 +94,62 @@
|
||||
function toggleStrategy() {
|
||||
showStrategy = !showStrategy;
|
||||
}
|
||||
|
||||
async function confirmTokenAddress() {
|
||||
if (!tokenAddressInput.trim() || !pendingStrategyData) {
|
||||
showTokenConfirm = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the pending strategy with the token address
|
||||
const updatedStrategy = { ...pendingStrategyData };
|
||||
|
||||
// Update conditions with token address
|
||||
if (updatedStrategy.conditions) {
|
||||
updatedStrategy.conditions = updatedStrategy.conditions.map((cond: any) => ({
|
||||
...cond,
|
||||
token_address: tokenAddressInput.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
// Update actions with token address
|
||||
if (updatedStrategy.actions) {
|
||||
updatedStrategy.actions = updatedStrategy.actions.map((action: any) => ({
|
||||
...action,
|
||||
token_address: tokenAddressInput.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
// Update bot with the strategy
|
||||
await api.bots.update(botId, { strategy_config: updatedStrategy });
|
||||
|
||||
// Refresh bot data
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Add success message
|
||||
addMessage({ role: 'assistant', content: `Perfect! I've saved your strategy with the token address. You can now run backtests!`, thinking: null });
|
||||
} catch (e) {
|
||||
addMessage({ role: 'assistant', content: 'Failed to save strategy. Please try again.', thinking: null });
|
||||
}
|
||||
|
||||
showTokenConfirm = false;
|
||||
pendingStrategyData = null;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = [];
|
||||
}
|
||||
|
||||
function selectTokenResult(result: TokenSearchResult) {
|
||||
tokenAddressInput = result.address;
|
||||
}
|
||||
|
||||
function cancelTokenConfirm() {
|
||||
showTokenConfirm = false;
|
||||
pendingStrategyData = null;
|
||||
tokenAddressInput = '';
|
||||
tokenSearchResults = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -82,6 +157,34 @@
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
{#if showTokenConfirm}
|
||||
<div class="modal-overlay" onclick={cancelTokenConfirm}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Select Token Address</h3>
|
||||
<p class="modal-message">{confirmingMessage}</p>
|
||||
|
||||
{#if tokenSearchResults.length > 0}
|
||||
<div class="token-results">
|
||||
<p class="modal-hint">Select a token:</p>
|
||||
{#each tokenSearchResults as result}
|
||||
<button class="token-result" onclick={() => selectTokenResult(result)}>
|
||||
<span class="token-symbol">{result.symbol}</span>
|
||||
<span class="token-name">{result.name}</span>
|
||||
<span class="token-address">{result.address.slice(0, 10)}...{result.address.slice(-8)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="modal-divider">or enter manually:</p>
|
||||
{/if}
|
||||
|
||||
<input type="text" class="token-input" bind:value={tokenAddressInput} placeholder="0x..."/>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={cancelTokenConfirm}>Cancel</button>
|
||||
<button class="btn btn-primary" onclick={confirmTokenAddress} disabled={!tokenAddressInput.trim()}>Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<a href="/dashboard" class="back-link">← Dashboard</a>
|
||||
@@ -108,12 +211,12 @@
|
||||
<ChatInterface
|
||||
bot={$currentBotStore}
|
||||
messages={$chatStore}
|
||||
{isSending}
|
||||
isSending={isSending}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProUpgradeBanner feature="Auto-execute trades with your bot" />
|
||||
<!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> -->
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -199,4 +302,145 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
color: #ccc;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.token-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Token Results */
|
||||
.token-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-result {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.token-result:hover {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.token-result:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.token-symbol {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,14 +8,36 @@
|
||||
import type { Backtest } from '$lib/api';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
let tokenName = $state('');
|
||||
let tokenAddress = $state('');
|
||||
let timeframe = $state('1h');
|
||||
let startDate = $state('');
|
||||
let endDate = $state('');
|
||||
let isRunning = $state(false);
|
||||
let selectedBacktest = $state<Backtest | null>(null);
|
||||
|
||||
// Expandable trades state
|
||||
let expandedTrades = $state<Set<string>>(new Set());
|
||||
|
||||
// Pagination state for each backtest
|
||||
let tradesPage = $state<Record<string, number>>({});
|
||||
let tradesData = $state<Record<string, any>>({});
|
||||
const TRADES_PER_PAGE = 5;
|
||||
|
||||
onMount(async () => {
|
||||
// Set default dates - yesterday only (1 day range for fast testing)
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Set max date to yesterday
|
||||
const maxDate = yesterday.toISOString().split('T')[0];
|
||||
|
||||
// Set end to yesterday, start to day before (1 day range)
|
||||
endDate = maxDate;
|
||||
const dayBefore = new Date(yesterday);
|
||||
dayBefore.setDate(dayBefore.getDate() - 1);
|
||||
startDate = dayBefore.toISOString().split('T')[0];
|
||||
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
goto('/login');
|
||||
return;
|
||||
@@ -30,6 +52,16 @@
|
||||
try {
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Extract token info from strategy config
|
||||
const strategy = bot.strategy_config;
|
||||
if (strategy) {
|
||||
// Try conditions first, then actions
|
||||
const condition = strategy.conditions?.[0];
|
||||
const action = strategy.actions?.[0];
|
||||
tokenName = condition?.token || action?.token || '';
|
||||
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||
}
|
||||
} catch (e) {
|
||||
goto('/dashboard');
|
||||
}
|
||||
@@ -46,13 +78,25 @@
|
||||
|
||||
async function startBacktest() {
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
// Validate date range (max 7 days)
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff > 7) {
|
||||
setBacktestError('Maximum backtest duration is 7 days for fast testing');
|
||||
return;
|
||||
}
|
||||
|
||||
setBacktestError(null);
|
||||
setBacktestLoading(true);
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const backtest = await api.backtest.start(botId, {
|
||||
token,
|
||||
token: tokenAddress, // Use token address from strategy
|
||||
token_name: tokenName, // Also send token name for display
|
||||
timeframe,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
@@ -76,15 +120,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setBacktestHistory(backtests: any[]) {
|
||||
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
|
||||
}
|
||||
|
||||
function selectBacktest(backtest: Backtest) {
|
||||
if (backtest.status === 'completed' && backtest.result) {
|
||||
if (backtest.status === 'completed' && backtest.result && !backtest.result.error) {
|
||||
selectedBacktest = backtest;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrades(backtestId: string) {
|
||||
if (expandedTrades.has(backtestId)) {
|
||||
expandedTrades.delete(backtestId);
|
||||
} else {
|
||||
expandedTrades.add(backtestId);
|
||||
// Load first page of trades if not loaded
|
||||
if (!tradesData[backtestId]) {
|
||||
loadTrades(backtestId, 1);
|
||||
}
|
||||
}
|
||||
expandedTrades = new Set(expandedTrades); // Trigger reactivity
|
||||
}
|
||||
|
||||
async function loadTrades(backtestId: string, page: number) {
|
||||
try {
|
||||
const data = await api.backtest.getTrades(botId, backtestId, page, TRADES_PER_PAGE);
|
||||
tradesData[backtestId] = { ...data, currentPage: page };
|
||||
tradesData = { ...tradesData }; // Trigger reactivity
|
||||
} catch (e) {
|
||||
console.error('Failed to load trades:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function nextTradesPage(backtestId: string) {
|
||||
const data = tradesData[backtestId];
|
||||
if (data && data.has_next) {
|
||||
loadTrades(backtestId, data.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function prevTradesPage(backtestId: string) {
|
||||
const data = tradesData[backtestId];
|
||||
if (data && data.has_prev) {
|
||||
loadTrades(backtestId, data.page - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -109,17 +192,19 @@
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="token">Token</label>
|
||||
<input type="text" id="token" bind:value={token} required />
|
||||
<div class="field token-info">
|
||||
<label>Token</label>
|
||||
<div class="token-display">
|
||||
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||
{#if tokenAddress}
|
||||
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="timeframe">Timeframe</label>
|
||||
<select id="timeframe" bind:value={timeframe}>
|
||||
<option value="1m">1 minute</option>
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="1h">1 hour (recommended)</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="1d">1 day</option>
|
||||
</select>
|
||||
@@ -144,7 +229,12 @@
|
||||
</section>
|
||||
|
||||
<section class="results-section">
|
||||
<h2>Backtest History</h2>
|
||||
<div class="section-header">
|
||||
<h2>Backtest History</h2>
|
||||
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
|
||||
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $backtestStore.backtestHistory.length === 0}
|
||||
<p class="empty-state">No backtests yet. Run your first backtest above.</p>
|
||||
@@ -156,7 +246,11 @@
|
||||
<span class="backtest-status status-{backtest.status}">{backtest.status}</span>
|
||||
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{#if backtest.result}
|
||||
{#if backtest.result && backtest.result.error}
|
||||
<div class="backtest-error">
|
||||
<span class="error-label">Error:</span> {typeof backtest.result.error === 'string' ? backtest.result.error : JSON.stringify(backtest.result.error)}
|
||||
</div>
|
||||
{:else if backtest.result}
|
||||
<div class="backtest-results">
|
||||
<div class="result-item">
|
||||
<span class="result-label">Total Return</span>
|
||||
@@ -177,16 +271,68 @@
|
||||
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backtest-config">
|
||||
<span class="config-item">
|
||||
<span class="config-label">Token:</span> {backtest.config.token || 'Unknown'}
|
||||
</span>
|
||||
<span class="config-item">
|
||||
<span class="config-label">TF:</span> {backtest.config.timeframe || '1h'}
|
||||
</span>
|
||||
<span class="config-item">
|
||||
<span class="config-label">Period:</span> {backtest.config.start_date} to {backtest.config.end_date}
|
||||
</span>
|
||||
</div>
|
||||
{#if backtest.result.trades && backtest.result.trades.length > 0}
|
||||
<button class="btn-toggle-trades" onclick={() => toggleTrades(backtest.id)}>
|
||||
{expandedTrades.has(backtest.id) ? 'Hide' : 'Show'} Trade History ({backtest.result.trades.length})
|
||||
</button>
|
||||
{#if expandedTrades.has(backtest.id)}
|
||||
<div class="trades-inline">
|
||||
{#if tradesData[backtest.id]}
|
||||
<div class="trades-pagination-header">
|
||||
<span class="trades-count">
|
||||
Showing {((tradesData[backtest.id].page - 1) * TRADES_PER_PAGE) + 1}-{Math.min(tradesData[backtest.id].page * TRADES_PER_PAGE, tradesData[backtest.id].total_trades)} of {tradesData[backtest.id].total_trades}
|
||||
</span>
|
||||
{#if tradesData[backtest.id].total_pages > 1}
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-pagination" onclick={() => prevTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_prev}>← Prev</button>
|
||||
<span class="page-indicator">Page {tradesData[backtest.id].page} of {tradesData[backtest.id].total_pages}</span>
|
||||
<button class="btn-pagination" onclick={() => nextTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_next}>Next →</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="trades-list">
|
||||
{#each tradesData[backtest.id].trades as trade}
|
||||
<div class="trade-item">
|
||||
<span class="trade-type" class:buy={trade.type === 'buy'} class:sell={trade.type === 'sell'}>
|
||||
{trade.type.toUpperCase()}
|
||||
</span>
|
||||
<span class="trade-price">${trade.price?.toFixed(6)}</span>
|
||||
<span class="trade-amount">${trade.amount?.toFixed(2)}</span>
|
||||
<span class="trade-reason">{trade.exit_reason || 'entry'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trades-loading">Loading trades...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if backtest.status === 'running'}
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {backtest.progress ?? 0}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">{backtest.progress ?? 0}%</span>
|
||||
</div>
|
||||
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if selectedBacktest}
|
||||
<section class="chart-section">
|
||||
<div class="chart-header">
|
||||
@@ -196,6 +342,8 @@
|
||||
<BacktestChart results={selectedBacktest.result} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -237,7 +385,120 @@
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Trades Modal */
|
||||
.trades-modal {
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trades-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.trades-modal h3 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: yellow;
|
||||
color: black;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trades-table-wrapper {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.trades-table th,
|
||||
.trades-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trade-type {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.trade-type.buy {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trade-type.sell {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -262,6 +523,20 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.backtest-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -275,6 +550,27 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
@@ -334,6 +630,83 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Inline Trades */
|
||||
.trades-inline {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-inline h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.trades-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trade-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.trade-item .trade-type {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.trade-item .trade-type.buy {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trade-item .trade-type.sell {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.trade-price {
|
||||
color: #ccc;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trade-amount {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.trade-reason {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-toggle-trades {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #667eea;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-toggle-trades:hover {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.backtest-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -364,6 +737,11 @@
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.backtest-date {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
@@ -375,6 +753,24 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.backtest-config {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.config-item {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -399,6 +795,33 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
margin-top: 0.75rem;
|
||||
width: auto;
|
||||
@@ -439,4 +862,61 @@
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Pagination styles */
|
||||
.trades-pagination-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.trades-count {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-pagination {
|
||||
width: auto;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #667eea;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-pagination:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-pagination:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trades-loading {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { SignalChart, ProUpgradeBanner } from '$lib/components';
|
||||
import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
let intervalSeconds = $state(60);
|
||||
let autoExecute = $state(false);
|
||||
let tokenName = $state('');
|
||||
let tokenAddress = $state('');
|
||||
let klineInterval = $state('1m');
|
||||
let isRunning = $state(false);
|
||||
let isRefreshing = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
@@ -27,26 +28,40 @@
|
||||
try {
|
||||
const bot = await api.bots.get(botId);
|
||||
setCurrentBot(bot);
|
||||
|
||||
// Extract token info from strategy config
|
||||
const strategy = bot.strategy_config;
|
||||
if (strategy) {
|
||||
const condition = strategy.conditions?.[0];
|
||||
const action = strategy.actions?.[0];
|
||||
tokenName = condition?.token || action?.token || '';
|
||||
tokenAddress = condition?.token_address || action?.token_address || '';
|
||||
}
|
||||
} catch (e) {
|
||||
goto('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSimulations() {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const simulations = await api.simulate.list(botId);
|
||||
if (simulations.length > 0) {
|
||||
const latest = simulations[0];
|
||||
setCurrentSimulation(latest);
|
||||
if (latest.signals) {
|
||||
addSignals(latest.signals);
|
||||
}
|
||||
if (latest.status === 'running') {
|
||||
isRunning = true;
|
||||
|
||||
// Find the most recent running simulation, or fall back to most recent
|
||||
let current = simulations.find(s => s.status === 'running') || simulations[0];
|
||||
|
||||
if (current) {
|
||||
setCurrentSimulation(current);
|
||||
clearSignals();
|
||||
if (current.signals && current.signals.length > 0) {
|
||||
addSignals(current.signals);
|
||||
}
|
||||
isRunning = current.status === 'running';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load simulations:', e);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +72,9 @@
|
||||
|
||||
try {
|
||||
const simulation = await api.simulate.start(botId, {
|
||||
token,
|
||||
interval_seconds: intervalSeconds,
|
||||
auto_execute: autoExecute
|
||||
token: tokenAddress,
|
||||
chain: 'bsc',
|
||||
kline_interval: klineInterval
|
||||
});
|
||||
setCurrentSimulation(simulation);
|
||||
clearSignals();
|
||||
@@ -94,11 +109,23 @@
|
||||
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
|
||||
<h1>Simulation</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{#if $simulationStore.currentSimulation}
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-btn"
|
||||
onclick={() => loadSimulations()}
|
||||
class:refreshing={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '⟳ Refreshing...' : '⟳ Refresh'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="notice">
|
||||
<span class="notice-icon">⚠️</span>
|
||||
<span>Simulation Mode - Using REST polling (every {intervalSeconds}s). For real-time signals, consider upgrading to Pro tier.</span>
|
||||
<span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -111,26 +138,26 @@
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="token">Token</label>
|
||||
<input type="text" id="token" bind:value={token} required disabled={isRunning} />
|
||||
<div class="field token-info">
|
||||
<label>Token</label>
|
||||
<div class="token-display">
|
||||
<span class="token-name">{tokenName || 'Not configured'}</span>
|
||||
{#if tokenAddress}
|
||||
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="interval">Check Interval (seconds)</label>
|
||||
<select id="interval" bind:value={intervalSeconds} disabled={isRunning}>
|
||||
<option value={30}>30 seconds</option>
|
||||
<option value={60}>60 seconds</option>
|
||||
<option value={120}>2 minutes</option>
|
||||
<option value={300}>5 minutes</option>
|
||||
<label for="klineInterval">Kline Interval</label>
|
||||
<select id="klineInterval" bind:value={klineInterval} disabled={isRunning}>
|
||||
<option value="1m">1 minute</option>
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field checkbox-field">
|
||||
<input type="checkbox" id="autoExecute" bind:checked={autoExecute} disabled={isRunning} />
|
||||
<label for="autoExecute">Auto-execute trades (requires Pro tier)</label>
|
||||
</div>
|
||||
|
||||
{#if isRunning}
|
||||
<button type="button" onclick={stopSimulation} class="btn btn-danger">
|
||||
Stop Simulation
|
||||
@@ -143,16 +170,31 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
|
||||
|
||||
<section class="signals-section">
|
||||
<h2>Signals ({$simulationStore.signals.length})</h2>
|
||||
<h2>Portfolio</h2>
|
||||
|
||||
<PortfolioSummary
|
||||
initialBalance={$simulationStore.currentSimulation?.portfolio?.initial_balance || 10000}
|
||||
currentBalance={$simulationStore.currentSimulation?.portfolio?.current_balance || 10000}
|
||||
position={$simulationStore.currentSimulation?.portfolio?.position || 0}
|
||||
positionToken={$simulationStore.currentSimulation?.portfolio?.position_token || ''}
|
||||
entryPrice={$simulationStore.currentSimulation?.portfolio?.entry_price || 0}
|
||||
currentPrice={$simulationStore.currentSimulation?.portfolio?.current_price || 0}
|
||||
/>
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Price Chart</h2>
|
||||
|
||||
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={250} />
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Trade Activity</h2>
|
||||
|
||||
<TradeDashboard tradeLog={$simulationStore.currentSimulation?.trade_log || []} />
|
||||
|
||||
<h2 style="margin-top: 1.5rem;">Signals ({$simulationStore.signals.length})</h2>
|
||||
|
||||
{#if $simulationStore.signals.length === 0}
|
||||
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p>
|
||||
<p class="empty-state">No signals generated. The chart above shows price movement.</p>
|
||||
{:else}
|
||||
<SignalChart signals={$simulationStore.signals} height={200} />
|
||||
|
||||
<div class="signals-list">
|
||||
{#each $simulationStore.signals as signal}
|
||||
<div class="signal-card">
|
||||
@@ -198,6 +240,42 @@
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.refresh-btn.refreshing {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -276,6 +354,27 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.token-address {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user