Local changes: Updated model training, removed debug instrumentation, and configuration improvements
This commit is contained in:
53
frontend/README.md
Normal file
53
frontend/README.md
Normal 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
|
||||
```
|
||||
89
frontend/e2e/dashboard.spec.ts
Normal file
89
frontend/e2e/dashboard.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
73
frontend/e2e/settings.spec.ts
Normal file
73
frontend/e2e/settings.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
frontend/e2e/strategies.spec.ts
Normal file
79
frontend/e2e/strategies.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
96
frontend/e2e/trading.spec.ts
Normal file
96
frontend/e2e/trading.spec.ts
Normal 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
13
frontend/index.html
Normal 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
6715
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/package.json
Normal file
51
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
frontend/playwright.config.ts
Normal file
56
frontend/playwright.config.ts
Normal 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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
1
frontend/public/logo.svg
Normal file
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
71
frontend/src/App.tsx
Normal 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
|
||||
5
frontend/src/api/__init__.ts
Normal file
5
frontend/src/api/__init__.ts
Normal 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'
|
||||
105
frontend/src/api/__tests__/autopilot.test.ts
Normal file
105
frontend/src/api/__tests__/autopilot.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
167
frontend/src/api/__tests__/marketData.test.ts
Normal file
167
frontend/src/api/__tests__/marketData.test.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
136
frontend/src/api/__tests__/strategies.test.ts
Normal file
136
frontend/src/api/__tests__/strategies.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
126
frontend/src/api/__tests__/trading.test.ts
Normal file
126
frontend/src/api/__tests__/trading.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
53
frontend/src/api/alerts.ts
Normal file
53
frontend/src/api/alerts.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
200
frontend/src/api/autopilot.ts
Normal file
200
frontend/src/api/autopilot.ts
Normal 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
|
||||
}
|
||||
14
frontend/src/api/backtesting.ts
Normal file
14
frontend/src/api/backtesting.ts
Normal 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
|
||||
},
|
||||
}
|
||||
37
frontend/src/api/client.ts
Normal file
37
frontend/src/api/client.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
19
frontend/src/api/exchanges.ts
Normal file
19
frontend/src/api/exchanges.ts
Normal 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
|
||||
},
|
||||
}
|
||||
142
frontend/src/api/marketData.ts
Normal file
142
frontend/src/api/marketData.ts
Normal 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
|
||||
}
|
||||
41
frontend/src/api/portfolio.ts
Normal file
41
frontend/src/api/portfolio.ts
Normal 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
|
||||
},
|
||||
}
|
||||
33
frontend/src/api/reporting.ts
Normal file
33
frontend/src/api/reporting.ts
Normal 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
|
||||
},
|
||||
}
|
||||
137
frontend/src/api/settings.ts
Normal file
137
frontend/src/api/settings.ts
Normal 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
|
||||
},
|
||||
}
|
||||
76
frontend/src/api/strategies.ts
Normal file
76
frontend/src/api/strategies.ts
Normal 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[]
|
||||
}
|
||||
51
frontend/src/api/trading.ts
Normal file
51
frontend/src/api/trading.ts
Normal 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
|
||||
},
|
||||
}
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
99
frontend/src/components/AlertHistory.tsx
Normal file
99
frontend/src/components/AlertHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
99
frontend/src/components/Chart.tsx
Normal file
99
frontend/src/components/Chart.tsx
Normal 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 }} />
|
||||
}
|
||||
126
frontend/src/components/ChartGrid.tsx
Normal file
126
frontend/src/components/ChartGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/DataFreshness.tsx
Normal file
64
frontend/src/components/DataFreshness.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/components/ErrorDisplay.tsx
Normal file
39
frontend/src/components/ErrorDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
18
frontend/src/components/HelpTooltip.tsx
Normal file
18
frontend/src/components/HelpTooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/components/InfoCard.tsx
Normal file
39
frontend/src/components/InfoCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
132
frontend/src/components/Layout.tsx
Normal file
132
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/LoadingSkeleton.tsx
Normal file
64
frontend/src/components/LoadingSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
97
frontend/src/components/OperationsPanel.tsx
Normal file
97
frontend/src/components/OperationsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
120
frontend/src/components/OrderConfirmationDialog.tsx
Normal file
120
frontend/src/components/OrderConfirmationDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
316
frontend/src/components/OrderForm.tsx
Normal file
316
frontend/src/components/OrderForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
240
frontend/src/components/PositionCard.tsx
Normal file
240
frontend/src/components/PositionCard.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
45
frontend/src/components/ProgressOverlay.tsx
Normal file
45
frontend/src/components/ProgressOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
132
frontend/src/components/ProviderStatus.tsx
Normal file
132
frontend/src/components/ProviderStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/RealtimePrice.tsx
Normal file
185
frontend/src/components/RealtimePrice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
253
frontend/src/components/SpreadChart.tsx
Normal file
253
frontend/src/components/SpreadChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/StatusIndicator.tsx
Normal file
63
frontend/src/components/StatusIndicator.tsx
Normal 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
|
||||
}
|
||||
|
||||
404
frontend/src/components/StrategyDialog.tsx
Normal file
404
frontend/src/components/StrategyDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
792
frontend/src/components/StrategyParameterForm.tsx
Normal file
792
frontend/src/components/StrategyParameterForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
83
frontend/src/components/SystemHealth.tsx
Normal file
83
frontend/src/components/SystemHealth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
31
frontend/src/components/WebSocketProvider.tsx
Normal file
31
frontend/src/components/WebSocketProvider.tsx
Normal 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
|
||||
}
|
||||
2
frontend/src/components/__init__.ts
Normal file
2
frontend/src/components/__init__.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Layout } from './Layout'
|
||||
export { WebSocketProvider, useWebSocketContext } from './WebSocketProvider'
|
||||
65
frontend/src/components/__tests__/ErrorDisplay.test.tsx
Normal file
65
frontend/src/components/__tests__/ErrorDisplay.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
166
frontend/src/components/__tests__/PositionCard.test.tsx
Normal file
166
frontend/src/components/__tests__/PositionCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
71
frontend/src/components/__tests__/StatusIndicator.test.tsx
Normal file
71
frontend/src/components/__tests__/StatusIndicator.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
96
frontend/src/contexts/AutopilotSettingsContext.tsx
Normal file
96
frontend/src/contexts/AutopilotSettingsContext.tsx
Normal 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
|
||||
}
|
||||
|
||||
54
frontend/src/contexts/SnackbarContext.tsx
Normal file
54
frontend/src/contexts/SnackbarContext.tsx
Normal 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
|
||||
}
|
||||
|
||||
96
frontend/src/hooks/__tests__/useProviderStatus.test.ts
Normal file
96
frontend/src/hooks/__tests__/useProviderStatus.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
134
frontend/src/hooks/__tests__/useRealtimeData.test.ts
Normal file
134
frontend/src/hooks/__tests__/useRealtimeData.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
214
frontend/src/hooks/__tests__/useWebSocket.test.ts
Normal file
214
frontend/src/hooks/__tests__/useWebSocket.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
60
frontend/src/hooks/useProviderStatus.ts
Normal file
60
frontend/src/hooks/useProviderStatus.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
69
frontend/src/hooks/useRealtimeData.ts
Normal file
69
frontend/src/hooks/useRealtimeData.ts
Normal 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])
|
||||
}
|
||||
|
||||
116
frontend/src/hooks/useWebSocket.ts
Normal file
116
frontend/src/hooks/useWebSocket.ts
Normal 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
62
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
488
frontend/src/pages/BacktestPage.tsx
Normal file
488
frontend/src/pages/BacktestPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
733
frontend/src/pages/DashboardPage.tsx
Normal file
733
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
497
frontend/src/pages/PortfolioPage.tsx
Normal file
497
frontend/src/pages/PortfolioPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1744
frontend/src/pages/SettingsPage.tsx
Normal file
1744
frontend/src/pages/SettingsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
402
frontend/src/pages/StrategiesPage.tsx
Normal file
402
frontend/src/pages/StrategiesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
554
frontend/src/pages/TradingPage.tsx
Normal file
554
frontend/src/pages/TradingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
4
frontend/src/pages/__init__.ts
Normal file
4
frontend/src/pages/__init__.ts
Normal 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'
|
||||
196
frontend/src/pages/__tests__/DashboardPage.test.tsx
Normal file
196
frontend/src/pages/__tests__/DashboardPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
65
frontend/src/test/setup.ts
Normal file
65
frontend/src/test/setup.ts
Normal 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(() => {})
|
||||
80
frontend/src/test/utils.tsx
Normal file
80
frontend/src/test/utils.tsx
Normal 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
150
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
48
frontend/src/utils/errorHandler.ts
Normal file
48
frontend/src/utils/errorHandler.ts
Normal 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
|
||||
}
|
||||
|
||||
58
frontend/src/utils/formatters.ts
Normal file
58
frontend/src/utils/formatters.ts
Normal 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
10
frontend/src/vite-env.d.ts
vendored
Normal 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
29
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
26
frontend/vite.config.ts
Normal 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
30
frontend/vitest.config.ts
Normal 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/',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user