Merge branch 'worktree-agent-a46e78e2'
This commit is contained in:
commit
e470055354
32 changed files with 7035 additions and 0 deletions
24
dashboard/.gitignore
vendored
Normal file
24
dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
23
dashboard/eslint.config.js
Normal file
23
dashboard/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trading Bot Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4586
dashboard/package-lock.json
generated
Normal file
4586
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
dashboard/package.json
Normal file
38
dashboard/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"lightweight-charts": "^5.1.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwindcss": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dashboard/public/vite.svg
Normal file
1
dashboard/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
40
dashboard/src/App.tsx
Normal file
40
dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
|
import Portfolio from './pages/Portfolio';
|
||||||
|
import TradeLog from './pages/TradeLog';
|
||||||
|
import Strategies from './pages/Strategies';
|
||||||
|
import NewsFeed from './pages/NewsFeed';
|
||||||
|
import Backtest from './pages/Backtest';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/portfolio" replace />} />
|
||||||
|
<Route path="portfolio" element={<Portfolio />} />
|
||||||
|
<Route path="trades" element={<TradeLog />} />
|
||||||
|
<Route path="strategies" element={<Strategies />} />
|
||||||
|
<Route path="news" element={<NewsFeed />} />
|
||||||
|
<Route path="backtest" element={<Backtest />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch-all redirect */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
dashboard/src/api/auth.ts
Normal file
54
dashboard/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterBeginResponse {
|
||||||
|
options: PublicKeyCredentialCreationOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginBeginResponse {
|
||||||
|
options: PublicKeyCredentialRequestOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerBegin(username: string): Promise<RegisterBeginResponse> {
|
||||||
|
const response = await client.post('/auth/register/begin', { username });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerComplete(
|
||||||
|
username: string,
|
||||||
|
attestation: unknown
|
||||||
|
): Promise<AuthTokens> {
|
||||||
|
const response = await client.post('/auth/register/complete', {
|
||||||
|
username,
|
||||||
|
attestation,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBegin(username: string): Promise<LoginBeginResponse> {
|
||||||
|
const response = await client.post('/auth/login/begin', { username });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginComplete(
|
||||||
|
username: string,
|
||||||
|
assertion: unknown
|
||||||
|
): Promise<AuthTokens> {
|
||||||
|
const response = await client.post('/auth/login/complete', {
|
||||||
|
username,
|
||||||
|
assertion,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(): Promise<AuthTokens> {
|
||||||
|
const refresh = localStorage.getItem('refresh_token');
|
||||||
|
const response = await client.post('/auth/refresh', {
|
||||||
|
refresh_token: refresh,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
100
dashboard/src/api/client.ts
Normal file
100
dashboard/src/api/client.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: attach JWT from localStorage
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor: on 401, try refresh token, retry request
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const processQueue = (error: unknown | null, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
if (!refreshToken) {
|
||||||
|
isRefreshing = false;
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
if (newRefreshToken) {
|
||||||
|
localStorage.setItem('refresh_token', newRefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
processQueue(null, access_token);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default client;
|
||||||
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4 KiB |
89
dashboard/src/components/EquityCurve.tsx
Normal file
89
dashboard/src/components/EquityCurve.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createChart, type IChartApi, type ISeriesApi, LineSeries } from 'lightweight-charts';
|
||||||
|
|
||||||
|
interface EquityCurveProps {
|
||||||
|
data: Array<{ timestamp: string; value: number }>;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquityCurve({ data, height = 300 }: EquityCurveProps) {
|
||||||
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const seriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartContainerRef.current) return;
|
||||||
|
|
||||||
|
const chart = createChart(chartContainerRef.current, {
|
||||||
|
height,
|
||||||
|
layout: {
|
||||||
|
background: { color: '#1e293b' },
|
||||||
|
textColor: '#94a3b8',
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: '#334155' },
|
||||||
|
horzLines: { color: '#334155' },
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: 0,
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: '#475569',
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: '#475569',
|
||||||
|
timeVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = chart.addSeries(LineSeries, {
|
||||||
|
color: '#3b82f6',
|
||||||
|
lineWidth: 2,
|
||||||
|
priceFormat: {
|
||||||
|
type: 'custom',
|
||||||
|
formatter: (price: number) =>
|
||||||
|
'$' + price.toLocaleString('en-US', { minimumFractionDigits: 2 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
chartRef.current = chart;
|
||||||
|
seriesRef.current = series;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (chartContainerRef.current) {
|
||||||
|
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
chart.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
seriesRef.current = null;
|
||||||
|
};
|
||||||
|
}, [height]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seriesRef.current || !data.length) return;
|
||||||
|
|
||||||
|
const chartData = data.map((point) => ({
|
||||||
|
time: point.timestamp.split('T')[0] as string,
|
||||||
|
value: point.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
seriesRef.current.setData(chartData as any);
|
||||||
|
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={chartContainerRef}
|
||||||
|
className="w-full rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
dashboard/src/components/Layout.tsx
Normal file
141
dashboard/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { usePortfolio } from '../hooks/usePortfolio';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/portfolio', label: 'Portfolio', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||||
|
{ to: '/trades', label: 'Trades', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
||||||
|
{ to: '/strategies', label: 'Strategies', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||||
|
{ to: '/news', label: 'News', icon: 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2' },
|
||||||
|
{ to: '/backtest', label: 'Backtest', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: portfolio } = usePortfolio();
|
||||||
|
const { lastEvent } = useWebSocket();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolioValue = portfolio?.total_value ?? 0;
|
||||||
|
const dailyPnl = portfolio?.daily_pnl ?? 0;
|
||||||
|
const isActive = portfolio?.trading_active ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-900">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-700">
|
||||||
|
<h1 className="text-xl font-bold text-white">Trading Bot</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Automated Trading Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/20 text-blue-400'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d={item.icon}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="h-16 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-slate-400">Portfolio Value</span>
|
||||||
|
<p className="text-lg font-semibold text-white">
|
||||||
|
${portfolioValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-slate-400">Daily P&L</span>
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
dailyPnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dailyPnl >= 0 ? '+' : ''}
|
||||||
|
${dailyPnl.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastEvent && (
|
||||||
|
<span className="text-xs text-slate-400 bg-slate-700 px-2 py-1 rounded">
|
||||||
|
{lastEvent.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`w-2.5 h-2.5 rounded-full ${
|
||||||
|
isActive ? 'bg-green-400 animate-pulse' : 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-300">
|
||||||
|
{isActive ? 'Active' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
dashboard/src/components/MetricsRow.tsx
Normal file
27
dashboard/src/components/MetricsRow.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
interface Metric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsRowProps {
|
||||||
|
metrics: Metric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsRow({ metrics }: MetricsRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className="bg-slate-800 rounded-lg p-4 border border-slate-700"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">{metric.label}</p>
|
||||||
|
<p className={`text-lg font-semibold ${metric.color || 'text-white'}`}>
|
||||||
|
{metric.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
dashboard/src/components/PositionsTable.tsx
Normal file
86
dashboard/src/components/PositionsTable.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { Position } from '../hooks/usePortfolio';
|
||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
interface PositionsTableProps {
|
||||||
|
positions: Position[];
|
||||||
|
onPositionClosed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PositionsTable({ positions, onPositionClosed }: PositionsTableProps) {
|
||||||
|
const handleClose = async (ticker: string) => {
|
||||||
|
if (!confirm(`Close position in ${ticker}?`)) return;
|
||||||
|
try {
|
||||||
|
await client.post('/controls/close-position', { ticker });
|
||||||
|
onPositionClosed?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to close position:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!positions.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
No open positions
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700">
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-medium">Ticker</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Qty</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Avg Entry</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Current</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L %</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((pos) => (
|
||||||
|
<tr
|
||||||
|
key={pos.id}
|
||||||
|
className="border-b border-slate-700/50 hover:bg-slate-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4 font-medium text-white">{pos.ticker}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300">{pos.qty}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300">
|
||||||
|
${pos.avg_entry.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300">
|
||||||
|
${pos.current_price.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-3 px-4 text-right font-medium ${
|
||||||
|
pos.unrealized_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||||
|
${pos.unrealized_pnl.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-3 px-4 text-right font-medium ${
|
||||||
|
pos.unrealized_pnl_pct >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}
|
||||||
|
{pos.unrealized_pnl_pct.toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleClose(pos.ticker)}
|
||||||
|
className="px-3 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
dashboard/src/components/ProtectedRoute.tsx
Normal file
16
dashboard/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
43
dashboard/src/components/SentimentBadge.tsx
Normal file
43
dashboard/src/components/SentimentBadge.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
interface SentimentBadgeProps {
|
||||||
|
score: number;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentBadge({ score, size = 'md' }: SentimentBadgeProps) {
|
||||||
|
let bgColor: string;
|
||||||
|
let textColor: string;
|
||||||
|
let label: string;
|
||||||
|
|
||||||
|
if (score >= 0.3) {
|
||||||
|
bgColor = 'bg-green-900/50';
|
||||||
|
textColor = 'text-green-400';
|
||||||
|
label = 'Positive';
|
||||||
|
} else if (score <= -0.3) {
|
||||||
|
bgColor = 'bg-red-900/50';
|
||||||
|
textColor = 'text-red-400';
|
||||||
|
label = 'Negative';
|
||||||
|
} else {
|
||||||
|
bgColor = 'bg-yellow-900/50';
|
||||||
|
textColor = 'text-yellow-400';
|
||||||
|
label = 'Neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full font-medium ${bgColor} ${textColor} ${sizeClasses}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`rounded-full ${size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2'} ${
|
||||||
|
score >= 0.3
|
||||||
|
? 'bg-green-400'
|
||||||
|
: score <= -0.3
|
||||||
|
? 'bg-red-400'
|
||||||
|
: 'bg-yellow-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{label} ({score >= 0 ? '+' : ''}{score.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
dashboard/src/hooks/useAuth.ts
Normal file
75
dashboard/src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import {
|
||||||
|
registerBegin,
|
||||||
|
registerComplete,
|
||||||
|
loginBegin,
|
||||||
|
loginComplete,
|
||||||
|
} from '../api/auth';
|
||||||
|
|
||||||
|
export interface UseAuthReturn {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string) => Promise<void>;
|
||||||
|
register: (username: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): UseAuthReturn {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(() =>
|
||||||
|
localStorage.getItem('access_token')
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAuthenticated = useMemo(() => !!token, [token]);
|
||||||
|
|
||||||
|
const register = useCallback(async (username: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { options } = await registerBegin(username);
|
||||||
|
const attestation = await startRegistration({ optionsJSON: options as any });
|
||||||
|
const tokens = await registerComplete(username, attestation);
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
setToken(tokens.access_token);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message =
|
||||||
|
err?.response?.data?.detail || err?.message || 'Registration failed';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { options } = await loginBegin(username);
|
||||||
|
const assertion = await startAuthentication({ optionsJSON: options as any });
|
||||||
|
const tokens = await loginComplete(username, assertion);
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
setToken(tokens.access_token);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message =
|
||||||
|
err?.response?.data?.detail || err?.message || 'Login failed';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
setToken(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isAuthenticated, login, register, logout, error, loading };
|
||||||
|
}
|
||||||
83
dashboard/src/hooks/usePortfolio.ts
Normal file
83
dashboard/src/hooks/usePortfolio.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
export interface PortfolioSummary {
|
||||||
|
total_value: number;
|
||||||
|
cash: number;
|
||||||
|
buying_power: number;
|
||||||
|
daily_pnl: number;
|
||||||
|
daily_pnl_pct: number;
|
||||||
|
total_pnl: number;
|
||||||
|
total_pnl_pct: number;
|
||||||
|
trading_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
id: string;
|
||||||
|
ticker: string;
|
||||||
|
qty: number;
|
||||||
|
avg_entry: number;
|
||||||
|
current_price: number;
|
||||||
|
unrealized_pnl: number;
|
||||||
|
unrealized_pnl_pct: number;
|
||||||
|
stop_loss: number | null;
|
||||||
|
take_profit: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquityPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioMetrics {
|
||||||
|
roi: number;
|
||||||
|
sharpe: number;
|
||||||
|
win_rate: number;
|
||||||
|
max_drawdown: number;
|
||||||
|
total_trades: number;
|
||||||
|
avg_hold_duration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortfolio() {
|
||||||
|
return useQuery<PortfolioSummary>({
|
||||||
|
queryKey: ['portfolio'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/portfolio');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositions() {
|
||||||
|
return useQuery<Position[]>({
|
||||||
|
queryKey: ['positions'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/portfolio/positions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEquityCurve(period: string = '1M') {
|
||||||
|
return useQuery<EquityPoint[]>({
|
||||||
|
queryKey: ['equity-curve', period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/portfolio/history', {
|
||||||
|
params: { period },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortfolioMetrics() {
|
||||||
|
return useQuery<PortfolioMetrics>({
|
||||||
|
queryKey: ['portfolio', 'metrics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/portfolio/metrics');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
108
dashboard/src/hooks/useWebSocket.ts
Normal file
108
dashboard/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export interface WebSocketEvent {
|
||||||
|
type: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_RECONNECT_DELAY = 3000;
|
||||||
|
const WS_MAX_RECONNECT_DELAY = 30000;
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
|
||||||
|
const [lastEvent, setLastEvent] = useState<WebSocketEvent | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const handleMessage = useCallback(
|
||||||
|
(event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const parsed: WebSocketEvent = JSON.parse(event.data);
|
||||||
|
setLastEvent(parsed);
|
||||||
|
|
||||||
|
// Invalidate relevant TanStack Query caches based on event type
|
||||||
|
switch (parsed.type) {
|
||||||
|
case 'trade_executed':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trades'] });
|
||||||
|
break;
|
||||||
|
case 'signal_generated':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['signals'] });
|
||||||
|
break;
|
||||||
|
case 'portfolio_update':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['equity-curve'] });
|
||||||
|
break;
|
||||||
|
case 'news_scored':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||||
|
break;
|
||||||
|
case 'strategy_update':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['strategies'] });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON messages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const wsUrl = `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = handleMessage;
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
// Reconnect with exponential backoff
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
reconnectDelayRef.current = Math.min(
|
||||||
|
reconnectDelayRef.current * 2,
|
||||||
|
WS_MAX_RECONNECT_DELAY
|
||||||
|
);
|
||||||
|
connect();
|
||||||
|
}, reconnectDelayRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, [handleMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
return { lastEvent, isConnected };
|
||||||
|
}
|
||||||
13
dashboard/src/index.css
Normal file
13
dashboard/src/index.css
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
26
dashboard/src/main.tsx
Normal file
26
dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
297
dashboard/src/pages/Backtest.tsx
Normal file
297
dashboard/src/pages/Backtest.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import client from '../api/client';
|
||||||
|
import { EquityCurve } from '../components/EquityCurve';
|
||||||
|
import { MetricsRow } from '../components/MetricsRow';
|
||||||
|
|
||||||
|
interface BacktestConfig {
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
initial_capital: number;
|
||||||
|
strategies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacktestResult {
|
||||||
|
run_id: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
equity_curve: Array<{ timestamp: string; value: number }>;
|
||||||
|
metrics: {
|
||||||
|
total_return: number;
|
||||||
|
annualized_return: number;
|
||||||
|
sharpe_ratio: number;
|
||||||
|
max_drawdown: number;
|
||||||
|
win_rate: number;
|
||||||
|
total_trades: number;
|
||||||
|
avg_hold_duration: string;
|
||||||
|
} | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StrategyOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Backtest() {
|
||||||
|
const [startDate, setStartDate] = useState('2025-01-01');
|
||||||
|
const [endDate, setEndDate] = useState('2026-01-01');
|
||||||
|
const [initialCapital, setInitialCapital] = useState(100000);
|
||||||
|
const [selectedStrategies, setSelectedStrategies] = useState<string[]>([]);
|
||||||
|
const [currentRunId, setCurrentRunId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: strategyOptions } = useQuery<StrategyOption[]>({
|
||||||
|
queryKey: ['strategies-options'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/strategies');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: result, isLoading: resultLoading } = useQuery<BacktestResult>({
|
||||||
|
queryKey: ['backtest', currentRunId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get(`/backtest/${currentRunId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!currentRunId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data;
|
||||||
|
if (data && (data.status === 'pending' || data.status === 'running')) {
|
||||||
|
return 2000;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runMutation = useMutation({
|
||||||
|
mutationFn: async (config: BacktestConfig) => {
|
||||||
|
const response = await client.post('/backtest/run', config);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCurrentRunId(data.run_id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!startDate || !endDate || selectedStrategies.length === 0) return;
|
||||||
|
runMutation.mutate({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
initial_capital: initialCapital,
|
||||||
|
strategies: selectedStrategies,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStrategy = (name: string) => {
|
||||||
|
setSelectedStrategies((prev) =>
|
||||||
|
prev.includes(name) ? prev.filter((s) => s !== name) : [...prev, name]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricsDisplay = result?.metrics
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Total Return',
|
||||||
|
value: `${result.metrics.total_return >= 0 ? '+' : ''}${result.metrics.total_return.toFixed(2)}%`,
|
||||||
|
color: result.metrics.total_return >= 0 ? 'text-green-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Annualized Return',
|
||||||
|
value: `${result.metrics.annualized_return >= 0 ? '+' : ''}${result.metrics.annualized_return.toFixed(2)}%`,
|
||||||
|
color: result.metrics.annualized_return >= 0 ? 'text-green-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sharpe Ratio',
|
||||||
|
value: result.metrics.sharpe_ratio.toFixed(2),
|
||||||
|
color: result.metrics.sharpe_ratio >= 1 ? 'text-green-400' : 'text-yellow-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Max Drawdown',
|
||||||
|
value: `${result.metrics.max_drawdown.toFixed(2)}%`,
|
||||||
|
color: 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Win Rate',
|
||||||
|
value: `${(result.metrics.win_rate * 100).toFixed(1)}%`,
|
||||||
|
color: result.metrics.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Trade Count',
|
||||||
|
value: result.metrics.total_trades.toString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Backtesting</h2>
|
||||||
|
|
||||||
|
{/* Config form */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">
|
||||||
|
Configuration
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Initial Capital ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={initialCapital}
|
||||||
|
onChange={(e) => setInitialCapital(Number(e.target.value))}
|
||||||
|
min={1000}
|
||||||
|
step={1000}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Strategies
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2 mt-1">
|
||||||
|
{strategyOptions?.map((s) => (
|
||||||
|
<label
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedStrategies.includes(s.name)}
|
||||||
|
onChange={() => toggleStrategy(s.name)}
|
||||||
|
className="w-4 h-4 rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-300">{s.name}</span>
|
||||||
|
</label>
|
||||||
|
)) ?? (
|
||||||
|
<span className="text-xs text-slate-500">Loading strategies...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={
|
||||||
|
runMutation.isPending ||
|
||||||
|
selectedStrategies.length === 0 ||
|
||||||
|
!startDate ||
|
||||||
|
!endDate
|
||||||
|
}
|
||||||
|
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{runMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Run Backtest
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{runMutation.isError && (
|
||||||
|
<p className="mt-3 text-sm text-red-400">
|
||||||
|
Failed to start backtest. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results panel */}
|
||||||
|
{currentRunId && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status */}
|
||||||
|
{result && (result.status === 'pending' || result.status === 'running') && (
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 text-center">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin mb-4" />
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
Backtest {result.status}...
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
This may take a moment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.status === 'failed' && (
|
||||||
|
<div className="bg-red-900/20 rounded-xl p-6 border border-red-700">
|
||||||
|
<p className="text-red-400 font-medium">Backtest Failed</p>
|
||||||
|
<p className="text-sm text-red-300 mt-1">
|
||||||
|
{result.error || 'An unknown error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.status === 'completed' && (
|
||||||
|
<>
|
||||||
|
{/* Metrics */}
|
||||||
|
{metricsDisplay.length > 0 && (
|
||||||
|
<MetricsRow metrics={metricsDisplay} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equity curve */}
|
||||||
|
{result.equity_curve && result.equity_curve.length > 0 && (
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">
|
||||||
|
Equity Curve
|
||||||
|
</h3>
|
||||||
|
<EquityCurve data={result.equity_curve} height={350} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultLoading && !result && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
dashboard/src/pages/Login.tsx
Normal file
92
dashboard/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const { login, error, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim()) return;
|
||||||
|
try {
|
||||||
|
await login(username.trim());
|
||||||
|
navigate('/portfolio');
|
||||||
|
} catch {
|
||||||
|
// error is set in useAuth
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-900">
|
||||||
|
<div className="w-full max-w-md p-8 bg-slate-800 rounded-xl shadow-2xl">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Trading Bot</h1>
|
||||||
|
<p className="text-slate-400">Sign in to your dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-slate-300 mb-2"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim()}
|
||||||
|
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-block w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Passkey
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-slate-400 text-sm">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
dashboard/src/pages/NewsFeed.tsx
Normal file
164
dashboard/src/pages/NewsFeed.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import client from '../api/client';
|
||||||
|
import { SentimentBadge } from '../components/SentimentBadge';
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
source: string;
|
||||||
|
url: string;
|
||||||
|
published_at: string;
|
||||||
|
ticker: string;
|
||||||
|
sentiment_score: number;
|
||||||
|
confidence: number;
|
||||||
|
model_used: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsResponse {
|
||||||
|
articles: Article[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsFeed() {
|
||||||
|
const [ticker, setTicker] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<NewsResponse>({
|
||||||
|
queryKey: ['news', ticker, page],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: Record<string, string | number> = { page, page_size: pageSize };
|
||||||
|
if (ticker) params.ticker = ticker;
|
||||||
|
const response = await client.get('/news', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-white">News & Sentiment</h2>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{data?.total ?? 0} articles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticker filter */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Filter by Ticker
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticker}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTicker(e.target.value.toUpperCase());
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. AAPL, TSLA"
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ticker && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTicker('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-sm text-slate-400 hover:text-white bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : data && data.articles.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.articles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="bg-slate-800 rounded-xl p-5 border border-slate-700 hover:border-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a
|
||||||
|
href={article.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-white font-medium hover:text-blue-400 transition-colors block truncate"
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||||
|
<span>{article.source}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>
|
||||||
|
{new Date(article.published_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{article.ticker && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-300 font-medium">
|
||||||
|
${article.ticker}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>|</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
via {article.model_used}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||||
|
<SentimentBadge score={article.sentiment_score} />
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
conf: {(article.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-slate-400 bg-slate-800 rounded-xl border border-slate-700">
|
||||||
|
No articles found{ticker ? ` for ${ticker}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
dashboard/src/pages/Portfolio.tsx
Normal file
143
dashboard/src/pages/Portfolio.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { usePortfolio, usePositions, useEquityCurve, usePortfolioMetrics } from '../hooks/usePortfolio';
|
||||||
|
import { EquityCurve } from '../components/EquityCurve';
|
||||||
|
import { PositionsTable } from '../components/PositionsTable';
|
||||||
|
import { MetricsRow } from '../components/MetricsRow';
|
||||||
|
|
||||||
|
const PERIODS = ['1D', '1W', '1M', '3M', '6M', '1Y', 'ALL'] as const;
|
||||||
|
|
||||||
|
export default function Portfolio() {
|
||||||
|
const [period, setPeriod] = useState<string>('1M');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: portfolio, isLoading: portfolioLoading } = usePortfolio();
|
||||||
|
const { data: positions, isLoading: positionsLoading } = usePositions();
|
||||||
|
const { data: equityData, isLoading: equityLoading } = useEquityCurve(period);
|
||||||
|
const { data: metrics } = usePortfolioMetrics();
|
||||||
|
|
||||||
|
const handlePositionClosed = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricsDisplay = metrics
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Total ROI',
|
||||||
|
value: `${metrics.roi >= 0 ? '+' : ''}${metrics.roi.toFixed(2)}%`,
|
||||||
|
color: metrics.roi >= 0 ? 'text-green-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sharpe Ratio',
|
||||||
|
value: metrics.sharpe.toFixed(2),
|
||||||
|
color: metrics.sharpe >= 1 ? 'text-green-400' : metrics.sharpe >= 0 ? 'text-yellow-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Win Rate',
|
||||||
|
value: `${(metrics.win_rate * 100).toFixed(1)}%`,
|
||||||
|
color: metrics.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Max Drawdown',
|
||||||
|
value: `${metrics.max_drawdown.toFixed(2)}%`,
|
||||||
|
color: 'text-red-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Trades',
|
||||||
|
value: metrics.total_trades.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Hold Duration',
|
||||||
|
value: metrics.avg_hold_duration,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Portfolio value card */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Portfolio Overview</h2>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">Real-time portfolio performance</p>
|
||||||
|
</div>
|
||||||
|
{portfolio && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-3xl font-bold text-white">
|
||||||
|
${portfolio.total_value.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
portfolio.daily_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{portfolio.daily_pnl >= 0 ? '+' : ''}
|
||||||
|
${portfolio.daily_pnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||||
|
{' '}
|
||||||
|
({portfolio.daily_pnl_pct >= 0 ? '+' : ''}{portfolio.daily_pnl_pct.toFixed(2)}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portfolioLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Equity curve */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Equity Curve</h3>
|
||||||
|
<div className="flex gap-1 bg-slate-700 rounded-lg p-1">
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{equityLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : equityData && equityData.length > 0 ? (
|
||||||
|
<EquityCurve data={equityData} height={350} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-slate-400">
|
||||||
|
No equity data available for this period
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key metrics */}
|
||||||
|
{metricsDisplay.length > 0 && <MetricsRow metrics={metricsDisplay} />}
|
||||||
|
|
||||||
|
{/* Positions table */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Open Positions</h3>
|
||||||
|
{positionsLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PositionsTable
|
||||||
|
positions={positions ?? []}
|
||||||
|
onPositionClosed={handlePositionClosed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
dashboard/src/pages/Register.tsx
Normal file
92
dashboard/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const { register, error, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim()) return;
|
||||||
|
try {
|
||||||
|
await register(username.trim());
|
||||||
|
navigate('/portfolio');
|
||||||
|
} catch {
|
||||||
|
// error is set in useAuth
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-900">
|
||||||
|
<div className="w-full max-w-md p-8 bg-slate-800 rounded-xl shadow-2xl">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Trading Bot</h1>
|
||||||
|
<p className="text-slate-400">Create your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-slate-300 mb-2"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Choose a username"
|
||||||
|
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim()}
|
||||||
|
className="w-full py-3 px-4 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-block w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Register Passkey
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-slate-400 text-sm">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
dashboard/src/pages/Strategies.tsx
Normal file
235
dashboard/src/pages/Strategies.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
interface Strategy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
current_weight: number;
|
||||||
|
active: boolean;
|
||||||
|
win_rate: number;
|
||||||
|
total_pnl: number;
|
||||||
|
total_trades: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeightHistoryPoint {
|
||||||
|
timestamp: string;
|
||||||
|
[strategyName: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||||
|
|
||||||
|
export default function Strategies() {
|
||||||
|
const { data: strategies, isLoading } = useQuery<Strategy[]>({
|
||||||
|
queryKey: ['strategies'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/strategies');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: weightHistory } = useQuery<WeightHistoryPoint[]>({
|
||||||
|
queryKey: ['strategies', 'weight-history'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/strategies/weight-history');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pieData =
|
||||||
|
strategies?.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
value: s.current_weight,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const strategyNames = strategies?.map((s) => s.name) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Strategy Performance</h2>
|
||||||
|
|
||||||
|
{/* Strategy cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{strategies?.map((strategy, idx) => (
|
||||||
|
<div
|
||||||
|
key={strategy.id}
|
||||||
|
className="bg-slate-800 rounded-xl p-5 border border-slate-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white">{strategy.name}</h3>
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 rounded-full text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${COLORS[idx % COLORS.length]}20`,
|
||||||
|
color: COLORS[idx % COLORS.length],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(strategy.current_weight * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">{strategy.description}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Win Rate</p>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
strategy.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(strategy.win_rate * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Total P&L</p>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
strategy.total_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{strategy.total_pnl >= 0 ? '+' : ''}${strategy.total_pnl.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Trades</p>
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{strategy.total_trades}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!strategy.active && (
|
||||||
|
<span className="mt-3 inline-block px-2 py-0.5 bg-slate-700 text-slate-400 text-xs rounded">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Weight allocation pie chart */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">
|
||||||
|
Weight Allocation
|
||||||
|
</h3>
|
||||||
|
{pieData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, value }) =>
|
||||||
|
`${name}: ${(value * 100).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pieData.map((_, idx) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${idx}`}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => `${(Number(value) * 100).toFixed(1)}%`}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[300px] text-slate-400">
|
||||||
|
No strategy data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight history line chart */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">
|
||||||
|
Weight History
|
||||||
|
</h3>
|
||||||
|
{weightHistory && weightHistory.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={weightHistory}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#94a3b8"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
new Date(value).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#94a3b8"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => `${(Number(value) * 100).toFixed(1)}%`}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
}}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
new Date(label).toLocaleDateString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{strategyNames.map((name, idx) => (
|
||||||
|
<Line
|
||||||
|
key={name}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={name}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[300px] text-slate-400">
|
||||||
|
No weight history available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
dashboard/src/pages/TradeLog.tsx
Normal file
345
dashboard/src/pages/TradeLog.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
interface Trade {
|
||||||
|
id: string;
|
||||||
|
ticker: string;
|
||||||
|
side: 'BUY' | 'SELL';
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
pnl: number | null;
|
||||||
|
strategy_name: string;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
signal_detail?: {
|
||||||
|
direction: string;
|
||||||
|
strength: number;
|
||||||
|
strategy_sources: Record<string, number>;
|
||||||
|
};
|
||||||
|
news_context?: Array<{
|
||||||
|
title: string;
|
||||||
|
source: string;
|
||||||
|
sentiment_score: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradesResponse {
|
||||||
|
trades: Trade[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TradeLog() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [ticker, setTicker] = useState('');
|
||||||
|
const [strategy, setStrategy] = useState('');
|
||||||
|
const [profitable, setProfitable] = useState<boolean | null>(null);
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<TradesResponse>({
|
||||||
|
queryKey: ['trades', page, ticker, strategy, profitable, dateFrom, dateTo],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: Record<string, string | number | boolean> = {
|
||||||
|
page,
|
||||||
|
page_size: 20,
|
||||||
|
};
|
||||||
|
if (ticker) params.ticker = ticker;
|
||||||
|
if (strategy) params.strategy = strategy;
|
||||||
|
if (profitable !== null) params.profitable = profitable;
|
||||||
|
if (dateFrom) params.date_from = dateFrom;
|
||||||
|
if (dateTo) params.date_to = dateTo;
|
||||||
|
|
||||||
|
const response = await client.get('/trades', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: strategies } = useQuery<Array<{ id: string; name: string }>>({
|
||||||
|
queryKey: ['strategies-list'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get('/strategies');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / data.page_size) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Trade Log</h2>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{data?.total ?? 0} total trades
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">Ticker</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticker}
|
||||||
|
onChange={(e) => { setTicker(e.target.value.toUpperCase()); setPage(1); }}
|
||||||
|
placeholder="e.g. AAPL"
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">Strategy</label>
|
||||||
|
<select
|
||||||
|
value={strategy}
|
||||||
|
onChange={(e) => { setStrategy(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All strategies</option>
|
||||||
|
{strategies?.map((s) => (
|
||||||
|
<option key={s.id} value={s.name}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">Profitable</label>
|
||||||
|
<select
|
||||||
|
value={profitable === null ? '' : profitable.toString()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setProfitable(v === '' ? null : v === 'true');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="true">Profitable</option>
|
||||||
|
<option value="false">Losing</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trade table */}
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : data && data.trades.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700 bg-slate-800/80">
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-medium">Ticker</th>
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-medium">Side</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Qty</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">Price</th>
|
||||||
|
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L</th>
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-medium">Strategy</th>
|
||||||
|
<th className="text-left py-3 px-4 text-slate-400 font-medium">Time</th>
|
||||||
|
<th className="w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.trades.map((trade) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={trade.id}
|
||||||
|
className="border-b border-slate-700/50 hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRow(expandedRow === trade.id ? null : trade.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4 font-medium text-white">
|
||||||
|
{trade.ticker}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
trade.side === 'BUY'
|
||||||
|
? 'bg-green-900/50 text-green-400'
|
||||||
|
: 'bg-red-900/50 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trade.side}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300">
|
||||||
|
{trade.qty}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-300">
|
||||||
|
${trade.price.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-3 px-4 text-right font-medium ${
|
||||||
|
trade.pnl !== null
|
||||||
|
? trade.pnl >= 0
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
: 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trade.pnl !== null
|
||||||
|
? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl.toFixed(2)}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-300">
|
||||||
|
{trade.strategy_name}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-400 text-xs">
|
||||||
|
{new Date(trade.timestamp).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-400">
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${
|
||||||
|
expandedRow === trade.id ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedRow === trade.id && (
|
||||||
|
<tr key={`${trade.id}-detail`} className="bg-slate-900/50">
|
||||||
|
<td colSpan={8} className="py-4 px-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Signal detail */}
|
||||||
|
{trade.signal_detail && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Signal Detail
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 text-xs text-slate-400">
|
||||||
|
<p>
|
||||||
|
Direction:{' '}
|
||||||
|
<span className="text-white">
|
||||||
|
{trade.signal_detail.direction}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Strength:{' '}
|
||||||
|
<span className="text-white">
|
||||||
|
{(trade.signal_detail.strength * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">Strategy Contributions:</p>
|
||||||
|
{Object.entries(
|
||||||
|
trade.signal_detail.strategy_sources
|
||||||
|
).map(([name, weight]) => (
|
||||||
|
<p key={name} className="ml-2">
|
||||||
|
{name}:{' '}
|
||||||
|
<span className="text-white">
|
||||||
|
{(weight * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* News context */}
|
||||||
|
{trade.news_context && trade.news_context.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||||
|
News Context
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trade.news_context.map((article, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="text-xs bg-slate-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<p className="text-white">
|
||||||
|
{article.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 mt-1">
|
||||||
|
{article.source} | Sentiment:{' '}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
article.sentiment_score >= 0.3
|
||||||
|
? 'text-green-400'
|
||||||
|
: article.sentiment_score <= -0.3
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-yellow-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{article.sentiment_score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-slate-400">
|
||||||
|
No trades found matching your filters
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
dashboard/tsconfig.app.json
Normal file
28
dashboard/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
dashboard/tsconfig.node.json
Normal file
26
dashboard/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
dashboard/vite.config.ts
Normal file
19
dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue