Merge branch 'worktree-agent-a46e78e2'

This commit is contained in:
Viktor Barzin 2026-02-22 15:55:29 +00:00
commit e470055354
No known key found for this signature in database
GPG key ID: 0EB088298288D958
32 changed files with 7035 additions and 0 deletions

24
dashboard/.gitignore vendored Normal file
View 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?

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

File diff suppressed because it is too large Load diff

38
dashboard/package.json Normal file
View 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"
}
}

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

View 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

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

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

View 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;
},
});
}

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
})