Compare commits
11 Commits
fix/issue-
...
b6f99aa8fe
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f99aa8fe | |||
|
|
3806af3e23 | ||
| a892a403fb | |||
|
|
0bb5d9a5d6 | ||
| 875427a0c1 | |||
| a59a1ccd97 | |||
|
|
965efa122b | ||
| 0fb16f06e4 | |||
|
|
a461005015 | ||
| b0311bc96f | |||
|
|
a280217254 |
265
src/backend/app/api/ave.py
Normal file
265
src/backend/app/api/ave.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Annotated, Optional
|
||||
import httpx
|
||||
|
||||
from .auth import get_current_user
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.models import User
|
||||
from ..services.ave import AveCloudClient, check_tier_access
|
||||
from ..db.schemas import (
|
||||
AveBatchPricesRequest,
|
||||
AveKlinesRequest,
|
||||
AveChainQuoteRequest,
|
||||
AveChainSwapRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_ave_client() -> AveCloudClient:
|
||||
settings = get_settings()
|
||||
return AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def search_tokens(
|
||||
query: Optional[str] = None,
|
||||
chain: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
tokens = await client.get_tokens(query=query, chain=chain, limit=limit)
|
||||
return {"tokens": tokens}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch tokens: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tokens/price")
|
||||
async def get_batch_prices(
|
||||
request: AveBatchPricesRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
prices = await client.get_batch_prices(request.token_ids)
|
||||
return {"prices": prices}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch batch prices: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tokens/{token_id}")
|
||||
async def get_token_details(
|
||||
token_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
token = await client.get_token_details(token_id)
|
||||
if token is None:
|
||||
return {"token": None, "upsell_message": None}
|
||||
return {"token": token}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch token details: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/klines/{token_id}")
|
||||
async def get_klines(
|
||||
token_id: str,
|
||||
interval: str = "1h",
|
||||
limit: int = 100,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
klines = await client.get_klines(
|
||||
token_id=token_id,
|
||||
interval=interval,
|
||||
limit=limit,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
return {"klines": klines}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch klines: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tokens/trending")
|
||||
async def get_trending_tokens(
|
||||
chain: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
tokens = await client.get_trending_tokens(chain=chain, limit=limit)
|
||||
return {"tokens": tokens}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch trending tokens: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contracts/{contract_id}")
|
||||
async def get_token_risk(
|
||||
contract_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
risk = await client.get_token_risk(contract_id)
|
||||
if risk is None:
|
||||
return {"risk": None, "upsell_message": None}
|
||||
return {"risk": risk}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch token risk: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chain/quote")
|
||||
async def get_chain_quote(
|
||||
request: AveChainQuoteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
quote = await client.get_chain_quote(
|
||||
chain=request.chain,
|
||||
from_token=request.from_token,
|
||||
to_token=request.to_token,
|
||||
amount=request.amount,
|
||||
slippage=request.slippage,
|
||||
)
|
||||
if quote is None:
|
||||
return {"quote": None, "upsell_message": None}
|
||||
return {"quote": quote}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch chain quote: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chain/swap")
|
||||
async def get_chain_swap(
|
||||
request: AveChainSwapRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
client = get_ave_client()
|
||||
try:
|
||||
swap = await client.get_chain_swap(
|
||||
chain=request.chain,
|
||||
from_token=request.from_token,
|
||||
to_token=request.to_token,
|
||||
amount=request.amount,
|
||||
slippage=request.slippage,
|
||||
wallet_address=request.wallet_address,
|
||||
)
|
||||
if swap is None:
|
||||
return {"swap": None, "upsell_message": None}
|
||||
return {"swap": swap}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Please try again later.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"AVE API error: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch chain swap: {str(e)}",
|
||||
)
|
||||
@@ -1,36 +1,219 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from typing import List, Dict, Any, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .auth import get_current_user
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import BacktestCreate, BacktestResponse
|
||||
from ..db.models import Bot, Backtest, Signal, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
running_backtests: Dict[str, Any] = {}
|
||||
executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
@router.post("/bots/{bot_id}/backtest", response_model=BacktestResponse)
|
||||
def start_backtest(bot_id: str, config: BacktestCreate, db: Session = Depends(get_db)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
|
||||
def run_backtest_sync(
|
||||
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
import asyncio
|
||||
from ..services.backtest.engine import BacktestEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
async def _run():
|
||||
engine = BacktestEngine(config)
|
||||
engine.run_id = backtest_id
|
||||
running_backtests[backtest_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
|
||||
if backtest:
|
||||
backtest.status = engine.status
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
backtest.result = results
|
||||
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()
|
||||
finally:
|
||||
if backtest_id in running_backtests:
|
||||
del running_backtests[backtest_id]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bots/{bot_id}/backtest",
|
||||
response_model=BacktestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def start_backtest(
|
||||
bot_id: str,
|
||||
config: BacktestCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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"
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
backtest_id = str(uuid.uuid4())
|
||||
|
||||
backtest_config = {
|
||||
"bot_id": bot_id,
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"timeframe": config.timeframe,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
"strategy_config": bot.strategy_config,
|
||||
"ave_api_key": settings.AVE_API_KEY,
|
||||
"ave_api_plan": settings.AVE_API_PLAN,
|
||||
"initial_balance": 10000.0,
|
||||
}
|
||||
|
||||
backtest = Backtest(
|
||||
id=backtest_id,
|
||||
bot_id=bot_id,
|
||||
started_at=datetime.utcnow(),
|
||||
status="running",
|
||||
config={
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"timeframe": config.timeframe,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
},
|
||||
)
|
||||
db.add(backtest)
|
||||
db.commit()
|
||||
db.refresh(backtest)
|
||||
|
||||
db_url = str(settings.DATABASE_URL)
|
||||
background_tasks.add_task(
|
||||
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config
|
||||
)
|
||||
|
||||
return backtest
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/backtest/{run_id}", response_model=BacktestResponse)
|
||||
def get_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
||||
def get_backtest(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
return backtest
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
|
||||
def list_backtests(bot_id: str, db: Session = Depends(get_db)):
|
||||
def list_backtests(
|
||||
bot_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
backtests = (
|
||||
db.query(Backtest)
|
||||
.filter(Backtest.bot_id == bot_id)
|
||||
.order_by(Backtest.started_at.desc())
|
||||
.all()
|
||||
)
|
||||
return backtests
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/backtest/{run_id}/stop")
|
||||
def stop_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
||||
def stop_backtest(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
if run_id in running_backtests:
|
||||
engine = running_backtests[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
backtest.status = "stopped"
|
||||
backtest.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"status": "stopping", "run_id": run_id}
|
||||
|
||||
@@ -4,14 +4,18 @@ from typing import List, Annotated
|
||||
|
||||
from .auth import get_current_user
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..db.schemas import (
|
||||
BotCreate,
|
||||
BotUpdate,
|
||||
BotResponse,
|
||||
BotConversationCreate,
|
||||
BotConversationResponse,
|
||||
BotChatRequest,
|
||||
BotChatResponse,
|
||||
)
|
||||
from ..db.models import Bot, BotConversation, User
|
||||
from ..services.ai_agent.crew import get_trading_crew
|
||||
|
||||
router = APIRouter()
|
||||
MAX_BOTS_PER_USER = 3
|
||||
@@ -154,10 +158,10 @@ def delete_bot(
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chat", response_model=BotConversationResponse)
|
||||
@router.post("/{bot_id}/chat", response_model=BotChatResponse)
|
||||
def chat(
|
||||
bot_id: str,
|
||||
message: BotConversationCreate,
|
||||
request: BotChatRequest,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -173,15 +177,75 @@ def chat(
|
||||
detail="Not authorized to chat with this bot",
|
||||
)
|
||||
|
||||
conversation_history = (
|
||||
db.query(BotConversation)
|
||||
.filter(BotConversation.bot_id == bot_id)
|
||||
.order_by(BotConversation.created_at)
|
||||
.all()
|
||||
)
|
||||
history_for_crew = [
|
||||
{"role": conv.role, "content": conv.content}
|
||||
for conv in conversation_history[-10:]
|
||||
]
|
||||
|
||||
user_message = request.message
|
||||
if request.strategy_config:
|
||||
crew = get_trading_crew()
|
||||
result = crew.chat(user_message, history_for_crew)
|
||||
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
if result.get("success") and result.get("strategy_config"):
|
||||
bot.strategy_config = result["strategy_config"]
|
||||
db.commit()
|
||||
|
||||
db_conversation = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role=message.role,
|
||||
content=message.content,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(db_conversation)
|
||||
|
||||
db_assistant = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(db_assistant)
|
||||
db.commit()
|
||||
db.refresh(db_conversation)
|
||||
return db_conversation
|
||||
db.refresh(db_assistant)
|
||||
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
strategy_config=result.get("strategy_config"),
|
||||
success=result.get("success", False),
|
||||
)
|
||||
else:
|
||||
crew = get_trading_crew()
|
||||
result = crew.chat(user_message, history_for_crew)
|
||||
|
||||
assistant_content = result.get("response", "I couldn't process your request.")
|
||||
|
||||
db_conversation = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(db_conversation)
|
||||
|
||||
db_assistant = BotConversation(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
db.add(db_assistant)
|
||||
db.commit()
|
||||
db.refresh(db_assistant)
|
||||
|
||||
return BotChatResponse(
|
||||
response=assistant_content,
|
||||
strategy_config=result.get("strategy_config"),
|
||||
success=result.get("success", False),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
|
||||
|
||||
@@ -1,38 +1,233 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from typing import List, Dict, Any, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .auth import get_current_user
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
running_simulations: Dict[str, Any] = {}
|
||||
executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
@router.post("/bots/{bot_id}/simulate", response_model=SimulationResponse)
|
||||
def start_simulation(
|
||||
bot_id: str, config: SimulationCreate, db: Session = Depends(get_db)
|
||||
|
||||
def run_simulation_sync(
|
||||
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
import asyncio
|
||||
from ..services.simulate.engine import SimulateEngine
|
||||
from ..core.database import SessionLocal
|
||||
|
||||
async def _run():
|
||||
engine = SimulateEngine(config)
|
||||
engine.run_id = simulation_id
|
||||
running_simulations[simulation_id] = engine
|
||||
try:
|
||||
results = await engine.run()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
simulation = (
|
||||
db.query(Simulation).filter(Simulation.id == simulation_id).first()
|
||||
)
|
||||
if simulation:
|
||||
simulation.status = engine.status
|
||||
simulation.signals = engine.signals
|
||||
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()
|
||||
finally:
|
||||
if simulation_id in running_simulations:
|
||||
del running_simulations[simulation_id]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bots/{bot_id}/simulate",
|
||||
response_model=SimulationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def start_simulation(
|
||||
bot_id: str,
|
||||
config: SimulationCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
"strategy_config": bot.strategy_config,
|
||||
"ave_api_key": settings.AVE_API_KEY,
|
||||
"ave_api_plan": settings.AVE_API_PLAN,
|
||||
}
|
||||
|
||||
simulation = Simulation(
|
||||
id=simulation_id,
|
||||
bot_id=bot_id,
|
||||
started_at=datetime.utcnow(),
|
||||
status="running",
|
||||
config={
|
||||
"token": config.token,
|
||||
"chain": config.chain,
|
||||
"duration_seconds": config.duration_seconds,
|
||||
"check_interval": check_interval,
|
||||
"auto_execute": config.auto_execute,
|
||||
},
|
||||
signals=[],
|
||||
)
|
||||
db.add(simulation)
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
|
||||
db_url = str(settings.DATABASE_URL)
|
||||
background_tasks.add_task(
|
||||
run_simulation_sync, simulation_id, db_url, bot_id, simulation_config
|
||||
)
|
||||
|
||||
return simulation
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/simulate/{run_id}", response_model=SimulationResponse)
|
||||
def get_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
||||
def get_simulation(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
simulation = (
|
||||
db.query(Simulation)
|
||||
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
|
||||
.first()
|
||||
)
|
||||
if not simulation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||
)
|
||||
|
||||
if run_id in running_simulations:
|
||||
engine = running_simulations[run_id]
|
||||
simulation.signals = engine.get_signals()
|
||||
|
||||
return simulation
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/simulations", response_model=List[SimulationResponse])
|
||||
def list_simulations(bot_id: str, db: Session = Depends(get_db)):
|
||||
def list_simulations(
|
||||
bot_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
simulations = (
|
||||
db.query(Simulation)
|
||||
.filter(Simulation.bot_id == bot_id)
|
||||
.order_by(Simulation.started_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for sim in simulations:
|
||||
if sim.id in running_simulations:
|
||||
engine = running_simulations[sim.id]
|
||||
sim.signals = engine.get_signals()
|
||||
|
||||
return simulations
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/simulate/{run_id}/stop")
|
||||
def stop_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)):
|
||||
def stop_simulation(
|
||||
bot_id: str,
|
||||
run_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bot = db.query(Bot).filter(Bot.id == bot_id).first()
|
||||
if not bot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
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"
|
||||
)
|
||||
|
||||
simulation = (
|
||||
db.query(Simulation)
|
||||
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
|
||||
.first()
|
||||
)
|
||||
if not simulation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
|
||||
)
|
||||
|
||||
if run_id in running_simulations:
|
||||
engine = running_simulations[run_id]
|
||||
asyncio.create_task(engine.stop())
|
||||
simulation.status = "stopped"
|
||||
db.commit()
|
||||
|
||||
return {"status": "stopping", "run_id": run_id}
|
||||
|
||||
@@ -25,6 +25,7 @@ class User(Base):
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
email = Column(String, unique=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
tier = Column(String, default="free")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class SimulationCreate(BaseModel):
|
||||
token: str
|
||||
chain: str
|
||||
duration_seconds: int = 3600
|
||||
check_interval: int = 60
|
||||
auto_execute: bool = False
|
||||
|
||||
|
||||
@@ -118,6 +119,17 @@ class BotConversationResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BotChatRequest(BaseModel):
|
||||
message: str
|
||||
strategy_config: Optional[bool] = False
|
||||
|
||||
|
||||
class BotChatResponse(BaseModel):
|
||||
response: str
|
||||
strategy_config: Optional[dict] = None
|
||||
success: bool = False
|
||||
|
||||
|
||||
class SignalResponse(BaseModel):
|
||||
id: str
|
||||
bot_id: str
|
||||
@@ -132,3 +144,72 @@ class SignalResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AveTokenSearchResponse(BaseModel):
|
||||
tokens: List[dict]
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveBatchPricesRequest(BaseModel):
|
||||
token_ids: List[str]
|
||||
|
||||
|
||||
class AveBatchPricesResponse(BaseModel):
|
||||
prices: Dict[str, dict]
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveTokenDetailsResponse(BaseModel):
|
||||
token: Optional[dict] = None
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveKlinesRequest(BaseModel):
|
||||
token_id: str
|
||||
interval: str = "1h"
|
||||
limit: int = 100
|
||||
start_time: Optional[int] = None
|
||||
end_time: Optional[int] = None
|
||||
|
||||
|
||||
class AveKlinesResponse(BaseModel):
|
||||
klines: List[dict]
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveTrendingTokensResponse(BaseModel):
|
||||
tokens: List[dict]
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveTokenRiskResponse(BaseModel):
|
||||
risk: Optional[dict] = None
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveChainQuoteRequest(BaseModel):
|
||||
chain: str
|
||||
from_token: str
|
||||
to_token: str
|
||||
amount: str
|
||||
slippage: float = 0.5
|
||||
|
||||
|
||||
class AveChainQuoteResponse(BaseModel):
|
||||
quote: Optional[dict] = None
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
|
||||
class AveChainSwapRequest(BaseModel):
|
||||
chain: str
|
||||
from_token: str
|
||||
to_token: str
|
||||
amount: str
|
||||
slippage: float = 0.5
|
||||
wallet_address: Optional[str] = None
|
||||
|
||||
|
||||
class AveChainSwapResponse(BaseModel):
|
||||
swap: Optional[dict] = None
|
||||
upsell_message: Optional[str] = None
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from .api import auth, bots, backtest, simulate, config
|
||||
from .api import auth, bots, backtest, simulate, config, ave
|
||||
from .core.limiter import limiter
|
||||
|
||||
app = FastAPI(
|
||||
@@ -26,6 +26,7 @@ app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
|
||||
app.include_router(backtest.router, prefix="/api", tags=["backtest"])
|
||||
app.include_router(simulate.router, prefix="/api", tags=["simulate"])
|
||||
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
||||
app.include_router(ave.router, prefix="/api/ave", tags=["ave"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,15 +1,247 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
from crewai import Agent, Task, Crew
|
||||
from .llm_connector import MiniMaxConnector, MiniMaxLLM
|
||||
from ..core.config import get_settings
|
||||
|
||||
|
||||
class CrewAgent:
|
||||
def __init__(self, role: str, goal: str, backstory: str):
|
||||
self.role = role
|
||||
self.goal = goal
|
||||
self.backstory = backstory
|
||||
class StrategyValidator:
|
||||
SUPPORTED_CONDITIONS = ["price_drop", "price_rise", "volume_spike", "price_level"]
|
||||
SUPPORTED_ACTIONS = ["buy", "sell", "notify"]
|
||||
|
||||
def execute_task(self, task: str) -> str:
|
||||
raise NotImplementedError("CrewAI agent not yet implemented")
|
||||
def validate(self, strategy_config: dict) -> tuple[bool, list[str]]:
|
||||
errors = []
|
||||
|
||||
if "conditions" not in strategy_config:
|
||||
errors.append("Missing 'conditions' in strategy config")
|
||||
return False, errors
|
||||
|
||||
if not isinstance(strategy_config["conditions"], list):
|
||||
errors.append("'conditions' must be a list")
|
||||
return False, errors
|
||||
|
||||
if len(strategy_config["conditions"]) == 0:
|
||||
errors.append("At least one condition is required")
|
||||
return False, errors
|
||||
|
||||
for i, condition in enumerate(strategy_config["conditions"]):
|
||||
if "type" not in condition:
|
||||
errors.append(f"Condition {i}: missing 'type'")
|
||||
continue
|
||||
|
||||
cond_type = condition.get("type")
|
||||
if cond_type not in self.SUPPORTED_CONDITIONS:
|
||||
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
|
||||
continue
|
||||
|
||||
params = condition.get("params", {})
|
||||
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
|
||||
if "token" not in params:
|
||||
errors.append(f"Condition {i}: missing 'token'")
|
||||
if "threshold_percent" not in params:
|
||||
errors.append(f"Condition {i}: missing 'threshold_percent'")
|
||||
elif not isinstance(params["threshold_percent"], (int, float)):
|
||||
errors.append(
|
||||
f"Condition {i}: 'threshold_percent' must be a number"
|
||||
)
|
||||
elif params["threshold_percent"] <= 0:
|
||||
errors.append(
|
||||
f"Condition {i}: 'threshold_percent' must be positive"
|
||||
)
|
||||
|
||||
elif cond_type == "price_level":
|
||||
if "token" not in params:
|
||||
errors.append(f"Condition {i}: missing 'token'")
|
||||
if "price" not in params:
|
||||
errors.append(f"Condition {i}: missing 'price'")
|
||||
if "direction" not in params:
|
||||
errors.append(f"Condition {i}: missing 'direction'")
|
||||
elif params["direction"] not in ["above", "below"]:
|
||||
errors.append(
|
||||
f"Condition {i}: direction must be 'above' or 'below'"
|
||||
)
|
||||
|
||||
if "actions" in strategy_config:
|
||||
if not isinstance(strategy_config["actions"], list):
|
||||
errors.append("'actions' must be a list")
|
||||
else:
|
||||
for i, action in enumerate(strategy_config["actions"]):
|
||||
if "type" not in action:
|
||||
errors.append(f"Action {i}: missing 'type'")
|
||||
elif action["type"] not in self.SUPPORTED_ACTIONS:
|
||||
errors.append(
|
||||
f"Action {i}: unsupported type '{action['type']}'"
|
||||
)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def get_trading_crew():
|
||||
raise NotImplementedError("Trading crew not yet implemented")
|
||||
class StrategyExplainer:
|
||||
def explain(self, strategy_config: dict) -> str:
|
||||
explanations = []
|
||||
|
||||
if "conditions" in strategy_config:
|
||||
cond_list = strategy_config["conditions"]
|
||||
if cond_list:
|
||||
explanations.append("This strategy will trigger when:")
|
||||
for cond in cond_list:
|
||||
cond_type = cond.get("type")
|
||||
params = cond.get("params", {})
|
||||
token = params.get("token", "the token")
|
||||
|
||||
if cond_type == "price_drop":
|
||||
pct = params.get("threshold_percent", 0)
|
||||
explanations.append(f" - {token} price drops by {pct}%")
|
||||
elif cond_type == "price_rise":
|
||||
pct = params.get("threshold_percent", 0)
|
||||
explanations.append(f" - {token} price rises by {pct}%")
|
||||
elif cond_type == "volume_spike":
|
||||
pct = params.get("threshold_percent", 0)
|
||||
explanations.append(
|
||||
f" - {token} trading volume increases by {pct}%"
|
||||
)
|
||||
elif cond_type == "price_level":
|
||||
price = params.get("price", 0)
|
||||
direction = params.get("direction", "unknown")
|
||||
explanations.append(
|
||||
f" - {token} price crosses {direction} ${price}"
|
||||
)
|
||||
|
||||
if "actions" in strategy_config:
|
||||
actions = strategy_config.get("actions", [])
|
||||
if actions:
|
||||
explanations.append("\nWhen triggered, the strategy will:")
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
if action_type == "buy":
|
||||
explanations.append(" - Buy the token")
|
||||
elif action_type == "sell":
|
||||
explanations.append(" - Sell the token")
|
||||
elif action_type == "notify":
|
||||
explanations.append(" - Send a notification")
|
||||
|
||||
if not explanations:
|
||||
explanations.append("Strategy configuration is empty or invalid.")
|
||||
|
||||
return "\n".join(explanations)
|
||||
|
||||
|
||||
def create_trading_designer_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
) -> Agent:
|
||||
connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||
|
||||
system_prompt = """You are a Trading Strategy Designer AI. Your role is to parse user requests
|
||||
for trading strategies into structured JSON configuration.
|
||||
|
||||
Supported conditions (MVP):
|
||||
- price_drop: Triggers when a token's price drops by a specified percentage
|
||||
- price_rise: Triggers when a token's price rises by a specified percentage
|
||||
- volume_spike: Triggers when trading volume increases by a specified percentage
|
||||
- price_level: Triggers when price crosses above or below a specified level
|
||||
|
||||
Always ask clarifying questions if the user's request is ambiguous.
|
||||
Output strategy_config in valid JSON format only when you have all required information.
|
||||
"""
|
||||
|
||||
return Agent(
|
||||
role="Trading Strategy Designer",
|
||||
goal="Convert natural language trading requests into precise strategy configurations",
|
||||
backstory=system_prompt,
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
def create_strategy_validator_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
role="Strategy Validator",
|
||||
goal="Validate trading strategy configurations for feasibility and identify potential issues",
|
||||
backstory="""You are a meticulous strategy validator with expertise in trading systems.
|
||||
You check that all required parameters are present, values are reasonable, and the
|
||||
strategy makes logical sense. You never approve strategies with missing or invalid data.""",
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
def create_strategy_explainer_agent(
|
||||
api_key: str, model: str = "MiniMax-Text-01"
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
role="Strategy Explainer",
|
||||
goal="Generate clear, user-friendly explanations of trading strategies",
|
||||
backstory="""You are a patient trading strategy explainer. You translate complex
|
||||
strategy configurations into easy-to-understand language. You help users understand
|
||||
exactly what their strategies will do when triggered.""",
|
||||
llm=MiniMaxLLM(api_key=api_key, model=model),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
|
||||
class TradingCrew:
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.validator = StrategyValidator()
|
||||
self.explainer = StrategyExplainer()
|
||||
self.connector = MiniMaxConnector(api_key=api_key, model=model)
|
||||
|
||||
def parse_strategy(
|
||||
self, user_message: str, conversation_history: list[dict] = None
|
||||
) -> dict:
|
||||
strategy_config = self.connector.parse_strategy(
|
||||
user_message, conversation_history
|
||||
)
|
||||
|
||||
if "error" in strategy_config:
|
||||
return strategy_config
|
||||
|
||||
is_valid, errors = self.validator.validate(strategy_config)
|
||||
if not is_valid:
|
||||
return {
|
||||
"error": "Strategy validation failed",
|
||||
"validation_errors": errors,
|
||||
"partial_config": strategy_config,
|
||||
}
|
||||
|
||||
return strategy_config
|
||||
|
||||
def explain_strategy(self, strategy_config: dict) -> str:
|
||||
return self.explainer.explain(strategy_config)
|
||||
|
||||
def chat(self, user_message: str, conversation_history: list[dict] = None) -> dict:
|
||||
strategy_config = self.parse_strategy(user_message, conversation_history)
|
||||
|
||||
if "error" in strategy_config:
|
||||
explanation = f"I had trouble understanding your strategy: {strategy_config.get('error', 'Unknown error')}"
|
||||
if "validation_errors" in strategy_config:
|
||||
explanation += "\n\nValidation issues:"
|
||||
for err in strategy_config["validation_errors"]:
|
||||
explanation += f"\n - {err}"
|
||||
return {
|
||||
"response": explanation,
|
||||
"strategy_config": strategy_config.get("partial_config"),
|
||||
"success": False,
|
||||
}
|
||||
|
||||
explanation = self.explain_strategy(strategy_config)
|
||||
return {
|
||||
"response": f"I've configured your strategy:\n\n{explanation}",
|
||||
"strategy_config": strategy_config,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
def get_trading_crew(
|
||||
api_key: Optional[str] = None, model: Optional[str] = None
|
||||
) -> TradingCrew:
|
||||
if api_key is None:
|
||||
settings = get_settings()
|
||||
api_key = settings.MINIMAX_API_KEY
|
||||
if model is None:
|
||||
settings = get_settings()
|
||||
model = settings.MINIMAX_MODEL
|
||||
|
||||
return TradingCrew(api_key=api_key, model=model)
|
||||
|
||||
@@ -1,13 +1,108 @@
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
import httpx
|
||||
from crewai import LLM
|
||||
|
||||
|
||||
class LLMConnector:
|
||||
class MiniMaxLLM(LLM):
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = "https://api.minimax.chat/v1"
|
||||
|
||||
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": kwargs.get("temperature", 0.7),
|
||||
"max_tokens": kwargs.get("max_tokens", 2048),
|
||||
}
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
|
||||
def call(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
||||
return self._call(messages, **kwargs)
|
||||
|
||||
|
||||
class MiniMaxConnector:
|
||||
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
def chat(self, messages: list[dict], **kwargs):
|
||||
raise NotImplementedError("LLM integration not yet implemented")
|
||||
def chat(self, messages: list[dict], **kwargs) -> str:
|
||||
formatted_messages = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
formatted_messages.append(
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", str(msg)),
|
||||
}
|
||||
)
|
||||
else:
|
||||
formatted_messages.append({"role": "user", "content": str(msg)})
|
||||
|
||||
def parse_strategy(self, user_message: str) -> dict:
|
||||
raise NotImplementedError("Strategy parsing not yet implemented")
|
||||
llm = MiniMaxLLM(api_key=self.api_key, model=self.model)
|
||||
return llm.call(formatted_messages, **kwargs)
|
||||
|
||||
def parse_strategy(
|
||||
self, user_message: str, conversation_history: list[dict] = None
|
||||
) -> dict:
|
||||
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
|
||||
|
||||
Supported conditions (MVP):
|
||||
- price_drop: Token price drops by X% (requires: token, threshold_percent)
|
||||
- price_rise: Token price rises by X% (requires: token, threshold_percent)
|
||||
- volume_spike: Trading volume increases X% (requires: token, threshold_percent)
|
||||
- price_level: Price crosses above/below X (requires: token, price, direction)
|
||||
|
||||
Output ONLY valid JSON with this schema:
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"type": "price_drop|price_rise|volume_spike|price_level",
|
||||
"params": {
|
||||
"token": "TOKEN_SYMBOL",
|
||||
"threshold_percent": number, // for price_drop, price_rise, volume_spike
|
||||
"price": number, // for price_level
|
||||
"direction": "above|below" // for price_level
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "buy|sell|notify",
|
||||
"params": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
If the user wants a condition not in the supported list, ask for clarification.
|
||||
"""
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
if conversation_history:
|
||||
for msg in conversation_history:
|
||||
messages.append(
|
||||
{"role": msg.get("role", "user"), "content": msg.get("content", "")}
|
||||
)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
response = self.chat(messages)
|
||||
try:
|
||||
import json
|
||||
|
||||
result = json.loads(response)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Failed to parse strategy", "raw_response": response}
|
||||
|
||||
3
src/backend/app/services/ave/__init__.py
Normal file
3
src/backend/app/services/ave/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import AveCloudClient, check_tier_access
|
||||
|
||||
__all__ = ["AveCloudClient", "check_tier_access"]
|
||||
213
src/backend/app/services/ave/client.py
Normal file
213
src/backend/app/services/ave/client.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AveCloudClient:
|
||||
DATA_API_URL = "https://prod.ave-api.com"
|
||||
TRADING_API_URL = "https://bot-api.ave.ai"
|
||||
|
||||
def __init__(self, api_key: str, plan: str = "free"):
|
||||
self.api_key = api_key
|
||||
self.plan = plan
|
||||
|
||||
def _data_headers(self) -> Dict[str, str]:
|
||||
return {"X-API-KEY": self.api_key}
|
||||
|
||||
def _trading_headers(self) -> Dict[str, str]:
|
||||
return {"X-API-KEY": self.api_key, "Content-Type": "application/json"}
|
||||
|
||||
async def get_tokens(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
chain: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens"
|
||||
params = {"limit": limit}
|
||||
if query:
|
||||
params["query"] = query
|
||||
if chain:
|
||||
params["chain"] = chain
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
raise Exception(f"Failed to fetch tokens: {data}")
|
||||
|
||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/price"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self._data_headers(),
|
||||
json={"token_ids": token_ids},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", {})
|
||||
return {}
|
||||
|
||||
async def get_token_details(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/{token_id}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data")
|
||||
return None
|
||||
|
||||
async def get_klines(
|
||||
self,
|
||||
token_id: str,
|
||||
interval: str = "1h",
|
||||
limit: int = 100,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
|
||||
params = {"interval": interval, "limit": limit}
|
||||
if start_time:
|
||||
params["start_time"] = start_time
|
||||
if end_time:
|
||||
params["end_time"] = end_time
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
raise Exception(f"Failed to fetch klines: {data}")
|
||||
|
||||
async def get_trending_tokens(
|
||||
self, chain: Optional[str] = None, limit: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/tokens/trending"
|
||||
params = {"limit": limit}
|
||||
if chain:
|
||||
params["chain"] = chain
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self._data_headers(), params=params, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
raise Exception(f"Failed to fetch trending tokens: {data}")
|
||||
|
||||
async def get_token_risk(self, contract_id: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.DATA_API_URL}/v2/contracts/{contract_id}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data")
|
||||
return None
|
||||
|
||||
async def get_chain_quote(
|
||||
self,
|
||||
chain: str,
|
||||
from_token: str,
|
||||
to_token: str,
|
||||
amount: str,
|
||||
slippage: float = 0.5,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.TRADING_API_URL}/v1/chain/quote"
|
||||
payload = {
|
||||
"chain": chain,
|
||||
"from_token": from_token,
|
||||
"to_token": to_token,
|
||||
"amount": amount,
|
||||
"slippage": slippage,
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url, headers=self._trading_headers(), json=payload, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data")
|
||||
return None
|
||||
|
||||
async def get_chain_swap(
|
||||
self,
|
||||
chain: str,
|
||||
from_token: str,
|
||||
to_token: str,
|
||||
amount: str,
|
||||
slippage: float = 0.5,
|
||||
wallet_address: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.TRADING_API_URL}/v1/chain/swap"
|
||||
payload = {
|
||||
"chain": chain,
|
||||
"from_token": from_token,
|
||||
"to_token": to_token,
|
||||
"amount": amount,
|
||||
"slippage": slippage,
|
||||
}
|
||||
if wallet_address:
|
||||
payload["wallet_address"] = wallet_address
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url, headers=self._trading_headers(), json=payload, timeout=60.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data")
|
||||
return None
|
||||
|
||||
|
||||
def check_tier_access(user_tier: str, feature: str) -> tuple[bool, Optional[str]]:
|
||||
tier_access = {
|
||||
"free": {
|
||||
"data_rest": True,
|
||||
"websocket": False,
|
||||
"chain_wallet": True,
|
||||
"proxy_wallet": False,
|
||||
},
|
||||
"normal": {
|
||||
"data_rest": True,
|
||||
"websocket": False,
|
||||
"chain_wallet": True,
|
||||
"proxy_wallet": True,
|
||||
},
|
||||
"pro": {
|
||||
"data_rest": True,
|
||||
"websocket": True,
|
||||
"chain_wallet": True,
|
||||
"proxy_wallet": True,
|
||||
},
|
||||
}
|
||||
|
||||
if user_tier not in tier_access:
|
||||
user_tier = "free"
|
||||
|
||||
access = tier_access[user_tier]
|
||||
if access.get(feature):
|
||||
return True, None
|
||||
|
||||
upsell_messages = {
|
||||
"websocket": "Upgrade to Pro plan to access WebSocket streaming data. Visit your account settings.",
|
||||
"proxy_wallet": "Upgrade to Normal or Pro plan to access Proxy Wallet functionality. Visit your account settings.",
|
||||
}
|
||||
|
||||
return False, upsell_messages.get(
|
||||
feature, "Upgrade your plan to access this feature."
|
||||
)
|
||||
70
src/backend/app/services/backtest/ave_client.py
Normal file
70
src/backend/app/services/backtest/ave_client.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AveCloudClient:
|
||||
BASE_URL = "https://prod.ave-api.com"
|
||||
|
||||
def __init__(self, api_key: str, plan: str = "free"):
|
||||
self.api_key = api_key
|
||||
self.plan = plan
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {"X-API-KEY": self.api_key}
|
||||
|
||||
async def get_klines(
|
||||
self,
|
||||
token_id: str,
|
||||
interval: str = "1h",
|
||||
limit: int = 100,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = f"{self.BASE_URL}/v2/klines/token/{token_id}"
|
||||
params = {"interval": interval, "limit": limit}
|
||||
if start_time:
|
||||
params["start_time"] = start_time
|
||||
if end_time:
|
||||
params["end_time"] = end_time
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self._headers(), params=params, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", [])
|
||||
raise Exception(f"Failed to fetch klines: {data}")
|
||||
|
||||
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self._headers(),
|
||||
json={"token_ids": [token_id]},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
prices = data.get("data", {})
|
||||
return prices.get(token_id)
|
||||
return None
|
||||
|
||||
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
url = f"{self.BASE_URL}/v2/tokens/price"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self._headers(),
|
||||
json={"token_ids": token_ids},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") == 200:
|
||||
return data.get("data", {})
|
||||
return {}
|
||||
@@ -1,15 +1,324 @@
|
||||
from typing import Optional, Dict, Any
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from .ave_client import AveCloudClient
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.run_id = str(uuid.uuid4())
|
||||
self.status = "pending"
|
||||
self.results: Optional[Dict[str, Any]] = None
|
||||
self.signals: List[Dict[str, Any]] = []
|
||||
self.ave_client = AveCloudClient(
|
||||
api_key=config.get("ave_api_key", ""),
|
||||
plan=config.get("ave_api_plan", "free"),
|
||||
)
|
||||
self.bot_id = config.get("bot_id")
|
||||
self.strategy_config = config.get("strategy_config", {})
|
||||
self.conditions = self.strategy_config.get("conditions", [])
|
||||
self.actions = self.strategy_config.get("actions", [])
|
||||
self.initial_balance = config.get("initial_balance", 10000.0)
|
||||
self.current_balance = self.initial_balance
|
||||
self.position = 0.0
|
||||
self.position_token = ""
|
||||
self.trades: List[Dict[str, Any]] = []
|
||||
self.running = False
|
||||
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError("Backtest engine not yet implemented")
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
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
|
||||
)
|
||||
|
||||
if not token_id or token_id == f"-{chain}":
|
||||
raise ValueError("Token ID is required")
|
||||
|
||||
start_ts = None
|
||||
end_ts = None
|
||||
if start_date:
|
||||
start_ts = int(
|
||||
datetime.fromisoformat(
|
||||
start_date.replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
* 1000
|
||||
)
|
||||
if end_date:
|
||||
end_ts = int(
|
||||
datetime.fromisoformat(end_date.replace("Z", "+00:00")).timestamp()
|
||||
* 1000
|
||||
)
|
||||
|
||||
klines = await self.ave_client.get_klines(
|
||||
token_id=token_id,
|
||||
interval=timeframe,
|
||||
limit=1000,
|
||||
start_time=start_ts,
|
||||
end_time=end_ts,
|
||||
)
|
||||
|
||||
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]]):
|
||||
for i, kline in enumerate(klines):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
price = float(kline.get("close", 0))
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
timestamp = kline.get("timestamp", 0)
|
||||
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, klines, i, price):
|
||||
await self._execute_actions(price, timestamp, condition)
|
||||
break
|
||||
|
||||
def _check_condition(
|
||||
self,
|
||||
condition: Dict[str, Any],
|
||||
klines: List[Dict[str, Any]],
|
||||
current_idx: int,
|
||||
current_price: float,
|
||||
) -> bool:
|
||||
cond_type = condition.get("type", "")
|
||||
threshold = condition.get("threshold", 0)
|
||||
timeframe = condition.get("timeframe", "1h")
|
||||
price_level = condition.get("price")
|
||||
direction = condition.get("direction", "above")
|
||||
|
||||
if cond_type == "price_drop":
|
||||
if current_idx == 0:
|
||||
return False
|
||||
prev_price = float(klines[current_idx - 1].get("close", 0))
|
||||
if prev_price <= 0:
|
||||
return False
|
||||
drop_pct = ((prev_price - current_price) / prev_price) * 100
|
||||
return drop_pct >= threshold
|
||||
|
||||
elif cond_type == "price_rise":
|
||||
if current_idx == 0:
|
||||
return False
|
||||
prev_price = float(klines[current_idx - 1].get("close", 0))
|
||||
if prev_price <= 0:
|
||||
return False
|
||||
rise_pct = ((current_price - prev_price) / prev_price) * 100
|
||||
return rise_pct >= threshold
|
||||
|
||||
elif cond_type == "volume_spike":
|
||||
if current_idx == 0:
|
||||
return False
|
||||
prev_volume = float(klines[current_idx - 1].get("volume", 0))
|
||||
current_volume = float(kline.get("volume", 0))
|
||||
if prev_volume <= 0:
|
||||
return False
|
||||
volume_increase = ((current_volume - prev_volume) / prev_volume) * 100
|
||||
return volume_increase >= threshold
|
||||
|
||||
elif cond_type == "price_level":
|
||||
if price_level is None:
|
||||
return False
|
||||
if direction == "above":
|
||||
return current_price > price_level
|
||||
else:
|
||||
return current_price < price_level
|
||||
|
||||
return False
|
||||
|
||||
async def _execute_actions(
|
||||
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||
):
|
||||
token = matched_condition.get("token", self.config.get("token", ""))
|
||||
|
||||
for action in self.actions:
|
||||
action_type = action.get("type", "")
|
||||
amount_percent = action.get("amount_percent", 10)
|
||||
amount = self.current_balance * (amount_percent / 100)
|
||||
|
||||
if action_type == "buy" and self.current_balance >= amount:
|
||||
self.position += amount / price
|
||||
self.current_balance -= amount
|
||||
self.position_token = token
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "buy",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"amount": amount,
|
||||
"quantity": amount / price,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
self.signals.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "buy",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"confidence": 0.8,
|
||||
"reasoning": f"Condition {matched_condition.get('type')} triggered buy",
|
||||
"executed": False,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
elif action_type == "sell" and self.position > 0:
|
||||
sell_amount = self.position * price
|
||||
self.current_balance += sell_amount
|
||||
self.trades.append(
|
||||
{
|
||||
"type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"amount": sell_amount,
|
||||
"quantity": self.position,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
self.position = 0
|
||||
self.signals.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "sell",
|
||||
"token": self.position_token,
|
||||
"price": price,
|
||||
"confidence": 0.8,
|
||||
"reasoning": f"Condition {matched_condition.get('type')} triggered sell",
|
||||
"executed": False,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
def _calculate_metrics(self):
|
||||
final_balance = self.current_balance + (
|
||||
self.position * self.trades[-1]["price"]
|
||||
if self.trades and self.position > 0
|
||||
else 0
|
||||
)
|
||||
total_return = (
|
||||
(final_balance - self.initial_balance) / self.initial_balance
|
||||
) * 100
|
||||
|
||||
buy_trades = [t for t in self.trades if t["type"] == "buy"]
|
||||
sell_trades = [t for t in self.trades if t["type"] == "sell"]
|
||||
total_trades = len(buy_trades) + len(sell_trades)
|
||||
|
||||
winning_trades = 0
|
||||
for i, trade in enumerate(sell_trades):
|
||||
if i < len(buy_trades):
|
||||
buy_price = buy_trades[i]["price"]
|
||||
sell_price = trade["price"]
|
||||
if sell_price > buy_price:
|
||||
winning_trades += 1
|
||||
|
||||
win_rate = (winning_trades / len(sell_trades) * 100) if sell_trades else 0
|
||||
|
||||
portfolio_values = []
|
||||
running_balance = self.initial_balance
|
||||
running_position = 0.0
|
||||
current_token = ""
|
||||
last_price = 0.0
|
||||
|
||||
for trade in self.trades:
|
||||
if trade["type"] == "buy":
|
||||
running_position = trade["quantity"]
|
||||
running_balance = trade["amount"]
|
||||
current_token = trade["token"]
|
||||
last_price = trade["price"]
|
||||
else:
|
||||
running_balance = trade["amount"]
|
||||
running_position = 0
|
||||
last_price = trade["price"]
|
||||
|
||||
portfolio_value = running_balance + (running_position * last_price)
|
||||
portfolio_values.append(portfolio_value)
|
||||
|
||||
max_value = self.initial_balance
|
||||
max_drawdown = 0.0
|
||||
for value in portfolio_values:
|
||||
if value > max_value:
|
||||
max_value = value
|
||||
drawdown = ((max_value - value) / max_value) * 100
|
||||
if drawdown > max_drawdown:
|
||||
max_drawdown = drawdown
|
||||
|
||||
sharpe_ratio = 0.0
|
||||
if len(portfolio_values) > 1:
|
||||
returns = []
|
||||
for i in range(1, len(portfolio_values)):
|
||||
ret = (
|
||||
portfolio_values[i] - portfolio_values[i - 1]
|
||||
) / portfolio_values[i - 1]
|
||||
returns.append(ret)
|
||||
if returns:
|
||||
avg_return = sum(returns) / len(returns)
|
||||
variance = sum((r - avg_return) ** 2 for r in returns) / len(returns)
|
||||
std_dev = variance**0.5
|
||||
if std_dev > 0:
|
||||
sharpe_ratio = avg_return / std_dev
|
||||
|
||||
buy_signals = len(buy_trades)
|
||||
sell_signals = len(sell_trades)
|
||||
|
||||
self.results = {
|
||||
"total_return": round(total_return, 2),
|
||||
"win_rate": round(win_rate, 2),
|
||||
"total_trades": total_trades,
|
||||
"buy_signals": buy_signals,
|
||||
"sell_signals": sell_signals,
|
||||
"max_drawdown": round(max_drawdown, 2),
|
||||
"sharpe_ratio": round(sharpe_ratio, 2),
|
||||
"final_balance": round(final_balance, 2),
|
||||
"signals": self.signals,
|
||||
}
|
||||
|
||||
async def stop(self):
|
||||
raise NotImplementedError("Backtest stop not yet implemented")
|
||||
self.running = False
|
||||
self.status = "stopped"
|
||||
self._calculate_metrics()
|
||||
|
||||
def get_results(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError("Backtest results not yet implemented")
|
||||
return {
|
||||
"id": self.run_id,
|
||||
"status": self.status,
|
||||
"results": self.results,
|
||||
"signals": self.signals,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,177 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from ..backtest.ave_client import AveCloudClient
|
||||
|
||||
|
||||
class SimulateEngine:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.run_id = str(uuid.uuid4())
|
||||
self.status = "pending"
|
||||
self.results: Optional[Dict[str, Any]] = None
|
||||
self.signals: List[Dict[str, Any]] = []
|
||||
self.ave_client = AveCloudClient(
|
||||
api_key=config.get("ave_api_key", ""),
|
||||
plan=config.get("ave_api_plan", "free"),
|
||||
)
|
||||
self.bot_id = config.get("bot_id")
|
||||
self.strategy_config = config.get("strategy_config", {})
|
||||
self.conditions = self.strategy_config.get("conditions", [])
|
||||
self.actions = self.strategy_config.get("actions", [])
|
||||
self.check_interval = config.get("check_interval", 60)
|
||||
self.duration_seconds = config.get("duration_seconds", 3600)
|
||||
self.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
|
||||
self.last_volume: Optional[float] = None
|
||||
|
||||
async def run(self) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError("Simulation engine not yet implemented")
|
||||
async def run(self) -> Dict[str, Any]:
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
self.started_at = datetime.utcnow()
|
||||
|
||||
token_id = (
|
||||
f"{self.token}-{self.chain}"
|
||||
if self.token and not self.token.endswith(f"-{self.chain}")
|
||||
else self.token
|
||||
)
|
||||
|
||||
if not token_id or token_id == f"-{self.chain}":
|
||||
self.status = "failed"
|
||||
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))
|
||||
|
||||
if current_price > 0:
|
||||
await self._check_conditions(
|
||||
current_price, current_volume, price_data
|
||||
)
|
||||
|
||||
self.last_price = current_price
|
||||
self.last_volume = current_volume
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
for _ in range(self.check_interval):
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if self.running:
|
||||
self.status = "completed"
|
||||
else:
|
||||
self.status = "stopped"
|
||||
|
||||
except Exception as e:
|
||||
self.status = "failed"
|
||||
self.results = {"error": str(e)}
|
||||
|
||||
self.results = self.results or {}
|
||||
self.results["total_signals"] = len(self.signals)
|
||||
self.results["signals"] = self.signals
|
||||
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)
|
||||
|
||||
for condition in self.conditions:
|
||||
if self._check_condition(condition, current_price, current_volume):
|
||||
await self._execute_actions(current_price, timestamp, condition)
|
||||
break
|
||||
|
||||
def _check_condition(
|
||||
self,
|
||||
condition: Dict[str, Any],
|
||||
current_price: float,
|
||||
current_volume: float,
|
||||
) -> bool:
|
||||
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:
|
||||
return False
|
||||
drop_pct = ((self.last_price - current_price) / self.last_price) * 100
|
||||
return drop_pct >= threshold
|
||||
|
||||
elif cond_type == "price_rise":
|
||||
if self.last_price is None or self.last_price <= 0:
|
||||
return False
|
||||
rise_pct = ((current_price - self.last_price) / self.last_price) * 100
|
||||
return rise_pct >= threshold
|
||||
|
||||
elif cond_type == "volume_spike":
|
||||
if self.last_volume is None or self.last_volume <= 0:
|
||||
return False
|
||||
volume_increase = (
|
||||
(current_volume - self.last_volume) / self.last_volume
|
||||
) * 100
|
||||
return volume_increase >= threshold
|
||||
|
||||
elif cond_type == "price_level":
|
||||
if price_level is None:
|
||||
return False
|
||||
if direction == "above":
|
||||
return current_price > price_level
|
||||
else:
|
||||
return current_price < price_level
|
||||
|
||||
return False
|
||||
|
||||
async def _execute_actions(
|
||||
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
|
||||
):
|
||||
token = matched_condition.get("token", self.token)
|
||||
reasoning = f"Condition {matched_condition.get('type')} triggered"
|
||||
|
||||
signal = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"bot_id": self.bot_id,
|
||||
"run_id": self.run_id,
|
||||
"signal_type": "signal",
|
||||
"token": token,
|
||||
"price": price,
|
||||
"confidence": 0.8,
|
||||
"reasoning": reasoning,
|
||||
"executed": self.auto_execute,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
self.signals.append(signal)
|
||||
|
||||
async def stop(self):
|
||||
raise NotImplementedError("Simulation stop not yet implemented")
|
||||
self.running = False
|
||||
self.status = "stopped"
|
||||
|
||||
def get_results(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.run_id,
|
||||
"status": self.status,
|
||||
"results": self.results,
|
||||
"signals": self.signals,
|
||||
}
|
||||
|
||||
def get_signals(self) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError("Simulation signals not yet implemented")
|
||||
return self.signals
|
||||
|
||||
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
313
src/frontend/src/lib/components/BacktestChart.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import type { BacktestResult } from '$lib/api';
|
||||
|
||||
interface ChartDataPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
results: BacktestResult | null;
|
||||
signals?: Array<{ created_at: string; signal_type: string; price: number }>;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { results, signals = [], height = 300 }: Props = $props();
|
||||
|
||||
let width = $state(800);
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
$effect(() => {
|
||||
if (containerEl) {
|
||||
width = containerEl.clientWidth;
|
||||
}
|
||||
});
|
||||
|
||||
function generatePortfolioCurve(): ChartDataPoint[] {
|
||||
if (!results || signals.length === 0) return [];
|
||||
|
||||
const points: ChartDataPoint[] = [];
|
||||
const startValue = 10000;
|
||||
let currentValue = startValue;
|
||||
|
||||
const sortedSignals = [...signals].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
points.push({
|
||||
timestamp: sortedSignals[0]?.created_at || new Date().toISOString(),
|
||||
value: currentValue
|
||||
});
|
||||
|
||||
for (const signal of sortedSignals) {
|
||||
if (signal.signal_type === 'buy') {
|
||||
currentValue *= 1.05;
|
||||
} else if (signal.signal_type === 'sell') {
|
||||
currentValue *= 0.95;
|
||||
}
|
||||
points.push({
|
||||
timestamp: signal.created_at,
|
||||
value: currentValue
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
function getChartArea(w: number, h: number): { x: number; y: number; width: number; height: number } {
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
|
||||
return {
|
||||
x: padding.left,
|
||||
y: padding.top,
|
||||
width: w - padding.left - padding.right,
|
||||
height: h - padding.top - padding.bottom
|
||||
};
|
||||
}
|
||||
|
||||
function getValueRange(pts: ChartDataPoint[]): { min: number; max: number } {
|
||||
if (pts.length === 0) return { min: 0, max: 10000 };
|
||||
const values = pts.map(p => p.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const padding = (max - min) * 0.1 || 1000;
|
||||
return { min: min - padding, max: max + padding };
|
||||
}
|
||||
|
||||
function getPointPosition(point: ChartDataPoint, index: number, total: number, area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): { x: number; y: number } {
|
||||
const x = area.x + (index / Math.max(total - 1, 1)) * area.width;
|
||||
const normalizedValue = (point.value - range.min) / (range.max - range.min);
|
||||
const y = area.y + area.height - normalizedValue * area.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function getYAxisLabels(area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ value: number; y: number }> {
|
||||
const step = (range.max - range.min) / 4;
|
||||
return [0, 1, 2, 3, 4].map(i => ({
|
||||
value: range.max - i * step,
|
||||
y: area.y + (i / 4) * area.height
|
||||
}));
|
||||
}
|
||||
|
||||
function getXAxisLabels(pts: ChartDataPoint[], area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ label: string; x: number }> {
|
||||
if (pts.length === 0) return [];
|
||||
const step = Math.max(1, Math.floor(pts.length / 5));
|
||||
return pts
|
||||
.filter((_, i) => i % step === 0 || i === pts.length - 1)
|
||||
.map((p, i, arr) => ({
|
||||
label: new Date(p.timestamp).toLocaleDateString(),
|
||||
x: getPointPosition(p, pts.indexOf(p), pts.length, area, range).x
|
||||
}));
|
||||
}
|
||||
|
||||
function getReturnColor(): string {
|
||||
if (!results) return '#888';
|
||||
return results.total_return >= 0 ? '#22c55e' : '#ef4444';
|
||||
}
|
||||
|
||||
let points = $derived(generatePortfolioCurve());
|
||||
let area = $derived(getChartArea(width, height));
|
||||
let range = $derived(getValueRange(points));
|
||||
let yAxisLabels = $derived(getYAxisLabels(area, range));
|
||||
let xAxisLabels = $derived(getXAxisLabels(points, area, range));
|
||||
</script>
|
||||
|
||||
<div class="backtest-chart" bind:this={containerEl}>
|
||||
{#if !results}
|
||||
<div class="empty-state">
|
||||
<p>No backtest results to display</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="chart-header">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Return</span>
|
||||
<span class="metric-value" style="color: {getReturnColor()}">
|
||||
{results.total_return >= 0 ? '+' : ''}{results.total_return.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Win Rate</span>
|
||||
<span class="metric-value">{results.win_rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Trades</span>
|
||||
<span class="metric-value">{results.total_trades}</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Sharpe Ratio</span>
|
||||
<span class="metric-value">{results.sharpe_ratio.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg {width} {height} viewBox="0 0 {width} {height}">
|
||||
<defs>
|
||||
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.4)" />
|
||||
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="grid-lines">
|
||||
{#each [0, 1, 2, 3, 4] as i}
|
||||
{@const y = area.y + (i / 4) * area.height}
|
||||
<line
|
||||
x1={area.x}
|
||||
y1={y}
|
||||
x2={area.x + area.width}
|
||||
y2={y}
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
stroke-dasharray="4,4"
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="y-axis">
|
||||
{#each yAxisLabels as label}
|
||||
<text x={area.x - 8} y={label.y + 4} class="axis-label" text-anchor="end">
|
||||
${label.value.toLocaleString()}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="x-axis">
|
||||
{#each xAxisLabels as label}
|
||||
<text x={label.x} y={height - 8} class="axis-label" text-anchor="middle">
|
||||
{label.label}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
{#if points.length > 1}
|
||||
<path
|
||||
d={points.map((p, i) => {
|
||||
const pos = getPointPosition(p, i, points.length, area, range);
|
||||
if (i === 0) {
|
||||
return `M ${pos.x} ${area.y + area.height} L ${pos.x} ${pos.y}`;
|
||||
}
|
||||
return `L ${pos.x} ${pos.y}`;
|
||||
}).join(' ') + ` L ${getPointPosition(points[points.length - 1], points.length - 1, points.length, area, range).x} ${area.y + area.height} Z`}
|
||||
fill="url(#portfolioGradient)"
|
||||
/>
|
||||
|
||||
<path
|
||||
d={points.map((p, i) => {
|
||||
const pos = getPointPosition(p, i, points.length, area, range);
|
||||
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke="#667eea"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<div class="chart-footer">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Buy Signals</span>
|
||||
<span class="stat-value buy">{results.buy_signals}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Sell Signals</span>
|
||||
<span class="stat-value sell">{results.sell_signals}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Max Drawdown</span>
|
||||
<span class="stat-value negative">-{results.max_drawdown.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backtest-chart {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
font-size: 10px;
|
||||
fill: #666;
|
||||
}
|
||||
|
||||
.chart-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.buy {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.sell {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
127
src/frontend/src/lib/components/BotCard.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
bot: Bot;
|
||||
onOpen?: (botId: string) => void;
|
||||
onDelete?: (botId: string) => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
let { bot, onOpen, onDelete, showActions = true }: Props = $props();
|
||||
|
||||
function handleOpen() {
|
||||
onOpen?.(bot.id);
|
||||
}
|
||||
|
||||
function handleDelete(e: Event) {
|
||||
e.stopPropagation();
|
||||
onDelete?.(bot.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}>
|
||||
<div class="bot-info">
|
||||
<h3>{bot.name}</h3>
|
||||
{#if bot.description}
|
||||
<p class="bot-description">{bot.description}</p>
|
||||
{/if}
|
||||
<span class="bot-status status-{bot.status}">{bot.status}</span>
|
||||
</div>
|
||||
{#if showActions}
|
||||
<div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group">
|
||||
<button class="btn btn-primary" onclick={handleOpen}>Open</button>
|
||||
<button class="btn btn-danger" onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bot-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.bot-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.bot-card:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bot-info h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.bot-description {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.bot-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.bot-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
94
src/frontend/src/lib/components/BotSelector.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
bots: Bot[];
|
||||
selectedBotId?: string | null;
|
||||
onSelect: (botId: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { bots, selectedBotId = null, onSelect, disabled = false, label = 'Select Bot' }: Props = $props();
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
onSelect(target.value);
|
||||
}
|
||||
|
||||
const MAX_BOTS = 3;
|
||||
</script>
|
||||
|
||||
<div class="bot-selector">
|
||||
{#if label}
|
||||
<label for="bot-select">{label}</label>
|
||||
{/if}
|
||||
<div class="select-wrapper">
|
||||
<select
|
||||
id="bot-select"
|
||||
onchange={handleChange}
|
||||
disabled={disabled || bots.length === 0}
|
||||
value={selectedBotId || ''}
|
||||
>
|
||||
{#if bots.length === 0}
|
||||
<option value="" disabled>No bots available</option>
|
||||
{:else}
|
||||
{#each bots as bot}
|
||||
<option value={bot.id}>{bot.name}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<span class="bot-count">{bots.length}/{MAX_BOTS}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bot-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bot-count {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
300
src/frontend/src/lib/components/ChatInterface.svelte
Normal file
300
src/frontend/src/lib/components/ChatInterface.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
import type { ChatMessage } from '$lib/stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
bot: Bot | null;
|
||||
messages: ChatMessage[];
|
||||
isSending?: boolean;
|
||||
onSendMessage: (message: string) => void;
|
||||
onSelectBot?: (botId: string) => void;
|
||||
availableBots?: Bot[];
|
||||
showBotSelector?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
bot,
|
||||
messages,
|
||||
isSending = false,
|
||||
onSendMessage,
|
||||
onSelectBot,
|
||||
availableBots = [],
|
||||
showBotSelector = false
|
||||
}: Props = $props();
|
||||
|
||||
let messageInput = $state('');
|
||||
let chatContainer: HTMLDivElement;
|
||||
|
||||
function handleSend() {
|
||||
if (!messageInput.trim()) return;
|
||||
onSendMessage(messageInput);
|
||||
messageInput = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBotChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
if (onSelectBot && target.value) {
|
||||
onSelectBot(target.value);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.length && chatContainer) {
|
||||
setTimeout(() => {
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chat-interface">
|
||||
{#if showBotSelector && availableBots.length > 0}
|
||||
<div class="bot-selector">
|
||||
<label for="bot-select">Active Bot:</label>
|
||||
<select id="bot-select" onchange={handleBotChange}>
|
||||
{#each availableBots as availableBot}
|
||||
<option value={availableBot.id} selected={availableBot.id === bot?.id}>
|
||||
{availableBot.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="chat-messages" bind:this={chatContainer}>
|
||||
{#if messages.length === 0}
|
||||
<div class="welcome-message">
|
||||
<p>Welcome to {bot?.name || 'your bot'}! Describe your trading strategy in plain English.</p>
|
||||
<p class="hint">Example: "Buy PEPE when the price drops by 5% within 1 hour"</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as message}
|
||||
<div class="message {message.role}">
|
||||
<div class="message-content">
|
||||
{message.content}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isSending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if bot}
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
bind:value={messageInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Describe your trading strategy..."
|
||||
rows="1"
|
||||
disabled={isSending}
|
||||
></textarea>
|
||||
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-interface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bot-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bot-selector label {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.bot-selector select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bot-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.welcome-message .hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.system .message-content {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #888;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
348
src/frontend/src/lib/components/ConditionBuilder.svelte
Normal file
@@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
import type { Condition } from '$lib/api';
|
||||
import TokenPicker from './TokenPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
conditions: Condition[];
|
||||
onUpdate: (conditions: Condition[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { conditions, onUpdate, disabled = false }: Props = $props();
|
||||
|
||||
type ConditionType = Condition['type'];
|
||||
|
||||
const conditionTypes: { value: ConditionType; label: string; description: string }[] = [
|
||||
{ value: 'price_drop', label: 'Price Drop', description: 'Trigger when price falls by X%' },
|
||||
{ value: 'price_rise', label: 'Price Rise', description: 'Trigger when price rises by X%' },
|
||||
{ value: 'volume_spike', label: 'Volume Spike', description: 'Trigger when volume increases by X%' },
|
||||
{ value: 'price_level', label: 'Price Level', description: 'Trigger when price crosses a level' },
|
||||
];
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d'];
|
||||
|
||||
function addCondition() {
|
||||
const newCondition: Condition = {
|
||||
type: 'price_drop',
|
||||
token: '',
|
||||
threshold: 5,
|
||||
timeframe: '1h'
|
||||
};
|
||||
onUpdate([...conditions, newCondition]);
|
||||
}
|
||||
|
||||
function removeCondition(index: number) {
|
||||
onUpdate(conditions.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function updateCondition(index: number, updates: Partial<Condition>) {
|
||||
const updated = conditions.map((c, i) =>
|
||||
i === index ? { ...c, ...updates } : c
|
||||
);
|
||||
onUpdate(updated);
|
||||
}
|
||||
|
||||
function getConditionDescription(condition: Condition): string {
|
||||
switch (condition.type) {
|
||||
case 'price_drop':
|
||||
return `Price drops ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||
case 'price_rise':
|
||||
return `Price rises ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||
case 'volume_spike':
|
||||
return `Volume spikes ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
|
||||
case 'price_level':
|
||||
return `Price crosses ${condition.direction || 'above'} $${condition.price || 0}`;
|
||||
default:
|
||||
return 'Unknown condition';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="condition-builder">
|
||||
<div class="conditions-header">
|
||||
<h4>Conditions</h4>
|
||||
<button type="button" class="add-btn" onclick={addCondition} {disabled}>
|
||||
+ Add Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if conditions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No conditions set</p>
|
||||
<p class="hint">Add a condition to define when your strategy triggers</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="conditions-list">
|
||||
{#each conditions as condition, index}
|
||||
<div class="condition-card">
|
||||
<div class="condition-header">
|
||||
<span class="condition-number">#{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
onclick={() => removeCondition(index)}
|
||||
disabled={disabled}
|
||||
aria-label="Remove condition"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="condition-fields">
|
||||
<div class="field">
|
||||
<label for="type-{index}">Type</label>
|
||||
<select
|
||||
id="type-{index}"
|
||||
value={condition.type}
|
||||
onchange={(e) => updateCondition(index, { type: (e.target as HTMLSelectElement).value as ConditionType })}
|
||||
disabled={disabled}
|
||||
>
|
||||
{#each conditionTypes as ct}
|
||||
<option value={ct.value}>{ct.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<TokenPicker
|
||||
label="Token"
|
||||
selectedToken={condition.token}
|
||||
selectedChain={condition.chain || ''}
|
||||
onSelect={(token, chain) => updateCondition(index, { token, chain })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{#if condition.type === 'price_level'}
|
||||
<div class="field">
|
||||
<label for="direction-{index}">Direction</label>
|
||||
<select
|
||||
id="direction-{index}"
|
||||
value={condition.direction || 'above'}
|
||||
onchange={(e) => updateCondition(index, { direction: (e.target as HTMLSelectElement).value as 'above' | 'below' })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="above">Above</option>
|
||||
<option value="below">Below</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="price-{index}">Price ($)</label>
|
||||
<input
|
||||
id="price-{index}"
|
||||
type="number"
|
||||
value={condition.price || ''}
|
||||
oninput={(e) => updateCondition(index, { price: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||
placeholder="0.000001"
|
||||
step="any"
|
||||
min="0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="threshold-{index}">Threshold (%)</label>
|
||||
<input
|
||||
id="threshold-{index}"
|
||||
type="number"
|
||||
value={condition.threshold || ''}
|
||||
oninput={(e) => updateCondition(index, { threshold: parseFloat((e.target as HTMLInputElement).value) || undefined })}
|
||||
placeholder="5"
|
||||
step="any"
|
||||
min="0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="timeframe-{index}">Timeframe</label>
|
||||
<select
|
||||
id="timeframe-{index}"
|
||||
value={condition.timeframe || '1h'}
|
||||
onchange={(e) => updateCondition(index, { timeframe: (e.target as HTMLSelectElement).value })}
|
||||
disabled={disabled}
|
||||
>
|
||||
{#each timeframes as tf}
|
||||
<option value={tf}>{tf}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="condition-preview">
|
||||
<span class="preview-label">Summary:</span>
|
||||
{getConditionDescription(condition)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.condition-builder {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.conditions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.add-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.conditions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.condition-card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.condition-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.condition-number {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.remove-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.condition-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.condition-preview {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
color: #666;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
121
src/frontend/src/lib/components/ProUpgradeBanner.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
feature?: string;
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { feature, dismissible = true, onDismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pro-upgrade-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="banner-text">
|
||||
<strong>Upgrade to Pro</strong>
|
||||
{#if feature}
|
||||
<p>{feature}</p>
|
||||
{:else}
|
||||
<p>Unlock advanced features and unlimited bots</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/settings" class="upgrade-btn">Upgrade Now</a>
|
||||
</div>
|
||||
{#if dismissible && onDismiss}
|
||||
<button class="dismiss-btn" onclick={onDismiss} aria-label="Dismiss">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pro-upgrade-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.banner-text strong {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.banner-text p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.upgrade-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.upgrade-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
228
src/frontend/src/lib/components/SignalChart.svelte
Normal file
228
src/frontend/src/lib/components/SignalChart.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import type { Signal } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
signals: Signal[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { signals, height = 200 }: Props = $props();
|
||||
|
||||
let width = $state(800);
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
$effect(() => {
|
||||
if (containerEl) {
|
||||
width = containerEl.clientWidth;
|
||||
}
|
||||
});
|
||||
|
||||
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } {
|
||||
const padding = 30;
|
||||
const chartWidth = width - padding * 2;
|
||||
const chartHeight = height - padding * 2;
|
||||
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
|
||||
const priceRange = getPriceRange();
|
||||
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
|
||||
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
|
||||
const y = padding + (1 - normalizedPrice) * chartHeight;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function getPriceRange(): { min: number; max: number } {
|
||||
if (signals.length === 0) return { min: 0, max: 1 };
|
||||
const prices = signals.map(s => s.price);
|
||||
const min = Math.min(...prices);
|
||||
const max = Math.max(...prices);
|
||||
const padding = (max - min) * 0.1 || 1;
|
||||
return { min: min - padding, max: max + padding };
|
||||
}
|
||||
|
||||
function getSignalColor(signal: Signal): string {
|
||||
switch (signal.signal_type) {
|
||||
case 'buy': return '#22c55e';
|
||||
case 'sell': return '#ef4444';
|
||||
case 'hold': return '#fbbf24';
|
||||
default: return '#888';
|
||||
}
|
||||
}
|
||||
|
||||
function getYAxisLabels(): string[] {
|
||||
const range = getPriceRange();
|
||||
const step = (range.max - range.min) / 4;
|
||||
return [
|
||||
range.max.toFixed(6),
|
||||
(range.max - step).toFixed(6),
|
||||
(range.min + step).toFixed(6),
|
||||
range.min.toFixed(6)
|
||||
];
|
||||
}
|
||||
|
||||
function getXAxisLabels(): string[] {
|
||||
if (signals.length === 0) return [];
|
||||
const step = Math.max(1, Math.floor(signals.length / 5));
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < signals.length; i += step) {
|
||||
labels.push(new Date(signals[i].created_at).toLocaleTimeString());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="signal-chart" bind:this={containerEl}>
|
||||
{#if signals.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No signals to display</p>
|
||||
</div>
|
||||
{:else}
|
||||
<svg {width} {height} viewBox="0 0 {width} {height}">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" />
|
||||
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="grid-lines">
|
||||
{#each [0, 1, 2, 3] as i}
|
||||
{@const y = 30 + (i / 3) * (height - 60)}
|
||||
<line
|
||||
x1="30" y1={y}
|
||||
x2={width - 30} y2={y}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke-dasharray="4,4"
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="y-axis">
|
||||
{#each getYAxisLabels() as label, i}
|
||||
{@const y = 30 + (i / 3) * (height - 60)}
|
||||
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g class="x-axis">
|
||||
{#each getXAxisLabels() as label, i}
|
||||
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
|
||||
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<path
|
||||
d={signals.map((s, i) => {
|
||||
const pos = getSignalPosition(s, i, signals.length);
|
||||
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke="#667eea"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
{#each signals as signal, i}
|
||||
{@const pos = getSignalPosition(signal, i, signals.length)}
|
||||
{@const color = getSignalColor(signal)}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
class="signal-dot"
|
||||
>
|
||||
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
|
||||
</circle>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot buy"></span>
|
||||
<span>Buy</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot sell"></span>
|
||||
<span>Sell</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot hold"></span>
|
||||
<span>Hold</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.signal-chart {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
font-size: 10px;
|
||||
fill: #666;
|
||||
}
|
||||
|
||||
.signal-dot {
|
||||
cursor: pointer;
|
||||
transition: r 0.2s;
|
||||
}
|
||||
|
||||
.signal-dot:hover {
|
||||
r: 8;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.buy {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.legend-dot.sell {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.legend-dot.hold {
|
||||
background: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
227
src/frontend/src/lib/components/StrategyPreview.svelte
Normal file
227
src/frontend/src/lib/components/StrategyPreview.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import type { StrategyConfig } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
config: StrategyConfig | null;
|
||||
editable?: boolean;
|
||||
onUpdate?: (config: StrategyConfig) => void;
|
||||
}
|
||||
|
||||
let { config, editable = false, onUpdate }: Props = $props();
|
||||
|
||||
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
|
||||
switch (condition.type) {
|
||||
case 'price_drop':
|
||||
return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
case 'price_rise':
|
||||
return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
case 'volume_spike':
|
||||
return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.timeframe}`;
|
||||
case 'price_level':
|
||||
return `${condition.token} crosses ${condition.direction} $${condition.price}`;
|
||||
default:
|
||||
return 'Unknown condition';
|
||||
}
|
||||
}
|
||||
|
||||
function getActionDescription(action: StrategyConfig['actions'][0]): string {
|
||||
switch (action.type) {
|
||||
case 'buy':
|
||||
return `Buy ${action.amount_percent}% of ${action.token || 'portfolio'}`;
|
||||
case 'sell':
|
||||
return `Sell ${action.amount_percent}% of ${action.token || 'portfolio'}`;
|
||||
case 'hold':
|
||||
return 'Hold';
|
||||
default:
|
||||
return 'Unknown action';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="strategy-preview">
|
||||
{#if !config || (config.conditions.length === 0 && config.actions.length === 0)}
|
||||
<div class="empty-state">
|
||||
<p>No strategy configured yet.</p>
|
||||
<p class="hint">Describe your trading strategy in the chat to create one.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="strategy-section">
|
||||
<h4>Conditions</h4>
|
||||
{#if config.conditions.length === 0}
|
||||
<p class="empty">No conditions set</p>
|
||||
{:else}
|
||||
<ul class="items-list">
|
||||
{#each config.conditions as condition, i}
|
||||
<li>
|
||||
<span class="condition-badge">{condition.type.replace('_', ' ')}</span>
|
||||
{getConditionDescription(condition)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="strategy-section">
|
||||
<h4>Actions</h4>
|
||||
{#if config.actions.length === 0}
|
||||
<p class="empty">No actions set</p>
|
||||
{:else}
|
||||
<ul class="items-list">
|
||||
{#each config.actions as action}
|
||||
<li>
|
||||
<span class="action-badge action-{action.type}">{action.type}</span>
|
||||
{getActionDescription(action)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if config.risk_management}
|
||||
<div class="strategy-section">
|
||||
<h4>Risk Management</h4>
|
||||
<div class="risk-items">
|
||||
{#if config.risk_management.stop_loss_percent}
|
||||
<div class="risk-item">
|
||||
<span class="risk-label">Stop Loss</span>
|
||||
<span class="risk-value negative">{config.risk_management.stop_loss_percent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if config.risk_management.take_profit_percent}
|
||||
<div class="risk-item">
|
||||
<span class="risk-label">Take Profit</span>
|
||||
<span class="risk-value positive">{config.risk_management.take_profit_percent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.strategy-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.strategy-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.strategy-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.items-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.items-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.condition-badge,
|
||||
.action-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.condition-badge {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buy {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.action-sell {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-hold {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.risk-items {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.risk-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
256
src/frontend/src/lib/components/TokenPicker.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
interface Token {
|
||||
symbol: string;
|
||||
chain: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedToken?: string;
|
||||
selectedChain?: string;
|
||||
onSelect: (token: string, chain: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { selectedToken = '', selectedChain = '', onSelect, disabled = false, label = 'Select Token' }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let tokens = $state<Token[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let inputEl: HTMLInputElement;
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
const commonTokens: Token[] = [
|
||||
{ symbol: 'BTC', chain: 'btc', name: 'Bitcoin' },
|
||||
{ symbol: 'ETH', chain: 'eth', name: 'Ethereum' },
|
||||
{ symbol: 'BNB', chain: 'bsc', name: 'BNB' },
|
||||
{ symbol: 'PEPE', chain: 'bsc', name: 'Pepe' },
|
||||
{ symbol: 'SHIB', chain: 'eth', name: 'Shiba Inu' },
|
||||
{ symbol: 'DOGE', chain: 'doge', name: 'Dogecoin' },
|
||||
{ symbol: 'SOL', chain: 'sol', name: 'Solana' },
|
||||
{ symbol: 'XRP', chain: 'xrp', name: 'Ripple' },
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerEl && !containerEl.contains(event.target as Node)) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
async function loadTokens() {
|
||||
isLoading = true;
|
||||
try {
|
||||
tokens = await api.config.getTokens();
|
||||
} catch (e) {
|
||||
tokens = commonTokens;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredTokens(): Token[] {
|
||||
const allTokens = tokens.length > 0 ? tokens : commonTokens;
|
||||
if (!searchQuery) return allTokens.slice(0, 10);
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allTokens.filter(
|
||||
t => t.symbol.toLowerCase().includes(query) ||
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.chain.toLowerCase().includes(query)
|
||||
).slice(0, 10);
|
||||
}
|
||||
|
||||
function handleSelect(token: Token) {
|
||||
onSelect(token.symbol, token.chain);
|
||||
searchQuery = '';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
isOpen = true;
|
||||
if (tokens.length === 0 && !isLoading) {
|
||||
loadTokens();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="token-picker" bind:this={containerEl}>
|
||||
{#if label}
|
||||
<label>{label}</label>
|
||||
{/if}
|
||||
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value={searchQuery}
|
||||
onfocus={handleInputFocus}
|
||||
placeholder={selectedToken ? `${selectedToken}${selectedChain ? ` (${selectedChain})` : ''}` : 'Search tokens...'}
|
||||
{disabled}
|
||||
class:has-value={selectedToken}
|
||||
/>
|
||||
{#if selectedToken}
|
||||
<button class="clear-btn" onclick={() => onSelect('', '')} disabled={disabled}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading tokens...</div>
|
||||
{:else if getFilteredTokens().length === 0}
|
||||
<div class="no-results">No tokens found</div>
|
||||
{:else}
|
||||
{#each getFilteredTokens() as token}
|
||||
<button
|
||||
class="token-option"
|
||||
class:selected={token.symbol === selectedToken && token.chain === selectedChain}
|
||||
onclick={() => handleSelect(token)}
|
||||
>
|
||||
<span class="token-symbol">{token.symbol}</span>
|
||||
<span class="token-chain">{token.chain.toUpperCase()}</span>
|
||||
<span class="token-name">{token.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.token-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
input.has-value {
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-btn:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-results {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.token-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.token-option:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.token-option.selected {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.token-symbol {
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.token-chain {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
flex: 1;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
9
src/frontend/src/lib/components/index.ts
Normal file
9
src/frontend/src/lib/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as ChatInterface } from './ChatInterface.svelte';
|
||||
export { default as BotCard } from './BotCard.svelte';
|
||||
export { default as BotSelector } from './BotSelector.svelte';
|
||||
export { default as StrategyPreview } from './StrategyPreview.svelte';
|
||||
export { default as SignalChart } from './SignalChart.svelte';
|
||||
export { default as BacktestChart } from './BacktestChart.svelte';
|
||||
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
||||
export { default as TokenPicker } from './TokenPicker.svelte';
|
||||
export { default as ConditionBuilder } from './ConditionBuilder.svelte';
|
||||
@@ -4,11 +4,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let messageInput = $state('');
|
||||
let isSending = $state(false);
|
||||
let chatContainer: HTMLDivElement;
|
||||
let showStrategy = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
@@ -34,24 +34,18 @@
|
||||
try {
|
||||
const history = await api.bots.getHistory(botId);
|
||||
setMessages(history);
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
console.error('Failed to load chat history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!messageInput.trim() || isSending) return;
|
||||
async function handleSendMessage(message: string) {
|
||||
if (isSending) return;
|
||||
|
||||
const userMessage = messageInput;
|
||||
messageInput = '';
|
||||
isSending = true;
|
||||
|
||||
addMessage({ role: 'user', content: userMessage });
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const response = await api.bots.chat(botId, userMessage);
|
||||
const response = await api.bots.chat(botId, message);
|
||||
addMessage({ role: 'assistant', content: response.response });
|
||||
|
||||
if (response.strategy_config) {
|
||||
@@ -62,23 +56,11 @@
|
||||
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
|
||||
} finally {
|
||||
isSending = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
if (chatContainer) {
|
||||
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
function toggleStrategy() {
|
||||
showStrategy = !showStrategy;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -93,55 +75,32 @@
|
||||
<h1>{$currentBotStore?.name || 'Loading...'}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if $currentBotStore?.strategy_config}
|
||||
<button class="btn btn-secondary" onclick={toggleStrategy}>
|
||||
{showStrategy ? 'Hide' : 'Show'} Strategy
|
||||
</button>
|
||||
{/if}
|
||||
<a href="/bot/{botId}/backtest" class="btn btn-secondary">Backtest</a>
|
||||
<a href="/bot/{botId}/simulate" class="btn btn-secondary">Simulate</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-container" bind:this={chatContainer}>
|
||||
{#if $chatStore.length === 0}
|
||||
<div class="welcome-message">
|
||||
<p>Welcome to {$currentBotStore?.name}! Describe your trading strategy in plain English.</p>
|
||||
<p class="hint">Example: "Buy PEPE when the price drops by 5% within 1 hour"</p>
|
||||
{#if showStrategy && $currentBotStore?.strategy_config}
|
||||
<div class="strategy-panel">
|
||||
<StrategyPreview config={$currentBotStore.strategy_config} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each $chatStore as message}
|
||||
<div class="message {message.role}">
|
||||
<div class="message-content">
|
||||
{message.content}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isSending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="chat-wrapper">
|
||||
<ChatInterface
|
||||
bot={$currentBotStore}
|
||||
messages={$chatStore}
|
||||
{isSending}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $currentBotStore}
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
bind:value={messageInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Describe your trading strategy..."
|
||||
rows="1"
|
||||
disabled={isSending}
|
||||
></textarea>
|
||||
<button onclick={sendMessage} disabled={isSending || !messageInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<ProUpgradeBanner feature="Auto-execute trades with your bot" />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -217,138 +176,14 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.welcome-message .hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.strategy-panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #888;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
.chat-wrapper {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, backtestStore, setCurrentBacktest, addBacktestToHistory, setBacktestLoading, setBacktestError } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { BacktestChart } from '$lib/components';
|
||||
import type { Backtest } from '$lib/api';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
@@ -11,6 +13,7 @@
|
||||
let startDate = $state('');
|
||||
let endDate = $state('');
|
||||
let isRunning = $state(false);
|
||||
let selectedBacktest = $state<Backtest | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated && !$isLoading) {
|
||||
@@ -76,6 +79,12 @@
|
||||
function setBacktestHistory(backtests: any[]) {
|
||||
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
|
||||
}
|
||||
|
||||
function selectBacktest(backtest: Backtest) {
|
||||
if (backtest.status === 'completed' && backtest.result) {
|
||||
selectedBacktest = backtest;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -177,6 +186,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if selectedBacktest}
|
||||
<section class="chart-section">
|
||||
<div class="chart-header">
|
||||
<h2>Portfolio Performance</h2>
|
||||
<button class="close-btn" onclick={() => selectedBacktest = null}>×</button>
|
||||
</div>
|
||||
<BacktestChart results={selectedBacktest.result} />
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -388,4 +407,36 @@
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
|
||||
let botId = $derived($page.params.id);
|
||||
let token = $state('PEPE');
|
||||
@@ -142,12 +143,16 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
|
||||
|
||||
<section class="signals-section">
|
||||
<h2>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>
|
||||
{:else}
|
||||
<SignalChart signals={$simulationStore.signals} height={200} />
|
||||
|
||||
<div class="signals-list">
|
||||
{#each $simulationStore.signals as signal}
|
||||
<div class="signal-card">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, isLoading, botsStore, setBots, addBot, removeBot, userStore, logout } from '$lib/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { BotCard } from '$lib/components';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let newBotName = $state('');
|
||||
@@ -30,7 +31,7 @@
|
||||
}
|
||||
|
||||
async function createBot() {
|
||||
if (!$newBotName.trim()) return;
|
||||
if (!newBotName.trim()) return;
|
||||
createError = '';
|
||||
isCreating = true;
|
||||
try {
|
||||
@@ -95,19 +96,7 @@
|
||||
{:else}
|
||||
<div class="bots-grid">
|
||||
{#each $botsStore as bot}
|
||||
<div class="bot-card">
|
||||
<div class="bot-info">
|
||||
<h3>{bot.name}</h3>
|
||||
{#if bot.description}
|
||||
<p class="bot-description">{bot.description}</p>
|
||||
{/if}
|
||||
<span class="bot-status status-{bot.status}">{bot.status}</span>
|
||||
</div>
|
||||
<div class="bot-actions">
|
||||
<a href="/bot/{bot.id}" class="btn btn-primary">Open</a>
|
||||
<button onclick={() => deleteBot(bot.id)} class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -201,57 +190,6 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bot-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bot-info h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.bot-description {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.bot-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.bot-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
@@ -274,12 +212,6 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user