Local changes: Updated model training, removed debug instrumentation, and configuration improvements

This commit is contained in:
kfox
2025-12-26 01:15:43 -05:00
commit cc60da49e7
388 changed files with 57127 additions and 0 deletions

53
frontend/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Crypto Trader Frontend
Modern React frontend for the Crypto Trader application.
## Setup
```bash
npm install
```
## Development
```bash
npm run dev
```
Access at: http://localhost:3000
## Build
```bash
npm run build
```
## Environment Variables
Create `.env` file:
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws/
```
## Tech Stack
- **React 18** - UI framework
- **TypeScript** - Type safety
- **Material-UI** - Component library
- **React Query** - Data fetching
- **React Router** - Routing
- **Recharts** - Charts
- **Vite** - Build tool
## Project Structure
```
src/
├── api/ # API client functions
├── components/ # Reusable components
├── hooks/ # Custom React hooks
├── pages/ # Page components
└── types/ # TypeScript types
```

View File

@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test'
test.describe('Dashboard Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('loads dashboard page', async ({ page }) => {
await expect(page).toHaveTitle(/FXQ One|Crypto Trader/i)
})
test('displays main sections', async ({ page }) => {
// Check for main dashboard elements
await expect(page.getByRole('heading', { level: 4 })).toBeVisible()
// Wait for content to load
await page.waitForLoadState('networkidle')
// Check for navigation elements
await expect(page.getByRole('navigation')).toBeVisible()
})
test('displays autopilot configuration section', async ({ page }) => {
await page.waitForLoadState('networkidle')
// Look for autopilot related elements
await expect(page.getByText(/autopilot/i).first()).toBeVisible()
})
test('navigation works correctly', async ({ page }) => {
// Navigate to different pages
await page.click('text=Trading')
await expect(page).toHaveURL(/.*trading/i)
await page.click('text=Portfolio')
await expect(page).toHaveURL(/.*portfolio/i)
await page.click('text=Strategies')
await expect(page).toHaveURL(/.*strateg/i)
await page.click('text=Settings')
await expect(page).toHaveURL(/.*settings/i)
// Go back to dashboard
await page.click('text=Dashboard')
await expect(page).toHaveURL(/.*\/$/)
})
test('displays real-time status indicators', async ({ page }) => {
await page.waitForLoadState('networkidle')
// Look for status indicators (chips, badges, etc.)
const statusChips = page.locator('.MuiChip-root')
await expect(statusChips.first()).toBeVisible({ timeout: 10000 })
})
})
test.describe('Dashboard - Autopilot Controls', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('shows autopilot start button', async ({ page }) => {
const startButton = page.getByRole('button', { name: /start.*autopilot/i })
// Button should be visible
await expect(startButton).toBeVisible({ timeout: 10000 })
})
test('symbol selection is available', async ({ page }) => {
// Look for symbol selector (autocomplete or select)
const symbolInput = page.locator('[data-testid="autopilot-symbols"], .MuiAutocomplete-root, input[placeholder*="symbol" i]').first()
await expect(symbolInput).toBeVisible({ timeout: 10000 })
})
})
test.describe('Dashboard - Charts', () => {
test('chart grid displays', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Wait for charts to potentially load
await page.waitForTimeout(2000)
// Look for chart container
const chartArea = page.locator('[class*="chart"], canvas, svg').first()
await expect(chartArea).toBeVisible({ timeout: 15000 })
})
})

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
test.describe('Settings Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await page.waitForLoadState('networkidle')
})
test('displays settings page title', async ({ page }) => {
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible()
})
test('shows exchange configuration section', async ({ page }) => {
await expect(page.getByText(/exchange/i).first()).toBeVisible()
})
test('shows alert configuration section', async ({ page }) => {
await expect(page.getByText(/alert/i).first()).toBeVisible()
})
test('has tabs for different settings categories', async ({ page }) => {
// Look for tab navigation
const tabs = page.getByRole('tab')
await expect(tabs.first()).toBeVisible()
})
})
test.describe('Settings - Exchange Configuration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await page.waitForLoadState('networkidle')
})
test('shows add exchange option', async ({ page }) => {
// Look for add exchange button or option
const addExchange = page.getByRole('button', { name: /add.*exchange|new.*exchange|configure/i })
await expect(addExchange.first()).toBeVisible()
})
})
test.describe('Settings - Performance', () => {
test('page loads within reasonable time', async ({ page }) => {
const startTime = Date.now()
await page.goto('/settings')
await page.waitForLoadState('domcontentloaded')
const loadTime = Date.now() - startTime
// Page should load within 5 seconds
expect(loadTime).toBeLessThan(5000)
})
test('no console errors on load', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
await page.goto('/settings')
await page.waitForLoadState('networkidle')
// Filter out known non-critical errors
const criticalErrors = errors.filter(
(e) => !e.includes('WebSocket') && !e.includes('Failed to load resource')
)
expect(criticalErrors).toHaveLength(0)
})
})

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test'
test.describe('Strategies Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/strategies')
await page.waitForLoadState('networkidle')
})
test('displays strategies page title', async ({ page }) => {
await expect(page.getByRole('heading', { name: /strateg/i })).toBeVisible()
})
test('shows create strategy button', async ({ page }) => {
const createButton = page.getByRole('button', { name: /create|new|add/i }).first()
await expect(createButton).toBeVisible()
})
test('displays strategy list or empty state', async ({ page }) => {
// Either show strategies or empty state message
const content = page.getByText(/no strategies|create your first|strategy/i).first()
await expect(content).toBeVisible()
})
})
test.describe('Strategies - Create Strategy Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/strategies')
await page.waitForLoadState('networkidle')
})
test('opens strategy creation dialog', async ({ page }) => {
const createButton = page.getByRole('button', { name: /create|new|add/i }).first()
await createButton.click()
// Dialog should open
await expect(page.getByRole('dialog')).toBeVisible()
})
test('strategy dialog has required fields', async ({ page }) => {
const createButton = page.getByRole('button', { name: /create|new|add/i }).first()
await createButton.click()
await expect(page.getByRole('dialog')).toBeVisible()
// Check for strategy name field
await expect(page.getByLabel(/name/i)).toBeVisible()
// Check for strategy type selector
await expect(page.getByLabel(/type|strategy type/i).or(page.getByText(/select.*strategy/i).first())).toBeVisible()
})
test('shows available strategy types', async ({ page }) => {
const createButton = page.getByRole('button', { name: /create|new|add/i }).first()
await createButton.click()
await expect(page.getByRole('dialog')).toBeVisible()
// Open strategy type dropdown
const typeSelector = page.getByLabel(/type|strategy type/i).or(
page.locator('[data-testid="strategy-type-select"]')
)
await typeSelector.click()
// Should see strategy options like RSI, MACD, etc.
await expect(page.getByRole('option').first()).toBeVisible({ timeout: 5000 })
})
test('can cancel strategy creation', async ({ page }) => {
const createButton = page.getByRole('button', { name: /create|new|add/i }).first()
await createButton.click()
await expect(page.getByRole('dialog')).toBeVisible()
// Cancel
await page.getByRole('button', { name: /cancel/i }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test'
test.describe('Trading Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/trading')
await page.waitForLoadState('networkidle')
})
test('displays trading page title', async ({ page }) => {
await expect(page.getByRole('heading', { name: /trading/i })).toBeVisible()
})
test('shows order form button', async ({ page }) => {
// Look for new order button or order form
const orderButton = page.getByRole('button', { name: /new order|place order|create order/i })
await expect(orderButton).toBeVisible()
})
test('displays positions section', async ({ page }) => {
// Look for positions area
await expect(page.getByText(/positions/i).first()).toBeVisible()
})
test('displays orders section', async ({ page }) => {
// Look for orders area
await expect(page.getByText(/orders/i).first()).toBeVisible()
})
test('paper trading toggle is visible', async ({ page }) => {
// Look for paper trading switch
const paperToggle = page.getByRole('switch', { name: /paper/i }).or(
page.getByText(/paper trading/i)
)
await expect(paperToggle.first()).toBeVisible()
})
})
test.describe('Trading - Order Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/trading')
await page.waitForLoadState('networkidle')
})
test('opens order form dialog', async ({ page }) => {
// Click new order button
const orderButton = page.getByRole('button', { name: /new order|place order|create order/i })
await orderButton.click()
// Check dialog opens
await expect(page.getByRole('dialog')).toBeVisible()
})
test('order form has required fields', async ({ page }) => {
// Open order form
const orderButton = page.getByRole('button', { name: /new order|place order|create order/i })
await orderButton.click()
await expect(page.getByRole('dialog')).toBeVisible()
// Check for symbol field
await expect(page.getByLabel(/symbol/i)).toBeVisible()
// Check for quantity field
await expect(page.getByLabel(/quantity|amount/i)).toBeVisible()
// Check for side selection (buy/sell)
await expect(page.getByText(/buy|sell/i).first()).toBeVisible()
// Check for order type
await expect(page.getByLabel(/order type|type/i)).toBeVisible()
})
test('can close order form', async ({ page }) => {
// Open order form
const orderButton = page.getByRole('button', { name: /new order|place order|create order/i })
await orderButton.click()
await expect(page.getByRole('dialog')).toBeVisible()
// Close dialog
await page.getByRole('button', { name: /cancel|close/i }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
})
})
test.describe('Trading - Balance Display', () => {
test('shows balance information', async ({ page }) => {
await page.goto('/trading')
await page.waitForLoadState('networkidle')
// Look for balance display
const balanceText = page.getByText(/balance|total|available/i)
await expect(balanceText.first()).toBeVisible()
})
})

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FXQ One</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6715
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
frontend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "fxq-one-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2",
"date-fns": "^3.0.0",
"lightweight-charts": "^4.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^27.3.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^4.0.16"
}
}

View File

@@ -0,0 +1,56 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright E2E Test Configuration
* See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use */
reporter: 'html',
/* Shared settings for all the projects below */
use: {
/* Base URL to use in actions like `await page.goto('/')` */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Take screenshot on failure */
screenshot: 'only-on-failure',
/* Record video on failure */
video: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
/* Uncomment for WebKit testing
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
*/
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

1
frontend/public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

71
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { Routes, Route } from 'react-router-dom'
import { Box, Alert } from '@mui/material'
import { Component, ReactNode } from 'react'
import Layout from './components/Layout'
import DashboardPage from './pages/DashboardPage'
import PortfolioPage from './pages/PortfolioPage'
import BacktestPage from './pages/BacktestPage'
import SettingsPage from './pages/SettingsPage'
import StrategiesPage from './pages/StrategiesPage'
import TradingPage from './pages/TradingPage'
import { useRealtimeData } from './hooks/useRealtimeData'
// Error Boundary Component
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">
<strong>Something went wrong:</strong> {this.state.error?.message || 'Unknown error'}
</Alert>
<Box sx={{ mt: 2 }}>
<pre style={{ fontSize: '12px', overflow: 'auto' }}>
{this.state.error?.stack}
</pre>
</Box>
</Box>
)
}
return this.props.children
}
}
function App() {
// Enable real-time data updates
useRealtimeData()
return (
<ErrorBoundary>
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/strategies" element={<StrategiesPage />} />
<Route path="/trading" element={<TradingPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/backtesting" element={<BacktestPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
</ErrorBoundary>
)
}
export default App

View File

@@ -0,0 +1,5 @@
export { apiClient } from './client'
export * as tradingApi from './trading'
export * as portfolioApi from './portfolio'
export * as strategiesApi from './strategies'
export * as backtestingApi from './backtesting'

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { autopilotApi } from '../autopilot'
import { apiClient } from '../client'
vi.mock('../client')
describe('autopilotApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getModes', () => {
it('calls correct endpoint', async () => {
const mockResponse = {
data: {
modes: {
pattern: { name: 'Pattern-Based' },
intelligent: { name: 'ML-Based' },
},
},
}
vi.mocked(apiClient.get).mockResolvedValue(mockResponse)
const result = await autopilotApi.getModes()
expect(apiClient.get).toHaveBeenCalledWith('/api/autopilot/modes')
expect(result).toEqual(mockResponse.data)
})
})
describe('startUnified', () => {
it('calls correct endpoint with config', async () => {
const config = {
symbol: 'BTC/USD',
mode: 'pattern' as const,
auto_execute: false,
}
const mockResponse = { data: { status: 'started' } }
vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
const result = await autopilotApi.startUnified(config)
expect(apiClient.post).toHaveBeenCalledWith('/api/autopilot/start-unified', config)
expect(result).toEqual(mockResponse.data)
})
})
describe('stopUnified', () => {
it('calls correct endpoint with parameters', async () => {
const mockResponse = { data: { status: 'stopped' } }
vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
const result = await autopilotApi.stopUnified('BTC/USD', 'pattern', '1h')
expect(apiClient.post).toHaveBeenCalledWith(
'/api/autopilot/stop-unified?symbol=BTC/USD&mode=pattern&timeframe=1h'
)
expect(result).toEqual(mockResponse.data)
})
})
describe('getUnifiedStatus', () => {
it('calls correct endpoint with parameters', async () => {
const mockResponse = {
data: {
running: true,
mode: 'pattern',
},
}
vi.mocked(apiClient.get).mockResolvedValue(mockResponse)
const result = await autopilotApi.getUnifiedStatus('BTC/USD', 'pattern', '1h')
expect(apiClient.get).toHaveBeenCalledWith(
'/api/autopilot/status-unified/BTC/USD?mode=pattern&timeframe=1h'
)
expect(result).toEqual(mockResponse.data)
})
})
describe('backward compatibility', () => {
it('start method still exists', () => {
expect(autopilotApi.start).toBeDefined()
})
it('stop method still exists', () => {
expect(autopilotApi.stop).toBeDefined()
})
it('getStatus method still exists', () => {
expect(autopilotApi.getStatus).toBeDefined()
})
it('startIntelligent method still exists', () => {
expect(autopilotApi.startIntelligent).toBeDefined()
})
it('stopIntelligent method still exists', () => {
expect(autopilotApi.stopIntelligent).toBeDefined()
})
})
})

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { marketDataApi } from '../marketData'
import { apiClient } from '../client'
vi.mock('../client')
describe('marketDataApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getOHLCV', () => {
it('calls correct endpoint with symbol', async () => {
const mockData = [{ time: 1234567890, open: 100, high: 105, low: 95, close: 102, volume: 1000 }]
vi.mocked(apiClient.get).mockResolvedValue({ data: mockData })
const result = await marketDataApi.getOHLCV('BTC/USD')
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ohlcv/BTC/USD', {
params: { timeframe: '1h' },
})
expect(result).toEqual(mockData)
})
it('passes custom timeframe', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] })
await marketDataApi.getOHLCV('ETH/USD', '4h')
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ohlcv/ETH/USD', {
params: { timeframe: '4h' },
})
})
})
describe('getTicker', () => {
it('calls correct endpoint', async () => {
const mockTicker = {
symbol: 'BTC/USD',
bid: 41000,
ask: 41050,
last: 41025,
high: 42000,
low: 40000,
volume: 5000,
timestamp: 1234567890,
provider: 'CoinGecko',
}
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicker })
const result = await marketDataApi.getTicker('BTC/USD')
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ticker/BTC/USD')
expect(result).toEqual(mockTicker)
})
})
describe('getProviderHealth', () => {
it('calls correct endpoint without provider param', async () => {
const mockHealth = { active_provider: 'CoinGecko', health: {} }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHealth })
const result = await marketDataApi.getProviderHealth()
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/health', { params: {} })
expect(result).toEqual(mockHealth)
})
it('passes provider param when specified', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} })
await marketDataApi.getProviderHealth('CCXT')
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/health', {
params: { provider: 'CCXT' },
})
})
})
describe('getProviderStatus', () => {
it('calls correct endpoint', async () => {
const mockStatus = {
active_provider: 'CoinGecko',
providers: {},
cache: { size: 10, max_size: 100 },
}
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus })
const result = await marketDataApi.getProviderStatus()
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/status')
expect(result).toEqual(mockStatus)
})
})
describe('getProviderConfig', () => {
it('calls correct endpoint', async () => {
const mockConfig = {
primary: [{ name: 'CoinGecko', enabled: true, priority: 1 }],
fallback: { name: 'CCXT', enabled: true },
caching: { ticker_ttl: 60, ohlcv_ttl: 300, max_cache_size: 1000 },
websocket: { enabled: true, reconnect_interval: 5000, ping_interval: 30000 },
}
vi.mocked(apiClient.get).mockResolvedValue({ data: mockConfig })
const result = await marketDataApi.getProviderConfig()
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/config')
expect(result).toEqual(mockConfig)
})
})
describe('updateProviderConfig', () => {
it('calls correct endpoint with config updates', async () => {
const updates = { caching: { ticker_ttl: 120 } }
const mockResponse = { message: 'Updated', config: {} }
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse })
const result = await marketDataApi.updateProviderConfig(updates as any)
expect(apiClient.put).toHaveBeenCalledWith('/api/market-data/providers/config', updates)
expect(result).toEqual(mockResponse)
})
})
describe('getSpreadData', () => {
it('calls correct endpoint with all parameters', async () => {
const mockResponse = {
primarySymbol: 'BTC/USD',
secondarySymbol: 'ETH/USD',
timeframe: '1h',
lookbackWindow: 50,
data: [],
currentSpread: 0.5,
currentZScore: 1.2,
}
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse })
const result = await marketDataApi.getSpreadData('BTC/USD', 'ETH/USD', '4h', 100)
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/spread', {
params: {
primary_symbol: 'BTC/USD',
secondary_symbol: 'ETH/USD',
timeframe: '4h',
lookback: 100,
},
})
expect(result).toEqual(mockResponse)
})
it('uses default values for optional parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} })
await marketDataApi.getSpreadData('BTC/USD', 'ETH/USD')
expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/spread', {
params: {
primary_symbol: 'BTC/USD',
secondary_symbol: 'ETH/USD',
timeframe: '1h',
lookback: 50,
},
})
})
})
})

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { strategiesApi } from '../strategies'
import { apiClient } from '../client'
vi.mock('../client')
describe('strategiesApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('listStrategies', () => {
it('calls correct endpoint', async () => {
const mockStrategies = [{ id: 1, name: 'RSI Strategy' }]
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStrategies })
const result = await strategiesApi.listStrategies()
expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/')
expect(result).toEqual(mockStrategies)
})
})
describe('getAvailableStrategies', () => {
it('calls correct endpoint', async () => {
const mockResponse = { strategies: ['RSI', 'MACD', 'Moving Average'] }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse })
const result = await strategiesApi.getAvailableStrategies()
expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/available')
expect(result).toEqual(mockResponse)
})
})
describe('createStrategy', () => {
it('calls correct endpoint with strategy data', async () => {
const strategy = {
name: 'My Strategy',
type: 'RSI',
symbol: 'BTC/USD',
parameters: { period: 14 },
enabled: true,
}
const mockResponse = { id: 1, ...strategy }
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse })
const result = await strategiesApi.createStrategy(strategy)
expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/', strategy)
expect(result).toEqual(mockResponse)
})
})
describe('getStrategy', () => {
it('calls correct endpoint with ID', async () => {
const mockStrategy = { id: 5, name: 'Test Strategy' }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStrategy })
const result = await strategiesApi.getStrategy(5)
expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/5')
expect(result).toEqual(mockStrategy)
})
})
describe('updateStrategy', () => {
it('calls correct endpoint with updates', async () => {
const updates = { name: 'Updated Name', enabled: false }
const mockResponse = { id: 5, ...updates }
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse })
const result = await strategiesApi.updateStrategy(5, updates)
expect(apiClient.put).toHaveBeenCalledWith('/api/strategies/5', updates)
expect(result).toEqual(mockResponse)
})
})
describe('deleteStrategy', () => {
it('calls correct endpoint', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} })
await strategiesApi.deleteStrategy(5)
expect(apiClient.delete).toHaveBeenCalledWith('/api/strategies/5')
})
})
describe('startStrategy', () => {
it('calls correct endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} })
await strategiesApi.startStrategy(5)
expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/5/start')
})
})
describe('stopStrategy', () => {
it('calls correct endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} })
await strategiesApi.stopStrategy(5)
expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/5/stop')
})
})
describe('getStrategyStatus', () => {
it('calls correct endpoint', async () => {
const mockStatus = { strategy_id: 5, running: true, name: 'Test' }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus })
const result = await strategiesApi.getStrategyStatus(5)
expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/5/status')
expect(result).toEqual(mockStatus)
})
})
describe('getRunningStrategies', () => {
it('calls correct endpoint', async () => {
const mockResponse = {
total_running: 2,
strategies: [{ strategy_id: 1 }, { strategy_id: 2 }],
}
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse })
const result = await strategiesApi.getRunningStrategies()
expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/running/all')
expect(result).toEqual(mockResponse)
})
})
})

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tradingApi } from '../trading'
import { apiClient } from '../client'
import { OrderSide, OrderType } from '../../types'
vi.mock('../client')
describe('tradingApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('createOrder', () => {
it('calls correct endpoint with order data', async () => {
const order = {
exchange_id: 1,
symbol: 'BTC/USD',
side: OrderSide.BUY,
order_type: OrderType.MARKET,
quantity: 0.1,
paper_trading: true,
}
const mockResponse = { data: { id: 1, ...order, status: 'pending' } }
vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
const result = await tradingApi.createOrder(order)
expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders', order)
expect(result).toEqual(mockResponse.data)
})
})
describe('getOrders', () => {
it('calls correct endpoint with default parameters', async () => {
const mockOrders = [{ id: 1, symbol: 'BTC/USD' }]
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrders })
const result = await tradingApi.getOrders()
expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders', {
params: { paper_trading: true, limit: 100 },
})
expect(result).toEqual(mockOrders)
})
it('passes custom parameters', async () => {
const mockOrders = [{ id: 1 }]
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrders })
await tradingApi.getOrders(false, 50)
expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders', {
params: { paper_trading: false, limit: 50 },
})
})
})
describe('getOrder', () => {
it('calls correct endpoint with order ID', async () => {
const mockOrder = { id: 123, symbol: 'ETH/USD' }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrder })
const result = await tradingApi.getOrder(123)
expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders/123')
expect(result).toEqual(mockOrder)
})
})
describe('cancelOrder', () => {
it('calls correct endpoint to cancel order', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} })
await tradingApi.cancelOrder(123)
expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders/123/cancel')
})
})
describe('cancelAllOrders', () => {
it('calls correct endpoint with paper trading param', async () => {
const mockResponse = {
status: 'success',
cancelled_count: 5,
failed_count: 0,
total_orders: 5,
}
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse })
const result = await tradingApi.cancelAllOrders(true)
expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders/cancel-all', null, {
params: { paper_trading: true },
})
expect(result).toEqual(mockResponse)
})
})
describe('getPositions', () => {
it('calls correct endpoint', async () => {
const mockPositions = [{ id: 1, symbol: 'BTC/USD', quantity: 0.5 }]
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPositions })
const result = await tradingApi.getPositions()
expect(apiClient.get).toHaveBeenCalledWith('/api/trading/positions', {
params: { paper_trading: true },
})
expect(result).toEqual(mockPositions)
})
})
describe('getBalance', () => {
it('calls correct endpoint', async () => {
const mockBalance = { total: 10000, available: 8000 }
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBalance })
const result = await tradingApi.getBalance()
expect(apiClient.get).toHaveBeenCalledWith('/api/trading/balance', {
params: { paper_trading: true },
})
expect(result).toEqual(mockBalance)
})
})
})

View File

@@ -0,0 +1,53 @@
import { apiClient } from './client'
export interface AlertCreate {
name: string
alert_type: string
condition: Record<string, any>
}
export interface AlertUpdate {
name?: string
condition?: Record<string, any>
enabled?: boolean
}
export interface AlertResponse {
id: number
name: string
alert_type: string
condition: Record<string, any>
enabled: boolean
triggered: boolean
triggered_at: string | null
created_at: string
updated_at: string
}
export const alertsApi = {
listAlerts: async (enabledOnly: boolean = false): Promise<AlertResponse[]> => {
const response = await apiClient.get('/api/alerts/', {
params: { enabled_only: enabledOnly },
})
return response.data
},
getAlert: async (alertId: number): Promise<AlertResponse> => {
const response = await apiClient.get(`/api/alerts/${alertId}`)
return response.data
},
createAlert: async (alert: AlertCreate): Promise<AlertResponse> => {
const response = await apiClient.post('/api/alerts/', alert)
return response.data
},
updateAlert: async (alertId: number, updates: AlertUpdate): Promise<AlertResponse> => {
const response = await apiClient.put(`/api/alerts/${alertId}`, updates)
return response.data
},
deleteAlert: async (alertId: number): Promise<void> => {
await apiClient.delete(`/api/alerts/${alertId}`)
},
}

View File

@@ -0,0 +1,200 @@
import { apiClient } from './client'
// Autopilot configuration (intelligent mode only)
export interface UnifiedAutopilotConfig {
symbol: string
mode?: 'intelligent' // Only intelligent mode supported
auto_execute?: boolean
interval?: number
exchange_id?: number
timeframe?: string
paper_trading?: boolean
}
export const autopilotApi = {
// Intelligent Autopilot APIs
startIntelligent: async (config: {
symbol: string
exchange_id?: number
timeframe?: string
interval?: number
paper_trading?: boolean
}) => {
const response = await apiClient.post('/api/autopilot/intelligent/start', config)
return response.data
},
stopIntelligent: async (symbol: string, timeframe: string = '1h') => {
const response = await apiClient.post(
`/api/autopilot/intelligent/stop?symbol=${symbol}&timeframe=${timeframe}`
)
return response.data
},
getIntelligentStatus: async (symbol: string, timeframe: string = '1h'): Promise<{
symbol: string
timeframe: string
running: boolean
selected_strategy: string | null
trades_today: number
max_trades_per_day: number
min_confidence_threshold: number
enable_auto_execution: boolean
last_analysis: any
model_info: any
}> => {
const response = await apiClient.get(
`/api/autopilot/intelligent/status/${symbol}?timeframe=${timeframe}`
)
return response.data
},
getIntelligentPerformance: async (strategy_name?: string, days: number = 30) => {
const params: any = { days }
if (strategy_name) params.strategy_name = strategy_name
const response = await apiClient.get('/api/autopilot/intelligent/performance', { params })
return response.data
},
retrainModel: async (force: boolean = false) => {
const response = await apiClient.post('/api/autopilot/intelligent/retrain', null, {
params: { force },
})
return response.data
},
getTrainingStats: async (days: number = 365): Promise<TrainingStats> => {
const response = await apiClient.get('/api/autopilot/intelligent/training-stats', {
params: { days }
})
return response.data
},
getModelInfo: async () => {
const response = await apiClient.get('/api/autopilot/intelligent/model-info')
return response.data
},
resetModel: async (): Promise<{ status: string; message: string; deleted_count: number }> => {
const response = await apiClient.post('/api/autopilot/intelligent/reset')
return response.data
},
// Unified Autopilot APIs
getModes: async (): Promise<any> => {
const response = await apiClient.get('/api/autopilot/modes')
return response.data
},
startUnified: async (config: UnifiedAutopilotConfig) => {
const response = await apiClient.post('/api/autopilot/start-unified', config)
return response.data
},
stopUnified: async (symbol: string, mode: 'intelligent' = 'intelligent', timeframe: string = '1h') => {
const response = await apiClient.post(
`/api/autopilot/stop-unified?symbol=${symbol}&mode=${mode}&timeframe=${timeframe}`
)
return response.data
},
getUnifiedStatus: async (
symbol: string,
mode: 'intelligent' = 'intelligent',
timeframe: string = '1h'
) => {
const response = await apiClient.get(
`/api/autopilot/status-unified/${symbol}?mode=${mode}&timeframe=${timeframe}`
)
return response.data
},
// Bootstrap Configuration APIs
getBootstrapConfig: async (): Promise<BootstrapConfig> => {
const response = await apiClient.get('/api/autopilot/bootstrap-config')
return response.data
},
updateBootstrapConfig: async (config: BootstrapConfig) => {
const response = await apiClient.put('/api/autopilot/bootstrap-config', config)
return response.data
},
// Multi-Symbol Autopilot APIs
startMultiSymbol: async (config: MultiSymbolAutopilotConfig): Promise<MultiSymbolResponse> => {
const response = await apiClient.post('/api/autopilot/multi-symbol/start', config)
return response.data
},
stopMultiSymbol: async (symbols: string[], mode: string = 'intelligent', timeframe: string = '1h'): Promise<MultiSymbolResponse> => {
const response = await apiClient.post('/api/autopilot/multi-symbol/stop', symbols, {
params: { mode, timeframe }
})
return response.data
},
getTaskStatus: async (taskId: string): Promise<any> => {
const response = await apiClient.get(`/api/autopilot/tasks/${taskId}`)
return response.data
},
getMultiSymbolStatus: async (symbols?: string[], mode: string = 'intelligent', timeframe: string = '1h'): Promise<MultiSymbolStatusResponse> => {
const response = await apiClient.get('/api/autopilot/multi-symbol/status', {
params: {
symbols: symbols?.join(',') || '',
mode,
timeframe
}
})
return response.data
},
}
export interface TrainingStats {
total_samples: number
strategy_counts: Record<string, number>
}
export interface BootstrapConfig {
days: number
timeframe: string
min_samples_per_strategy: number
symbols: string[]
}
export interface MultiSymbolAutopilotConfig {
symbols: string[]
mode?: 'intelligent' // Only intelligent mode supported
auto_execute?: boolean
timeframe?: string
exchange_id?: number
paper_trading?: boolean
interval?: number
}
export interface MultiSymbolResponse {
status: string
mode?: string
symbols: Array<{
symbol: string
status: string
}>
}
export interface MultiSymbolStatusResponse {
mode: string
symbols: Array<{
symbol: string
mode: string
running: boolean
selected_strategy?: string
trades_today?: number
last_analysis?: any
}>
total_running: number
}

View File

@@ -0,0 +1,14 @@
import { apiClient } from './client'
import { BacktestRequest, BacktestResponse } from '../types'
export const backtestingApi = {
runBacktest: async (backtest: BacktestRequest): Promise<BacktestResponse> => {
const response = await apiClient.post('/api/backtesting/run', backtest)
return response.data
},
getBacktestResults: async (backtestId: string): Promise<any> => {
const response = await apiClient.get(`/api/backtesting/results/${backtestId}`)
return response.data
},
}

View File

@@ -0,0 +1,37 @@
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
apiClient.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('token')
}
return Promise.reject(error)
}
)

View File

@@ -0,0 +1,19 @@
import { apiClient } from './client'
export interface ExchangeResponse {
id: number
name: string
enabled: boolean
}
export const exchangesApi = {
listExchanges: async (): Promise<ExchangeResponse[]> => {
const response = await apiClient.get('/api/exchanges/')
return response.data
},
getExchange: async (exchangeId: number): Promise<ExchangeResponse> => {
const response = await apiClient.get(`/api/exchanges/${exchangeId}`)
return response.data
},
}

View File

@@ -0,0 +1,142 @@
import { apiClient } from './client'
export interface OHLCVData {
time: number
open: number
high: number
low: number
close: number
volume: number
}
export interface TickerData {
symbol: string
bid: number
ask: number
last: number
high: number
low: number
volume: number
timestamp: number
provider: string
}
export interface ProviderHealth {
status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
last_check?: string
last_success?: string
last_failure?: string
success_count: number
failure_count: number
avg_response_time: number
consecutive_failures: number
circuit_breaker_open: boolean
circuit_breaker_opened_at?: string
}
export interface ProviderStatus {
active_provider: string | null
providers: Record<string, ProviderHealth>
cache: {
size: number
max_size: number
hits: number
misses: number
hit_rate: number
evictions: number
avg_age_seconds: number
}
}
export interface ProviderConfig {
primary: Array<{
name: string
enabled: boolean
priority: number
}>
fallback: {
name: string
enabled: boolean
api_key?: string
}
caching: {
ticker_ttl: number
ohlcv_ttl: number
max_cache_size: number
}
websocket: {
enabled: boolean
reconnect_interval: number
ping_interval: number
}
}
export const marketDataApi = {
getOHLCV: async (symbol: string, timeframe: string = '1h'): Promise<OHLCVData[]> => {
const response = await apiClient.get(`/api/market-data/ohlcv/${symbol}`, {
params: { timeframe }
})
return response.data
},
getTicker: async (symbol: string): Promise<TickerData> => {
const response = await apiClient.get(`/api/market-data/ticker/${symbol}`)
return response.data
},
getProviderHealth: async (provider?: string): Promise<{ active_provider: string | null; health: Record<string, ProviderHealth> | ProviderHealth }> => {
const params = provider ? { provider } : {}
const response = await apiClient.get('/api/market-data/providers/health', { params })
return response.data
},
getProviderStatus: async (): Promise<ProviderStatus> => {
const response = await apiClient.get('/api/market-data/providers/status')
return response.data
},
getProviderConfig: async (): Promise<ProviderConfig> => {
const response = await apiClient.get('/api/market-data/providers/config')
return response.data
},
updateProviderConfig: async (config: Partial<ProviderConfig>): Promise<{ message: string; config: ProviderConfig }> => {
const response = await apiClient.put('/api/market-data/providers/config', config)
return response.data
},
getSpreadData: async (
primarySymbol: string,
secondarySymbol: string,
timeframe: string = '1h',
lookback: number = 50
): Promise<SpreadDataResponse> => {
const response = await apiClient.get('/api/market-data/spread', {
params: {
primary_symbol: primarySymbol,
secondary_symbol: secondarySymbol,
timeframe,
lookback,
}
})
return response.data
}
}
export interface SpreadDataPoint {
timestamp: number
spread: number
zScore: number
priceA: number
priceB: number
}
export interface SpreadDataResponse {
primarySymbol: string
secondarySymbol: string
timeframe: string
lookbackWindow: number
data: SpreadDataPoint[]
currentSpread: number | null
currentZScore: number | null
}

View File

@@ -0,0 +1,41 @@
import { apiClient } from './client'
import { PortfolioResponse, PortfolioHistoryResponse } from '../types'
export const portfolioApi = {
getCurrentPortfolio: async (paperTrading: boolean = true): Promise<PortfolioResponse> => {
const response = await apiClient.get('/api/portfolio/current', {
params: { paper_trading: paperTrading },
})
return response.data
},
getPortfolioHistory: async (days: number = 30, paperTrading: boolean = true): Promise<PortfolioHistoryResponse> => {
const response = await apiClient.get('/api/portfolio/history', {
params: { days, paper_trading: paperTrading },
})
return response.data
},
updatePositionsPrices: async (prices: Record<string, number>, paperTrading: boolean = true): Promise<void> => {
await apiClient.post('/api/portfolio/positions/update-prices', prices, {
params: { paper_trading: paperTrading },
})
},
getRiskMetrics: async (days: number = 30, paperTrading: boolean = true): Promise<{
total_return: number
total_return_percent: number
sharpe_ratio: number
sortino_ratio: number
max_drawdown: number
current_drawdown: number
win_rate: number
initial_value: number
final_value: number
}> => {
const response = await apiClient.get('/api/portfolio/risk-metrics', {
params: { days, paper_trading: paperTrading },
})
return response.data
},
}

View File

@@ -0,0 +1,33 @@
import { apiClient } from './client'
export const reportingApi = {
exportBacktestCSV: async (results: any): Promise<Blob> => {
const response = await apiClient.post('/api/reporting/backtest/csv', results, {
responseType: 'blob',
})
return response.data
},
exportBacktestPDF: async (results: any): Promise<Blob> => {
const response = await apiClient.post('/api/reporting/backtest/pdf', results, {
responseType: 'blob',
})
return response.data
},
exportTradesCSV: async (startDate?: string, endDate?: string, paperTrading: boolean = true): Promise<Blob> => {
const response = await apiClient.get('/api/reporting/trades/csv', {
params: { start_date: startDate, end_date: endDate, paper_trading: paperTrading },
responseType: 'blob',
})
return response.data
},
exportPortfolioCSV: async (paperTrading: boolean = true): Promise<Blob> => {
const response = await apiClient.get('/api/reporting/portfolio/csv', {
params: { paper_trading: paperTrading },
responseType: 'blob',
})
return response.data
},
}

View File

@@ -0,0 +1,137 @@
import { apiClient } from './client'
export interface RiskSettings {
max_drawdown_percent: number
daily_loss_limit_percent: number
position_size_percent: number
}
export interface PaperTradingSettings {
initial_capital: number
fee_exchange: string
fee_rates?: {
maker: number
taker: number
minimum?: number
}
}
export interface FeeExchange {
name: string
fees: {
maker: number
taker: number
minimum?: number
}
}
export interface FeeExchangesResponse {
exchanges: FeeExchange[]
current: string
}
export interface LoggingSettings {
level: string
dir: string
retention_days: number
}
export interface GeneralSettings {
timezone: string
theme: string
currency: string
}
export interface ExchangeCreate {
name: string
api_key?: string
api_secret?: string
sandbox?: boolean
read_only?: boolean
enabled?: boolean
}
export interface ExchangeUpdate {
api_key?: string
api_secret?: string
sandbox?: boolean
read_only?: boolean
enabled?: boolean
}
export const settingsApi = {
// Risk Management
getRiskSettings: async (): Promise<RiskSettings> => {
const response = await apiClient.get('/api/settings/risk')
return response.data
},
updateRiskSettings: async (settings: RiskSettings) => {
const response = await apiClient.put('/api/settings/risk', settings)
return response.data
},
// Paper Trading
getPaperTradingSettings: async (): Promise<PaperTradingSettings> => {
const response = await apiClient.get('/api/settings/paper-trading')
return response.data
},
updatePaperTradingSettings: async (settings: PaperTradingSettings) => {
const response = await apiClient.put('/api/settings/paper-trading', settings)
return response.data
},
resetPaperAccount: async () => {
const response = await apiClient.post('/api/settings/paper-trading/reset')
return response.data
},
getFeeExchanges: async (): Promise<FeeExchangesResponse> => {
const response = await apiClient.get('/api/settings/paper-trading/fee-exchanges')
return response.data
},
// Logging
getLoggingSettings: async (): Promise<LoggingSettings> => {
const response = await apiClient.get('/api/settings/logging')
return response.data
},
updateLoggingSettings: async (settings: LoggingSettings) => {
const response = await apiClient.put('/api/settings/logging', settings)
return response.data
},
// General Settings
getGeneralSettings: async (): Promise<GeneralSettings> => {
const response = await apiClient.get('/api/settings/general')
return response.data
},
updateGeneralSettings: async (settings: GeneralSettings) => {
const response = await apiClient.put('/api/settings/general', settings)
return response.data
},
// Exchanges
createExchange: async (exchange: ExchangeCreate) => {
const response = await apiClient.post('/api/settings/exchanges', exchange)
return response.data
},
updateExchange: async (exchangeId: number, exchange: ExchangeUpdate) => {
const response = await apiClient.put(`/api/settings/exchanges/${exchangeId}`, exchange)
return response.data
},
deleteExchange: async (exchangeId: number) => {
const response = await apiClient.delete(`/api/settings/exchanges/${exchangeId}`)
return response.data
},
testExchangeConnection: async (exchangeId: number) => {
const response = await apiClient.post(`/api/settings/exchanges/${exchangeId}/test`)
return response.data
},
}

View File

@@ -0,0 +1,76 @@
import { apiClient } from './client'
import { StrategyCreate, StrategyUpdate, StrategyResponse } from '../types'
export const strategiesApi = {
listStrategies: async (): Promise<StrategyResponse[]> => {
const response = await apiClient.get('/api/strategies/')
return response.data
},
getAvailableStrategies: async (): Promise<{ strategies: string[] }> => {
const response = await apiClient.get('/api/strategies/available')
return response.data
},
createStrategy: async (strategy: StrategyCreate): Promise<StrategyResponse> => {
const response = await apiClient.post('/api/strategies/', strategy)
return response.data
},
getStrategy: async (strategyId: number): Promise<StrategyResponse> => {
const response = await apiClient.get(`/api/strategies/${strategyId}`)
return response.data
},
updateStrategy: async (strategyId: number, updates: StrategyUpdate): Promise<StrategyResponse> => {
const response = await apiClient.put(`/api/strategies/${strategyId}`, updates)
return response.data
},
deleteStrategy: async (strategyId: number): Promise<void> => {
await apiClient.delete(`/api/strategies/${strategyId}`)
},
startStrategy: async (strategyId: number): Promise<void> => {
await apiClient.post(`/api/strategies/${strategyId}/start`)
},
stopStrategy: async (strategyId: number): Promise<void> => {
await apiClient.post(`/api/strategies/${strategyId}/stop`)
},
getStrategyStatus: async (strategyId: number): Promise<StrategyStatusResponse> => {
const response = await apiClient.get(`/api/strategies/${strategyId}/status`)
return response.data
},
getRunningStrategies: async (): Promise<RunningStrategiesResponse> => {
const response = await apiClient.get('/api/strategies/running/all')
return response.data
},
}
export interface StrategyStatusResponse {
strategy_id: number
name: string
type: string
symbol: string | null
running: boolean
enabled?: boolean
started_at?: string
last_tick?: string
last_signal?: {
type: string
strength: number
price: number
timestamp: string
metadata?: Record<string, any>
}
signal_count?: number
error_count?: number
}
export interface RunningStrategiesResponse {
total_running: number
strategies: StrategyStatusResponse[]
}

View File

@@ -0,0 +1,51 @@
import { apiClient } from './client'
import { OrderCreate, OrderResponse, PositionResponse } from '../types'
export const tradingApi = {
createOrder: async (order: OrderCreate): Promise<OrderResponse> => {
const response = await apiClient.post('/api/trading/orders', order)
return response.data
},
getOrders: async (paperTrading: boolean = true, limit: number = 100): Promise<OrderResponse[]> => {
const response = await apiClient.get('/api/trading/orders', {
params: { paper_trading: paperTrading, limit },
})
return response.data
},
getOrder: async (orderId: number): Promise<OrderResponse> => {
const response = await apiClient.get(`/api/trading/orders/${orderId}`)
return response.data
},
cancelOrder: async (orderId: number): Promise<void> => {
await apiClient.post(`/api/trading/orders/${orderId}/cancel`)
},
cancelAllOrders: async (paperTrading: boolean = true): Promise<{
status: string
cancelled_count: number
failed_count: number
total_orders: number
}> => {
const response = await apiClient.post('/api/trading/orders/cancel-all', null, {
params: { paper_trading: paperTrading },
})
return response.data
},
getPositions: async (paperTrading: boolean = true): Promise<PositionResponse[]> => {
const response = await apiClient.get('/api/trading/positions', {
params: { paper_trading: paperTrading },
})
return response.data
},
getBalance: async (paperTrading: boolean = true) => {
const response = await apiClient.get('/api/trading/balance', {
params: { paper_trading: paperTrading },
})
return response.data
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

View File

@@ -0,0 +1,99 @@
import { useQuery } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
CircularProgress,
} from '@mui/material'
import { alertsApi, AlertResponse } from '../api/alerts'
import DataFreshness from './DataFreshness'
import { settingsApi } from '../api/settings'
import { formatDate } from '../utils/formatters'
export default function AlertHistory() {
const { data: alerts, isLoading } = useQuery({
queryKey: ['alerts'],
queryFn: () => alertsApi.listAlerts(false),
refetchInterval: 5000,
})
const { data: generalSettings } = useQuery({
queryKey: ['general-settings'],
queryFn: () => settingsApi.getGeneralSettings(),
})
const triggeredAlerts = alerts?.filter((alert) => alert.triggered) || []
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
)
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Alert History</Typography>
{alerts && alerts.length > 0 && (
<DataFreshness timestamp={alerts[0]?.updated_at} />
)}
</Box>
{triggeredAlerts.length === 0 ? (
<Paper sx={{ p: 3 }}>
<Typography variant="body1" color="text.secondary" align="center">
No alerts have been triggered yet
</Typography>
</Paper>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Alert Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Condition</TableCell>
<TableCell>Triggered At</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{triggeredAlerts.map((alert: AlertResponse) => (
<TableRow key={alert.id}>
<TableCell>{alert.name}</TableCell>
<TableCell>
<Chip label={alert.alert_type} size="small" variant="outlined" />
</TableCell>
<TableCell>
{alert.condition?.symbol || 'N/A'}
{alert.condition?.price_threshold && ` @ $${alert.condition.price_threshold}`}
</TableCell>
<TableCell>
{formatDate(alert.triggered_at || '', generalSettings)}
</TableCell>
<TableCell>
<Chip
label={alert.enabled ? 'Enabled' : 'Disabled'}
color={alert.enabled ? 'success' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react'
import { createChart, ColorType, IChartApi, ISeriesApi } from 'lightweight-charts'
import { Box, Typography } from '@mui/material'
interface ChartProps {
data: {
time: number
open: number
high: number
low: number
close: number
}[]
height?: number
colors?: {
backgroundColor?: string
lineColor?: string
textColor?: string
areaTopColor?: string
areaBottomColor?: string
}
}
export default function Chart({ data, height = 400, colors }: ChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candlestickSeriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null)
// Validate data
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<Box sx={{ width: '100%', height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="body2" color="text.secondary">
No chart data available
</Typography>
</Box>
)
}
useEffect(() => {
if (!chartContainerRef.current) return
try {
const handleResize = () => {
if (chartRef.current && chartContainerRef.current) {
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth })
}
}
const chart = createChart(chartContainerRef.current, {
layout: {
background: { type: ColorType.Solid, color: colors?.backgroundColor || '#1E1E1E' },
textColor: colors?.textColor || '#DDD',
},
grid: {
vertLines: { color: '#333' },
horzLines: { color: '#333' },
},
width: chartContainerRef.current.clientWidth,
height,
})
const candlestickSeries = chart.addCandlestickSeries({
upColor: '#26a69a',
downColor: '#ef5350',
borderVisible: false,
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
if (data && data.length > 0) {
candlestickSeries.setData(data as any)
}
chartRef.current = chart
candlestickSeriesRef.current = candlestickSeries
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
}
} catch (error) {
console.error('Chart initialization error:', error)
}
}, [colors, height])
useEffect(() => {
if (candlestickSeriesRef.current && data && data.length > 0) {
try {
candlestickSeriesRef.current.setData(data as any)
} catch (error) {
console.error('Chart data update error:', error)
}
}
}, [data])
return <Box ref={chartContainerRef} sx={{ width: '100%', height }} />
}

View File

@@ -0,0 +1,126 @@
import { useQueries } from '@tanstack/react-query'
import { Box, Paper, Typography, Button, Grid, Skeleton } from '@mui/material'
import { Refresh } from '@mui/icons-material'
import Chart from './Chart'
import RealtimePrice from './RealtimePrice'
import { marketDataApi } from '../api/marketData'
interface ChartGridProps {
symbols: string[]
onAnalyze?: (symbol: string) => void
isAnalyzing?: boolean
}
export default function ChartGrid({ symbols, onAnalyze, isAnalyzing }: ChartGridProps) {
// Fetch OHLCV data for all symbols in parallel
const ohlcvQueries = useQueries({
queries: symbols.map((symbol) => ({
queryKey: ['market-data', symbol],
queryFn: () => marketDataApi.getOHLCV(symbol),
refetchInterval: 60000,
staleTime: 30000,
})),
})
// Determine grid columns based on symbol count
const getGridColumns = () => {
if (symbols.length === 1) return 12
if (symbols.length === 2) return 6
return 6 // 2x2 grid for 3-4 symbols
}
const chartHeight = symbols.length === 1 ? 400 : 280
return (
<Grid container spacing={2}>
{symbols.map((symbol, index) => {
const query = ohlcvQueries[index]
const isLoading = query?.isLoading
const data = query?.data
return (
<Grid item xs={12} md={getGridColumns()} key={symbol}>
<Paper
sx={{
p: 2,
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 4,
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1.5,
flexWrap: 'wrap',
gap: 1,
}}
>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 600, lineHeight: 1.2 }}
>
{symbol}
</Typography>
<RealtimePrice
symbol={symbol}
size="small"
showProvider={false}
showChange={true}
/>
</Box>
{onAnalyze && (
<Button
size="small"
variant="outlined"
startIcon={<Refresh />}
onClick={() => onAnalyze(symbol)}
disabled={isAnalyzing}
sx={{ minWidth: 'auto', px: 1.5 }}
>
Analyze
</Button>
)}
</Box>
{/* Chart */}
{isLoading ? (
<Skeleton
variant="rectangular"
width="100%"
height={chartHeight}
sx={{ borderRadius: 1 }}
/>
) : data && Array.isArray(data) && data.length > 0 ? (
<Box sx={{ height: chartHeight }}>
<Chart data={data} height={chartHeight} />
</Box>
) : (
<Box
sx={{
height: chartHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
borderRadius: 1,
}}
>
<Typography color="text.secondary" variant="body2">
No data for {symbol}
</Typography>
</Box>
)}
</Paper>
</Grid>
)
})}
</Grid>
)
}

View File

@@ -0,0 +1,64 @@
import { Typography, Chip, Tooltip } from '@mui/material'
import { AccessTime } from '@mui/icons-material'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { settingsApi } from '../api/settings'
import { formatDate } from '../utils/formatters'
interface DataFreshnessProps {
timestamp: string | Date | null | undefined
refreshInterval?: number
}
export default function DataFreshness({ timestamp, refreshInterval = 5000 }: DataFreshnessProps) {
const { data: generalSettings } = useQuery({
queryKey: ['general-settings'],
queryFn: settingsApi.getGeneralSettings,
})
const freshness = useMemo(() => {
if (!timestamp) return { status: 'unknown', text: 'No data', color: 'default' as const }
const lastUpdate = new Date(timestamp)
const now = new Date()
const ageMs = now.getTime() - lastUpdate.getTime()
const ageSeconds = Math.floor(ageMs / 1000)
if (ageSeconds < refreshInterval / 1000) {
return { status: 'fresh', text: 'Just now', color: 'success' as const }
} else if (ageSeconds < 60) {
return { status: 'fresh', text: `${ageSeconds}s ago`, color: 'success' as const }
} else if (ageSeconds < 300) {
const minutes = Math.floor(ageSeconds / 60)
return { status: 'stale', text: `${minutes}m ago`, color: 'warning' as const }
} else {
const minutes = Math.floor(ageSeconds / 60)
return { status: 'outdated', text: `${minutes}m ago`, color: 'error' as const }
}
}, [timestamp, refreshInterval])
if (!timestamp) {
return (
<Chip
icon={<AccessTime />}
label="No data"
size="small"
color="default"
variant="outlined"
/>
)
}
return (
<Tooltip title={`Last updated: ${formatDate(timestamp, generalSettings)}`}>
<Chip
icon={<AccessTime />}
label={freshness.text}
size="small"
color={freshness.color}
variant="outlined"
/>
</Tooltip>
)
}

View File

@@ -0,0 +1,39 @@
import { Alert, AlertTitle, Button, Box } from '@mui/material'
import { Refresh } from '@mui/icons-material'
interface ErrorDisplayProps {
error: Error | string
onRetry?: () => void
title?: string
}
export default function ErrorDisplay({ error, onRetry, title = 'Error' }: ErrorDisplayProps) {
const errorMessage = error instanceof Error ? error.message : error
return (
<Alert
severity="error"
action={
onRetry && (
<Button color="inherit" size="small" onClick={onRetry} startIcon={<Refresh />}>
Retry
</Button>
)
}
>
<AlertTitle>{title}</AlertTitle>
{errorMessage}
{error instanceof Error && error.stack && (
<Box sx={{ mt: 1 }}>
<details>
<summary style={{ cursor: 'pointer', fontSize: '12px' }}>Technical Details</summary>
<pre style={{ fontSize: '11px', marginTop: '8px', overflow: 'auto' }}>
{error.stack}
</pre>
</details>
</Box>
)}
</Alert>
)
}

View File

@@ -0,0 +1,18 @@
import { Tooltip, IconButton } from '@mui/material'
import { HelpOutline } from '@mui/icons-material'
interface HelpTooltipProps {
title: string
placement?: 'top' | 'bottom' | 'left' | 'right'
}
export default function HelpTooltip({ title, placement = 'top' }: HelpTooltipProps) {
return (
<Tooltip title={title} placement={placement}>
<IconButton size="small" sx={{ p: 0.5, ml: 0.5 }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
)
}

View File

@@ -0,0 +1,39 @@
import { Card, CardContent, Typography, Box, IconButton, Collapse } from '@mui/material'
import { Info, ExpandMore, ExpandLess } from '@mui/icons-material'
import { useState } from 'react'
interface InfoCardProps {
title: string
children: React.ReactNode
collapsible?: boolean
}
export default function InfoCard({ title, children, collapsible = false }: InfoCardProps) {
const [expanded, setExpanded] = useState(!collapsible)
return (
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: collapsible && !expanded ? 0 : 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Info color="primary" fontSize="small" />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
</Box>
{collapsible && (
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
{expanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
)}
</Box>
<Collapse in={expanded}>
<Typography variant="body2" color="text.secondary">
{children}
</Typography>
</Collapse>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,132 @@
import { ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
Box,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
AppBar,
Toolbar,
Typography,
Container,
} from '@mui/material'
import {
Dashboard,
AccountBalance,
Assessment,
Settings,
Psychology,
ShoppingCart,
} from '@mui/icons-material'
import logo from '../assets/logo.png'
const Logo = () => (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 2,
mt: 'auto',
}}
>
<Box
component="img"
src={logo}
alt="FXQ One Logo"
sx={{
height: 120,
width: 'auto',
maxWidth: '90%',
filter: 'drop-shadow(0 0 12px rgba(255, 152, 0, 0.7)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.6))',
transition: 'filter 0.3s ease, transform 0.3s ease',
'&:hover': {
filter: 'drop-shadow(0 0 20px rgba(255, 152, 0, 0.9)) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.7))',
transform: 'scale(1.08)',
},
}}
/>
</Box>
)
const drawerWidth = 200
interface LayoutProps {
children: ReactNode
}
const menuItems = [
{ text: 'Dashboard', icon: <Dashboard />, path: '/' },
{ text: 'Strategies', icon: <Psychology />, path: '/strategies' },
{ text: 'Trading', icon: <ShoppingCart />, path: '/trading' },
{ text: 'Portfolio', icon: <AccountBalance />, path: '/portfolio' },
{ text: 'Backtesting', icon: <Assessment />, path: '/backtesting' },
{ text: 'Settings', icon: <Settings />, path: '/settings' },
]
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const location = useLocation()
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<Typography variant="h6" noWrap component="div" sx={{ fontWeight: 'bold' }}>
FXQ One
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto', flexGrow: 1 }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
<Logo />
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
bgcolor: 'background.default',
minHeight: '100vh',
}}
>
<Toolbar />
<Container maxWidth="xl" sx={{ py: 3 }}>
{children}
</Container>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,64 @@
import { Skeleton, Box } from '@mui/material'
interface LoadingSkeletonProps {
variant?: 'table' | 'card' | 'list' | 'text'
rows?: number
}
export default function LoadingSkeleton({ variant = 'text', rows = 3 }: LoadingSkeletonProps) {
if (variant === 'table') {
return (
<Box>
{Array.from({ length: rows }).map((_, i) => (
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Skeleton variant="rectangular" width="20%" height={40} />
<Skeleton variant="rectangular" width="15%" height={40} />
<Skeleton variant="rectangular" width="15%" height={40} />
<Skeleton variant="rectangular" width="15%" height={40} />
<Skeleton variant="rectangular" width="15%" height={40} />
<Skeleton variant="rectangular" width="20%" height={40} />
</Box>
))}
</Box>
)
}
if (variant === 'card') {
return (
<Box>
{Array.from({ length: rows }).map((_, i) => (
<Box key={i} sx={{ mb: 2 }}>
<Skeleton variant="rectangular" height={200} />
<Box sx={{ p: 2 }}>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="40%" />
</Box>
</Box>
))}
</Box>
)
}
if (variant === 'list') {
return (
<Box>
{Array.from({ length: rows }).map((_, i) => (
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="text" width="70%" height={40} />
<Skeleton variant="text" width="20%" height={40} />
</Box>
))}
</Box>
)
}
return (
<Box>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} variant="text" sx={{ mb: 1 }} />
))}
</Box>
)
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react'
import {
Box,
Paper,
Typography,
List,
ListItem,
ListItemText,
Chip,
IconButton,
Collapse,
LinearProgress,
} from '@mui/material'
import { ExpandMore, ExpandLess, PlayArrow, Stop } from '@mui/icons-material'
interface Operation {
id: string
type: 'backtest' | 'optimization' | 'strategy' | 'order'
name: string
status: 'running' | 'queued' | 'completed' | 'failed'
progress?: number
startTime?: Date
estimatedTimeRemaining?: number
}
interface OperationsPanelProps {
operations?: Operation[]
onCancel?: (id: string) => void
}
export default function OperationsPanel({ operations = [], onCancel }: OperationsPanelProps) {
const [expanded, setExpanded] = useState(true)
const runningOperations = operations.filter((op) => op.status === 'running' || op.status === 'queued')
const completedOperations = operations.filter((op) => op.status === 'completed' || op.status === 'failed')
if (operations.length === 0) {
return null
}
return (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">
Operations ({runningOperations.length} running)
</Typography>
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
{expanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Collapse in={expanded}>
<List dense>
{runningOperations.map((op) => (
<ListItem key={op.id}>
<ListItemText
primary={op.name}
secondary={
<Box>
<Chip
label={op.status}
size="small"
color={op.status === 'running' ? 'primary' : 'default'}
sx={{ mr: 1 }}
/>
{op.progress !== undefined && (
<Box sx={{ mt: 1, width: '100%' }}>
<LinearProgress variant="determinate" value={op.progress} />
<Typography variant="caption" color="text.secondary">
{op.progress}%
</Typography>
</Box>
)}
</Box>
}
/>
{onCancel && op.status === 'running' && (
<IconButton size="small" onClick={() => onCancel(op.id)}>
<Stop fontSize="small" />
</IconButton>
)}
</ListItem>
))}
{runningOperations.length === 0 && (
<ListItem>
<ListItemText
primary="No running operations"
secondary="All operations are idle"
/>
</ListItem>
)}
</List>
</Collapse>
</Paper>
)
}

View File

@@ -0,0 +1,120 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Alert,
} from '@mui/material'
import { OrderSide, OrderType } from '../types'
interface OrderConfirmationDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
symbol: string
side: OrderSide
orderType: OrderType
quantity: number
price?: number
stopPrice?: number
takeProfitPrice?: number
trailingPercent?: number
}
export default function OrderConfirmationDialog({
open,
onClose,
onConfirm,
symbol,
side,
orderType,
quantity,
price,
stopPrice,
takeProfitPrice,
trailingPercent,
}: OrderConfirmationDialogProps) {
const estimatedTotal = price ? quantity * price : null
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
Confirm {side.toUpperCase()} Order
</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Alert severity="warning" sx={{ mb: 2 }}>
Please verify all details carefully before confirming.
</Alert>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Symbol:</Typography>
<Typography variant="body2" fontWeight="bold">{symbol}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Side:</Typography>
<Typography
variant="body2"
fontWeight="bold"
color={side === OrderSide.BUY ? 'success.main' : 'error.main'}
>
{side.toUpperCase()}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Type:</Typography>
<Typography variant="body2" fontWeight="bold">{orderType.replace('_', ' ')}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Quantity:</Typography>
<Typography variant="body2" fontWeight="bold">{quantity.toFixed(8)}</Typography>
</Box>
{price && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Price:</Typography>
<Typography variant="body2" fontWeight="bold">${price.toFixed(2)}</Typography>
</Box>
)}
{stopPrice && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Stop Price:</Typography>
<Typography variant="body2" fontWeight="bold">${stopPrice.toFixed(2)}</Typography>
</Box>
)}
{takeProfitPrice && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Take Profit Price:</Typography>
<Typography variant="body2" fontWeight="bold">${takeProfitPrice.toFixed(2)}</Typography>
</Box>
)}
{trailingPercent && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">Trailing Percent:</Typography>
<Typography variant="body2" fontWeight="bold">{(trailingPercent * 100).toFixed(2)}%</Typography>
</Box>
)}
{estimatedTotal && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body1" fontWeight="bold">Est. Total:</Typography>
<Typography variant="body1" fontWeight="bold">${estimatedTotal.toFixed(2)}</Typography>
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
onClick={onConfirm}
variant="contained"
color={side === OrderSide.BUY ? 'success' : 'error'}
>
Confirm Order
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,316 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Alert,
Box,
Typography,
Divider,
Collapse,
} from '@mui/material'
import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { tradingApi } from '../api/trading'
import { OrderCreate, OrderSide, OrderType } from '../types'
import { useSnackbar } from '../contexts/SnackbarContext'
import RealtimePrice from './RealtimePrice'
import ProviderStatusDisplay from './ProviderStatus'
interface OrderFormProps {
open: boolean
onClose: () => void
exchanges: Array<{ id: number; name: string }>
paperTrading: boolean
onSuccess: () => void
}
const CRYPTO_PAIRS = [
'BTC/USD',
'ETH/USD',
'BTC/USDT',
'ETH/USDT',
'SOL/USD',
'ADA/USD',
'XRP/USD',
'DOGE/USD',
'DOT/USD',
'MATIC/USD',
'AVAX/USD',
'LINK/USD',
]
const ORDER_TYPES = [
{ value: OrderType.MARKET, label: 'Market' },
{ value: OrderType.LIMIT, label: 'Limit' },
{ value: OrderType.STOP_LOSS, label: 'Stop Loss' },
{ value: OrderType.TAKE_PROFIT, label: 'Take Profit' },
{ value: OrderType.TRAILING_STOP, label: 'Trailing Stop' },
{ value: OrderType.OCO, label: 'OCO (One-Cancels-Other)' },
{ value: OrderType.ICEBERG, label: 'Iceberg' },
]
export default function OrderForm({
open,
onClose,
exchanges,
paperTrading,
onSuccess,
}: OrderFormProps) {
const queryClient = useQueryClient()
const { showError } = useSnackbar()
const [exchangeId, setExchangeId] = useState<number | ''>('')
const [symbol, setSymbol] = useState('BTC/USD')
const [side, setSide] = useState<OrderSide>(OrderSide.BUY)
const [orderType, setOrderType] = useState<OrderType>(OrderType.MARKET)
const [quantity, setQuantity] = useState('')
const [price, setPrice] = useState('')
const [stopPrice, setStopPrice] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
useEffect(() => {
if (exchanges.length > 0 && !exchangeId) {
setExchangeId(exchanges[0].id)
}
}, [exchanges, exchangeId])
useEffect(() => {
if (!open) {
// Reset form when dialog closes
setExchangeId(exchanges.length > 0 ? exchanges[0].id : '')
setSymbol('BTC/USD')
setSide(OrderSide.BUY)
setOrderType(OrderType.MARKET)
setQuantity('')
setPrice('')
setStopPrice('')
setShowAdvanced(false)
}
}, [open, exchanges])
const createOrderMutation = useMutation({
mutationFn: (order: OrderCreate) => tradingApi.createOrder(order),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
queryClient.invalidateQueries({ queryKey: ['positions'] })
queryClient.invalidateQueries({ queryKey: ['balance'] })
onSuccess()
},
})
const handleSubmit = () => {
if (!exchangeId) {
showError('Please select an exchange')
return
}
if (!quantity || parseFloat(quantity) <= 0) {
showError('Please enter a valid quantity')
return
}
const requiresPrice = [
OrderType.LIMIT,
OrderType.STOP_LOSS,
OrderType.TAKE_PROFIT,
].includes(orderType)
if (requiresPrice && (!price || parseFloat(price) <= 0)) {
showError('Please enter a valid price for this order type')
return
}
const order: OrderCreate = {
exchange_id: exchangeId as number,
symbol,
side,
order_type: orderType,
quantity: parseFloat(quantity),
price: requiresPrice ? parseFloat(price) : undefined,
paper_trading: paperTrading,
}
createOrderMutation.mutate(order)
}
const requiresPrice = [
OrderType.LIMIT,
OrderType.STOP_LOSS,
OrderType.TAKE_PROFIT,
].includes(orderType)
const requiresStopPrice = [
OrderType.STOP_LOSS,
OrderType.TRAILING_STOP,
].includes(orderType)
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Place Order</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Exchange</InputLabel>
<Select
value={exchangeId}
label="Exchange"
onChange={(e) => setExchangeId(e.target.value as number)}
>
{exchanges.map((ex) => (
<MenuItem key={ex.id} value={ex.id}>
{ex.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Symbol</InputLabel>
<Select
value={symbol}
label="Symbol"
onChange={(e) => setSymbol(e.target.value)}
>
{CRYPTO_PAIRS.map((pair) => (
<MenuItem key={pair} value={pair}>
{pair}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<Box sx={{ mb: 1 }}>
<RealtimePrice symbol={symbol} size="small" showProvider={false} showChange={true} />
</Box>
<Divider sx={{ my: 1 }} />
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Side</InputLabel>
<Select
value={side}
label="Side"
onChange={(e) => setSide(e.target.value as OrderSide)}
>
<MenuItem value={OrderSide.BUY}>Buy</MenuItem>
<MenuItem value={OrderSide.SELL}>Sell</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Order Type</InputLabel>
<Select
value={orderType}
label="Order Type"
onChange={(e) => setOrderType(e.target.value as OrderType)}
>
{ORDER_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
required
inputProps={{ min: 0, step: 0.00000001 }}
helperText="Amount to buy or sell"
/>
</Grid>
{requiresPrice && (
<Grid item xs={12}>
<TextField
fullWidth
label="Price"
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
required
inputProps={{ min: 0, step: 0.01 }}
helperText="Limit price for the order"
/>
</Grid>
)}
{requiresStopPrice && (
<Grid item xs={12}>
<TextField
fullWidth
label="Stop Price"
type="number"
value={stopPrice}
onChange={(e) => setStopPrice(e.target.value)}
inputProps={{ min: 0, step: 0.01 }}
helperText="Stop price for stop-loss or trailing stop orders"
/>
</Grid>
)}
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
onClick={() => setShowAdvanced(!showAdvanced)}
endIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />}
>
Advanced Options
</Button>
</Box>
<Collapse in={showAdvanced}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.default', borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Advanced order types (OCO, Iceberg) require additional configuration.
These features are available in the API but may need custom implementation.
</Typography>
</Box>
</Collapse>
</Grid>
{paperTrading && (
<Grid item xs={12}>
<Alert severity="info">
This order will be executed in paper trading mode with virtual funds.
</Alert>
</Grid>
)}
{createOrderMutation.isError && (
<Grid item xs={12}>
<Alert severity="error">
{createOrderMutation.error instanceof Error
? createOrderMutation.error.message
: 'Failed to place order'}
</Alert>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={createOrderMutation.isPending}
>
{createOrderMutation.isPending ? 'Placing...' : 'Place Order'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,240 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Card,
CardContent,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Grid,
Chip,
Alert,
Divider,
} from '@mui/material'
import { Close } from '@mui/icons-material'
import { tradingApi } from '../api/trading'
import { PositionResponse, OrderCreate, OrderSide, OrderType } from '../types'
import { useSnackbar } from '../contexts/SnackbarContext'
interface PositionCardProps {
position: PositionResponse
paperTrading: boolean
onClose: () => void
}
export default function PositionCard({
position,
paperTrading,
onClose,
}: PositionCardProps) {
const queryClient = useQueryClient()
const { showError } = useSnackbar()
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
const [closeType, setCloseType] = useState<OrderType>(OrderType.MARKET)
const [closePrice, setClosePrice] = useState('')
const closePositionMutation = useMutation({
mutationFn: (order: OrderCreate) => tradingApi.createOrder(order),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
queryClient.invalidateQueries({ queryKey: ['orders'] })
queryClient.invalidateQueries({ queryKey: ['balance'] })
setCloseDialogOpen(false)
onClose()
},
})
const handleClosePosition = () => {
setCloseDialogOpen(true)
}
const handleConfirmClose = () => {
// Extract exchange_id from position or use default
// In a real implementation, you'd get this from the position or context
const exchangeId = 1 // This should come from position data or context
const requiresPrice = closeType === OrderType.LIMIT
if (requiresPrice && (!closePrice || parseFloat(closePrice) <= 0)) {
showError('Please enter a valid price for limit orders')
return
}
const order: OrderCreate = {
exchange_id: exchangeId,
symbol: position.symbol,
side: OrderSide.SELL,
order_type: closeType,
quantity: position.quantity,
price: requiresPrice ? parseFloat(closePrice) : undefined,
paper_trading: paperTrading,
}
closePositionMutation.mutate(order)
}
const pnlPercent =
position.entry_price > 0
? ((position.current_price - position.entry_price) / position.entry_price) * 100
: 0
return (
<>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="h6">{position.symbol}</Typography>
<Chip
label={pnlPercent >= 0 ? `+${pnlPercent.toFixed(2)}%` : `${pnlPercent.toFixed(2)}%`}
color={pnlPercent >= 0 ? 'success' : 'error'}
size="small"
/>
</Box>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Quantity
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{Number(position.quantity).toFixed(8)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Entry Price
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
${Number(position.entry_price).toFixed(2)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Current Price
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
${Number(position.current_price).toFixed(2)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Value
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
${(Number(position.quantity) * Number(position.current_price)).toFixed(2)}
</Typography>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Unrealized P&L
</Typography>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: position.unrealized_pnl >= 0 ? 'success.main' : 'error.main',
}}
>
{position.unrealized_pnl >= 0 ? '+' : ''}
${Number(position.unrealized_pnl).toFixed(2)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary">
Realized P&L
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 500,
color: position.realized_pnl >= 0 ? 'success.main' : 'error.main',
}}
>
{position.realized_pnl >= 0 ? '+' : ''}
${Number(position.realized_pnl).toFixed(2)}
</Typography>
</Grid>
<Grid item xs={12}>
<Button
variant="outlined"
color="error"
fullWidth
startIcon={<Close />}
onClick={handleClosePosition}
sx={{ mt: 1 }}
>
Close Position
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
<Dialog open={closeDialogOpen} onClose={() => setCloseDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Close Position</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Alert severity="info" sx={{ mb: 2 }}>
Closing {Number(position.quantity).toFixed(8)} {position.symbol} at current price of ${Number(position.current_price).toFixed(2)}
</Alert>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Order Type</InputLabel>
<Select
value={closeType}
label="Order Type"
onChange={(e) => {
setCloseType(e.target.value as OrderType)
setClosePrice('')
}}
>
<MenuItem value={OrderType.MARKET}>Market</MenuItem>
<MenuItem value={OrderType.LIMIT}>Limit</MenuItem>
</Select>
</FormControl>
{closeType === OrderType.LIMIT && (
<TextField
fullWidth
label="Limit Price"
type="number"
value={closePrice}
onChange={(e) => setClosePrice(e.target.value)}
inputProps={{ min: 0, step: 0.01 }}
helperText="Price at which to close the position"
/>
)}
{closePositionMutation.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{closePositionMutation.error instanceof Error
? closePositionMutation.error.message
: 'Failed to close position'}
</Alert>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCloseDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="error"
onClick={handleConfirmClose}
disabled={closePositionMutation.isPending}
>
{closePositionMutation.isPending ? 'Closing...' : 'Close Position'}
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,45 @@
import { Box, CircularProgress, Typography, LinearProgress } from '@mui/material'
interface ProgressOverlayProps {
message?: string
progress?: number
variant?: 'indeterminate' | 'determinate'
}
export default function ProgressOverlay({
message = 'Loading...',
progress,
variant = 'indeterminate',
}: ProgressOverlayProps) {
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1000,
}}
>
<CircularProgress sx={{ mb: 2 }} />
{variant === 'determinate' && progress !== undefined && (
<Box sx={{ width: '200px', mb: 2 }}>
<LinearProgress variant="determinate" value={progress} />
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{progress.toFixed(0)}%
</Typography>
</Box>
)}
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Box>
)
}

View File

@@ -0,0 +1,132 @@
import { Box, Chip, Tooltip, Typography, CircularProgress } from '@mui/material'
import { CheckCircle, Error, Warning, CloudOff, Info } from '@mui/icons-material'
import { useProviderStatus } from '../hooks/useProviderStatus'
import StatusIndicator from './StatusIndicator'
interface ProviderStatusProps {
compact?: boolean
showDetails?: boolean
}
export default function ProviderStatusDisplay({ compact = false, showDetails = false }: ProviderStatusProps) {
const { status, isLoading, error } = useProviderStatus()
if (isLoading) {
return compact ? (
<CircularProgress size={16} />
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} />
<Typography variant="body2">Loading provider status...</Typography>
</Box>
)
}
if (error) {
return (
<Chip
icon={<Error />}
label="Provider status unavailable"
color="error"
size="small"
variant="outlined"
/>
)
}
if (!status) {
return null
}
const activeProvider = status.active_provider
const providerHealth = activeProvider ? status.providers[activeProvider] : null
if (compact) {
return (
<Tooltip
title={
activeProvider
? `Active: ${activeProvider}${providerHealth ? ` (${providerHealth.status})` : ''}`
: 'No active provider'
}
>
<Box>
{providerHealth ? (
<StatusIndicator
status={
providerHealth.status === 'healthy'
? 'connected'
: providerHealth.status === 'degraded'
? 'warning'
: 'error'
}
label={activeProvider || 'None'}
/>
) : (
<Chip label={activeProvider || 'None'} size="small" color="default" variant="outlined" />
)}
</Box>
</Tooltip>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">Data Providers</Typography>
{activeProvider && (
<Chip
label={`Active: ${activeProvider}`}
color={providerHealth?.status === 'healthy' ? 'success' : 'default'}
size="small"
/>
)}
</Box>
{showDetails && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{Object.entries(status.providers).map(([name, health]) => (
<Box
key={name}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StatusIndicator
status={
health.status === 'healthy'
? 'connected'
: health.status === 'degraded'
? 'warning'
: 'error'
}
label={name}
/>
{name === activeProvider && (
<Chip label="Active" size="small" color="primary" variant="outlined" />
)}
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
{health.avg_response_time > 0 && (
<Typography variant="caption" color="text.secondary">
{health.avg_response_time.toFixed(3)}s avg
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{health.success_count} success, {health.failure_count} failures
</Typography>
</Box>
</Box>
))}
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react'
import { Box, Typography, Chip, CircularProgress, Tooltip, Fade } from '@mui/material'
import { TrendingUp, TrendingDown } from '@mui/icons-material'
import { useQuery } from '@tanstack/react-query'
import { marketDataApi, TickerData } from '../api/marketData'
import { useWebSocketContext } from './WebSocketProvider'
interface RealtimePriceProps {
symbol: string
showProvider?: boolean
showChange?: boolean
size?: 'small' | 'medium' | 'large'
onPriceUpdate?: (price: number) => void
}
export default function RealtimePrice({
symbol,
showProvider = true,
showChange = true,
size = 'medium',
onPriceUpdate,
}: RealtimePriceProps) {
const { isConnected, lastMessage } = useWebSocketContext()
const [currentPrice, setCurrentPrice] = useState<number | null>(null)
const [previousPrice, setPreviousPrice] = useState<number | null>(null)
const [priceChange, setPriceChange] = useState<number>(0)
const [flash, setFlash] = useState(false)
const { data: ticker, isLoading, error, refetch } = useQuery({
queryKey: ['ticker', symbol],
queryFn: () => marketDataApi.getTicker(symbol),
refetchInterval: 5000, // Refetch every 5 seconds as fallback
enabled: !!symbol,
})
// Update price when ticker data changes
useEffect(() => {
// Only update if we have a valid price (including 0, but not null/undefined)
if (ticker?.last !== undefined && ticker?.last !== null) {
const newPrice = ticker.last
setCurrentPrice((prevPrice) => {
// Check if price has changed
if (prevPrice !== null && newPrice !== prevPrice) {
setPreviousPrice(prevPrice)
setPriceChange(newPrice - prevPrice)
// Flash animation
setFlash(true)
setTimeout(() => setFlash(false), 2000)
// Call callback if provided
if (onPriceUpdate) {
onPriceUpdate(newPrice)
}
}
// Always update currentPrice when we have valid ticker data
// This ensures the price persists even if ticker becomes undefined later
return newPrice
})
}
}, [ticker?.last, onPriceUpdate])
// Listen for WebSocket price updates
useEffect(() => {
if (!isConnected || !lastMessage) return
try {
const message = typeof lastMessage === 'string' ? JSON.parse(lastMessage) : lastMessage
if (message.type === 'price_update' && message.symbol === symbol && message.price !== undefined && message.price !== null) {
const newPrice = parseFloat(message.price)
// Validate the parsed price
if (isNaN(newPrice)) return
setCurrentPrice((prevPrice) => {
if (prevPrice !== null && newPrice !== prevPrice) {
setPreviousPrice(prevPrice)
setPriceChange(newPrice - prevPrice)
// Flash animation
setFlash(true)
setTimeout(() => setFlash(false), 2000)
if (onPriceUpdate) {
onPriceUpdate(newPrice)
}
}
return newPrice
})
}
} catch (e) {
// Ignore parsing errors
}
}, [isConnected, lastMessage, symbol, onPriceUpdate])
if (isLoading && currentPrice === null) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={size === 'small' ? 16 : size === 'large' ? 24 : 20} />
<Typography variant={size === 'small' ? 'body2' : size === 'large' ? 'h6' : 'body1'}>
Loading...
</Typography>
</Box>
)
}
if (error) {
return (
<Typography variant={size === 'small' ? 'body2' : 'body1'} color="error">
Error loading price
</Typography>
)
}
// Use currentPrice if available, otherwise fall back to ticker?.last
// Only show 0 if both are explicitly 0 (not null/undefined)
const price = currentPrice !== null ? currentPrice : (ticker?.last !== undefined && ticker?.last !== null ? ticker.last : null)
const isPositive = priceChange >= 0
const changePercent = currentPrice !== null && previousPrice !== null ? ((priceChange / previousPrice) * 100).toFixed(2) : null
const priceVariant = size === 'small' ? 'body2' : size === 'large' ? 'h5' : 'h6'
const priceColor = flash ? (isPositive ? 'success.main' : 'error.main') : 'text.primary'
// Don't render price if we don't have a valid price value
if (price === null) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={size === 'small' ? 16 : size === 'large' ? 24 : 20} />
<Typography variant={size === 'small' ? 'body2' : size === 'large' ? 'h6' : 'body1'}>
Loading price...
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography
variant={priceVariant}
sx={{
color: priceColor,
fontWeight: 600,
transition: 'color 0.3s',
}}
>
${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 })}
</Typography>
{showChange && changePercent && (
<Chip
icon={isPositive ? <TrendingUp /> : <TrendingDown />}
label={`${isPositive ? '+' : ''}${changePercent}%`}
color={isPositive ? 'success' : 'error'}
size="small"
variant="outlined"
/>
)}
{showProvider && ticker?.provider && (
<Tooltip title={`Data provider: ${ticker.provider}`}>
<Chip label={ticker.provider} size="small" variant="outlined" color="default" />
</Tooltip>
)}
</Box>
{ticker && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
24h: ${ticker.high.toFixed(2)} / ${ticker.low.toFixed(2)}
</Typography>
{ticker.volume > 0 && (
<Typography variant="caption" color="text.secondary">
Vol: ${ticker.volume.toLocaleString('en-US', { maximumFractionDigits: 0 })}
</Typography>
)}
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,253 @@
import { useQuery } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Grid,
Chip,
Tooltip,
CircularProgress,
} from '@mui/material'
import {
TrendingUp,
TrendingDown,
SwapHoriz,
} from '@mui/icons-material'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import { marketDataApi } from '../api/marketData'
interface SpreadChartProps {
primarySymbol: string
secondarySymbol: string
lookbackPeriod?: number
zScoreThreshold?: number
}
export default function SpreadChart({
primarySymbol,
secondarySymbol,
lookbackPeriod = 20,
zScoreThreshold = 2.0,
}: SpreadChartProps) {
// Fetch spread data from backend
const { data: spreadResponse, isLoading, error } = useQuery({
queryKey: ['spread-data', primarySymbol, secondarySymbol, lookbackPeriod],
queryFn: () => marketDataApi.getSpreadData(
primarySymbol,
secondarySymbol,
'1h',
lookbackPeriod + 30
),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
})
const spreadData = spreadResponse?.data ?? []
const currentZScore = spreadResponse?.currentZScore ?? 0
const currentSpread = spreadResponse?.currentSpread ?? 0
// Determine signal state
const getSignalState = () => {
if (currentZScore > zScoreThreshold) {
return { label: `Short Spread (Sell ${primarySymbol})`, color: 'error' as const, icon: <TrendingDown /> }
} else if (currentZScore < -zScoreThreshold) {
return { label: `Long Spread (Buy ${primarySymbol})`, color: 'success' as const, icon: <TrendingUp /> }
}
return { label: 'Neutral (No Signal)', color: 'default' as const, icon: <SwapHoriz /> }
}
const signalState = getSignalState()
if (isLoading) {
return (
<Paper sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 400 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading spread data...</Typography>
</Paper>
)
}
if (error) {
return (
<Paper sx={{ p: 2, minHeight: 400 }}>
<Typography color="error">Failed to load spread data: {(error as Error).message}</Typography>
</Paper>
)
}
return (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box>
<Typography variant="h6">
Pairs Trading: {primarySymbol} / {secondarySymbol}
</Typography>
<Typography variant="body2" color="text.secondary">
Statistical Arbitrage - Spread Analysis
</Typography>
</Box>
<Chip
icon={signalState.icon}
label={signalState.label}
color={signalState.color}
variant="outlined"
/>
</Box>
{/* Key Metrics */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={4}>
<Paper elevation={0} sx={{ p: 1.5, bgcolor: 'background.default', textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary">Current Spread</Typography>
<Typography variant="h5">{currentSpread?.toFixed(4) ?? 'N/A'}</Typography>
</Paper>
</Grid>
<Grid item xs={4}>
<Paper
elevation={0}
sx={{
p: 1.5,
bgcolor: Math.abs(currentZScore) > zScoreThreshold
? (currentZScore > 0 ? 'error.dark' : 'success.dark')
: 'background.default',
textAlign: 'center',
transition: 'background-color 0.3s',
}}
>
<Typography variant="caption" color="text.secondary">Z-Score</Typography>
<Typography
variant="h5"
sx={{
color: Math.abs(currentZScore) > zScoreThreshold ? 'white' : 'inherit'
}}
>
{currentZScore?.toFixed(2) ?? 'N/A'}
</Typography>
</Paper>
</Grid>
<Grid item xs={4}>
<Paper elevation={0} sx={{ p: 1.5, bgcolor: 'background.default', textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary">Threshold</Typography>
<Typography variant="h5">±{zScoreThreshold}</Typography>
</Paper>
</Grid>
</Grid>
{/* Z-Score Visual Gauge */}
<Box sx={{ mb: 3, px: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="success.main">-{zScoreThreshold} (Buy)</Typography>
<Typography variant="caption" color="text.secondary">0 (Neutral)</Typography>
<Typography variant="caption" color="error.main">+{zScoreThreshold} (Sell)</Typography>
</Box>
<Tooltip title={`Current Z-Score: ${currentZScore?.toFixed(2) ?? 'N/A'}`}>
<Box sx={{ position: 'relative', height: 20, bgcolor: 'background.default', borderRadius: 1 }}>
{/* Threshold zones */}
<Box sx={{
position: 'absolute',
left: 0,
width: `${(1 - zScoreThreshold / 4) * 50}%`,
height: '100%',
bgcolor: 'success.main',
opacity: 0.2,
borderRadius: '4px 0 0 4px'
}} />
<Box sx={{
position: 'absolute',
right: 0,
width: `${(1 - zScoreThreshold / 4) * 50}%`,
height: '100%',
bgcolor: 'error.main',
opacity: 0.2,
borderRadius: '0 4px 4px 0'
}} />
{/* Current position indicator */}
<Box sx={{
position: 'absolute',
left: `${Math.min(100, Math.max(0, (currentZScore + 4) / 8 * 100))}%`,
transform: 'translateX(-50%)',
width: 4,
height: '100%',
bgcolor: Math.abs(currentZScore) > zScoreThreshold
? (currentZScore > 0 ? 'error.main' : 'success.main')
: 'primary.main',
borderRadius: 1,
}} />
</Box>
</Tooltip>
</Box>
{/* Spread Chart */}
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Spread History (Ratio: {primarySymbol} / {secondarySymbol})
</Typography>
<Box sx={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={spreadData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="timestamp"
tickFormatter={(t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
stroke="rgba(255,255,255,0.5)"
/>
<YAxis stroke="rgba(255,255,255,0.5)" domain={['auto', 'auto']} />
<RechartsTooltip
contentStyle={{ backgroundColor: '#1e1e1e', border: '1px solid #333' }}
labelFormatter={(t) => new Date(t).toLocaleString()}
/>
<Line
type="monotone"
dataKey="spread"
stroke="#8884d8"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</Box>
{/* Z-Score Chart */}
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ mt: 2 }}>
Z-Score History
</Typography>
<Box sx={{ height: 150 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={spreadData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="timestamp"
tickFormatter={(t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
stroke="rgba(255,255,255,0.5)"
/>
<YAxis stroke="rgba(255,255,255,0.5)" domain={[-4, 4]} />
<RechartsTooltip
contentStyle={{ backgroundColor: '#1e1e1e', border: '1px solid #333' }}
labelFormatter={(t) => new Date(t).toLocaleString()}
/>
{/* Threshold lines */}
<ReferenceLine y={zScoreThreshold} stroke="#f44336" strokeDasharray="5 5" />
<ReferenceLine y={-zScoreThreshold} stroke="#4caf50" strokeDasharray="5 5" />
<ReferenceLine y={0} stroke="rgba(255,255,255,0.3)" />
<Line
type="monotone"
dataKey="zScore"
stroke="#82ca9d"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</Box>
</Paper>
)
}

View File

@@ -0,0 +1,63 @@
import { Chip, Tooltip, Box } from '@mui/material'
import { CheckCircle, Error, Warning, CloudOff } from '@mui/icons-material'
export type StatusType = 'connected' | 'disconnected' | 'error' | 'warning' | 'unknown'
interface StatusIndicatorProps {
status: StatusType
label: string
tooltip?: string
}
export default function StatusIndicator({ status, label, tooltip }: StatusIndicatorProps) {
const getColor = () => {
switch (status) {
case 'connected':
return 'success'
case 'disconnected':
return 'default'
case 'error':
return 'error'
case 'warning':
return 'warning'
default:
return 'default'
}
}
const getIcon = () => {
switch (status) {
case 'connected':
return <CheckCircle fontSize="small" />
case 'disconnected':
return <CloudOff fontSize="small" />
case 'error':
return <Error fontSize="small" />
case 'warning':
return <Warning fontSize="small" />
default:
return null
}
}
const chip = (
<Chip
icon={getIcon()}
label={label}
color={getColor()}
size="small"
variant="outlined"
/>
)
if (tooltip) {
return (
<Tooltip title={tooltip}>
{chip}
</Tooltip>
)
}
return chip
}

View File

@@ -0,0 +1,404 @@
import { useState, useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Alert,
Switch,
FormControlLabel,
Box,
Typography,
Tabs,
Tab,
} from '@mui/material'
import { strategiesApi } from '../api/strategies'
import { StrategyResponse, StrategyCreate, StrategyUpdate } from '../types'
import StrategyParameterForm from './StrategyParameterForm'
import { useSnackbar } from '../contexts/SnackbarContext'
interface StrategyDialogProps {
open: boolean
onClose: () => void
strategy: StrategyResponse | null
exchanges: Array<{ id: number; name: string }>
onSave: () => void
}
const STRATEGY_TYPES = [
{ value: 'rsi', label: 'RSI Strategy' },
{ value: 'macd', label: 'MACD Strategy' },
{ value: 'moving_average', label: 'Moving Average Crossover' },
{ value: 'confirmed', label: 'Confirmed Strategy (Multi-Indicator)' },
{ value: 'divergence', label: 'Divergence Strategy' },
{ value: 'bollinger_mean_reversion', label: 'Bollinger Bands Mean Reversion' },
{ value: 'consensus', label: 'Consensus Strategy (Ensemble)' },
{ value: 'dca', label: 'Dollar Cost Averaging' },
{ value: 'grid', label: 'Grid Trading' },
{ value: 'momentum', label: 'Momentum Strategy' },
{ value: 'pairs_trading', label: 'Statistical Arbitrage (Pairs)' },
{ value: 'volatility_breakout', label: 'Volatility Breakout' },
{ value: 'sentiment', label: 'Sentiment / News Trading' },
{ value: 'market_making', label: 'Market Making' },
]
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '4h', '1d']
const CRYPTO_PAIRS = [
'BTC/USD',
'ETH/USD',
'BTC/USDT',
'ETH/USDT',
'SOL/USD',
'ADA/USD',
'XRP/USD',
'DOGE/USD',
'DOT/USD',
'MATIC/USD',
'AVAX/USD',
'LINK/USD',
]
export default function StrategyDialog({
open,
onClose,
strategy,
exchanges,
onSave,
}: StrategyDialogProps) {
const { showError } = useSnackbar()
const [tabValue, setTabValue] = useState(0)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [strategyType, setStrategyType] = useState('rsi')
const [symbol, setSymbol] = useState('BTC/USD')
const [exchangeId, setExchangeId] = useState<number | ''>('')
const [timeframes, setTimeframes] = useState<string[]>(['1h'])
const [paperTrading, setPaperTrading] = useState(true)
const [enabled, setEnabled] = useState(false)
const [parameters, setParameters] = useState<Record<string, any>>({})
useEffect(() => {
if (strategy) {
setName(strategy.name)
setDescription(strategy.description || '')
setStrategyType(strategy.strategy_type)
setSymbol(strategy.parameters?.symbol || 'BTC/USD')
setExchangeId(strategy.parameters?.exchange_id || '')
setTimeframes(strategy.timeframes || ['1h'])
setPaperTrading(strategy.paper_trading)
setEnabled(strategy.enabled)
// Extract strategy-specific parameters (exclude symbol, exchange_id)
const { symbol: _, exchange_id: __, ...strategyParams } = strategy.parameters || {}
setParameters(strategyParams)
} else {
// Reset form
setName('')
setDescription('')
setStrategyType('rsi')
setSymbol('BTC/USD')
setExchangeId('')
setTimeframes(['1h'])
setPaperTrading(true)
setEnabled(false)
setParameters({})
}
}, [strategy, open])
const createMutation = useMutation({
mutationFn: (data: StrategyCreate) => strategiesApi.createStrategy(data),
onSuccess: onSave,
})
const updateMutation = useMutation({
mutationFn: (data: StrategyUpdate) =>
strategiesApi.updateStrategy(strategy!.id, data),
onSuccess: onSave,
})
const handleSave = () => {
if (!name.trim()) {
showError('Strategy name is required')
return
}
if (!exchangeId) {
showError('Please select an exchange')
return
}
const strategyData: StrategyCreate | StrategyUpdate = {
name: name.trim(),
description: description.trim() || undefined,
strategy_type: strategyType,
class_name: strategyType,
parameters: {
...parameters,
symbol,
exchange_id: exchangeId,
},
timeframes,
paper_trading: paperTrading,
enabled: strategy ? enabled : false, // New strategies start disabled
}
if (strategy) {
updateMutation.mutate(strategyData as StrategyUpdate)
} else {
createMutation.mutate(strategyData as StrategyCreate)
}
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
{strategy ? 'Edit Strategy' : 'Create Strategy'}
</DialogTitle>
<DialogContent>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ mb: 3 }}>
<Tab label="Basic Settings" />
<Tab label="Parameters" />
<Tab label="Risk Settings" />
</Tabs>
{tabValue === 0 && (
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12}>
<TextField
fullWidth
label="Strategy Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
helperText="A descriptive name for this strategy"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
multiline
rows={2}
helperText="Optional description of the strategy"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Strategy Type</InputLabel>
<Select
value={strategyType}
label="Strategy Type"
onChange={(e) => {
setStrategyType(e.target.value)
setParameters({}) // Reset parameters when type changes
}}
disabled={!!strategy}
>
{STRATEGY_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Symbol</InputLabel>
<Select
value={symbol}
label="Symbol"
onChange={(e) => setSymbol(e.target.value)}
>
{CRYPTO_PAIRS.map((pair) => (
<MenuItem key={pair} value={pair}>
{pair}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Exchange</InputLabel>
<Select
value={exchangeId}
label="Exchange"
onChange={(e) => setExchangeId(e.target.value as number)}
>
{exchanges.map((ex) => (
<MenuItem key={ex.id} value={ex.id}>
{ex.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Timeframes</InputLabel>
<Select
multiple
value={timeframes}
label="Timeframes"
onChange={(e) => setTimeframes(e.target.value as string[])}
renderValue={(selected) => (selected as string[]).join(', ')}
>
{TIMEFRAMES.map((tf) => (
<MenuItem key={tf} value={tf}>
{tf}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={paperTrading}
onChange={(e) => setPaperTrading(e.target.checked)}
/>
}
label="Paper Trading Mode"
/>
<Typography variant="caption" display="block" color="text.secondary">
Paper trading uses virtual funds for testing
</Typography>
</Grid>
{strategy && (
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
}
label="Enable for Autopilot"
/>
<Typography variant="caption" display="block" color="text.secondary">
Make this strategy available for ML-based selection
</Typography>
</Grid>
)}
</Grid>
)}
{tabValue === 1 && (
<Box sx={{ mt: 2 }}>
<StrategyParameterForm
strategyType={strategyType}
parameters={parameters}
onChange={setParameters}
/>
</Box>
)}
{tabValue === 2 && (
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Position Size (%)"
type="number"
value={parameters.position_size_percent || 10}
onChange={(e) =>
setParameters({
...parameters,
position_size_percent: parseFloat(e.target.value) || 10,
})
}
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
helperText="Percentage of capital to use per trade"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Stop Loss (%)"
type="number"
value={parameters.stop_loss_percent || 5}
onChange={(e) =>
setParameters({
...parameters,
stop_loss_percent: parseFloat(e.target.value) || 5,
})
}
inputProps={{ min: 0.1, max: 50, step: 0.1 }}
helperText="Maximum loss percentage before exit"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Take Profit (%)"
type="number"
value={parameters.take_profit_percent || 10}
onChange={(e) =>
setParameters({
...parameters,
take_profit_percent: parseFloat(e.target.value) || 10,
})
}
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
helperText="Profit target percentage"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Max Position Size"
type="number"
value={parameters.max_position_size || ''}
onChange={(e) =>
setParameters({
...parameters,
max_position_size: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
inputProps={{ min: 0, step: 0.01 }}
helperText="Maximum position size (optional)"
/>
</Grid>
</Grid>
)}
{(createMutation.isError || updateMutation.isError) && (
<Alert severity="error" sx={{ mt: 2 }}>
{createMutation.error instanceof Error
? createMutation.error.message
: updateMutation.error instanceof Error
? updateMutation.error.message
: 'Failed to save strategy'}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending
? 'Saving...'
: strategy
? 'Update'
: 'Create'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,792 @@
import { Grid, TextField, FormControl, InputLabel, Select, MenuItem, Typography, Box } from '@mui/material'
import { Info } from '@mui/icons-material'
interface StrategyParameterFormProps {
strategyType: string
parameters: Record<string, any>
onChange: (parameters: Record<string, any>) => void
}
export default function StrategyParameterForm({
strategyType,
parameters,
onChange,
}: StrategyParameterFormProps) {
const updateParameter = (key: string, value: any) => {
onChange({ ...parameters, [key]: value })
}
const renderRSIParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="RSI Period"
type="number"
value={parameters.rsi_period || 14}
onChange={(e) => updateParameter('rsi_period', parseInt(e.target.value) || 14)}
inputProps={{ min: 2, max: 100 }}
helperText="Period for RSI calculation"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Oversold Threshold"
type="number"
value={parameters.oversold || 30}
onChange={(e) => updateParameter('oversold', parseFloat(e.target.value) || 30)}
inputProps={{ min: 0, max: 50 }}
helperText="RSI level considered oversold"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Overbought Threshold"
type="number"
value={parameters.overbought || 70}
onChange={(e) => updateParameter('overbought', parseFloat(e.target.value) || 70)}
inputProps={{ min: 50, max: 100 }}
helperText="RSI level considered overbought"
/>
</Grid>
</Grid>
)
const renderMACDParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Fast Period"
type="number"
value={parameters.fast_period || 12}
onChange={(e) => updateParameter('fast_period', parseInt(e.target.value) || 12)}
inputProps={{ min: 1, max: 50 }}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Slow Period"
type="number"
value={parameters.slow_period || 26}
onChange={(e) => updateParameter('slow_period', parseInt(e.target.value) || 26)}
inputProps={{ min: 1, max: 100 }}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Signal Period"
type="number"
value={parameters.signal_period || 9}
onChange={(e) => updateParameter('signal_period', parseInt(e.target.value) || 9)}
inputProps={{ min: 1, max: 50 }}
/>
</Grid>
</Grid>
)
const renderMovingAverageParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Short MA Period"
type="number"
value={parameters.short_period || 20}
onChange={(e) => updateParameter('short_period', parseInt(e.target.value) || 20)}
inputProps={{ min: 1, max: 200 }}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Long MA Period"
type="number"
value={parameters.long_period || 50}
onChange={(e) => updateParameter('long_period', parseInt(e.target.value) || 50)}
inputProps={{ min: 1, max: 200 }}
/>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>MA Type</InputLabel>
<Select
value={parameters.ma_type || 'ema'}
label="MA Type"
onChange={(e) => updateParameter('ma_type', e.target.value)}
>
<MenuItem value="sma">SMA (Simple)</MenuItem>
<MenuItem value="ema">EMA (Exponential)</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
)
const renderDCAParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Amount per Interval"
type="number"
value={parameters.amount || 10}
onChange={(e) => updateParameter('amount', parseFloat(e.target.value) || 10)}
inputProps={{ min: 0.01, step: 0.01 }}
helperText="Fixed amount to invest per interval"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Interval</InputLabel>
<Select
value={parameters.interval || 'daily'}
label="Interval"
onChange={(e) => updateParameter('interval', e.target.value)}
>
<MenuItem value="daily">Daily</MenuItem>
<MenuItem value="weekly">Weekly</MenuItem>
<MenuItem value="monthly">Monthly</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Target Allocation (%)"
type="number"
value={parameters.target_allocation || 10}
onChange={(e) => updateParameter('target_allocation', parseFloat(e.target.value) || 10)}
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
helperText="Target portfolio allocation percentage"
/>
</Grid>
</Grid>
)
const renderGridParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Grid Spacing (%)"
type="number"
value={parameters.grid_spacing || 1}
onChange={(e) => updateParameter('grid_spacing', parseFloat(e.target.value) || 1)}
inputProps={{ min: 0.1, max: 10, step: 0.1 }}
helperText="Percentage spacing between grid levels"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Number of Levels"
type="number"
value={parameters.num_levels || 10}
onChange={(e) => updateParameter('num_levels', parseInt(e.target.value) || 10)}
inputProps={{ min: 1, max: 50 }}
helperText="Grid levels above and below center"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Profit Target (%)"
type="number"
value={parameters.profit_target || 2}
onChange={(e) => updateParameter('profit_target', parseFloat(e.target.value) || 2)}
inputProps={{ min: 0.1, max: 50, step: 0.1 }}
helperText="Profit percentage to take"
/>
</Grid>
</Grid>
)
const renderMomentumParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Lookback Period"
type="number"
value={parameters.lookback_period || 20}
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
inputProps={{ min: 1, max: 100 }}
helperText="Period for momentum calculation"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Momentum Threshold"
type="number"
value={parameters.momentum_threshold || 0.05}
onChange={(e) => updateParameter('momentum_threshold', parseFloat(e.target.value) || 0.05)}
inputProps={{ min: 0, max: 1, step: 0.01 }}
helperText="Minimum momentum to enter (0.05 = 5%)"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Volume Threshold"
type="number"
value={parameters.volume_threshold || 1.5}
onChange={(e) => updateParameter('volume_threshold', parseFloat(e.target.value) || 1.5)}
inputProps={{ min: 1, max: 10, step: 0.1 }}
helperText="Volume increase multiplier for confirmation"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Exit Threshold"
type="number"
value={parameters.exit_threshold || -0.02}
onChange={(e) => updateParameter('exit_threshold', parseFloat(e.target.value) || -0.02)}
inputProps={{ min: -1, max: 0, step: 0.01 }}
helperText="Momentum reversal threshold for exit"
/>
</Grid>
</Grid>
)
const renderConfirmedParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>
RSI Parameters
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="RSI Period"
type="number"
value={parameters.rsi_period || 14}
onChange={(e) => updateParameter('rsi_period', parseInt(e.target.value) || 14)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="RSI Oversold"
type="number"
value={parameters.rsi_oversold || 30}
onChange={(e) => updateParameter('rsi_oversold', parseFloat(e.target.value) || 30)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="RSI Overbought"
type="number"
value={parameters.rsi_overbought || 70}
onChange={(e) => updateParameter('rsi_overbought', parseFloat(e.target.value) || 70)}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
MACD Parameters
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="MACD Fast"
type="number"
value={parameters.macd_fast || 12}
onChange={(e) => updateParameter('macd_fast', parseInt(e.target.value) || 12)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="MACD Slow"
type="number"
value={parameters.macd_slow || 26}
onChange={(e) => updateParameter('macd_slow', parseInt(e.target.value) || 26)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="MACD Signal"
type="number"
value={parameters.macd_signal || 9}
onChange={(e) => updateParameter('macd_signal', parseInt(e.target.value) || 9)}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
Moving Average Parameters
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="MA Fast Period"
type="number"
value={parameters.ma_fast || 10}
onChange={(e) => updateParameter('ma_fast', parseInt(e.target.value) || 10)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="MA Slow Period"
type="number"
value={parameters.ma_slow || 30}
onChange={(e) => updateParameter('ma_slow', parseInt(e.target.value) || 30)}
/>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>MA Type</InputLabel>
<Select
value={parameters.ma_type || 'ema'}
label="MA Type"
onChange={(e) => updateParameter('ma_type', e.target.value)}
>
<MenuItem value="sma">SMA</MenuItem>
<MenuItem value="ema">EMA</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
Confirmation Settings
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Min Confirmations"
type="number"
value={parameters.min_confirmations || 2}
onChange={(e) => updateParameter('min_confirmations', parseInt(e.target.value) || 2)}
inputProps={{ min: 1, max: 3 }}
helperText="Minimum indicators that must agree"
/>
</Grid>
</Grid>
)
const renderDivergenceParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Indicator Type</InputLabel>
<Select
value={parameters.indicator_type || 'rsi'}
label="Indicator Type"
onChange={(e) => updateParameter('indicator_type', e.target.value)}
>
<MenuItem value="rsi">RSI</MenuItem>
<MenuItem value="macd">MACD</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Lookback Period"
type="number"
value={parameters.lookback_period || 20}
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
inputProps={{ min: 5, max: 100 }}
helperText="Period for swing detection"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Min Swings Required"
type="number"
value={parameters.min_swings || 2}
onChange={(e) => updateParameter('min_swings', parseInt(e.target.value) || 2)}
inputProps={{ min: 1, max: 10 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Confidence Threshold"
type="number"
value={parameters.confidence_threshold || 0.5}
onChange={(e) => updateParameter('confidence_threshold', parseFloat(e.target.value) || 0.5)}
inputProps={{ min: 0, max: 1, step: 0.1 }}
/>
</Grid>
</Grid>
)
const renderBollingerParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Period"
type="number"
value={parameters.period || 20}
onChange={(e) => updateParameter('period', parseInt(e.target.value) || 20)}
inputProps={{ min: 5, max: 100 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Std Dev Multiplier"
type="number"
value={parameters.std_dev_multiplier || 2.0}
onChange={(e) => updateParameter('std_dev_multiplier', parseFloat(e.target.value) || 2.0)}
inputProps={{ min: 1, max: 5, step: 0.1 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Trend MA Period"
type="number"
value={parameters.trend_ma_period || 50}
onChange={(e) => updateParameter('trend_ma_period', parseInt(e.target.value) || 50)}
inputProps={{ min: 10, max: 200 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Entry Threshold"
type="number"
value={parameters.entry_threshold || 0.95}
onChange={(e) => updateParameter('entry_threshold', parseFloat(e.target.value) || 0.95)}
inputProps={{ min: 0, max: 1, step: 0.01 }}
helperText="How close to band (0.95 = 95%)"
/>
</Grid>
</Grid>
)
const renderConsensusParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Min Consensus Count"
type="number"
value={parameters.min_consensus_count || 2}
onChange={(e) => updateParameter('min_consensus_count', parseInt(e.target.value) || 2)}
inputProps={{ min: 1, max: 10 }}
helperText="Minimum strategies that must agree"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Min Weight Threshold"
type="number"
value={parameters.min_weight_threshold || 0.3}
onChange={(e) => updateParameter('min_weight_threshold', parseFloat(e.target.value) || 0.3)}
inputProps={{ min: 0, max: 1, step: 0.1 }}
/>
</Grid>
</Grid>
)
const renderPairsTradingParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={12}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Statistical Arbitrage trades the spread between the main symbol and a second symbol.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Second Symbol"
value={parameters.second_symbol || 'ETH/USD'}
onChange={(e) => updateParameter('second_symbol', e.target.value)}
helperText="The correlated asset to pair with"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Lookback Period"
type="number"
value={parameters.lookback_period || 20}
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
inputProps={{ min: 5, max: 100 }}
helperText="Rolling window for Z-Score calc"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Z-Score Threshold"
type="number"
value={parameters.z_score_threshold || 2.0}
onChange={(e) => updateParameter('z_score_threshold', parseFloat(e.target.value) || 2.0)}
inputProps={{ min: 1.0, max: 5.0, step: 0.1 }}
helperText="Entry trigger (Standard Deviations)"
/>
</Grid>
</Grid>
)
const renderVolatilityBreakoutParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={12}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Captures explosive moves after periods of low volatility (squeeze).
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="BB Period"
type="number"
value={parameters.bb_period || 20}
onChange={(e) => updateParameter('bb_period', parseInt(e.target.value) || 20)}
inputProps={{ min: 5, max: 50 }}
helperText="Bollinger Bands period"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="BB Std Dev"
type="number"
value={parameters.bb_std_dev || 2.0}
onChange={(e) => updateParameter('bb_std_dev', parseFloat(e.target.value) || 2.0)}
inputProps={{ min: 1.0, max: 4.0, step: 0.1 }}
helperText="Standard deviation multiplier"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Squeeze Threshold"
type="number"
value={parameters.squeeze_threshold || 0.1}
onChange={(e) => updateParameter('squeeze_threshold', parseFloat(e.target.value) || 0.1)}
inputProps={{ min: 0.01, max: 0.5, step: 0.01 }}
helperText="BB Width for squeeze detection"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Volume Multiplier"
type="number"
value={parameters.volume_multiplier || 1.5}
onChange={(e) => updateParameter('volume_multiplier', parseFloat(e.target.value) || 1.5)}
inputProps={{ min: 1.0, max: 5.0, step: 0.1 }}
helperText="Min volume vs 20-day avg"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Min ADX"
type="number"
value={parameters.min_adx || 25}
onChange={(e) => updateParameter('min_adx', parseFloat(e.target.value) || 25)}
inputProps={{ min: 10, max: 50 }}
helperText="Trend strength filter"
/>
</Grid>
</Grid>
)
const renderSentimentParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={12}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Trades based on news sentiment and Fear & Greed Index.
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>Mode</InputLabel>
<Select
value={parameters.mode || 'contrarian'}
label="Mode"
onChange={(e) => updateParameter('mode', e.target.value)}
>
<MenuItem value="contrarian">Contrarian (Buy Fear, Sell Greed)</MenuItem>
<MenuItem value="momentum">Momentum (Follow Sentiment)</MenuItem>
<MenuItem value="combo">Combo (News + Fear = Buy)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Min Sentiment Score"
type="number"
value={parameters.min_sentiment_score || 0.5}
onChange={(e) => updateParameter('min_sentiment_score', parseFloat(e.target.value) || 0.5)}
inputProps={{ min: 0.1, max: 1.0, step: 0.1 }}
helperText="Threshold for momentum signals"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Fear Threshold"
type="number"
value={parameters.fear_threshold || 25}
onChange={(e) => updateParameter('fear_threshold', parseInt(e.target.value) || 25)}
inputProps={{ min: 0, max: 50 }}
helperText="F&G value for 'extreme fear'"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Greed Threshold"
type="number"
value={parameters.greed_threshold || 75}
onChange={(e) => updateParameter('greed_threshold', parseInt(e.target.value) || 75)}
inputProps={{ min: 50, max: 100 }}
helperText="F&G value for 'extreme greed'"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="News Lookback (hours)"
type="number"
value={parameters.news_lookback_hours || 24}
onChange={(e) => updateParameter('news_lookback_hours', parseInt(e.target.value) || 24)}
inputProps={{ min: 1, max: 72 }}
helperText="How far back to analyze news"
/>
</Grid>
</Grid>
)
const renderMarketMakingParameters = () => (
<Grid container spacing={2}>
<Grid item xs={12} md={12}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Places limit orders on both sides of the spread. Best for ranging markets.
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Spread %"
type="number"
value={parameters.spread_percent || 0.2}
onChange={(e) => updateParameter('spread_percent', parseFloat(e.target.value) || 0.2)}
inputProps={{ min: 0.05, max: 2.0, step: 0.05 }}
helperText="Distance from mid price"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Requote Threshold %"
type="number"
value={parameters.requote_threshold || 0.5}
onChange={(e) => updateParameter('requote_threshold', parseFloat(e.target.value) || 0.5)}
inputProps={{ min: 0.1, max: 2.0, step: 0.1 }}
helperText="Price move to trigger requote"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Max Inventory"
type="number"
value={parameters.max_inventory || 1.0}
onChange={(e) => updateParameter('max_inventory', parseFloat(e.target.value) || 1.0)}
inputProps={{ min: 0.1, max: 10, step: 0.1 }}
helperText="Max position before skewing"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Inventory Skew Factor"
type="number"
value={parameters.inventory_skew_factor || 0.5}
onChange={(e) => updateParameter('inventory_skew_factor', parseFloat(e.target.value) || 0.5)}
inputProps={{ min: 0, max: 1, step: 0.1 }}
helperText="How much to skew quotes"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Max ADX (Trend Filter)"
type="number"
value={parameters.min_adx || 20}
onChange={(e) => updateParameter('min_adx', parseInt(e.target.value) || 20)}
inputProps={{ min: 10, max: 40 }}
helperText="Skip if ADX above this"
/>
</Grid>
</Grid>
)
const renderParameters = () => {
switch (strategyType) {
case 'rsi':
return renderRSIParameters()
case 'macd':
return renderMACDParameters()
case 'moving_average':
return renderMovingAverageParameters()
case 'confirmed':
return renderConfirmedParameters()
case 'divergence':
return renderDivergenceParameters()
case 'bollinger_mean_reversion':
return renderBollingerParameters()
case 'consensus':
return renderConsensusParameters()
case 'dca':
return renderDCAParameters()
case 'grid':
return renderGridParameters()
case 'momentum':
return renderMomentumParameters()
case 'pairs_trading':
return renderPairsTradingParameters()
case 'volatility_breakout':
return renderVolatilityBreakoutParameters()
case 'sentiment':
return renderSentimentParameters()
case 'market_making':
return renderMarketMakingParameters()
default:
return (
<Box sx={{ p: 2 }}>
<Typography color="text.secondary">
No specific parameters for this strategy type.
</Typography>
</Box>
)
}
}
return (
<Box>
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Info color="action" fontSize="small" />
<Typography variant="body2" color="text.secondary">
Configure strategy-specific parameters. Default values are provided.
</Typography>
</Box>
{renderParameters()}
</Box>
)
}

View File

@@ -0,0 +1,83 @@
import { Card, CardContent, Typography, Box, Grid, Chip } from '@mui/material'
import { CheckCircle, Error, Warning } from '@mui/icons-material'
import StatusIndicator from './StatusIndicator'
interface SystemHealthProps {
websocketStatus: 'connected' | 'disconnected' | 'error'
exchangeStatuses?: Array<{ name: string; status: 'connected' | 'disconnected' | 'error' }>
databaseStatus?: 'connected' | 'disconnected' | 'error'
}
export default function SystemHealth({
websocketStatus,
exchangeStatuses = [],
databaseStatus = 'connected',
}: SystemHealthProps) {
const getOverallHealth = () => {
const statuses = [
websocketStatus,
databaseStatus,
...exchangeStatuses.map((e) => e.status),
]
if (statuses.some((s) => s === 'error')) return 'error'
if (statuses.some((s) => s === 'disconnected')) return 'warning'
return 'connected'
}
const overallHealth = getOverallHealth()
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
System Health
</Typography>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Overall Status:
</Typography>
<StatusIndicator
status={overallHealth}
label={overallHealth === 'connected' ? 'Healthy' : overallHealth === 'error' ? 'Error' : 'Warning'}
/>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<StatusIndicator
status={websocketStatus}
label="WebSocket"
tooltip="Real-time data connection status"
/>
</Grid>
<Grid item xs={12} md={4}>
<StatusIndicator
status={databaseStatus}
label="Database"
tooltip="Database connection status"
/>
</Grid>
{exchangeStatuses.length > 0 && (
<Grid item xs={12}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Exchanges:
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{exchangeStatuses.map((exchange) => (
<StatusIndicator
key={exchange.name}
status={exchange.status}
label={exchange.name}
/>
))}
</Box>
</Grid>
)}
</Grid>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, ReactNode } from 'react'
import { useWebSocket, WebSocketMessage } from '../hooks/useWebSocket'
interface WebSocketContextType {
isConnected: boolean
lastMessage: WebSocketMessage | null
messageHistory: WebSocketMessage[]
sendMessage: (message: any) => void
subscribe: (messageType: string, handler: (message: WebSocketMessage) => void) => () => void
}
const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
export function WebSocketProvider({ children }: { children: ReactNode }) {
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws/'
const { isConnected, lastMessage, messageHistory, sendMessage, subscribe } = useWebSocket(wsUrl)
return (
<WebSocketContext.Provider value={{ isConnected, lastMessage, messageHistory, sendMessage, subscribe }}>
{children}
</WebSocketContext.Provider>
)
}
export function useWebSocketContext() {
const context = useContext(WebSocketContext)
if (!context) {
throw new Error('useWebSocketContext must be used within WebSocketProvider')
}
return context
}

View File

@@ -0,0 +1,2 @@
export { default as Layout } from './Layout'
export { WebSocketProvider, useWebSocketContext } from './WebSocketProvider'

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ErrorDisplay from '../ErrorDisplay'
describe('ErrorDisplay', () => {
describe('error message rendering', () => {
it('renders error string message', () => {
render(<ErrorDisplay error="Test error message" />)
expect(screen.getByText('Test error message')).toBeInTheDocument()
})
it('renders Error object message', () => {
const error = new Error('Error object message')
render(<ErrorDisplay error={error} />)
expect(screen.getByText('Error object message')).toBeInTheDocument()
})
it('renders default title', () => {
render(<ErrorDisplay error="Test error" />)
expect(screen.getByText('Error')).toBeInTheDocument()
})
it('renders custom title', () => {
render(<ErrorDisplay error="Test error" title="Custom Error Title" />)
expect(screen.getByText('Custom Error Title')).toBeInTheDocument()
})
})
describe('retry functionality', () => {
it('does not show retry button when onRetry is not provided', () => {
render(<ErrorDisplay error="Test error" />)
expect(screen.queryByText('Retry')).not.toBeInTheDocument()
})
it('shows retry button when onRetry is provided', () => {
const onRetry = vi.fn()
render(<ErrorDisplay error="Test error" onRetry={onRetry} />)
expect(screen.getByText('Retry')).toBeInTheDocument()
})
it('calls onRetry when retry button is clicked', () => {
const onRetry = vi.fn()
render(<ErrorDisplay error="Test error" onRetry={onRetry} />)
fireEvent.click(screen.getByText('Retry'))
expect(onRetry).toHaveBeenCalledTimes(1)
})
})
describe('technical details', () => {
it('shows technical details section for Error object with stack', () => {
const error = new Error('Error with stack')
render(<ErrorDisplay error={error} />)
expect(screen.getByText('Technical Details')).toBeInTheDocument()
})
it('does not show technical details for string errors', () => {
render(<ErrorDisplay error="String error" />)
expect(screen.queryByText('Technical Details')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PositionCard from '../PositionCard'
import { PositionResponse, OrderType } from '../../types'
import * as tradingApi from '../../api/trading'
vi.mock('../../api/trading')
vi.mock('../../contexts/SnackbarContext', () => ({
useSnackbar: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
showInfo: vi.fn(),
}),
}))
const mockPosition: PositionResponse = {
id: 1,
symbol: 'BTC/USD',
quantity: 0.5,
entry_price: 40000,
current_price: 42000,
unrealized_pnl: 1000,
realized_pnl: 500,
side: 'long',
opened_at: '2024-01-01T00:00:00Z',
}
describe('PositionCard', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
vi.clearAllMocks()
})
const renderComponent = (position = mockPosition, paperTrading = false) => {
const onClose = vi.fn()
return {
onClose,
...render(
<QueryClientProvider client={queryClient}>
<PositionCard position={position} paperTrading={paperTrading} onClose={onClose} />
</QueryClientProvider>
),
}
}
describe('position data display', () => {
it('renders position symbol', () => {
renderComponent()
expect(screen.getByText('BTC/USD')).toBeInTheDocument()
})
it('renders position quantity', () => {
renderComponent()
expect(screen.getByText('0.50000000')).toBeInTheDocument()
})
it('renders entry price', () => {
renderComponent()
expect(screen.getByText('$40000.00')).toBeInTheDocument()
})
it('renders current price', () => {
renderComponent()
expect(screen.getByText('$42000.00')).toBeInTheDocument()
})
})
describe('PnL display', () => {
it('displays positive unrealized PnL with plus sign', () => {
renderComponent()
expect(screen.getByText('+$1000.00')).toBeInTheDocument()
})
it('displays positive realized PnL with plus sign', () => {
renderComponent()
expect(screen.getByText('+$500.00')).toBeInTheDocument()
})
it('displays negative unrealized PnL correctly', () => {
const negativePosition = { ...mockPosition, unrealized_pnl: -500 }
renderComponent(negativePosition)
expect(screen.getByText('-$500.00')).toBeInTheDocument()
})
it('shows positive percent chip for profitable position', () => {
renderComponent()
expect(screen.getByText('+5.00%')).toBeInTheDocument()
})
it('shows negative percent chip for losing position', () => {
const losingPosition = { ...mockPosition, current_price: 38000 }
renderComponent(losingPosition)
expect(screen.getByText('-5.00%')).toBeInTheDocument()
})
})
describe('close position functionality', () => {
it('shows close position button', () => {
renderComponent()
expect(screen.getByText('Close Position')).toBeInTheDocument()
})
it('opens close dialog when button is clicked', async () => {
renderComponent()
fireEvent.click(screen.getByText('Close Position'))
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('shows order type selector in dialog', async () => {
renderComponent()
fireEvent.click(screen.getByText('Close Position'))
await waitFor(() => {
expect(screen.getByLabelText('Order Type')).toBeInTheDocument()
})
})
it('closes dialog when cancel is clicked', async () => {
renderComponent()
fireEvent.click(screen.getByText('Close Position'))
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Cancel'))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
it('submits market order when confirmed', async () => {
const mockCreateOrder = vi.fn().mockResolvedValue({})
vi.mocked(tradingApi.tradingApi.createOrder).mockImplementation(mockCreateOrder)
renderComponent()
fireEvent.click(screen.getByText('Close Position'))
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Click the confirm button (second "Close Position" button in dialog)
const buttons = screen.getAllByText('Close Position')
fireEvent.click(buttons[1])
await waitFor(() => {
expect(mockCreateOrder).toHaveBeenCalled()
})
})
})
})

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import StatusIndicator from '../StatusIndicator'
describe('StatusIndicator', () => {
describe('rendering', () => {
it('renders with label', () => {
render(<StatusIndicator status="connected" label="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('renders tooltip when provided', () => {
render(<StatusIndicator status="connected" label="Test" tooltip="Tooltip text" />)
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
describe('status colors', () => {
it('shows success color for connected status', () => {
render(<StatusIndicator status="connected" label="Connected" />)
const chip = screen.getByText('Connected').closest('.MuiChip-root')
expect(chip).toHaveClass('MuiChip-colorSuccess')
})
it('shows error color for error status', () => {
render(<StatusIndicator status="error" label="Error" />)
const chip = screen.getByText('Error').closest('.MuiChip-root')
expect(chip).toHaveClass('MuiChip-colorError')
})
it('shows warning color for warning status', () => {
render(<StatusIndicator status="warning" label="Warning" />)
const chip = screen.getByText('Warning').closest('.MuiChip-root')
expect(chip).toHaveClass('MuiChip-colorWarning')
})
it('shows default color for disconnected status', () => {
render(<StatusIndicator status="disconnected" label="Disconnected" />)
const chip = screen.getByText('Disconnected').closest('.MuiChip-root')
expect(chip).toHaveClass('MuiChip-colorDefault')
})
it('shows default color for unknown status', () => {
render(<StatusIndicator status="unknown" label="Unknown" />)
const chip = screen.getByText('Unknown').closest('.MuiChip-root')
expect(chip).toHaveClass('MuiChip-colorDefault')
})
})
describe('icons', () => {
it('renders CheckCircle icon for connected status', () => {
render(<StatusIndicator status="connected" label="Connected" />)
expect(document.querySelector('[data-testid="CheckCircleIcon"]')).toBeInTheDocument()
})
it('renders CloudOff icon for disconnected status', () => {
render(<StatusIndicator status="disconnected" label="Disconnected" />)
expect(document.querySelector('[data-testid="CloudOffIcon"]')).toBeInTheDocument()
})
it('renders Error icon for error status', () => {
render(<StatusIndicator status="error" label="Error" />)
expect(document.querySelector('[data-testid="ErrorIcon"]')).toBeInTheDocument()
})
it('renders Warning icon for warning status', () => {
render(<StatusIndicator status="warning" label="Warning" />)
expect(document.querySelector('[data-testid="WarningIcon"]')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,96 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
export interface AutopilotSettings {
autoExecute: boolean
// Intelligent mode settings
interval: number
timeframe: string
exchange_id: number
}
const DEFAULT_SETTINGS: AutopilotSettings = {
autoExecute: false,
interval: 60.0,
timeframe: '1h',
exchange_id: 1,
}
const STORAGE_KEY = 'autopilot_settings'
interface AutopilotSettingsContextType {
settings: AutopilotSettings
updateSettings: (updates: Partial<AutopilotSettings>) => void
resetSettings: () => void
}
const AutopilotSettingsContext = createContext<AutopilotSettingsContextType | undefined>(undefined)
export function AutopilotSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<AutopilotSettings>(() => {
// Load from localStorage on initialization
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return { ...DEFAULT_SETTINGS, ...parsed }
}
} catch (error) {
console.error('Failed to load autopilot settings from localStorage:', error)
}
return DEFAULT_SETTINGS
})
// Save to localStorage whenever settings change
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
} catch (error) {
console.error('Failed to save autopilot settings to localStorage:', error)
}
}, [settings])
const updateSettings = (updates: Partial<AutopilotSettings>) => {
setSettings((prev) => ({ ...prev, ...updates }))
}
const resetSettings = () => {
setSettings(DEFAULT_SETTINGS)
try {
localStorage.removeItem(STORAGE_KEY)
} catch (error) {
console.error('Failed to remove autopilot settings from localStorage:', error)
}
}
return (
<AutopilotSettingsContext.Provider value={{ settings, updateSettings, resetSettings }}>
{children}
</AutopilotSettingsContext.Provider>
)
}
export function useAutopilotSettings() {
const context = useContext(AutopilotSettingsContext)
if (context === undefined) {
throw new Error('useAutopilotSettings must be used within an AutopilotSettingsProvider')
}
return context.settings
}
export function useUpdateAutopilotSettings() {
const context = useContext(AutopilotSettingsContext)
if (context === undefined) {
throw new Error('useUpdateAutopilotSettings must be used within an AutopilotSettingsProvider')
}
return context.updateSettings
}
export function useAutopilotSettingsContext() {
const context = useContext(AutopilotSettingsContext)
if (context === undefined) {
throw new Error('useAutopilotSettingsContext must be used within an AutopilotSettingsProvider')
}
return context
}

View File

@@ -0,0 +1,54 @@
import { createContext, useContext, useState, ReactNode } from 'react'
import { Snackbar, Alert, AlertColor } from '@mui/material'
interface SnackbarContextType {
showSnackbar: (message: string, severity?: AlertColor) => void
showError: (message: string) => void
showSuccess: (message: string) => void
showWarning: (message: string) => void
showInfo: (message: string) => void
}
const SnackbarContext = createContext<SnackbarContextType | undefined>(undefined)
export function SnackbarProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [severity, setSeverity] = useState<AlertColor>('info')
const showSnackbar = (msg: string, sev: AlertColor = 'info') => {
setMessage(msg)
setSeverity(sev)
setOpen(true)
}
const showError = (msg: string) => showSnackbar(msg, 'error')
const showSuccess = (msg: string) => showSnackbar(msg, 'success')
const showWarning = (msg: string) => showSnackbar(msg, 'warning')
const showInfo = (msg: string) => showSnackbar(msg, 'info')
return (
<SnackbarContext.Provider value={{ showSnackbar, showError, showSuccess, showWarning, showInfo }}>
{children}
<Snackbar
open={open}
autoHideDuration={6000}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={() => setOpen(false)} severity={severity} sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
</SnackbarContext.Provider>
)
}
export function useSnackbar() {
const context = useContext(SnackbarContext)
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider')
}
return context
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode } from 'react'
import { useProviderStatus } from '../useProviderStatus'
import * as marketDataApi from '../../api/marketData'
vi.mock('../../api/marketData')
vi.mock('../../components/WebSocketProvider', () => ({
useWebSocketContext: () => ({
isConnected: true,
lastMessage: null,
subscribe: vi.fn(),
}),
}))
describe('useProviderStatus', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
vi.clearAllMocks()
})
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client= { queryClient } > { children } </QueryClientProvider>
)
it('returns loading state initially', () => {
vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockImplementation(
() => new Promise(() => { }) // Never resolves
)
const { result } = renderHook(() => useProviderStatus(), { wrapper })
expect(result.current.isLoading).toBe(true)
})
it('returns provider status after loading', async () => {
const mockStatus = {
primary_provider: 'CoinGecko',
primary_healthy: true,
fallback_provider: 'CCXT',
fallback_healthy: true,
last_check: '2024-01-01T00:00:00Z',
}
vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockResolvedValue(mockStatus)
const { result } = renderHook(() => useProviderStatus(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.status).toEqual(mockStatus)
})
it('returns error when API call fails', async () => {
const mockError = new Error('API Error')
vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockRejectedValue(mockError)
const { result } = renderHook(() => useProviderStatus(), { wrapper })
await waitFor(() => {
expect(result.current.error).not.toBeNull()
})
expect(result.current.error?.message).toBe('API Error')
})
it('provides refetch function', async () => {
const mockStatus = {
primary_provider: 'CoinGecko',
primary_healthy: true,
fallback_provider: 'CCXT',
fallback_healthy: true,
last_check: '2024-01-01T00:00:00Z',
}
vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockResolvedValue(mockStatus)
const { result } = renderHook(() => useProviderStatus(), { wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.refetch).toBeDefined()
expect(typeof result.current.refetch).toBe('function')
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode } from 'react'
import { useRealtimeData } from '../useRealtimeData'
const mockSubscribe = vi.fn(() => vi.fn())
const mockShowInfo = vi.fn()
const mockShowWarning = vi.fn()
vi.mock('../../components/WebSocketProvider', () => ({
useWebSocketContext: () => ({
isConnected: true,
lastMessage: null,
subscribe: mockSubscribe,
}),
}))
vi.mock('../../contexts/SnackbarContext', () => ({
useSnackbar: () => ({
showInfo: mockShowInfo,
showWarning: mockShowWarning,
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
describe('useRealtimeData', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
vi.clearAllMocks()
})
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client= { queryClient } > { children } </QueryClientProvider>
)
it('subscribes to all message types on mount', () => {
renderHook(() => useRealtimeData(), { wrapper })
expect(mockSubscribe).toHaveBeenCalledWith('order_update', expect.any(Function))
expect(mockSubscribe).toHaveBeenCalledWith('position_update', expect.any(Function))
expect(mockSubscribe).toHaveBeenCalledWith('price_update', expect.any(Function))
expect(mockSubscribe).toHaveBeenCalledWith('alert_triggered', expect.any(Function))
expect(mockSubscribe).toHaveBeenCalledWith('strategy_signal', expect.any(Function))
expect(mockSubscribe).toHaveBeenCalledWith('system_event', expect.any(Function))
})
it('handles order update messages', async () => {
const unsubscribeMock = vi.fn()
let orderHandler: ((message: any) => void) | undefined
mockSubscribe.mockImplementation((type: string, handler: any) => {
if (type === 'order_update') {
orderHandler = handler
}
return unsubscribeMock
})
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
renderHook(() => useRealtimeData(), { wrapper })
// Simulate order filled message
if (orderHandler) {
orderHandler({ type: 'order_update', order_id: '123', status: 'filled' })
}
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['orders'] })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['balance'] })
})
expect(mockShowInfo).toHaveBeenCalledWith('Order 123 filled')
})
it('handles alert triggered messages', async () => {
let alertHandler: ((message: any) => void) | undefined
mockSubscribe.mockImplementation((type: string, handler: any) => {
if (type === 'alert_triggered') {
alertHandler = handler
}
return vi.fn()
})
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
renderHook(() => useRealtimeData(), { wrapper })
// Simulate alert triggered
if (alertHandler) {
alertHandler({ type: 'alert_triggered', alert_name: 'BTC Price Alert' })
}
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['alerts'] })
})
expect(mockShowWarning).toHaveBeenCalledWith('Alert triggered: BTC Price Alert')
})
it('unsubscribes from all message types on unmount', () => {
const unsubscribeMocks = {
order_update: vi.fn(),
position_update: vi.fn(),
price_update: vi.fn(),
alert_triggered: vi.fn(),
strategy_signal: vi.fn(),
system_event: vi.fn(),
}
mockSubscribe.mockImplementation((type: string) => {
return unsubscribeMocks[type as keyof typeof unsubscribeMocks] || vi.fn()
})
const { unmount } = renderHook(() => useRealtimeData(), { wrapper })
unmount()
expect(unsubscribeMocks.order_update).toHaveBeenCalled()
expect(unsubscribeMocks.position_update).toHaveBeenCalled()
expect(unsubscribeMocks.price_update).toHaveBeenCalled()
expect(unsubscribeMocks.alert_triggered).toHaveBeenCalled()
expect(unsubscribeMocks.strategy_signal).toHaveBeenCalled()
expect(unsubscribeMocks.system_event).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useWebSocket } from '../useWebSocket'
// Mock WebSocket class
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = MockWebSocket.CONNECTING
url: string
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: ((error: Event) => void) | null = null
onclose: (() => void) | null = null
constructor(url: string) {
this.url = url
mockWebSocketInstances.push(this)
}
send = vi.fn()
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
})
// Helper to simulate connection
simulateOpen() {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
// Helper to simulate message
simulateMessage(data: object) {
this.onmessage?.({ data: JSON.stringify(data) })
}
// Helper to simulate close
simulateClose() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
}
// Helper to simulate error
simulateError() {
this.onerror?.(new Event('error'))
}
}
let mockWebSocketInstances: MockWebSocket[] = []
describe('useWebSocket', () => {
beforeEach(() => {
vi.useFakeTimers()
mockWebSocketInstances = []
; (globalThis as any).WebSocket = MockWebSocket
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('initializes as disconnected', () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
expect(result.current.isConnected).toBe(false)
})
it('connects and sets isConnected to true', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
// Simulate WebSocket open
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
await waitFor(() => {
expect(result.current.isConnected).toBe(true)
})
})
it('receives and stores messages', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
const testMessage = { type: 'order_update', order_id: '123' }
act(() => {
mockWebSocketInstances[0].simulateMessage(testMessage)
})
await waitFor(() => {
expect(result.current.lastMessage).toEqual(expect.objectContaining(testMessage))
})
})
it('adds messages to message history', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
const message1 = { type: 'order_update' as const, order_id: '1' }
const message2 = { type: 'position_update' as const, position_id: '2' }
act(() => {
mockWebSocketInstances[0].simulateMessage(message1)
mockWebSocketInstances[0].simulateMessage(message2)
})
await waitFor(() => {
expect(result.current.messageHistory).toHaveLength(2)
})
})
it('sends messages when connected', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
const testMessage = { action: 'subscribe', channel: 'prices' }
act(() => {
result.current.sendMessage(testMessage)
})
expect(mockWebSocketInstances[0].send).toHaveBeenCalledWith(JSON.stringify(testMessage))
})
it('allows subscribing to specific message types', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
const handler = vi.fn()
act(() => {
mockWebSocketInstances[0].simulateOpen()
result.current.subscribe('order_update', handler)
})
const testMessage = { type: 'order_update', order_id: '123' }
act(() => {
mockWebSocketInstances[0].simulateMessage(testMessage)
})
await waitFor(() => {
expect(handler).toHaveBeenCalledWith(expect.objectContaining(testMessage))
})
})
it('cleans up subscription on unsubscribe', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
const handler = vi.fn()
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
let unsubscribe: () => void
act(() => {
unsubscribe = result.current.subscribe('order_update', handler)
})
act(() => {
unsubscribe()
})
const testMessage = { type: 'order_update', order_id: '123' }
act(() => {
mockWebSocketInstances[0].simulateMessage(testMessage)
})
// Handler should not be called after unsubscribe
expect(handler).not.toHaveBeenCalled()
})
it('sets isConnected to false on disconnect', async () => {
const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
await waitFor(() => {
expect(result.current.isConnected).toBe(true)
})
act(() => {
mockWebSocketInstances[0].simulateClose()
})
await waitFor(() => {
expect(result.current.isConnected).toBe(false)
})
})
it('closes WebSocket on unmount', () => {
const { unmount } = renderHook(() => useWebSocket('ws://localhost:8000/ws'))
act(() => {
mockWebSocketInstances[0].simulateOpen()
})
unmount()
expect(mockWebSocketInstances[0].close).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { marketDataApi, ProviderStatus } from '../api/marketData'
import { useWebSocketContext } from '../components/WebSocketProvider'
import { useEffect, useState } from 'react'
export interface ProviderStatusData {
status: ProviderStatus | null
isLoading: boolean
error: Error | null
refetch: () => void
}
export function useProviderStatus(): ProviderStatusData {
const { isConnected, lastMessage } = useWebSocketContext()
const [status, setStatus] = useState<ProviderStatus | null>(null)
const {
data,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['provider-status'],
queryFn: () => marketDataApi.getProviderStatus(),
refetchInterval: 10000, // Refetch every 10 seconds
})
// Update local state when query data changes
useEffect(() => {
if (data) {
setStatus(data)
}
}, [data])
// Listen for provider status updates via WebSocket
useEffect(() => {
if (!isConnected || !lastMessage) return
try {
const message = typeof lastMessage === 'string' ? JSON.parse(lastMessage) : lastMessage
if (message.type === 'provider_status_update') {
// Update status from WebSocket message
setStatus((prev) => ({
...prev!,
...message.data,
}))
}
} catch (e) {
// Ignore parsing errors
}
}, [isConnected, lastMessage])
return {
status,
isLoading,
error: error as Error | null,
refetch,
}
}

View File

@@ -0,0 +1,69 @@
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useWebSocketContext } from '../components/WebSocketProvider'
import { useSnackbar } from '../contexts/SnackbarContext'
export function useRealtimeData() {
const queryClient = useQueryClient()
const { isConnected, lastMessage, subscribe } = useWebSocketContext()
const { showInfo, showWarning } = useSnackbar()
useEffect(() => {
if (!isConnected) return
// Subscribe to order updates
const unsubscribeOrder = subscribe('order_update', (message) => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
queryClient.invalidateQueries({ queryKey: ['balance'] })
if (message.status === 'filled') {
showInfo(`Order ${message.order_id} filled`)
}
})
// Subscribe to position updates
const unsubscribePosition = subscribe('position_update', (message) => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
queryClient.invalidateQueries({ queryKey: ['portfolio'] })
})
// Subscribe to price updates
const unsubscribePrice = subscribe('price_update', (message) => {
// Invalidate market data queries for the specific symbol
if (message.symbol) {
queryClient.invalidateQueries({ queryKey: ['market-data', message.symbol] })
}
})
// Subscribe to alert triggers
const unsubscribeAlert = subscribe('alert_triggered', (message) => {
queryClient.invalidateQueries({ queryKey: ['alerts'] })
showWarning(`Alert triggered: ${message.alert_name || 'Unknown alert'}`)
})
// Subscribe to strategy signals
const unsubscribeSignal = subscribe('strategy_signal', (message) => {
queryClient.invalidateQueries({ queryKey: ['autopilot-status'] })
if (message.signal_type) {
showInfo(`Strategy signal: ${message.signal_type.toUpperCase()} for ${message.symbol || 'N/A'}`)
}
})
// Subscribe to system events
const unsubscribeSystem = subscribe('system_event', (message) => {
if (message.event_type === 'error') {
showWarning(`System event: ${message.message || 'Unknown error'}`)
}
})
return () => {
unsubscribeOrder()
unsubscribePosition()
unsubscribePrice()
unsubscribeAlert()
unsubscribeSignal()
unsubscribeSystem()
}
}, [isConnected, subscribe, queryClient, showInfo, showWarning])
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useRef, useState, useCallback } from 'react'
export interface WebSocketMessage {
type: 'order_update' | 'position_update' | 'price_update' | 'alert_triggered' | 'strategy_signal' | 'system_event'
[key: string]: any
}
export function useWebSocket(url: string) {
const [isConnected, setIsConnected] = useState(false)
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
const [messageHistory, setMessageHistory] = useState<WebSocketMessage[]>([])
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout>()
const messageHandlersRef = useRef<Map<string, (message: WebSocketMessage) => void>>(new Map())
const isConnectingRef = useRef(false)
useEffect(() => {
let isMounted = true
const connect = () => {
// Prevent duplicate connections
if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) {
return
}
try {
isConnectingRef.current = true
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
if (isMounted) {
isConnectingRef.current = false
setIsConnected(true)
console.log('WebSocket connected')
}
}
ws.onmessage = (event) => {
if (!isMounted) return
try {
const message = JSON.parse(event.data) as WebSocketMessage
setLastMessage(message)
setMessageHistory((prev) => [...prev.slice(-99), message]) // Keep last 100 messages
// Call registered handlers for this message type
const handlers = messageHandlersRef.current.get(message.type)
if (handlers) {
handlers(message)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
ws.onerror = (error) => {
isConnectingRef.current = false
// Only log error if we're still mounted (avoid noise from StrictMode cleanup)
if (isMounted) {
console.error('WebSocket error:', error)
}
}
ws.onclose = () => {
isConnectingRef.current = false
if (isMounted) {
setIsConnected(false)
console.log('WebSocket disconnected')
// Reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
if (isMounted) connect()
}, 3000)
}
}
} catch (error) {
isConnectingRef.current = false
console.error('Failed to create WebSocket:', error)
}
}
connect()
return () => {
isMounted = false
isConnectingRef.current = false
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}
}, [url])
const sendMessage = useCallback((message: any) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
}
}, [])
const subscribe = useCallback((messageType: string, handler: (message: WebSocketMessage) => void) => {
messageHandlersRef.current.set(messageType, handler)
return () => {
messageHandlersRef.current.delete(messageType)
}
}, [])
return {
isConnected,
lastMessage,
messageHistory,
sendMessage,
subscribe,
}
}

62
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,62 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
import { WebSocketProvider } from './components/WebSocketProvider'
import { SnackbarProvider } from './contexts/SnackbarContext'
import { AutopilotSettingsProvider } from './contexts/AutopilotSettingsContext'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#FF9800', // Amber
},
secondary: {
main: '#2196F3', // Blue
},
success: {
main: '#00E676', // Neon Green
},
error: {
main: '#FF1744', // Neon Red
},
background: {
default: '#121212',
paper: '#1E1E1E',
},
},
typography: {
fontFamily: "'Segoe UI', 'Roboto', 'Arial', sans-serif",
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<WebSocketProvider>
<SnackbarProvider>
<AutopilotSettingsProvider>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
</BrowserRouter>
</AutopilotSettingsProvider>
</SnackbarProvider>
</WebSocketProvider>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,488 @@
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Button,
TextField,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Card,
CardContent,
} from '@mui/material'
import { backtestingApi } from '../api/backtesting'
import { strategiesApi } from '../api/strategies'
import { reportingApi } from '../api/reporting'
import { BacktestRequest, StrategyResponse } from '../types'
import { useSnackbar } from '../contexts/SnackbarContext'
import HelpTooltip from '../components/HelpTooltip'
import InfoCard from '../components/InfoCard'
import OperationsPanel from '../components/OperationsPanel'
import ProgressOverlay from '../components/ProgressOverlay'
import { settingsApi } from '../api/settings'
import { formatDate } from '../utils/formatters'
export default function BacktestPage() {
const { showError, showSuccess } = useSnackbar()
const [strategyId, setStrategyId] = useState('')
const [symbol, setSymbol] = useState('BTC/USD')
const [exchange, setExchange] = useState('coinbase')
const [timeframe, setTimeframe] = useState('1h')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [initialCapital, setInitialCapital] = useState('100')
const [slippage, setSlippage] = useState('0.1')
const [feeRate, setFeeRate] = useState('0.1')
const [backtestResults, setBacktestResults] = useState<any>(null)
const { data: strategies, isLoading: strategiesLoading } = useQuery({
queryKey: ['strategies'],
queryFn: () => strategiesApi.listStrategies(),
})
const { data: generalSettings } = useQuery({
queryKey: ['general-settings'],
queryFn: () => settingsApi.getGeneralSettings(),
})
const backtestMutation = useMutation({
mutationFn: backtestingApi.runBacktest,
onSuccess: (data) => {
setBacktestResults(data.results)
showSuccess('Backtest completed successfully')
},
onError: (error: any) => {
showError(error?.response?.data?.detail || error?.message || 'Backtest failed')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!strategyId) {
showError('Please select a strategy')
return
}
const request: BacktestRequest = {
strategy_id: parseInt(strategyId),
symbol,
exchange,
timeframe,
start_date: new Date(startDate).toISOString(),
end_date: new Date(endDate).toISOString(),
initial_capital: parseFloat(initialCapital),
slippage: parseFloat(slippage) / 100, // Convert percentage to decimal
fee_rate: parseFloat(feeRate) / 100, // Convert percentage to decimal
}
backtestMutation.mutate(request)
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">Backtesting</Typography>
<HelpTooltip title="Test your strategies on historical data to evaluate performance before live trading" />
</Box>
<InfoCard title="Parameter Optimization" collapsible>
Parameter optimization allows you to find the best strategy parameters automatically.
This feature requires backend API support for optimization methods (Grid Search, Genetic Algorithm, Bayesian Optimization).
The optimization UI can be added once the backend endpoints are available.
</InfoCard>
<Paper sx={{ p: 3, position: 'relative' }}>
{backtestMutation.isPending && (
<ProgressOverlay
message="Running backtest... This may take a moment."
variant="indeterminate"
/>
)}
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Strategy</InputLabel>
<Select
value={strategyId}
label="Strategy"
onChange={(e) => setStrategyId(e.target.value)}
disabled={strategiesLoading}
>
{strategiesLoading ? (
<MenuItem disabled>Loading strategies...</MenuItem>
) : strategies && strategies.length > 0 ? (
strategies.map((strategy: StrategyResponse) => (
<MenuItem key={strategy.id} value={strategy.id.toString()}>
{strategy.name} ({strategy.strategy_type})
</MenuItem>
))
) : (
<MenuItem disabled>No strategies available</MenuItem>
)}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Symbol"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
required
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Exchange"
value={exchange}
onChange={(e) => setExchange(e.target.value)}
required
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Timeframe</InputLabel>
<Select value={timeframe} onChange={(e) => setTimeframe(e.target.value)}>
<MenuItem value="1m">1 Minute</MenuItem>
<MenuItem value="5m">5 Minutes</MenuItem>
<MenuItem value="15m">15 Minutes</MenuItem>
<MenuItem value="1h">1 Hour</MenuItem>
<MenuItem value="4h">4 Hours</MenuItem>
<MenuItem value="1d">1 Day</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Initial Capital ($)"
type="number"
value={initialCapital}
onChange={(e) => setInitialCapital(e.target.value)}
required
inputProps={{ step: '0.01', min: 0 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Slippage (%)"
type="number"
value={slippage}
onChange={(e) => setSlippage(e.target.value)}
required
inputProps={{ step: '0.01', min: 0, max: 10 }}
helperText="Percentage slippage per trade (e.g., 0.1 for 0.1%)"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Fee Rate (%)"
type="number"
value={feeRate}
onChange={(e) => setFeeRate(e.target.value)}
required
inputProps={{ step: '0.01', min: 0, max: 10 }}
helperText="Trading fee percentage (e.g., 0.1 for 0.1%)"
/>
</Grid>
<Grid item xs={12}>
<Button
type="submit"
variant="contained"
disabled={backtestMutation.isPending || !strategyId}
fullWidth
>
{backtestMutation.isPending ? 'Running Backtest...' : 'Run Backtest'}
</Button>
</Grid>
</Grid>
</form>
{backtestMutation.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{backtestMutation.error instanceof Error
? backtestMutation.error.message
: 'Backtest failed'}
</Alert>
)}
{/* Operations Panel */}
{backtestMutation.isPending && (
<Box sx={{ mt: 3 }}>
<OperationsPanel
operations={[
{
id: 'backtest-running',
type: 'backtest',
name: `Backtesting ${symbol} from ${startDate} to ${endDate}`,
status: 'running',
startTime: new Date(),
},
]}
/>
</Box>
)}
{backtestMutation.isSuccess && backtestResults && (
<Box sx={{ mt: 3 }}>
<Alert severity="success" sx={{ mb: 2 }}>
Backtest completed successfully!
</Alert>
{/* Results Metrics */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Performance Metrics
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Total Return
</Typography>
<Typography
variant="h5"
sx={{
color: (backtestResults.total_return || 0) >= 0 ? 'success.main' : 'error.main',
}}
>
{((backtestResults.total_return || 0) * 100).toFixed(2)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Sharpe Ratio
</Typography>
<Typography variant="h5">
{(backtestResults.sharpe_ratio || 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Sortino Ratio
</Typography>
<Typography variant="h5">
{(backtestResults.sortino_ratio || 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Max Drawdown
</Typography>
<Typography
variant="h5"
sx={{
color: (backtestResults.max_drawdown || 0) < 0 ? 'error.main' : 'text.primary',
}}
>
{((backtestResults.max_drawdown || 0) * 100).toFixed(2)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Win Rate
</Typography>
<Typography variant="h5">
{((backtestResults.win_rate || 0) * 100).toFixed(1)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Total Trades
</Typography>
<Typography variant="h5">
{backtestResults.total_trades || 0}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Final Value
</Typography>
<Typography variant="h5">
${(backtestResults.final_value || 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" gutterBottom variant="body2">
Initial Capital
</Typography>
<Typography variant="h5">
${(backtestResults.initial_capital || parseFloat(initialCapital)).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
{/* Export Buttons */}
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
onClick={async () => {
try {
const blob = await reportingApi.exportBacktestCSV(backtestResults)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `backtest_results_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
showError('Failed to export CSV: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}}
>
Export CSV
</Button>
<Button
variant="outlined"
onClick={async () => {
try {
const blob = await reportingApi.exportBacktestPDF(backtestResults)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `backtest_report_${new Date().toISOString().split('T')[0]}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
showError('Failed to export PDF: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}}
>
Export PDF
</Button>
</Box>
</Paper>
{/* Trades Table */}
{backtestResults.trades && backtestResults.trades.length > 0 && (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Trades ({backtestResults.trades.length})
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Time</TableCell>
<TableCell>Side</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Quantity</TableCell>
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{backtestResults.trades.slice(0, 100).map((trade: any, index: number) => (
<TableRow key={index}>
<TableCell>
{formatDate(trade.timestamp || '', generalSettings)}
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{
color: trade.side === 'buy' ? 'success.main' : 'error.main',
fontWeight: 'bold',
}}
>
{trade.side?.toUpperCase() || 'N/A'}
</Typography>
</TableCell>
<TableCell align="right">${(trade.price || 0).toFixed(2)}</TableCell>
<TableCell align="right">{(trade.quantity || 0).toFixed(8)}</TableCell>
<TableCell align="right">
${((trade.price || 0) * (trade.quantity || 0)).toFixed(2)}
</TableCell>
</TableRow>
))}
{backtestResults.trades.length > 100 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary">
Showing first 100 trades of {backtestResults.trades.length} total
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</Box>
)}
</Paper>
</Box>
)
}

View File

@@ -0,0 +1,733 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Grid,
Button,
TextField,
Chip,
Card,
CardContent,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Switch,
FormControlLabel,
Autocomplete,
} from '@mui/material'
import {
PlayArrow,
Stop,
Refresh,
Psychology,
AutoMode,
TrendingUp,
TrendingDown,
} from '@mui/icons-material'
import { autopilotApi } from '../api/autopilot'
import { tradingApi } from '../api/trading'
import { strategiesApi } from '../api/strategies'
import { marketDataApi } from '../api/marketData'
import Chart from '../components/Chart'
import { OrderSide, OrderStatus, PositionResponse, OrderResponse } from '../types'
import { useSnackbar } from '../contexts/SnackbarContext'
import { useAutopilotSettings } from '../contexts/AutopilotSettingsContext'
import { useWebSocketContext } from '../components/WebSocketProvider'
import SystemHealth from '../components/SystemHealth'
import StatusIndicator from '../components/StatusIndicator'
import DataFreshness from '../components/DataFreshness'
import LoadingSkeleton from '../components/LoadingSkeleton'
import ErrorDisplay from '../components/ErrorDisplay'
import OperationsPanel from '../components/OperationsPanel'
import RealtimePrice from '../components/RealtimePrice'
import ProviderStatusDisplay from '../components/ProviderStatus'
import ChartGrid from '../components/ChartGrid'
import { formatTime } from '../utils/formatters'
import { settingsApi } from '../api/settings'
const CRYPTO_PAIRS = [
'BTC/USD',
'ETH/USD',
'BTC/USDT',
'ETH/USDT',
'SOL/USD',
'ADA/USD',
'XRP/USD',
'DOGE/USD',
'DOT/USD',
'MATIC/USD',
'AVAX/USD',
'LINK/USD',
'AVAX/USD',
'SOL/USD',
]
export default function DashboardPage() {
const queryClient = useQueryClient()
const { showSuccess, showError } = useSnackbar()
const { isConnected: wsConnected } = useWebSocketContext()
// Core state
const [selectedSymbols, setSelectedSymbols] = useState<string[]>(['BTC/USD'])
const [symbol, setSymbol] = useState('BTC/USD') // Primary symbol for chart display
const [paperTrading, setPaperTrading] = useState(true)
const [optimisticRunning, setOptimisticRunning] = useState<boolean | null>(null) // For immediate UI feedback
// Autopilot settings from context
const autopilotSettings = useAutopilotSettings()
const autoExecute = autopilotSettings.autoExecute
const timeframe = autopilotSettings.timeframe
const autopilotMode = 'intelligent' as const // Only intelligent mode is supported
// Unified Autopilot Status Query (intelligent mode only)
const { data: unifiedStatus, error: unifiedStatusError } = useQuery({
queryKey: ['unified-autopilot-status', symbol, timeframe],
queryFn: () => autopilotApi.getUnifiedStatus(symbol, autopilotMode, timeframe),
refetchInterval: 5000,
enabled: true,
})
// Use unified status for intelligent mode
const status = unifiedStatus
const statusError = unifiedStatusError
// Market Data Query
const { data: ohlcv, isLoading: isLoadingOHLCV } = useQuery({
queryKey: ['market-data', symbol],
queryFn: () => marketDataApi.getOHLCV(symbol),
refetchInterval: 60000,
retry: false,
})
// Positions Query
const { data: positions } = useQuery({
queryKey: ['positions', paperTrading],
queryFn: () => tradingApi.getPositions(paperTrading),
refetchInterval: 5000,
})
// Orders Query
const { data: orders } = useQuery({
queryKey: ['orders', paperTrading],
queryFn: () => tradingApi.getOrders(paperTrading, 10),
refetchInterval: 5000,
})
// General Settings for timezone and display preferences
const { data: generalSettings } = useQuery({
queryKey: ['general-settings'],
queryFn: () => settingsApi.getGeneralSettings(),
})
// Balance Query
const { data: balance } = useQuery({
queryKey: ['balance', paperTrading],
queryFn: () => tradingApi.getBalance(paperTrading),
refetchInterval: 5000,
})
// Multi-Symbol Status Query
const { data: multiSymbolStatus } = useQuery({
queryKey: ['multi-symbol-status', autopilotMode, timeframe],
queryFn: () => autopilotApi.getMultiSymbolStatus(undefined, autopilotMode, timeframe),
refetchInterval: 5000,
})
// Running Manual Strategies Query
const { data: runningStrategies } = useQuery({
queryKey: ['running-strategies'],
queryFn: () => strategiesApi.getRunningStrategies(),
refetchInterval: 5000,
})
// Sync selected symbols with running autopilots AND manual strategies
const [hasInitialized, setHasInitialized] = useState(false)
useEffect(() => {
if (!hasInitialized && multiSymbolStatus?.symbols && runningStrategies?.strategies) {
// Get autopilot symbols
const autopilotSymbols = multiSymbolStatus.symbols
.filter(s => s.running)
.map(s => s.symbol)
// Get manual strategy symbols
const manualSymbols = runningStrategies.strategies
.filter(s => s.running && s.symbol)
.map(s => s.symbol as string)
// Combine and deduplicate
const allActiveSymbols = Array.from(new Set([...autopilotSymbols, ...manualSymbols]))
if (allActiveSymbols.length > 0) {
// If current selections are just the default or empty, override them
// Or if we haven't initialized yet
if (!hasInitialized || (selectedSymbols.length === 1 && selectedSymbols[0] === 'BTC/USD')) {
setSelectedSymbols(allActiveSymbols)
setSymbol(allActiveSymbols[0]) // Set primary symbol for chart
}
}
setHasInitialized(true)
}
}, [multiSymbolStatus, runningStrategies, hasInitialized])
// Unified Autopilot Mutations
const startUnifiedMutation = useMutation({
mutationFn: autopilotApi.startUnified,
onMutate: () => {
setOptimisticRunning(true) // Immediately show as running
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] })
queryClient.invalidateQueries({ queryKey: ['autopilot-status'] })
queryClient.invalidateQueries({ queryKey: ['intelligent-autopilot-status'] })
showSuccess(`Autopilot started in ${autopilotMode} mode`)
},
onError: (error: any) => {
setOptimisticRunning(null) // Reset on error
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error'
showError(`Failed to start autopilot: ${errorMessage}`)
},
onSettled: () => {
// Reset optimistic state after query refetches
setTimeout(() => setOptimisticRunning(null), 1000)
},
})
const stopUnifiedMutation = useMutation({
mutationFn: ({ symbol, mode, timeframe }: { symbol: string; mode: 'intelligent'; timeframe: string }) =>
autopilotApi.stopUnified(symbol, mode, timeframe),
onMutate: () => {
setOptimisticRunning(false) // Immediately show as stopped
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] })
queryClient.invalidateQueries({ queryKey: ['autopilot-status'] })
queryClient.invalidateQueries({ queryKey: ['intelligent-autopilot-status'] })
showSuccess('Autopilot stopped')
},
onError: (error: any) => {
setOptimisticRunning(null) // Reset on error
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error'
showError(`Failed to stop autopilot: ${errorMessage}`)
},
onSettled: () => {
setTimeout(() => setOptimisticRunning(null), 1000)
},
})
// Multi-symbol mutations
const startMultiSymbolMutation = useMutation({
mutationFn: autopilotApi.startMultiSymbol,
onMutate: () => {
setOptimisticRunning(true) // Immediately show as running
},
onSuccess: (data) => {
const started = data.symbols.filter(s => s.status === 'started').length
showSuccess(`Started autopilot for ${started} symbol(s)`)
queryClient.invalidateQueries({ queryKey: ['multi-symbol-status'] })
},
onError: (error: any) => {
setOptimisticRunning(null) // Reset on error
showError(`Failed to start: ${error.message}`)
},
onSettled: () => {
setTimeout(() => setOptimisticRunning(null), 1000)
},
})
const stopMultiSymbolMutation = useMutation({
mutationFn: ({ symbols, mode, timeframe }: { symbols: string[]; mode: string; timeframe: string }) =>
autopilotApi.stopMultiSymbol(symbols, mode, timeframe),
onMutate: () => {
setOptimisticRunning(false) // Immediately show as stopped
},
onSuccess: (data) => {
showSuccess(`Stopped ${data.symbols.length} symbol(s)`)
queryClient.invalidateQueries({ queryKey: ['multi-symbol-status'] })
},
onError: (error: any) => {
setOptimisticRunning(null) // Reset on error
showError(`Failed to stop: ${error.message}`)
},
onSettled: () => {
setTimeout(() => setOptimisticRunning(null), 1000)
},
})
const handleStartUnifiedAutopilot = () => {
// Use multi-symbol start if multiple symbols selected
if (selectedSymbols.length > 0) {
startMultiSymbolMutation.mutate({
symbols: selectedSymbols,
mode: 'intelligent', // Enforce intelligent mode
auto_execute: autoExecute,
timeframe,
exchange_id: autopilotSettings.exchange_id,
paper_trading: paperTrading,
interval: autopilotSettings.interval,
})
} else {
startUnifiedMutation.mutate({
symbol,
mode: autopilotMode,
auto_execute: autoExecute,
interval: autopilotSettings.interval,
exchange_id: autopilotSettings.exchange_id,
timeframe,
paper_trading: paperTrading,
})
}
}
const handleStopUnifiedAutopilot = () => {
// Stop all running symbols
if (multiSymbolStatus && multiSymbolStatus.total_running > 0) {
const runningSymbols = multiSymbolStatus.symbols
.filter(s => s.running)
.map(s => s.symbol)
stopMultiSymbolMutation.mutate({ symbols: runningSymbols, mode: 'intelligent', timeframe })
} else {
stopUnifiedMutation.mutate({ symbol, mode: 'intelligent', timeframe })
}
}
const getStatusColor = (orderStatus: OrderStatus) => {
switch (orderStatus) {
case OrderStatus.FILLED:
return 'success'
case OrderStatus.PENDING:
case OrderStatus.OPEN:
return 'warning'
case OrderStatus.CANCELLED:
case OrderStatus.REJECTED:
return 'error'
default:
return 'default'
}
}
// Use optimistic state if set, otherwise fall back to server state
const serverRunning = unifiedStatus?.running ?? status?.running ?? false
const isRunning = optimisticRunning !== null ? optimisticRunning : serverRunning
return (
<Box>
{/* Header with controls */}
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>Dashboard</Typography>
<Chip
label={isRunning ? `AutoPilot Active (${autopilotMode})` : 'AutoPilot Stopped'}
color={isRunning ? 'success' : 'default'}
size="small"
/>
{isRunning && autoExecute && (
<Chip
label="Auto-Execute Enabled"
color="warning"
size="small"
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{/* Multi-Symbol Selector */}
<Autocomplete
multiple
size="small"
options={CRYPTO_PAIRS}
value={selectedSymbols}
onChange={(_, newValue) => {
setSelectedSymbols(newValue)
if (newValue.length > 0) {
setSymbol(newValue[0]) // Set primary symbol for chart
}
}}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
variant="outlined"
label={option}
size="small"
{...getTagProps({ index })}
key={option}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label="Trading Symbols"
placeholder={selectedSymbols.length === 0 ? "Select symbols..." : ""}
/>
)}
sx={{ minWidth: 280 }}
/>
{/* Paper/Real Toggle */}
<FormControlLabel
control={
<Switch
checked={!paperTrading}
onChange={(e) => setPaperTrading(!e.target.checked)}
color="warning"
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{paperTrading ? 'Paper' : 'Real'}
</Typography>
}
/>
{/* AutoPilot Toggle Button */}
<Button
variant={isRunning || (multiSymbolStatus?.total_running || 0) > 0 ? "contained" : "outlined"}
color={isRunning || (multiSymbolStatus?.total_running || 0) > 0 ? "success" : "inherit"}
startIcon={isRunning || (multiSymbolStatus?.total_running || 0) > 0 ? <Stop /> : <PlayArrow />}
onClick={isRunning || (multiSymbolStatus?.total_running || 0) > 0
? handleStopUnifiedAutopilot
: handleStartUnifiedAutopilot}
disabled={
(isRunning || (multiSymbolStatus?.total_running || 0) > 0)
? stopMultiSymbolMutation.isPending
: (startMultiSymbolMutation.isPending || selectedSymbols.length === 0)
}
sx={{
minWidth: 180,
fontWeight: 600,
transition: 'all 0.3s ease',
...(isRunning || (multiSymbolStatus?.total_running || 0) > 0 ? {
backgroundColor: '#4caf50',
'&:hover': {
backgroundColor: '#388e3c',
},
boxShadow: '0 0 12px rgba(76, 175, 80, 0.4)',
} : {
borderColor: 'rgba(255,255,255,0.3)',
color: 'text.secondary',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
},
}),
}}
>
{isRunning || (multiSymbolStatus?.total_running || 0) > 0
? ((multiSymbolStatus?.total_running || 0) > 1
? `AutoPilot ON (${multiSymbolStatus?.total_running})`
: 'AutoPilot ON')
: (selectedSymbols.length > 1
? `AutoPilot OFF (${selectedSymbols.length})`
: 'AutoPilot OFF')
}
</Button>
</Box>
</Box>
</Paper>
{/* System Health */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={6}>
<SystemHealth
websocketStatus={wsConnected ? 'connected' : 'disconnected'}
databaseStatus="connected"
/>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
System Status
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 2, mb: 2 }}>
<StatusIndicator
status={wsConnected ? 'connected' : 'disconnected'}
label={wsConnected ? 'WebSocket Connected' : 'WebSocket Disconnected'}
tooltip={wsConnected ? 'Real-time updates active' : 'Real-time updates unavailable'}
/>
{ohlcv && ohlcv.length > 0 && (
<DataFreshness timestamp={new Date(ohlcv[ohlcv.length - 1].time * 1000)} />
)}
</Box>
<Box sx={{ mt: 2 }}>
<ProviderStatusDisplay compact={true} />
</Box>
</Paper>
</Grid>
<Grid item xs={12}>
<OperationsPanel
operations={[
// Show all running autopilots from multi-symbol status
...(multiSymbolStatus?.symbols || [])
.filter(s => s.running)
.map((s, i) => ({
id: `autopilot-${s.symbol}-${i}`,
type: 'strategy' as const,
name: `AutoPilot: ${s.symbol} (${s.mode})`,
status: 'running' as const,
startTime: new Date(),
details: s.selected_strategy ? `Strategy: ${s.selected_strategy}` : undefined,
})),
// Show manually running strategies
...(runningStrategies?.strategies || [])
.filter(s => s.running)
.map((s) => ({
id: `manual-${s.strategy_id}`,
type: 'strategy' as const,
name: `Manual: ${s.name}`,
status: 'running' as const,
startTime: s.started_at ? new Date(s.started_at) : new Date(),
details: `Symbol: ${s.symbol} | Type: ${s.type}`,
})),
// Fallback for unified single symbol (if not covered above)
...(isRunning && (!multiSymbolStatus?.total_running)
? [{
id: 'autopilot-running',
type: 'strategy' as const,
name: `AutoPilot: ${symbol} (${autopilotMode})`,
status: 'running' as const,
startTime: new Date(),
}]
: []),
]}
/>
</Grid>
</Grid>
{/* Error Alert */}
{(statusError || unifiedStatusError) && (
<ErrorDisplay
error={(statusError || unifiedStatusError) as Error}
title="Connection Error"
onRetry={() => {
queryClient.invalidateQueries({ queryKey: ['autopilot-status'] })
queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] })
}}
/>
)}
<Grid container spacing={3}>
{/* Left Panel - ML Autopilot Info */}
<Grid item xs={12} md={3}>
<Paper sx={{ p: 2, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Psychology color="primary" />
<Typography variant="h6">ML Autopilot</Typography>
</Box>
{/* Active Strategy Display */}
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
Active Strategy
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<AutoMode fontSize="small" color={status?.selected_strategy ? 'success' : 'disabled'} />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{status?.selected_strategy
? status.selected_strategy.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
: 'Waiting for analysis...'}
</Typography>
</Box>
</Box>
{/* How it works */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
The ML model analyzes market conditions and automatically selects the best strategy from 14 available:
</Typography>
<Typography variant="caption" color="text.secondary" component="div" sx={{ lineHeight: 1.8 }}>
RSI MACD Moving Average Bollinger Momentum DCA Grid Pairs Trading Volatility Breakout Sentiment Market Making Consensus Confirmed Divergence
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
Tip: Configure strategy parameters and availability in the Strategy Configurations page.
</Typography>
<Divider sx={{ my: 2 }} />
{/* Balance Display */}
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Account Balance
</Typography>
<Typography variant="h5" sx={{ mb: 2 }}>
${balance?.balance?.toFixed(2) || '0.00'}
</Typography>
{/* Positions Summary */}
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Open Positions
</Typography>
{positions && positions.length > 0 ? (
<Box>
{positions.slice(0, 3).map((pos: PositionResponse) => (
<Box key={pos.symbol} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">{pos.symbol}</Typography>
<Typography
variant="body2"
sx={{
color: pos.unrealized_pnl >= 0 ? 'success.main' : 'error.main',
fontWeight: 500
}}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}{pos.unrealized_pnl.toFixed(2)}
</Typography>
</Box>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">No positions</Typography>
)}
</Paper>
</Grid>
{/* Charts Section */}
<Grid item xs={12} md={selectedSymbols.length > 1 ? 9 : 6}>
{selectedSymbols.length > 1 ? (
// Multi-chart grid for multiple symbols
<ChartGrid
symbols={selectedSymbols}
onAnalyze={() => { }}
isAnalyzing={false}
/>
) : (
// Single chart for one symbol
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 2 }}>
<Box>
<Typography variant="h6" gutterBottom>{symbol}</Typography>
<RealtimePrice symbol={symbol} size="medium" showProvider={true} showChange={true} />
</Box>
<Button
size="small"
startIcon={<Refresh />}
onClick={() => { }} // Analysis removed
disabled={true}
>
Analyze Now
</Button>
</Box>
{isLoadingOHLCV ? (
<LoadingSkeleton variant="card" rows={1} />
) : ohlcv && Array.isArray(ohlcv) && ohlcv.length > 0 ? (
<Chart data={ohlcv} />
) : (
<Box sx={{ height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography color="text.secondary">No market data available</Typography>
</Box>
)}
</Paper>
)}
</Grid>
{/* Right Panel - Status */}
<Grid item xs={12} md={3}>
<Grid container spacing={2} direction="column">
{/* Signal Card */}
<Grid item>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Last Signal</Typography>
{status?.last_signal ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{status.last_signal.type === 'buy' ? (
<TrendingUp color="success" />
) : (
<TrendingDown color="error" />
)}
<Typography
variant="h4"
sx={{
color: status.last_signal.type === 'buy' ? 'success.main' : 'error.main'
}}
>
{status.last_signal.type?.toUpperCase()}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Strength: {(status.last_signal.strength * 100).toFixed(0)}%
</Typography>
</Box>
) : (
<Typography variant="body1" color="text.secondary">
No signals yet
</Typography>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Grid>
{/* Bottom - Recent Orders */}
<Grid item xs={12}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Recent Activity</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Time</TableCell>
<TableCell>Symbol</TableCell>
<TableCell>Side</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Quantity</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders && orders.length > 0 ? (
orders.slice(0, 5).map((order: OrderResponse) => (
<TableRow key={order.id}>
<TableCell>{formatTime(order.created_at, generalSettings)}</TableCell>
<TableCell>{order.symbol}</TableCell>
<TableCell>
<Chip
label={order.side}
size="small"
color={order.side === OrderSide.BUY ? 'success' : 'error'}
/>
</TableCell>
<TableCell>{order.order_type.replace('_', ' ')}</TableCell>
<TableCell align="right">{Number(order.quantity).toFixed(6)}</TableCell>
<TableCell align="right">
{order.price ? `$${Number(order.price).toFixed(2)}` : 'Market'}
</TableCell>
<TableCell>
<Chip
label={order.status}
size="small"
color={getStatusColor(order.status) as any}
/>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
No recent activity
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
</Grid>
</Box>
)
}

View File

@@ -0,0 +1,497 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Box,
Grid,
Paper,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
Tab,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Alert,
} from '@mui/material'
import { Download, Description } from '@mui/icons-material'
import { portfolioApi } from '../api/portfolio'
import { reportingApi } from '../api/reporting'
import { useSnackbar } from '../contexts/SnackbarContext'
import LoadingSkeleton from '../components/LoadingSkeleton'
import DataFreshness from '../components/DataFreshness'
import PositionCard from '../components/PositionCard'
import HelpTooltip from '../components/HelpTooltip'
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts'
interface TabPanelProps {
children?: React.ReactNode
index: number
value: number
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props
return (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
)
}
export default function PortfolioPage() {
const [tabValue, setTabValue] = useState(0)
const { showError, showSuccess } = useSnackbar()
const { data: portfolio, isLoading } = useQuery({
queryKey: ['portfolio'],
queryFn: () => portfolioApi.getCurrentPortfolio(),
refetchInterval: 5000,
})
const { data: history } = useQuery({
queryKey: ['portfolio-history'],
queryFn: () => portfolioApi.getPortfolioHistory(30),
})
const { data: riskMetrics } = useQuery({
queryKey: ['portfolio-risk-metrics'],
queryFn: () => portfolioApi.getRiskMetrics(30),
refetchInterval: 30000,
})
const chartData = history
? history.dates.map((date, i) => ({
date: new Date(date).toLocaleDateString(),
value: history.values[i],
pnl: history.pnl[i],
}))
: []
if (isLoading) {
return <LoadingSkeleton variant="card" rows={3} />
}
// Calculate allocation data for pie chart
const allocationData = portfolio?.positions.map((pos) => ({
name: pos.symbol,
value: (pos.quantity ?? 0) * (pos.current_price ?? 0),
})) || []
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">Portfolio</Typography>
{portfolio && <DataFreshness timestamp={portfolio.timestamp} />}
</Box>
<Paper sx={{ mb: 3 }}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Overview" />
<Tab label="Allocations" />
<Tab label="Reports & Export" />
</Tabs>
</Paper>
<TabPanel value={tabValue} index={0}>
<Grid container spacing={3}>
{/* Summary Cards */}
{portfolio && (
<>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Current Value
</Typography>
<Typography variant="h4">
${(portfolio.performance?.current_value ?? 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Unrealized P&L
</Typography>
<Typography
variant="h4"
sx={{
color: (portfolio.performance?.unrealized_pnl ?? 0) >= 0 ? 'success.main' : 'error.main',
}}
>
${(portfolio.performance?.unrealized_pnl ?? 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Realized P&L
</Typography>
<Typography
variant="h4"
sx={{
color: (portfolio.performance?.realized_pnl ?? 0) >= 0 ? 'success.main' : 'error.main',
}}
>
${(portfolio.performance?.realized_pnl ?? 0).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
{history && history.values.length >= 2 && (
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Daily Change
</Typography>
<Typography
variant="h4"
sx={{
color:
(history.values[history.values.length - 1] - history.values[history.values.length - 2]) >= 0
? 'success.main'
: 'error.main',
}}
>
{(() => {
const change = history.values[history.values.length - 1] - history.values[history.values.length - 2]
const changePercent = history.values[history.values.length - 2] > 0
? (change / history.values[history.values.length - 2]) * 100
: 0
return `${changePercent >= 0 ? '+' : ''}${changePercent.toFixed(2)}%`
})()}
</Typography>
</CardContent>
</Card>
</Grid>
)}
</>
)}
{/* Portfolio Chart */}
<Grid item xs={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Portfolio Value History
</Typography>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="value" stroke="#2196F3" name="Portfolio Value" />
<Line type="monotone" dataKey="pnl" stroke="#00E676" name="P&L" />
</LineChart>
</ResponsiveContainer>
</Paper>
</Grid>
{/* Risk Metrics */}
{riskMetrics && (
<Grid item xs={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Risk Metrics (30 days)
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" variant="body2">Sharpe Ratio</Typography>
<Typography variant="h6">{(riskMetrics.sharpe_ratio ?? 0).toFixed(2)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" variant="body2">Sortino Ratio</Typography>
<Typography variant="h6">{(riskMetrics.sortino_ratio ?? 0).toFixed(2)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" variant="body2">Max Drawdown</Typography>
<Typography variant="h6" sx={{ color: (riskMetrics.max_drawdown ?? 0) < 0 ? 'error.main' : 'text.primary' }}>
{((riskMetrics.max_drawdown ?? 0) * 100).toFixed(2)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" variant="body2">Win Rate</Typography>
<Typography variant="h6">{((riskMetrics.win_rate ?? 0) * 100).toFixed(1)}%</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<Card variant="outlined">
<CardContent>
<Typography color="textSecondary" variant="body2">Total Return</Typography>
<Typography variant="h6" sx={{ color: (riskMetrics.total_return ?? 0) >= 0 ? 'success.main' : 'error.main' }}>
{(riskMetrics.total_return_percent ?? 0).toFixed(2)}%
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
</Grid>
)}
{/* Holdings - Card View */}
<Grid item xs={12}>
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Holdings
</Typography>
<HelpTooltip title="Click 'Close Position' on any position card to close it" />
</Box>
{portfolio && portfolio.positions.length > 0 ? (
<Grid container spacing={2}>
{portfolio.positions.map((pos) => (
<Grid item xs={12} md={6} lg={4} key={pos.symbol}>
<PositionCard
position={pos}
paperTrading={true}
onClose={() => {
// Position will be closed via PositionCard
}}
/>
</Grid>
))}
</Grid>
) : (
<Typography variant="body1" color="text.secondary" align="center" sx={{ py: 4 }}>
No holdings
</Typography>
)}
</Paper>
</Grid>
</Grid>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Portfolio Allocation
<HelpTooltip title="Visual representation of your portfolio distribution by asset" />
</Typography>
{allocationData.length > 0 ? (
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={allocationData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={120}
fill="#8884d8"
dataKey="value"
>
{allocationData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<Typography variant="body1" color="text.secondary" align="center" sx={{ py: 4 }}>
No allocation data available
</Typography>
)}
</Paper>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<ReportsSection showError={showError} showSuccess={showSuccess} />
</TabPanel>
</Box>
)
}
// Reports Section (merged from ReportingPage)
function ReportsSection({ showError, showSuccess }: { showError: (msg: string) => void; showSuccess: (msg: string) => void }) {
const [taxMethod, setTaxMethod] = useState('fifo')
const [taxYear, setTaxYear] = useState(new Date().getFullYear())
const [taxSymbol, setTaxSymbol] = useState('')
const handleExportTrades = async () => {
try {
const blob = await reportingApi.exportTradesCSV()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `trades_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
showSuccess('Trades exported successfully')
} catch (error) {
showError('Failed to export trades: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}
const handleExportPortfolio = async () => {
try {
const blob = await reportingApi.exportPortfolioCSV()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `portfolio_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
showSuccess('Portfolio exported successfully')
} catch (error) {
showError('Failed to export portfolio: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}
const handleExportTaxReport = async () => {
try {
const url = `/api/reporting/tax/${taxMethod}?year=${taxYear}${taxSymbol ? `&symbol=${taxSymbol}` : ''}&paper_trading=true`
const response = await fetch(url)
if (!response.ok) throw new Error('Failed to generate tax report')
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.download = `tax_report_${taxMethod}_${taxYear}_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
showSuccess('Tax report generated successfully')
} catch (error) {
showError('Failed to export tax report: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}
return (
<Grid container spacing={3}>
{/* Export Buttons */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Description sx={{ mr: 1 }} />
<Typography variant="h6">Export Trades</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Export all trading history to CSV format.
</Typography>
<Button variant="contained" startIcon={<Download />} onClick={handleExportTrades} fullWidth>
Export Trades CSV
</Button>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Description sx={{ mr: 1 }} />
<Typography variant="h6">Export Portfolio</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Export current portfolio holdings to CSV format.
</Typography>
<Button variant="contained" startIcon={<Download />} onClick={handleExportPortfolio} fullWidth>
Export Portfolio CSV
</Button>
</CardContent>
</Card>
</Grid>
{/* Tax Reporting */}
<Grid item xs={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Tax Reporting</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
Generate tax reports using FIFO or LIFO cost basis methods. Consult a tax professional for accurate reporting.
</Alert>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>Tax Method</InputLabel>
<Select value={taxMethod} label="Tax Method" onChange={(e) => setTaxMethod(e.target.value)}>
<MenuItem value="fifo">FIFO (First In, First Out)</MenuItem>
<MenuItem value="lifo">LIFO (Last In, First Out)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Tax Year"
type="number"
value={taxYear}
onChange={(e) => setTaxYear(parseInt(e.target.value) || new Date().getFullYear())}
inputProps={{ min: 2020, max: new Date().getFullYear() }}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Symbol (Optional)"
value={taxSymbol}
onChange={(e) => setTaxSymbol(e.target.value)}
placeholder="BTC/USD or leave empty for all"
/>
</Grid>
<Grid item xs={12}>
<Button variant="contained" size="large" startIcon={<Download />} onClick={handleExportTaxReport}>
Generate Tax Report ({taxMethod.toUpperCase()})
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
Tooltip,
Card,
CardContent,
Switch,
} from '@mui/material'
import {
Add,
Edit,
Delete,
Info,
PlayArrow,
} from '@mui/icons-material'
import { strategiesApi } from '../api/strategies'
import { exchangesApi } from '../api/exchanges'
import { StrategyResponse } from '../types'
import StrategyDialog from '../components/StrategyDialog'
import SpreadChart from '../components/SpreadChart'
export default function StrategiesPage() {
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingStrategy, setEditingStrategy] = useState<StrategyResponse | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [strategyToDelete, setStrategyToDelete] = useState<number | null>(null)
const { data: strategies, isLoading } = useQuery({
queryKey: ['strategies'],
queryFn: () => strategiesApi.listStrategies(),
})
const { data: exchanges } = useQuery({
queryKey: ['exchanges'],
queryFn: () => exchangesApi.listExchanges(),
})
// Query for real-time status of running strategies
const { data: runningStatus } = useQuery({
queryKey: ['running-strategies'],
queryFn: () => strategiesApi.getRunningStrategies(),
refetchInterval: 5000, // Refresh every 5 seconds
})
const startMutation = useMutation({
mutationFn: (id: number) => strategiesApi.startStrategy(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['strategies'] })
},
})
const stopMutation = useMutation({
mutationFn: (id: number) => strategiesApi.stopStrategy(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['strategies'] })
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => strategiesApi.deleteStrategy(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['strategies'] })
setDeleteConfirmOpen(false)
setStrategyToDelete(null)
},
})
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: number; updates: any }) =>
strategiesApi.updateStrategy(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['strategies'] })
}
})
const handleCreate = () => {
setEditingStrategy(null)
setDialogOpen(true)
}
const handleEdit = (strategy: StrategyResponse) => {
setEditingStrategy(strategy)
setDialogOpen(true)
}
const handleDelete = (id: number) => {
setStrategyToDelete(id)
setDeleteConfirmOpen(true)
}
const handleConfirmDelete = () => {
if (strategyToDelete) {
deleteMutation.mutate(strategyToDelete)
}
}
const handleStart = (id: number) => {
startMutation.mutate(id)
}
const handleStop = (id: number) => {
stopMutation.mutate(id)
}
const getStrategyTypeLabel = (type: string) => {
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
)
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box>
<Typography variant="h4">Strategy Configurations</Typography>
<Typography variant="body2" color="text.secondary">
Customize strategies for Autopilot or run them manually
</Typography>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreate}
>
Create Strategy
</Button>
</Box>
<Alert severity="info" icon={<Info />} sx={{ mb: 3 }}>
<strong>Autopilot</strong> = Available for ML selection. <strong>Manual Run</strong> = Execute immediately (bypasses Autopilot).
</Alert>
{strategies && strategies.length === 0 && (
<Card>
<CardContent>
<Typography variant="body1" color="text.secondary" align="center" sx={{ py: 4 }}>
No strategies created yet. Click "Create Strategy" to get started.
</Typography>
</CardContent>
</Card>
)}
{strategies && strategies.length > 0 && (
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Symbol</TableCell>
<TableCell>Timeframe</TableCell>
<TableCell align="center">Autopilot</TableCell>
<TableCell align="center">Manual Run</TableCell>
<TableCell>Trading Type</TableCell>
<TableCell align="right">Config</TableCell>
</TableRow>
</TableHead>
<TableBody>
{strategies.map((strategy: StrategyResponse) => (
<TableRow key={strategy.id}>
<TableCell>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{strategy.name}
</Typography>
{strategy.description && (
<Typography variant="caption" color="text.secondary">
{strategy.description}
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={getStrategyTypeLabel(strategy.strategy_type)}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>
{strategy.parameters?.symbol || 'N/A'}
</TableCell>
<TableCell>
{strategy.timeframes?.join(', ') || '1h'}
</TableCell>
<TableCell align="center">
<Tooltip title="Toggle Autopilot Availability">
<Switch
checked={strategy.enabled}
onChange={(e) => {
const isEnabling = e.target.checked
// Update enabled state
updateMutation.mutate({
id: strategy.id,
updates: { enabled: isEnabling }
})
// Safeguard: If enabling Autopilot and it's manually running, stop it
if (isEnabling && strategy.running) {
handleStop(strategy.id)
}
}}
size="small"
color="success"
/>
</Tooltip>
</TableCell>
<TableCell align="center">
<Tooltip title="Toggle Manual Run (Bypasses Autopilot)">
<Switch
checked={strategy.running}
onChange={(e) => {
const isStarting = e.target.checked
if (isStarting) {
handleStart(strategy.id)
// Safeguard: If starting manually and Autopilot is enabled, disable it
if (strategy.enabled) {
updateMutation.mutate({
id: strategy.id,
updates: { enabled: false }
})
}
} else {
handleStop(strategy.id)
}
}}
size="small"
color="primary"
disabled={startMutation.isPending || stopMutation.isPending}
/>
</Tooltip>
</TableCell>
<TableCell>
<Chip
label={strategy.paper_trading ? 'Paper' : 'Live'}
color={strategy.paper_trading ? 'info' : 'warning'}
size="small"
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Edit Strategy">
<IconButton
size="small"
onClick={() => handleEdit(strategy)}
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title="Delete Strategy">
<IconButton
size="small"
color="error"
onClick={() => handleDelete(strategy.id)}
disabled={deleteMutation.isPending}
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
{/* Running Strategies Status */}
{runningStatus && runningStatus.total_running > 0 && (
<Paper sx={{ p: 2, mt: 3, bgcolor: 'success.dark', backgroundImage: 'linear-gradient(135deg, rgba(76, 175, 80, 0.1) 0%, rgba(76, 175, 80, 0.05) 100%)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<PlayArrow color="success" />
<Typography variant="h6">
{runningStatus.total_running} Strategy Running
</Typography>
</Box>
{runningStatus.strategies.map((status) => (
<Paper key={status.strategy_id} sx={{ p: 2, mb: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{status.name} ({status.symbol})
</Typography>
<Typography variant="caption" color="text.secondary">
Type: {status.type} | Started: {status.started_at ? new Date(status.started_at).toLocaleTimeString() : 'Unknown'}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Chip
label={`${status.signal_count || 0} signals`}
size="small"
color={status.signal_count && status.signal_count > 0 ? 'success' : 'default'}
/>
{status.last_signal && (
<Chip
label={`Last: ${status.last_signal.type.toUpperCase()} @ $${status.last_signal.price.toFixed(2)}`}
size="small"
color={status.last_signal.type === 'buy' ? 'success' : 'error'}
/>
)}
{status.last_tick && (
<Typography variant="caption" color="text.secondary">
Last tick: {new Date(status.last_tick).toLocaleTimeString()}
</Typography>
)}
</Box>
</Box>
</Paper>
))}
</Paper>
)}
{/* Pairs Trading Visualization */}
{strategies && strategies.some((s: StrategyResponse) => s.strategy_type === 'pairs_trading' && (s.enabled || s.running)) && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Pairs Trading Analysis
</Typography>
{strategies
.filter((s: StrategyResponse) => s.strategy_type === 'pairs_trading' && (s.enabled || s.running))
.map((strategy: StrategyResponse) => (
<Box key={strategy.id} sx={{ mb: 2 }}>
<SpreadChart
primarySymbol={strategy.parameters?.symbol || 'BTC/USD'}
secondarySymbol={strategy.parameters?.second_symbol || 'ETH/USD'}
lookbackPeriod={strategy.parameters?.lookback_period || 20}
zScoreThreshold={strategy.parameters?.z_score_threshold || 2.0}
/>
</Box>
))}
</Box>
)}
<StrategyDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false)
setEditingStrategy(null)
}}
strategy={editingStrategy}
exchanges={exchanges || []}
onSave={() => {
setDialogOpen(false)
setEditingStrategy(null)
queryClient.invalidateQueries({ queryKey: ['strategies'] })
}}
/>
<Dialog open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)}>
<DialogTitle>Delete Strategy</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
Are you sure you want to delete this strategy? This action cannot be undone.
</Alert>
<Typography>
This will permanently delete the strategy and all its configuration.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="error"
onClick={handleConfirmDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,554 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Box,
Paper,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
IconButton,
CircularProgress,
Tooltip,
Tabs,
Tab,
Card,
CardContent,
Grid,
Switch,
FormControlLabel,
} from '@mui/material'
import {
Add,
Cancel,
TrendingUp,
TrendingDown,
} from '@mui/icons-material'
import { tradingApi } from '../api/trading'
import { exchangesApi } from '../api/exchanges'
import { OrderResponse, PositionResponse, OrderSide, OrderStatus } from '../types'
import OrderForm from '../components/OrderForm'
import PositionCard from '../components/PositionCard'
import { settingsApi } from '../api/settings'
import { formatDate } from '../utils/formatters'
import {
Checkbox,
TextField,
InputAdornment,
ButtonGroup,
Divider,
} from '@mui/material'
import {
Search,
DeleteSweep,
ClearAll,
} from '@mui/icons-material'
import { useSnackbar } from '../contexts/SnackbarContext'
interface TabPanelProps {
children?: React.ReactNode
index: number
value: number
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props
return (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
)
}
export default function TradingPage() {
const { showSuccess, showError } = useSnackbar()
const queryClient = useQueryClient()
const [tabValue, setTabValue] = useState(0)
const [orderDialogOpen, setOrderDialogOpen] = useState(false)
const [paperTrading, setPaperTrading] = useState(true)
const { data: exchanges } = useQuery({
queryKey: ['exchanges'],
queryFn: () => exchangesApi.listExchanges(),
})
const [selectedOrders, setSelectedOrders] = useState<number[]>([])
const [symbolFilter, setSymbolFilter] = useState('')
const { data: orders, isLoading: ordersLoading } = useQuery({
queryKey: ['orders', paperTrading],
queryFn: () => tradingApi.getOrders(paperTrading, 100),
refetchInterval: 5000,
})
const { data: positions, isLoading: positionsLoading } = useQuery({
queryKey: ['positions', paperTrading],
queryFn: () => tradingApi.getPositions(paperTrading),
refetchInterval: 5000,
})
const { data: generalSettings } = useQuery({
queryKey: ['general-settings'],
queryFn: () => settingsApi.getGeneralSettings(),
})
const { data: balance } = useQuery({
queryKey: ['balance', paperTrading],
queryFn: () => tradingApi.getBalance(paperTrading),
refetchInterval: 5000,
})
// Create order mutation moved to OrderForm or removed if redundant
const cancelOrderMutation = useMutation({
mutationFn: (orderId: number) => tradingApi.cancelOrder(orderId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
showSuccess?.('Order cancelled successfully')
},
onError: (error: any) => {
showError?.(`Failed to cancel order: ${error.message}`)
}
})
const cancelAllOrdersMutation = useMutation({
mutationFn: (paperTrading: boolean) => tradingApi.cancelAllOrders(paperTrading),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
showSuccess?.(`Cancelled ${data.cancelled_count} orders`)
},
onError: (error: any) => {
showError?.(`Failed to cancel all orders: ${error.message}`)
}
})
const cancelMultipleOrdersMutation = useMutation({
mutationFn: async (orderIds: number[]) => {
const results = await Promise.all(orderIds.map(id => tradingApi.cancelOrder(id)))
return results
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] })
setSelectedOrders([])
showSuccess?.('Selected orders cancelled')
},
onError: (error: any) => {
showError?.(`Failed to cancel selected orders: ${error.message}`)
}
})
const handleCreateOrder = () => {
setOrderDialogOpen(true)
}
const handleCancelOrder = (orderId: number) => {
if (window.confirm('Are you sure you want to cancel this order?')) {
cancelOrderMutation.mutate(orderId)
}
}
const handleCancelAll = () => {
if (window.confirm(`Are you sure you want to cancel ALL open ${paperTrading ? 'paper' : 'live'} orders?`)) {
cancelAllOrdersMutation.mutate(paperTrading)
}
}
const handleCancelSelected = () => {
if (selectedOrders.length === 0) return
if (window.confirm(`Are you sure you want to cancel ${selectedOrders.length} selected orders?`)) {
cancelMultipleOrdersMutation.mutate(selectedOrders)
}
}
const handleSelectOrder = (orderId: number) => {
setSelectedOrders(prev =>
prev.includes(orderId) ? prev.filter(id => id !== orderId) : [...prev, orderId]
)
}
const handleSelectAll = (orderIds: number[]) => {
if (selectedOrders.length === orderIds.length && orderIds.length > 0) {
setSelectedOrders([])
} else {
setSelectedOrders(orderIds)
}
}
const getStatusColor = (status: OrderStatus) => {
switch (status) {
case OrderStatus.FILLED:
return 'success'
case OrderStatus.PENDING:
case OrderStatus.OPEN:
case OrderStatus.PARTIALLY_FILLED:
return 'warning'
case OrderStatus.CANCELLED:
case OrderStatus.REJECTED:
case OrderStatus.EXPIRED:
return 'error'
default:
return 'default'
}
}
const filteredOrders = orders?.filter(o =>
!symbolFilter || o.symbol.toLowerCase().includes(symbolFilter.toLowerCase())
) || []
const activeOrders = filteredOrders.filter(
(o) => o.status === OrderStatus.OPEN || o.status === OrderStatus.PENDING || o.status === OrderStatus.PARTIALLY_FILLED
)
const orderHistory = filteredOrders.filter(
(o) => o.status === OrderStatus.FILLED || o.status === OrderStatus.CANCELLED || o.status === OrderStatus.REJECTED || o.status === OrderStatus.EXPIRED
)
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">Trading</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControlLabel
control={
<Switch
checked={!paperTrading}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPaperTrading(!e.target.checked)}
color="warning"
size="small"
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{paperTrading ? 'Paper' : 'Live'}
</Typography>
}
/>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateOrder}
>
Place Order
</Button>
</Box>
</Box>
{/* Balance Card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Typography color="text.secondary" variant="body2">
Available Balance
</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
${balance?.balance?.toFixed(2) || '0.00'}
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography color="text.secondary" variant="body2">
Open Positions
</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
{positions?.length || 0}
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography color="text.secondary" variant="body2">
Active Orders
</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
{activeOrders.length}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Paper>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Positions" />
<Tab label="Active Orders" />
<Tab label="Order History" />
</Tabs>
<TabPanel value={tabValue} index={0}>
{positionsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
) : positions && positions.length > 0 ? (
<Grid container spacing={2} sx={{ p: 2 }}>
{positions.map((position: PositionResponse) => (
<Grid item xs={12} md={6} key={position.symbol}>
<PositionCard
position={position}
paperTrading={paperTrading}
onClose={() => {
// Position closing will be handled in PositionCard
queryClient.invalidateQueries({ queryKey: ['positions'] })
}}
/>
</Grid>
))}
</Grid>
) : (
<Box sx={{ p: 5, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No open positions
</Typography>
</Box>
)}
</TabPanel>
<TabPanel value={tabValue} index={1}>
<Box sx={{ px: 2, pb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
size="small"
placeholder="Filter Symbol..."
value={symbolFilter}
onChange={(e) => setSymbolFilter(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{activeOrders.length > 0 && (
<ButtonGroup size="small" variant="outlined">
<Button
color="error"
startIcon={<DeleteSweep />}
onClick={handleCancelSelected}
disabled={selectedOrders.length === 0 || cancelMultipleOrdersMutation.isPending}
>
Cancel Selected ({selectedOrders.length})
</Button>
<Button
color="error"
startIcon={<ClearAll />}
onClick={handleCancelAll}
disabled={activeOrders.length === 0 || cancelAllOrdersMutation.isPending}
>
Cancel All
</Button>
</ButtonGroup>
)}
</Box>
</Box>
<Divider />
{ordersLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
) : activeOrders.length > 0 ? (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedOrders.length > 0 && selectedOrders.length < activeOrders.length}
checked={activeOrders.length > 0 && selectedOrders.length === activeOrders.length}
onChange={() => handleSelectAll(activeOrders.map(o => o.id))}
/>
</TableCell>
<TableCell>Time</TableCell>
<TableCell>Symbol</TableCell>
<TableCell>Side</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Quantity</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Filled</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{activeOrders.map((order: OrderResponse) => (
<TableRow
key={order.id}
hover
onClick={() => handleSelectOrder(order.id)}
sx={{ cursor: 'pointer' }}
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedOrders.includes(order.id)}
onChange={() => handleSelectOrder(order.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell>
{formatDate(order.created_at, generalSettings)}
</TableCell>
<TableCell>{order.symbol}</TableCell>
<TableCell>
<Chip
label={order.side}
size="small"
color={order.side === OrderSide.BUY ? 'success' : 'error'}
icon={order.side === OrderSide.BUY ? <TrendingUp /> : <TrendingDown />}
/>
</TableCell>
<TableCell>
{order.order_type.replace('_', ' ')}
</TableCell>
<TableCell align="right">
{Number(order.quantity).toFixed(8)}
</TableCell>
<TableCell align="right">
{order.price ? `$${Number(order.price).toFixed(2)}` : 'Market'}
</TableCell>
<TableCell align="right">
{Number(order.filled_quantity).toFixed(8)} / {Number(order.quantity).toFixed(8)}
</TableCell>
<TableCell>
<Chip
label={order.status}
size="small"
color={getStatusColor(order.status) as any}
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Cancel Order">
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation()
handleCancelOrder(order.id)
}}
disabled={cancelOrderMutation.isPending}
>
<Cancel />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Box sx={{ p: 5, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No active orders
</Typography>
</Box>
)}
</TabPanel>
<TabPanel value={tabValue} index={2}>
<Box sx={{ px: 2, pb: 2 }}>
<TextField
size="small"
placeholder="Filter Symbol..."
value={symbolFilter}
onChange={(e) => setSymbolFilter(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
</Box>
<Divider />
{ordersLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
) : orderHistory.length > 0 ? (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Time</TableCell>
<TableCell>Symbol</TableCell>
<TableCell>Side</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Quantity</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Filled</TableCell>
<TableCell align="right">Fee</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orderHistory.slice(0, 50).map((order: OrderResponse) => (
<TableRow key={order.id}>
<TableCell>
{formatDate(order.created_at, generalSettings)}
</TableCell>
<TableCell>{order.symbol}</TableCell>
<TableCell>
<Chip
label={order.side}
size="small"
color={order.side === OrderSide.BUY ? 'success' : 'error'}
/>
</TableCell>
<TableCell>
{order.order_type.replace('_', ' ')}
</TableCell>
<TableCell align="right">
{Number(order.quantity).toFixed(8)}
</TableCell>
<TableCell align="right">
{order.average_fill_price
? `$${Number(order.average_fill_price).toFixed(2)}`
: order.price
? `$${Number(order.price).toFixed(2)}`
: 'Market'}
</TableCell>
<TableCell align="right">
{Number(order.filled_quantity).toFixed(8)}
</TableCell>
<TableCell align="right">
${Number(order.fee).toFixed(2)}
</TableCell>
<TableCell>
<Chip
label={order.status}
size="small"
color={getStatusColor(order.status) as any}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Box sx={{ p: 5, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No order history
</Typography>
</Box>
)}
</TabPanel>
</Paper>
<OrderForm
open={orderDialogOpen}
onClose={() => setOrderDialogOpen(false)}
exchanges={exchanges || []}
paperTrading={paperTrading}
onSuccess={() => {
setOrderDialogOpen(false)
}}
/>
</Box>
)
}

View File

@@ -0,0 +1,4 @@
export { default as DashboardPage } from './DashboardPage'
export { default as PortfolioPage } from './PortfolioPage'
export { default as BacktestPage } from './BacktestPage'
export { default as SettingsPage } from './SettingsPage'

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import DashboardPage from '../DashboardPage'
import * as autopilotApi from '../../api/autopilot'
import { AutopilotSettingsProvider } from '../../contexts/AutopilotSettingsContext'
import { BrowserRouter } from 'react-router-dom'
vi.mock('../../api/autopilot')
vi.mock('../../api/trading')
vi.mock('../../api/marketData')
vi.mock('../../api/strategies')
vi.mock('../../components/WebSocketProvider', () => ({
useWebSocketContext: () => ({ isConnected: true, lastMessage: null, subscribe: vi.fn(() => vi.fn()) }),
}))
vi.mock('../../contexts/SnackbarContext', () => ({
useSnackbar: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
showInfo: vi.fn(),
}),
}))
describe('DashboardPage - Autopilot Section', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
vi.clearAllMocks()
})
// Helper to wrap component with all required providers
const renderDashboard = () => {
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AutopilotSettingsProvider>
<DashboardPage />
</AutopilotSettingsProvider>
</BrowserRouter>
</QueryClientProvider>
)
}
it('renders autopilot configuration section', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText('Autopilot Configuration')).toBeInTheDocument()
})
})
it('displays mode selector', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText('Select Autopilot Mode')).toBeInTheDocument()
})
})
it('shows auto-execute toggle', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText(/Auto-Execute/)).toBeInTheDocument()
})
})
it('shows start button when autopilot is not running', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({
running: false,
mode: 'pattern',
} as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText('Start AutoPilot')).toBeInTheDocument()
})
})
it('shows stop button when autopilot is running', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({
running: true,
mode: 'pattern',
} as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText('Stop AutoPilot')).toBeInTheDocument()
})
})
it('calls startUnified when start button is clicked', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
const startUnifiedMock = vi.fn().mockResolvedValue({ status: 'started' })
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({
running: false,
mode: 'pattern',
} as any)
vi.mocked(autopilotApi.autopilotApi.startUnified).mockImplementation(startUnifiedMock)
renderDashboard()
await waitFor(() => {
const startButton = screen.getByText('Start AutoPilot')
fireEvent.click(startButton)
})
await waitFor(() => {
expect(startUnifiedMock).toHaveBeenCalled()
})
})
it('displays current mode in status chip', async () => {
const mockModeInfo = {
modes: {
pattern: { name: 'Pattern-Based', description: 'Test' },
intelligent: { name: 'ML-Based', description: 'Test' },
},
comparison: {},
}
vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any)
vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({
running: true,
mode: 'intelligent',
} as any)
renderDashboard()
await waitFor(() => {
expect(screen.getByText(/AutoPilot Active \(intelligent\)/)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,65 @@
import '@testing-library/jest-dom'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock ResizeObserver
class ResizeObserverMock {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
}
window.ResizeObserver = ResizeObserverMock
// Mock WebSocket
class WebSocketMock {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = WebSocketMock.OPEN
url: string
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: ((error: Event) => void) | null = null
onclose: (() => void) | null = null
constructor(url: string) {
this.url = url
// Simulate connection
setTimeout(() => this.onopen?.(), 0)
}
send = vi.fn()
close = vi.fn(() => {
this.readyState = WebSocketMock.CLOSED
this.onclose?.()
})
}
; (globalThis as unknown as { WebSocket: typeof WebSocketMock }).WebSocket = WebSocketMock
// Mock fetch
globalThis.fetch = vi.fn() as unknown as typeof fetch
// Suppress console errors in tests (optional, enable if needed)
// vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@@ -0,0 +1,80 @@
import { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { vi } from 'vitest'
/**
* Creates a fresh QueryClient for testing with retry disabled
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
})
}
interface WrapperProps {
children: React.ReactNode
}
/**
* Custom render function that wraps components with necessary providers
*/
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & {
queryClient?: QueryClient
route?: string
}
) {
const { queryClient = createTestQueryClient(), route = '/', ...renderOptions } = options || {}
// Set the route
window.history.pushState({}, 'Test page', route)
function Wrapper({ children }: WrapperProps) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
)
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
}
}
/**
* Mock snackbar context for testing
*/
export const mockSnackbar = {
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
showInfo: vi.fn(),
}
/**
* Mock WebSocket context for testing
*/
export const mockWebSocketContext = {
isConnected: true,
lastMessage: null,
messageHistory: [],
sendMessage: vi.fn(),
subscribe: vi.fn(),
}
// Re-export everything from testing-library
export * from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'

150
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,150 @@
export enum OrderSide {
BUY = 'buy',
SELL = 'sell',
}
export enum OrderType {
MARKET = 'market',
LIMIT = 'limit',
STOP_LOSS = 'stop_loss',
TAKE_PROFIT = 'take_profit',
TRAILING_STOP = 'trailing_stop',
OCO = 'oco',
ICEBERG = 'iceberg',
}
export enum OrderStatus {
PENDING = 'pending',
OPEN = 'open',
PARTIALLY_FILLED = 'partially_filled',
FILLED = 'filled',
CANCELLED = 'cancelled',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export interface OrderCreate {
exchange_id: number
symbol: string
side: OrderSide
order_type: OrderType
quantity: number
price?: number
strategy_id?: number
paper_trading?: boolean
}
export interface OrderResponse {
id: number
exchange_id: number
strategy_id?: number
symbol: string
order_type: OrderType
side: OrderSide
status: OrderStatus
quantity: number
price?: number
filled_quantity: number
average_fill_price?: number
fee: number
paper_trading: boolean
created_at: string
updated_at: string
filled_at?: string
}
export interface PositionResponse {
symbol: string
quantity: number
entry_price: number
current_price: number
unrealized_pnl: number
realized_pnl: number
}
export interface PortfolioResponse {
positions: Array<{
symbol: string
quantity: number
entry_price: number
current_price: number
unrealized_pnl: number
}>
performance: {
current_value: number
unrealized_pnl: number
realized_pnl: number
}
timestamp: string
}
export interface PortfolioHistoryResponse {
dates: string[]
values: number[]
pnl: number[]
}
export interface StrategyCreate {
name: string
description?: string
strategy_type: string
class_name: string
parameters?: Record<string, any>
timeframes?: string[]
paper_trading?: boolean
schedule?: Record<string, any>
}
export interface StrategyUpdate {
name?: string
description?: string
parameters?: Record<string, any>
timeframes?: string[]
enabled?: boolean
schedule?: Record<string, any>
}
export interface StrategyResponse {
id: number
name: string
description?: string
strategy_type: string
class_name: string
parameters: Record<string, any>
timeframes: string[]
enabled: boolean
running: boolean
paper_trading: boolean
version: string
schedule?: Record<string, any>
created_at: string
updated_at: string
}
export interface BacktestRequest {
strategy_id: number
symbol: string
exchange: string
timeframe: string
start_date: string
end_date: string
initial_capital?: number
slippage?: number
fee_rate?: number
}
export interface BacktestResponse {
backtest_id?: number
results: Record<string, any>
status: string
}
export interface ExchangeResponse {
id: number
name: string
sandbox: boolean
read_only: boolean
enabled: boolean
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,48 @@
export interface AppError {
message: string
code?: string
details?: any
retryable?: boolean
}
export function formatError(error: unknown): AppError {
if (error instanceof Error) {
return {
message: error.message,
details: error.stack,
retryable: false,
}
}
if (typeof error === 'string') {
return {
message: error,
retryable: false,
}
}
if (error && typeof error === 'object' && 'message' in error) {
return {
message: String(error.message),
code: 'code' in error ? String(error.code) : undefined,
details: error,
retryable: 'retryable' in error ? Boolean(error.retryable) : false,
}
}
return {
message: 'An unknown error occurred',
retryable: false,
}
}
export function getErrorMessage(error: unknown): string {
const formatted = formatError(error)
return formatted.message
}
export function isRetryable(error: unknown): boolean {
const formatted = formatError(error)
return formatted.retryable || false
}

View File

@@ -0,0 +1,58 @@
import { GeneralSettings } from '../api/settings'
/**
* Formats a date string or object based on user settings
*/
export const formatDate = (date: string | Date | number, settings?: GeneralSettings): string => {
if (!date) return 'N/A'
const d = typeof date === 'string' && !date.includes('Z') && !date.includes('+')
? new Date(date + 'Z') // Ensure UTC if no timezone info
: new Date(date)
if (isNaN(d.getTime())) return 'Invalid Date'
const timezone = settings?.timezone || 'local'
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}
if (timezone !== 'local') {
options.timeZone = timezone
}
return d.toLocaleString(undefined, options)
}
/**
* Formats only the time part
*/
export const formatTime = (date: string | Date | number, settings?: GeneralSettings): string => {
if (!date) return 'N/A'
const d = typeof date === 'string' && !date.includes('Z') && !date.includes('+')
? new Date(date + 'Z')
: new Date(date)
if (isNaN(d.getTime())) return 'N/A'
const timezone = settings?.timezone || 'local'
const options: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}
if (timezone !== 'local') {
options.timeZone = timezone
}
return d.toLocaleTimeString(undefined, options)
}

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_WS_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

29
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

26
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})

30
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'e2e'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/types/',
],
},
},
})