Compare commits

..

6 Commits

Author SHA1 Message Date
shokollm
3806af3e23 feat(backend): Implement AVE Cloud integration for Data and Trading APIs
- Add tier field to User model for plan detection (free/normal/pro)
- Create AVE Cloud API client with all Data API endpoints:
  - Token search (GET /v2/tokens)
  - Batch prices (POST /v2/tokens/price)
  - Token details (GET /v2/tokens/{id})
  - Kline data (GET /v2/klines/token/{id})
  - Trending tokens (GET /v2/tokens/trending)
  - Token risk (GET /v2/contracts/{id})
- Add Trading API endpoints:
  - Chain wallet quote (POST /v1/chain/quote)
  - Chain wallet swap (POST /v1/chain/swap)
- Add tier gating with upsell messaging for Pro features
- Handle rate limiting gracefully with 429 responses
- Add Pydantic schemas for AVE API requests/responses

Fixes #11
2026-04-08 14:41:40 +00:00
a892a403fb Merge pull request '[Frontend] Components - Chat UI, Dashboard, Visualizations' (#21) from fix/issue-10 into main 2026-04-08 16:23:23 +02:00
shokollm
0bb5d9a5d6 feat: Implement frontend UI components for issue #10
Created the following components:
- ChatInterface: Message input, AI responses, chat history with bot selector dropdown
- BotCard: Bot preview card for dashboard
- BotSelector: Dropdown to select bot (max 3 bots)
- StrategyPreview: Shows parsed strategy config in readable format
- SignalChart: Visual representation of signals over time (SVG-based)
- BacktestChart: Portfolio value chart with metrics display
- ProUpgradeBanner: Upsell banner for Pro features
- TokenPicker: Search/select tokens for conditions
- ConditionBuilder: UI for building trading conditions

Updated pages to use new components:
- Dashboard now uses BotCard
- Bot detail page now uses ChatInterface and StrategyPreview
- Backtest page now uses BacktestChart
- Simulate page now uses SignalChart and ProUpgradeBanner
2026-04-08 13:41:43 +00:00
875427a0c1 Merge pull request '[Frontend] Project Setup - Svelte with TypeScript' (#20) from fix/issue-9 into main 2026-04-08 15:17:10 +02:00
shokollm
5eb623f022 feat: setup SvelteKit frontend with TypeScript
Implemented issue #9 - Frontend Project Setup with Svelte and TypeScript.

Changes:
- Created SvelteKit project with TypeScript
- Set up routing structure:
  - / (landing page)
  - /login
  - /register
  - /dashboard
  - /bot/[id]
  - /bot/[id]/backtest
  - /bot/[id]/simulate
  - /settings
- Created Svelte stores for state management:
  - userStore - Current user info
  - botsStore - List of user's bots
  - currentBotStore - Selected bot
  - chatStore - Chat messages
  - backtestStore - Backtest results
  - simulationStore - Simulation signals
  - authStore - Authentication state
- Created API client for backend communication
- Set up environment variables (.env.example)
- Created auth store with protected routes and login/register functionality
2026-04-08 12:58:33 +00:00
a59a1ccd97 Merge pull request '[Backend] Simulate Engine - Real-time Signal Detection' (#19) from fix/issue-8 into main 2026-04-08 14:57:28 +02:00
51 changed files with 6908 additions and 1 deletions

265
src/backend/app/api/ave.py Normal file
View 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)}",
)

View File

@@ -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)

View File

@@ -144,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

View File

@@ -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("/")

View File

@@ -0,0 +1,3 @@
from .client import AveCloudClient, check_tier_access
__all__ = ["AveCloudClient", "check_tier_access"]

View 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."
)

View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000/api
VITE_WS_URL=ws://localhost:8000/ws

23
src/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
src/frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
src/frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
src/frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.0 create --template minimal --types ts --no-install .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

1383
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
src/frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
}
}

13
src/frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/frontend/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,209 @@
import type {
User,
Bot,
BotConversation,
Backtest,
Simulation,
Signal,
AuthResponse,
BotChatRequest,
BotChatResponse,
StrategyConfig
} from './types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
throw new Error(error.detail || `HTTP error ${response.status}`);
}
return response.json();
}
export const api = {
auth: {
async register(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return handleResponse<AuthResponse>(response);
},
async login(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return handleResponse<AuthResponse>(response);
},
async logout(): Promise<void> {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: getAuthHeaders()
});
},
async me(): Promise<User> {
const response = await fetch(`${API_URL}/auth/me`, {
headers: getAuthHeaders()
});
return handleResponse<User>(response);
}
},
bots: {
async list(): Promise<Bot[]> {
const response = await fetch(`${API_URL}/bots`, {
headers: getAuthHeaders()
});
return handleResponse<Bot[]>(response);
},
async create(name: string, description?: string): Promise<Bot> {
const response = await fetch(`${API_URL}/bots`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name, description })
});
return handleResponse<Bot>(response);
},
async get(id: string): Promise<Bot> {
const response = await fetch(`${API_URL}/bots/${id}`, {
headers: getAuthHeaders()
});
return handleResponse<Bot>(response);
},
async update(id: string, data: Partial<Bot>): Promise<Bot> {
const response = await fetch(`${API_URL}/bots/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
});
return handleResponse<Bot>(response);
},
async delete(id: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
},
async chat(id: string, message: string): Promise<BotChatResponse> {
const response = await fetch(`${API_URL}/bots/${id}/chat`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ message } as BotChatRequest)
});
return handleResponse<BotChatResponse>(response);
},
async getHistory(id: string): Promise<BotConversation[]> {
const response = await fetch(`${API_URL}/bots/${id}/history`, {
headers: getAuthHeaders()
});
return handleResponse<BotConversation[]>(response);
}
},
backtest: {
async start(botId: string, config: { token: string; timeframe: string; start_date: string; end_date: string }): Promise<Backtest> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config)
});
return handleResponse<Backtest>(response);
},
async get(botId: string, runId: string): Promise<Backtest> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}`, {
headers: getAuthHeaders()
});
return handleResponse<Backtest>(response);
},
async list(botId: string): Promise<Backtest[]> {
const response = await fetch(`${API_URL}/bots/${botId}/backtests`, {
headers: getAuthHeaders()
});
return handleResponse<Backtest[]>(response);
},
async stop(botId: string, runId: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/stop`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
},
simulate: {
async start(botId: string, config: { token: string; interval_seconds: number; auto_execute: boolean }): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config)
});
return handleResponse<Simulation>(response);
},
async get(botId: string, runId: string): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}`, {
headers: getAuthHeaders()
});
return handleResponse<Simulation>(response);
},
async list(botId: string): Promise<Simulation[]> {
const response = await fetch(`${API_URL}/bots/${botId}/simulations`, {
headers: getAuthHeaders()
});
return handleResponse<Simulation[]>(response);
},
async stop(botId: string, runId: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}/stop`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
},
config: {
async getChains(): Promise<string[]> {
const response = await fetch(`${API_URL}/config/chains`, {
headers: getAuthHeaders()
});
return handleResponse<string[]>(response);
},
async getTokens(): Promise<{ symbol: string; chain: string; name: string }[]> {
const response = await fetch(`${API_URL}/config/tokens`, {
headers: getAuthHeaders()
});
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
}
}
};

View File

@@ -0,0 +1,2 @@
export { api } from './client';
export * from './types';

View File

@@ -0,0 +1,128 @@
export interface User {
id: string;
email: string;
created_at: string;
updated_at: string;
}
export interface Bot {
id: string;
user_id: string;
name: string;
description: string | null;
strategy_config: StrategyConfig;
llm_config: LLMConfig;
status: 'draft' | 'active' | 'paused';
created_at: string;
updated_at: string;
}
export interface StrategyConfig {
conditions: Condition[];
actions: Action[];
risk_management?: RiskManagement;
}
export interface Condition {
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
token: string;
chain?: string;
threshold?: number;
price?: number;
direction?: 'above' | 'below';
timeframe?: string;
}
export interface Action {
type: 'buy' | 'sell' | 'hold';
amount_percent?: number;
token?: string;
}
export interface RiskManagement {
stop_loss_percent?: number;
take_profit_percent?: number;
}
export interface LLMConfig {
model: string;
temperature: number;
}
export interface BotConversation {
id: string;
bot_id: string;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
}
export interface Backtest {
id: string;
bot_id: string;
started_at: string;
ended_at: string | null;
status: 'running' | 'completed' | 'failed';
config: BacktestConfig;
result: BacktestResult | null;
}
export interface BacktestConfig {
token: string;
timeframe: string;
start_date: string;
end_date: string;
}
export interface BacktestResult {
total_return: number;
win_rate: number;
total_trades: number;
buy_signals: number;
sell_signals: number;
max_drawdown: number;
sharpe_ratio: number;
}
export interface Simulation {
id: string;
bot_id: string;
started_at: string;
status: 'running' | 'stopped';
config: SimulationConfig;
signals: Signal[] | null;
}
export interface SimulationConfig {
token: string;
interval_seconds: number;
auto_execute: boolean;
}
export interface Signal {
id: string;
bot_id: string;
run_id: string;
signal_type: 'buy' | 'sell' | 'hold';
token: string;
price: number;
confidence: number | null;
reasoning: string | null;
executed: boolean;
created_at: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
}
export interface BotChatRequest {
message: string;
}
export interface BotChatResponse {
response: string;
strategy_config: StrategyConfig | null;
success: boolean;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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';

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,50 @@
import { writable, get } from 'svelte/store';
import { api } from '$lib/api';
import { setUser, clearUser, clearBots } from './index';
import { clearSimulationState } from './simulationStore';
import { clearBacktestState } from './backtestStore';
export const isAuthenticated = writable(false);
export const isLoading = writable(true);
export async function initAuth() {
isLoading.set(true);
const token = localStorage.getItem('token');
if (token) {
try {
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
} catch {
localStorage.removeItem('token');
isAuthenticated.set(false);
}
}
isLoading.set(false);
}
export async function login(email: string, password: string) {
const response = await api.auth.login(email, password);
localStorage.setItem('token', response.access_token);
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
}
export async function register(email: string, password: string) {
const response = await api.auth.register(email, password);
localStorage.setItem('token', response.access_token);
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
}
export function logout() {
api.auth.logout().catch(() => {});
localStorage.removeItem('token');
clearUser();
clearBots();
clearBacktestState();
clearSimulationState();
isAuthenticated.set(false);
}

View File

@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
import type { Backtest, BacktestResult } from '$lib/api';
export interface BacktestState {
currentBacktest: Backtest | null;
backtestHistory: Backtest[];
isLoading: boolean;
error: string | null;
}
const initialState: BacktestState = {
currentBacktest: null,
backtestHistory: [],
isLoading: false,
error: null
};
export const backtestStore = writable<BacktestState>(initialState);
export function setCurrentBacktest(backtest: Backtest | null) {
backtestStore.update(state => ({ ...state, currentBacktest: backtest }));
}
export function addBacktestToHistory(backtest: Backtest) {
backtestStore.update(state => ({
...state,
backtestHistory: [backtest, ...state.backtestHistory]
}));
}
export function setBacktestHistory(backtests: Backtest[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
}
export function setBacktestLoading(loading: boolean) {
backtestStore.update(state => ({ ...state, isLoading: loading }));
}
export function setBacktestError(error: string | null) {
backtestStore.update(state => ({ ...state, error }));
}
export function clearBacktestState() {
backtestStore.set(initialState);
}

View File

@@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
import type { Bot } from '$lib/api';
export const botsStore = writable<Bot[]>([]);
export function setBots(bots: Bot[]) {
botsStore.set(bots);
}
export function addBot(bot: Bot) {
botsStore.update(bots => [...bots, bot]);
}
export function updateBot(bot: Bot) {
botsStore.update(bots => bots.map(b => b.id === bot.id ? bot : b));
}
export function removeBot(botId: string) {
botsStore.update(bots => bots.filter(b => b.id !== botId));
}
export function clearBots() {
botsStore.set([]);
}

View File

@@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
import type { BotConversation } from '$lib/api';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
}
export const chatStore = writable<ChatMessage[]>([]);
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
const newMessage: ChatMessage = {
...message,
id: crypto.randomUUID(),
timestamp: new Date()
};
chatStore.update(messages => [...messages, newMessage]);
}
export function setMessages(messages: BotConversation[]) {
chatStore.set(messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: new Date(m.created_at)
})));
}
export function clearChat() {
chatStore.set([]);
}

View File

@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
import type { Bot } from '$lib/api';
export const currentBotStore = writable<Bot | null>(null);
export function setCurrentBot(bot: Bot | null) {
currentBotStore.set(bot);
}
export function clearCurrentBot() {
currentBotStore.set(null);
}

View File

@@ -0,0 +1,30 @@
export { userStore, setUser, clearUser } from './userStore';
export { botsStore, setBots, addBot, updateBot, removeBot, clearBots } from './botsStore';
export { currentBotStore, setCurrentBot, clearCurrentBot } from './currentBotStore';
export { chatStore, addMessage, setMessages, clearChat } from './chatStore';
export {
backtestStore,
setCurrentBacktest,
addBacktestToHistory,
setBacktestHistory,
setBacktestLoading,
setBacktestError,
clearBacktestState
} from './backtestStore';
export {
simulationStore,
setCurrentSimulation,
addSignals,
clearSignals,
setSimulationLoading,
setSimulationError,
clearSimulationState
} from './simulationStore';
export {
isAuthenticated,
isLoading,
initAuth,
login,
register,
logout
} from './authStore';

View File

@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
import type { Simulation, Signal } from '$lib/api';
export interface SimulationState {
currentSimulation: Simulation | null;
signals: Signal[];
isLoading: boolean;
error: string | null;
}
const initialState: SimulationState = {
currentSimulation: null,
signals: [],
isLoading: false,
error: null
};
export const simulationStore = writable<SimulationState>(initialState);
export function setCurrentSimulation(simulation: Simulation | null) {
simulationStore.update(state => ({ ...state, currentSimulation: simulation }));
}
export function addSignals(newSignals: Signal[]) {
simulationStore.update(state => ({
...state,
signals: [...state.signals, ...newSignals]
}));
}
export function clearSignals() {
simulationStore.update(state => ({ ...state, signals: [] }));
}
export function setSimulationLoading(loading: boolean) {
simulationStore.update(state => ({ ...state, isLoading: loading }));
}
export function setSimulationError(error: string | null) {
simulationStore.update(state => ({ ...state, error }));
}
export function clearSimulationState() {
simulationStore.set(initialState);
}

View File

@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
import type { User } from '$lib/api';
export const userStore = writable<User | null>(null);
export function setUser(user: User | null) {
userStore.set(user);
}
export function clearUser() {
userStore.set(null);
}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initAuth, isLoading } from '$lib/stores';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
onMount(() => {
initAuth();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{#if $isLoading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
{@render children()}
{/if}
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
.loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { isAuthenticated } from '$lib/stores';
</script>
<svelte:head>
<title>Randebu - AI Trading Bot Platform</title>
</svelte:head>
{#if $isAuthenticated}
<script>
window.location.href = '/dashboard';
</script>
{:else}
<main>
<div class="hero">
<h1>Randebu</h1>
<p class="tagline">Create trading bots through conversation with AI</p>
<div class="cta">
<a href="/register" class="btn btn-primary">Get Started</a>
<a href="/login" class="btn btn-secondary">Login</a>
</div>
</div>
<section class="features">
<h2>How It Works</h2>
<div class="feature-grid">
<div class="feature">
<h3>1. Describe Your Strategy</h3>
<p>Tell our AI what kind of trading you want to do in plain English</p>
</div>
<div class="feature">
<h3>2. Backtest & Validate</h3>
<p>Test your strategy against historical data before risking real funds</p>
</div>
<div class="feature">
<h3>3. Simulate & Monitor</h3>
<p>Run real-time simulations and watch for trading signals</p>
</div>
</div>
</section>
</main>
{/if}
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.hero {
text-align: center;
max-width: 600px;
}
h1 {
font-size: 3.5rem;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.25rem;
color: #aaa;
margin: 1rem 0 2rem;
}
.cta {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: transform 0.2s, opacity 0.2s;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.features {
margin-top: 4rem;
text-align: center;
}
.features h2 {
font-size: 2rem;
margin-bottom: 2rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
max-width: 800px;
}
.feature h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.feature p {
color: #888;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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 isSending = $state(false);
let showStrategy = $state(false);
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadChatHistory();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
} catch (e) {
goto('/dashboard');
}
}
async function loadChatHistory() {
try {
const history = await api.bots.getHistory(botId);
setMessages(history);
} catch (e) {
console.error('Failed to load chat history:', e);
}
}
async function handleSendMessage(message: string) {
if (isSending) return;
isSending = true;
try {
const response = await api.bots.chat(botId, message);
addMessage({ role: 'assistant', content: response.response });
if (response.strategy_config) {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
}
} catch (e) {
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
} finally {
isSending = false;
}
}
function toggleStrategy() {
showStrategy = !showStrategy;
}
</script>
<svelte:head>
<title>{$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a>
<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>
{#if showStrategy && $currentBotStore?.strategy_config}
<div class="strategy-panel">
<StrategyPreview config={$currentBotStore.strategy_config} />
</div>
{/if}
<div class="chat-wrapper">
<ChatInterface
bot={$currentBotStore}
messages={$chatStore}
{isSending}
onSendMessage={handleSendMessage}
/>
</div>
<ProUpgradeBanner feature="Auto-execute trades with your bot" />
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
height: 100vh;
display: flex;
flex-direction: column;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: transform 0.2s;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn:hover {
transform: translateY(-2px);
}
.strategy-panel {
margin-bottom: 1rem;
}
.chat-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,442 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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');
let timeframe = $state('1h');
let startDate = $state('');
let endDate = $state('');
let isRunning = $state(false);
let selectedBacktest = $state<Backtest | null>(null);
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadBacktests();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
} catch (e) {
goto('/dashboard');
}
}
async function loadBacktests() {
try {
const backtests = await api.backtest.list(botId);
setBacktestHistory(backtests);
} catch (e) {
console.error('Failed to load backtests:', e);
}
}
async function startBacktest() {
if (!startDate || !endDate) return;
setBacktestError(null);
setBacktestLoading(true);
isRunning = true;
try {
const backtest = await api.backtest.start(botId, {
token,
timeframe,
start_date: startDate,
end_date: endDate
});
setCurrentBacktest(backtest);
addBacktestToHistory(backtest);
} catch (e) {
setBacktestError(e instanceof Error ? e.message : 'Failed to start backtest');
} finally {
setBacktestLoading(false);
isRunning = false;
}
}
async function stopBacktest(runId: string) {
try {
await api.backtest.stop(botId, runId);
await loadBacktests();
} catch (e) {
console.error('Failed to stop backtest:', e);
}
}
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>
<title>Backtest - {$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Backtest</h1>
</div>
</header>
<div class="content">
<section class="config-section">
<h2>Configure Backtest</h2>
{#if $backtestStore.error}
<div class="error">{$backtestStore.error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
<div class="form-row">
<div class="field">
<label for="token">Token</label>
<input type="text" id="token" bind:value={token} required />
</div>
<div class="field">
<label for="timeframe">Timeframe</label>
<select id="timeframe" bind:value={timeframe}>
<option value="1m">1 minute</option>
<option value="5m">5 minutes</option>
<option value="15m">15 minutes</option>
<option value="1h">1 hour</option>
<option value="4h">4 hours</option>
<option value="1d">1 day</option>
</select>
</div>
</div>
<div class="form-row">
<div class="field">
<label for="startDate">Start Date</label>
<input type="date" id="startDate" bind:value={startDate} required />
</div>
<div class="field">
<label for="endDate">End Date</label>
<input type="date" id="endDate" bind:value={endDate} required />
</div>
</div>
<button type="submit" disabled={isRunning || $backtestStore.isLoading}>
{isRunning ? 'Running...' : 'Start Backtest'}
</button>
</form>
</section>
<section class="results-section">
<h2>Backtest History</h2>
{#if $backtestStore.backtestHistory.length === 0}
<p class="empty-state">No backtests yet. Run your first backtest above.</p>
{:else}
<div class="backtest-list">
{#each $backtestStore.backtestHistory as backtest}
<div class="backtest-card">
<div class="backtest-header">
<span class="backtest-status status-{backtest.status}">{backtest.status}</span>
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
</div>
{#if backtest.result}
<div class="backtest-results">
<div class="result-item">
<span class="result-label">Total Return</span>
<span class="result-value" class:positive={backtest.result.total_return > 0} class:negative={backtest.result.total_return < 0}>
{backtest.result.total_return.toFixed(2)}%
</span>
</div>
<div class="result-item">
<span class="result-label">Win Rate</span>
<span class="result-value">{backtest.result.win_rate.toFixed(1)}%</span>
</div>
<div class="result-item">
<span class="result-label">Total Trades</span>
<span class="result-value">{backtest.result.total_trades}</span>
</div>
<div class="result-item">
<span class="result-label">Max Drawdown</span>
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
</div>
</div>
{/if}
{#if backtest.status === 'running'}
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
{#if selectedBacktest}
<section class="chart-section">
<div class="chart-header">
<h2>Portfolio Performance</h2>
<button class="close-btn" onclick={() => selectedBacktest = null}>×</button>
</div>
<BacktestChart results={selectedBacktest.result} />
</section>
{/if}
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
h2 {
font-size: 1.25rem;
margin: 0 0 1rem;
}
.content {
display: grid;
gap: 2rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.9rem;
color: #ccc;
}
input, select {
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty-state {
color: #888;
text-align: center;
padding: 2rem;
}
.backtest-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.backtest-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
.backtest-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.backtest-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-running {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.status-completed {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-failed {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.backtest-date {
color: #888;
font-size: 0.85rem;
}
.backtest-results {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.result-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.result-label {
font-size: 0.75rem;
color: #888;
}
.result-value {
font-size: 1.1rem;
font-weight: 500;
}
.positive {
color: #22c55e;
}
.negative {
color: #ef4444;
}
.btn-danger {
margin-top: 0.75rem;
width: auto;
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.2);
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>

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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');
let intervalSeconds = $state(60);
let autoExecute = $state(false);
let isRunning = $state(false);
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadSimulations();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
} catch (e) {
goto('/dashboard');
}
}
async function loadSimulations() {
try {
const simulations = await api.simulate.list(botId);
if (simulations.length > 0) {
const latest = simulations[0];
setCurrentSimulation(latest);
if (latest.signals) {
addSignals(latest.signals);
}
if (latest.status === 'running') {
isRunning = true;
}
}
} catch (e) {
console.error('Failed to load simulations:', e);
}
}
async function startSimulation() {
setSimulationError(null);
setSimulationLoading(true);
isRunning = true;
try {
const simulation = await api.simulate.start(botId, {
token,
interval_seconds: intervalSeconds,
auto_execute: autoExecute
});
setCurrentSimulation(simulation);
clearSignals();
} catch (e) {
setSimulationError(e instanceof Error ? e.message : 'Failed to start simulation');
isRunning = false;
} finally {
setSimulationLoading(false);
}
}
async function stopSimulation() {
if (!$simulationStore.currentSimulation) return;
try {
await api.simulate.stop(botId, $simulationStore.currentSimulation.id);
await loadSimulations();
isRunning = false;
} catch (e) {
console.error('Failed to stop simulation:', e);
}
}
</script>
<svelte:head>
<title>Simulate - {$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Simulation</h1>
</div>
</header>
<div class="notice">
<span class="notice-icon">⚠️</span>
<span>Simulation Mode - Using REST polling (every {intervalSeconds}s). For real-time signals, consider upgrading to Pro tier.</span>
</div>
<div class="content">
<section class="config-section">
<h2>Configure Simulation</h2>
{#if $simulationStore.error}
<div class="error">{$simulationStore.error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
<div class="form-row">
<div class="field">
<label for="token">Token</label>
<input type="text" id="token" bind:value={token} required disabled={isRunning} />
</div>
<div class="field">
<label for="interval">Check Interval (seconds)</label>
<select id="interval" bind:value={intervalSeconds} disabled={isRunning}>
<option value={30}>30 seconds</option>
<option value={60}>60 seconds</option>
<option value={120}>2 minutes</option>
<option value={300}>5 minutes</option>
</select>
</div>
</div>
<div class="field checkbox-field">
<input type="checkbox" id="autoExecute" bind:checked={autoExecute} disabled={isRunning} />
<label for="autoExecute">Auto-execute trades (requires Pro tier)</label>
</div>
{#if isRunning}
<button type="button" onclick={stopSimulation} class="btn btn-danger">
Stop Simulation
</button>
{:else}
<button type="submit" disabled={$simulationStore.isLoading}>
{$simulationStore.isLoading ? 'Starting...' : 'Start Simulation'}
</button>
{/if}
</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">
<div class="signal-header">
<span class="signal-type type-{signal.signal_type}">{signal.signal_type}</span>
<span class="signal-token">{signal.token}</span>
<span class="signal-price">${signal.price.toFixed(6)}</span>
</div>
{#if signal.confidence}
<div class="signal-confidence">
<span>Confidence: {(signal.confidence * 100).toFixed(1)}%</span>
<div class="confidence-bar">
<div class="confidence-fill" style="width: {signal.confidence * 100}%"></div>
</div>
</div>
{/if}
{#if signal.reasoning}
<p class="signal-reasoning">{signal.reasoning}</p>
{/if}
<div class="signal-time">
{new Date(signal.created_at).toLocaleString()}
</div>
</div>
{/each}
</div>
{/if}
</section>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.notice {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #fbbf24;
}
.notice-icon {
font-size: 1.25rem;
}
.content {
display: grid;
gap: 2rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin: 0 0 1rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-field {
flex-direction: row;
align-items: center;
margin-bottom: 1rem;
}
.checkbox-field input {
width: auto;
margin-right: 0.5rem;
}
.checkbox-field label {
margin: 0;
}
label {
font-size: 0.9rem;
color: #ccc;
}
input, select {
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.empty-state {
color: #888;
text-align: center;
padding: 2rem;
}
.signals-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.signal-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
.signal-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.signal-type {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.type-buy {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.type-sell {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.type-hold {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.signal-token {
font-weight: 500;
}
.signal-price {
color: #888;
font-size: 0.9rem;
}
.signal-confidence {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.confidence-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 2px;
}
.signal-reasoning {
font-size: 0.9rem;
color: #ccc;
margin: 0.5rem 0;
line-height: 1.4;
}
.signal-time {
font-size: 0.75rem;
color: #666;
}
</style>

View File

@@ -0,0 +1,291 @@
<script lang="ts">
import { onMount } from 'svelte';
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('');
let newBotDescription = $state('');
let isCreating = $state(false);
let createError = $state('');
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated) {
await loadBots();
}
});
async function loadBots() {
try {
const bots = await api.bots.list();
setBots(bots);
} catch (e) {
console.error('Failed to load bots:', e);
}
}
async function createBot() {
if (!newBotName.trim()) return;
createError = '';
isCreating = true;
try {
const bot = await api.bots.create(newBotName, newBotDescription);
addBot(bot);
showCreateModal = false;
newBotName = '';
newBotDescription = '';
goto(`/bot/${bot.id}`);
} catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create bot';
} finally {
isCreating = false;
}
}
async function deleteBot(botId: string) {
if (!confirm('Are you sure you want to delete this bot?')) return;
try {
await api.bots.delete(botId);
removeBot(botId);
} catch (e) {
console.error('Failed to delete bot:', e);
}
}
function handleLogout() {
logout();
goto('/');
}
</script>
<svelte:head>
<title>Dashboard - Randebu</title>
</svelte:head>
<main>
<header>
<h1>Dashboard</h1>
<div class="header-actions">
<span class="user-email">{$userStore?.email}</span>
<a href="/settings" class="btn btn-secondary">Settings</a>
<button onclick={handleLogout} class="btn btn-secondary">Logout</button>
</div>
</header>
<section class="bots-section">
<div class="section-header">
<h2>Your Bots ({$botsStore.length}/3)</h2>
{#if $botsStore.length < 3}
<button onclick={() => showCreateModal = true} class="btn btn-primary">
Create New Bot
</button>
{/if}
</div>
{#if $botsStore.length === 0}
<div class="empty-state">
<p>You haven't created any bots yet.</p>
<p>Create your first bot to start trading!</p>
</div>
{:else}
<div class="bots-grid">
{#each $botsStore as bot}
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
{/each}
</div>
{/if}
</section>
{#if showCreateModal}
<div class="modal-overlay" onclick={() => showCreateModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2>Create New Bot</h2>
{#if createError}
<div class="error">{createError}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); createBot(); }}>
<div class="field">
<label for="botName">Bot Name</label>
<input type="text" id="botName" bind:value={newBotName} required />
</div>
<div class="field">
<label for="botDescription">Description (optional)</label>
<textarea id="botDescription" bind:value={newBotDescription} rows="3"></textarea>
</div>
<div class="modal-actions">
<button type="button" onclick={() => showCreateModal = false} class="btn btn-secondary">
Cancel
</button>
<button type="submit" disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
{/if}
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
h1 {
margin: 0;
font-size: 2rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.user-email {
color: #888;
font-size: 0.9rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
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-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn:hover {
transform: translateY(-2px);
}
.empty-state {
text-align: center;
padding: 3rem;
color: #888;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 450px;
}
.modal h2 {
margin: 0 0 1.5rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input, textarea {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
</style>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { login, isAuthenticated } from '$lib/stores';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
$effect(() => {
if ($isAuthenticated) {
goto('/dashboard');
}
});
async function handleSubmit() {
error = '';
isLoading = true;
try {
await login(email, password);
goto('/dashboard');
} catch (e) {
error = e instanceof Error ? e.message : 'Login failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login - Randebu</title>
</svelte:head>
<main>
<div class="auth-card">
<h1>Login</h1>
<p class="subtitle">Welcome back</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" bind:value={password} required />
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
<p class="footer">
Don't have an account? <a href="/register">Register</a>
</p>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
}
h1 {
margin: 0;
font-size: 1.75rem;
text-align: center;
}
.subtitle {
color: #888;
text-align: center;
margin: 0.5rem 0 2rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1.25rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.footer {
text-align: center;
margin-top: 1.5rem;
color: #888;
font-size: 0.9rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { register, isAuthenticated } from '$lib/stores';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let isLoading = $state(false);
$effect(() => {
if ($isAuthenticated) {
goto('/dashboard');
}
});
async function handleSubmit() {
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
isLoading = true;
try {
await register(email, password);
goto('/dashboard');
} catch (e) {
error = e instanceof Error ? e.message : 'Registration failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Register - Randebu</title>
</svelte:head>
<main>
<div class="auth-card">
<h1>Create Account</h1>
<p class="subtitle">Start creating trading bots</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" bind:value={password} required minlength="6" />
</div>
<div class="field">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p class="footer">
Already have an account? <a href="/login">Login</a>
</p>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
}
h1 {
margin: 0;
font-size: 1.75rem;
text-align: center;
}
.subtitle {
color: #888;
text-align: center;
margin: 0.5rem 0 2rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1.25rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.footer {
text-align: center;
margin-top: 1.5rem;
color: #888;
font-size: 0.9rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, userStore, logout } from '$lib/stores';
import { api } from '$lib/api';
let email = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let isUpdating = $state(false);
let updateSuccess = $state('');
let updateError = $state('');
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($userStore) {
email = $userStore.email;
}
});
async function updateEmail() {
updateSuccess = '';
updateError = '';
isUpdating = true;
try {
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ email })
});
updateSuccess = 'Email updated successfully';
} catch (e) {
updateError = e instanceof Error ? e.message : 'Failed to update email';
} finally {
isUpdating = false;
}
}
async function updatePassword() {
updateSuccess = '';
updateError = '';
if (newPassword !== confirmPassword) {
updateError = 'Passwords do not match';
return;
}
if (newPassword.length < 6) {
updateError = 'Password must be at least 6 characters';
return;
}
isUpdating = true;
try {
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ password: newPassword, current_password: currentPassword })
});
updateSuccess = 'Password updated successfully';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
updateError = e instanceof Error ? e.message : 'Failed to update password';
} finally {
isUpdating = false;
}
}
function handleLogout() {
logout();
goto('/');
}
</script>
<svelte:head>
<title>Settings - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a>
<h1>Settings</h1>
</div>
</header>
<div class="content">
<section class="settings-section">
<h2>Profile</h2>
{#if updateSuccess}
<div class="success">{updateSuccess}</div>
{/if}
{#if updateError}
<div class="error">{updateError}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); updateEmail(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<button type="submit" disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Update Email'}
</button>
</form>
</section>
<section class="settings-section">
<h2>Change Password</h2>
<form onsubmit={(e) => { e.preventDefault(); updatePassword(); }}>
<div class="field">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" bind:value={currentPassword} required />
</div>
<div class="field">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" bind:value={newPassword} required minlength="6" />
</div>
<div class="field">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
</div>
<button type="submit" disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Update Password'}
</button>
</form>
</section>
<section class="settings-section danger-section">
<h2>Account</h2>
<button onclick={handleLogout} class="btn btn-danger">
Logout
</button>
</section>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
h2 {
font-size: 1.1rem;
margin: 0 0 1rem;
}
.success {
background: rgba(34, 197, 94, 0.2);
border: 1px solid #22c55e;
color: #86efac;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.danger-section button {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
</style>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});