Initial release v1.1.0
- Complete MVP for tracking Fidelity brokerage account performance - Transaction import from CSV with deduplication - Automatic FIFO position tracking with options support - Real-time P&L calculations with market data caching - Dashboard with timeframe filtering (30/90/180 days, 1 year, YTD, all time) - Docker-based deployment with PostgreSQL backend - React/TypeScript frontend with TailwindCSS - FastAPI backend with SQLAlchemy ORM Features: - Multi-account support - Import via CSV upload or filesystem - Open and closed position tracking - Balance history charting - Performance analytics and metrics - Top trades analysis - Responsive UI design Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
33
frontend/Dockerfile
Normal file
33
frontend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Multi-stage build for React frontend
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
# Use npm install instead of npm ci since package-lock.json may not exist
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>myFidelityTracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/nginx.conf
Normal file
35
frontend/nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Don't cache HTML to ensure new builds are loaded
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets with versioned filenames (hash in name)
|
||||
# The hash changes when content changes, so long cache is safe
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
4544
frontend/package-lock.json
generated
Normal file
4544
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "myfidelitytracker-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"recharts": "^2.10.3",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"date-fns": "^3.0.6",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
118
frontend/src/App.tsx
Normal file
118
frontend/src/App.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { accountsApi } from './api/client';
|
||||
import DashboardV2 from './components/DashboardV2';
|
||||
import AccountManager from './components/AccountManager';
|
||||
import TransactionTable from './components/TransactionTable';
|
||||
import ImportDropzone from './components/ImportDropzone';
|
||||
import type { Account } from './types';
|
||||
|
||||
/**
|
||||
* Main application component.
|
||||
* Manages navigation and selected account state.
|
||||
*/
|
||||
function App() {
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
||||
const [currentView, setCurrentView] = useState<'dashboard' | 'transactions' | 'import' | 'accounts'>('dashboard');
|
||||
|
||||
// Fetch accounts
|
||||
const { data: accounts, isLoading, refetch: refetchAccounts } = useQuery({
|
||||
queryKey: ['accounts'],
|
||||
queryFn: async () => {
|
||||
const response = await accountsApi.list();
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select first account
|
||||
useEffect(() => {
|
||||
if (accounts && accounts.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(accounts[0].id);
|
||||
}
|
||||
}, [accounts, selectedAccountId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-robinhood-bg">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">myFidelityTracker</h1>
|
||||
|
||||
{/* Account Selector */}
|
||||
{accounts && accounts.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedAccountId || ''}
|
||||
onChange={(e) => setSelectedAccountId(Number(e.target.value))}
|
||||
className="input max-w-xs"
|
||||
>
|
||||
{accounts.map((account: Account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.account_name} ({account.account_number})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex space-x-8">
|
||||
{['dashboard', 'transactions', 'import', 'accounts'].map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
onClick={() => setCurrentView(view as typeof currentView)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
currentView === view
|
||||
? 'border-robinhood-green text-robinhood-green'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{view.charAt(0).toUpperCase() + view.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
) : !selectedAccountId && currentView !== 'accounts' ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No accounts found</h3>
|
||||
<p className="text-gray-500 mb-4">Create an account to get started</p>
|
||||
<button onClick={() => setCurrentView('accounts')} className="btn-primary">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentView === 'dashboard' && selectedAccountId && (
|
||||
<DashboardV2 accountId={selectedAccountId} />
|
||||
)}
|
||||
{currentView === 'transactions' && selectedAccountId && (
|
||||
<TransactionTable accountId={selectedAccountId} />
|
||||
)}
|
||||
{currentView === 'import' && selectedAccountId && (
|
||||
<ImportDropzone accountId={selectedAccountId} />
|
||||
)}
|
||||
{currentView === 'accounts' && (
|
||||
<AccountManager onAccountCreated={refetchAccounts} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
108
frontend/src/api/client.ts
Normal file
108
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* API client for communicating with the backend.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Account,
|
||||
Transaction,
|
||||
Position,
|
||||
AccountStats,
|
||||
BalancePoint,
|
||||
Trade,
|
||||
ImportResult,
|
||||
} from '../types';
|
||||
|
||||
// Configure axios instance
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Account APIs
|
||||
export const accountsApi = {
|
||||
list: () => api.get<Account[]>('/accounts'),
|
||||
get: (id: number) => api.get<Account>(`/accounts/${id}`),
|
||||
create: (data: {
|
||||
account_number: string;
|
||||
account_name: string;
|
||||
account_type: 'cash' | 'margin';
|
||||
}) => api.post<Account>('/accounts', data),
|
||||
update: (id: number, data: Partial<Account>) =>
|
||||
api.put<Account>(`/accounts/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/accounts/${id}`),
|
||||
};
|
||||
|
||||
// Transaction APIs
|
||||
export const transactionsApi = {
|
||||
list: (params?: {
|
||||
account_id?: number;
|
||||
symbol?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => api.get<Transaction[]>('/transactions', { params }),
|
||||
get: (id: number) => api.get<Transaction>(`/transactions/${id}`),
|
||||
getPositionDetails: (id: number) => api.get<any>(`/transactions/${id}/position-details`),
|
||||
};
|
||||
|
||||
// Position APIs
|
||||
export const positionsApi = {
|
||||
list: (params?: {
|
||||
account_id?: number;
|
||||
status?: 'open' | 'closed';
|
||||
symbol?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => api.get<Position[]>('/positions', { params }),
|
||||
get: (id: number) => api.get<Position>(`/positions/${id}`),
|
||||
rebuild: (accountId: number) =>
|
||||
api.post<{ positions_created: number }>(`/positions/${accountId}/rebuild`),
|
||||
};
|
||||
|
||||
// Analytics APIs
|
||||
export const analyticsApi = {
|
||||
getOverview: (accountId: number, params?: { refresh_prices?: boolean; max_api_calls?: number; start_date?: string; end_date?: string }) =>
|
||||
api.get<AccountStats>(`/analytics/overview/${accountId}`, { params }),
|
||||
getBalanceHistory: (accountId: number, days: number = 30) =>
|
||||
api.get<{ data: BalancePoint[] }>(`/analytics/balance-history/${accountId}`, {
|
||||
params: { days },
|
||||
}),
|
||||
getTopTrades: (accountId: number, limit: number = 10, startDate?: string, endDate?: string) =>
|
||||
api.get<{ data: Trade[] }>(`/analytics/top-trades/${accountId}`, {
|
||||
params: { limit, start_date: startDate, end_date: endDate },
|
||||
}),
|
||||
getWorstTrades: (accountId: number, limit: number = 10, startDate?: string, endDate?: string) =>
|
||||
api.get<{ data: Trade[] }>(`/analytics/worst-trades/${accountId}`, {
|
||||
params: { limit, start_date: startDate, end_date: endDate },
|
||||
}),
|
||||
updatePnL: (accountId: number) =>
|
||||
api.post<{ positions_updated: number }>(`/analytics/update-pnl/${accountId}`),
|
||||
refreshPrices: (accountId: number, params?: { max_api_calls?: number }) =>
|
||||
api.post<{ message: string; stats: any }>(`/analytics/refresh-prices/${accountId}`, null, { params }),
|
||||
refreshPricesBackground: (accountId: number, params?: { max_api_calls?: number }) =>
|
||||
api.post<{ message: string; account_id: number }>(`/analytics/refresh-prices-background/${accountId}`, null, { params }),
|
||||
};
|
||||
|
||||
// Import APIs
|
||||
export const importApi = {
|
||||
uploadCsv: (accountId: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post<ImportResult>(`/import/upload/${accountId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
importFromFilesystem: (accountId: number) =>
|
||||
api.post<{
|
||||
files: Record<string, Omit<ImportResult, 'filename'>>;
|
||||
total_imported: number;
|
||||
positions_created: number;
|
||||
}>(`/import/filesystem/${accountId}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
177
frontend/src/components/AccountManager.tsx
Normal file
177
frontend/src/components/AccountManager.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { accountsApi } from '../api/client';
|
||||
|
||||
interface AccountManagerProps {
|
||||
onAccountCreated: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for managing accounts (create, list, delete).
|
||||
*/
|
||||
export default function AccountManager({ onAccountCreated }: AccountManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
account_number: '',
|
||||
account_name: '',
|
||||
account_type: 'cash' as 'cash' | 'margin',
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch accounts
|
||||
const { data: accounts, isLoading } = useQuery({
|
||||
queryKey: ['accounts'],
|
||||
queryFn: async () => {
|
||||
const response = await accountsApi.list();
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Create account mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: accountsApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
setFormData({ account_number: '', account_name: '', account_type: 'cash' });
|
||||
setShowForm(false);
|
||||
onAccountCreated();
|
||||
},
|
||||
});
|
||||
|
||||
// Delete account mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: accountsApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Accounts</h2>
|
||||
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
|
||||
{showForm ? 'Cancel' : 'Add Account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showForm && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Create New Account</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Account Number</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.account_number}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, account_number: e.target.value })
|
||||
}
|
||||
className="input"
|
||||
placeholder="X38661988"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Account Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.account_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, account_name: e.target.value })
|
||||
}
|
||||
className="input"
|
||||
placeholder="My Trading Account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Account Type</label>
|
||||
<select
|
||||
value={formData.account_type}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
account_type: e.target.value as 'cash' | 'margin',
|
||||
})
|
||||
}
|
||||
className="input"
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="margin">Margin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Account'}
|
||||
</button>
|
||||
|
||||
{createMutation.isError && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||
Error: {(createMutation.error as any)?.response?.data?.detail || 'Failed to create account'}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts List */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Your Accounts</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading accounts...</div>
|
||||
) : !accounts || accounts.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No accounts yet. Create your first account to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg">{account.account_name}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{account.account_number} • {account.account_type}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Created {new Date(account.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete account ${account.account_name}? This will delete all transactions and positions.`)) {
|
||||
deleteMutation.mutate(account.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="btn-danger disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/Dashboard.tsx
Normal file
195
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi, positionsApi } from '../api/client';
|
||||
import MetricsCards from './MetricsCards';
|
||||
import PerformanceChart from './PerformanceChart';
|
||||
import PositionCard from './PositionCard';
|
||||
|
||||
interface DashboardProps {
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse option symbol to extract expiration and strike
|
||||
* Format: -SYMBOL251017C6 -> Oct 17 '25 C
|
||||
*/
|
||||
function parseOptionSymbol(optionSymbol: string | null): string {
|
||||
if (!optionSymbol) return '-';
|
||||
|
||||
// Extract components: -OPEN251017C6 -> YYMMDD + C/P + Strike
|
||||
const match = optionSymbol.match(/(\d{6})([CP])([\d.]+)$/);
|
||||
if (!match) return optionSymbol;
|
||||
|
||||
const [, dateStr, callPut, strike] = match;
|
||||
|
||||
// Parse date: YYMMDD
|
||||
const year = '20' + dateStr.substring(0, 2);
|
||||
const month = dateStr.substring(2, 4);
|
||||
const day = dateStr.substring(4, 6);
|
||||
|
||||
const date = new Date(`${year}-${month}-${day}`);
|
||||
const monthName = date.toLocaleDateString('en-US', { month: 'short' });
|
||||
const dayNum = date.getDate();
|
||||
const yearShort = dateStr.substring(0, 2);
|
||||
|
||||
return `${monthName} ${dayNum} '${yearShort} $${strike}${callPut}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main dashboard showing overview metrics, charts, and positions.
|
||||
*/
|
||||
export default function Dashboard({ accountId }: DashboardProps) {
|
||||
// Helper to safely convert to number
|
||||
const toNumber = (val: any): number | null => {
|
||||
if (val === null || val === undefined) return null;
|
||||
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||
return isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
// Fetch overview stats
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['analytics', 'overview', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await analyticsApi.getOverview(accountId);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch balance history
|
||||
const { data: balanceHistory } = useQuery({
|
||||
queryKey: ['analytics', 'balance-history', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await analyticsApi.getBalanceHistory(accountId, 180);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch open positions
|
||||
const { data: openPositions } = useQuery({
|
||||
queryKey: ['positions', 'open', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await positionsApi.list({
|
||||
account_id: accountId,
|
||||
status: 'open',
|
||||
limit: 10,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch top trades
|
||||
const { data: topTrades } = useQuery({
|
||||
queryKey: ['analytics', 'top-trades', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await analyticsApi.getTopTrades(accountId, 5);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (statsLoading) {
|
||||
return <div className="text-center py-12 text-gray-500">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Metrics Cards */}
|
||||
<MetricsCards stats={stats!} />
|
||||
|
||||
{/* Performance Chart */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Balance History</h2>
|
||||
<PerformanceChart data={balanceHistory || []} />
|
||||
</div>
|
||||
|
||||
{/* Open Positions */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Open Positions</h2>
|
||||
{openPositions && openPositions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{openPositions.map((position) => (
|
||||
<PositionCard key={position.id} position={position} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No open positions</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Trades */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Top Performing Trades</h2>
|
||||
{topTrades && topTrades.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Contract
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Dates
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Entry
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Exit
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
P&L
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{topTrades.map((trade, idx) => {
|
||||
const entryPrice = toNumber(trade.entry_price);
|
||||
const exitPrice = toNumber(trade.exit_price);
|
||||
const pnl = toNumber(trade.realized_pnl);
|
||||
const isOption = trade.position_type === 'call' || trade.position_type === 'put';
|
||||
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3 font-medium">{trade.symbol}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
|
||||
{isOption ? trade.position_type : 'Stock'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{isOption ? parseOptionSymbol(trade.option_symbol) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(trade.open_date).toLocaleDateString()} →{' '}
|
||||
{trade.close_date
|
||||
? new Date(trade.close_date).toLocaleDateString()
|
||||
: 'Open'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{entryPrice !== null ? `$${entryPrice.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{exitPrice !== null ? `$${exitPrice.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
pnl !== null && pnl >= 0 ? 'text-profit' : 'text-loss'
|
||||
}`}
|
||||
>
|
||||
{pnl !== null ? `$${pnl.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No closed trades yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
frontend/src/components/DashboardV2.tsx
Normal file
316
frontend/src/components/DashboardV2.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { analyticsApi, positionsApi } from '../api/client';
|
||||
import MetricsCards from './MetricsCards';
|
||||
import PerformanceChart from './PerformanceChart';
|
||||
import PositionCard from './PositionCard';
|
||||
import TimeframeFilter, { TimeframeOption, getTimeframeDates } from './TimeframeFilter';
|
||||
|
||||
interface DashboardProps {
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced dashboard with stale-while-revalidate pattern.
|
||||
*
|
||||
* Shows cached data immediately, then updates in background.
|
||||
* Provides manual refresh button for fresh data.
|
||||
*/
|
||||
export default function DashboardV2({ accountId }: DashboardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [timeframe, setTimeframe] = useState<TimeframeOption>('all');
|
||||
|
||||
// Convert timeframe to days for balance history
|
||||
const getDaysFromTimeframe = (tf: TimeframeOption): number => {
|
||||
switch (tf) {
|
||||
case 'last30days': return 30;
|
||||
case 'last90days': return 90;
|
||||
case 'last180days': return 180;
|
||||
case 'last1year': return 365;
|
||||
case 'ytd': {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
return Math.ceil((now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
case 'all':
|
||||
default:
|
||||
return 3650; // ~10 years
|
||||
}
|
||||
};
|
||||
|
||||
// Get date range from timeframe for filtering
|
||||
const { startDate, endDate } = getTimeframeDates(timeframe);
|
||||
|
||||
// Fetch overview stats (with cached prices - fast!)
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: statsLoading,
|
||||
dataUpdatedAt: statsUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['analytics', 'overview', accountId, startDate, endDate],
|
||||
queryFn: async () => {
|
||||
// Default: use cached prices (no API calls to Yahoo Finance)
|
||||
const response = await analyticsApi.getOverview(accountId, {
|
||||
refresh_prices: false,
|
||||
max_api_calls: 0,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
// Keep showing old data while fetching new
|
||||
staleTime: 30000, // 30 seconds
|
||||
// Refetch in background when window regains focus
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
// Fetch balance history (doesn't need market data - always fast)
|
||||
const { data: balanceHistory } = useQuery({
|
||||
queryKey: ['analytics', 'balance-history', accountId, timeframe],
|
||||
queryFn: async () => {
|
||||
const days = getDaysFromTimeframe(timeframe);
|
||||
const response = await analyticsApi.getBalanceHistory(accountId, days);
|
||||
return response.data.data;
|
||||
},
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
|
||||
// Fetch open positions
|
||||
const { data: openPositions } = useQuery({
|
||||
queryKey: ['positions', 'open', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await positionsApi.list({
|
||||
account_id: accountId,
|
||||
status: 'open',
|
||||
limit: 10,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Fetch top trades (doesn't need market data - always fast)
|
||||
const { data: topTrades } = useQuery({
|
||||
queryKey: ['analytics', 'top-trades', accountId],
|
||||
queryFn: async () => {
|
||||
const response = await analyticsApi.getTopTrades(accountId, 5);
|
||||
return response.data.data;
|
||||
},
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Mutation for manual price refresh
|
||||
const refreshPricesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Trigger background refresh
|
||||
await analyticsApi.refreshPricesBackground(accountId, { max_api_calls: 15 });
|
||||
|
||||
// Wait a bit, then refetch overview
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Refetch with fresh prices
|
||||
const response = await analyticsApi.getOverview(accountId, {
|
||||
refresh_prices: true,
|
||||
max_api_calls: 15,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Update the cache with fresh data
|
||||
queryClient.setQueryData(['analytics', 'overview', accountId], data);
|
||||
setIsRefreshing(false);
|
||||
},
|
||||
onError: () => {
|
||||
setIsRefreshing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRefreshPrices = () => {
|
||||
setIsRefreshing(true);
|
||||
refreshPricesMutation.mutate();
|
||||
};
|
||||
|
||||
// Calculate data age
|
||||
const getDataAge = () => {
|
||||
if (!statsUpdatedAt) return null;
|
||||
const ageSeconds = Math.floor((Date.now() - statsUpdatedAt) / 1000);
|
||||
|
||||
if (ageSeconds < 60) return `${ageSeconds}s ago`;
|
||||
const ageMinutes = Math.floor(ageSeconds / 60);
|
||||
if (ageMinutes < 60) return `${ageMinutes}m ago`;
|
||||
const ageHours = Math.floor(ageMinutes / 60);
|
||||
return `${ageHours}h ago`;
|
||||
};
|
||||
|
||||
// Check if we have update stats from the API
|
||||
const hasUpdateStats = stats?.price_update_stats;
|
||||
const updateStats = stats?.price_update_stats;
|
||||
|
||||
if (statsLoading && !stats) {
|
||||
// First load - show loading
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
// Error state or no data
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Unable to load dashboard data. Please try refreshing the page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Timeframe Filter */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Timeframe
|
||||
</label>
|
||||
<TimeframeFilter value={timeframe} onChange={(value) => setTimeframe(value as TimeframeOption)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data freshness indicator and refresh button */}
|
||||
<div className="flex items-center justify-between bg-gray-50 px-4 py-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{stats && (
|
||||
<>
|
||||
<span className="font-medium">Last updated:</span>{' '}
|
||||
{getDataAge() || 'just now'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasUpdateStats && updateStats && (
|
||||
<div className="text-xs text-gray-500 border-l border-gray-300 pl-4">
|
||||
{updateStats.cached > 0 && (
|
||||
<span className="mr-3">
|
||||
📦 {updateStats.cached} cached
|
||||
</span>
|
||||
)}
|
||||
{updateStats.failed > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠️ {updateStats.failed} unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefreshPrices}
|
||||
disabled={isRefreshing}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
isRefreshing
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-primary text-white hover:bg-primary-dark'
|
||||
}`}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 inline" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔄 Refresh Prices
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show info banner if using stale data */}
|
||||
{stats && !hasUpdateStats && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>💡 Tip:</strong> Showing cached data for fast loading. Click "Refresh Prices" to get the latest market prices.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<MetricsCards stats={stats} />
|
||||
|
||||
{/* Performance Chart */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Balance History</h2>
|
||||
<PerformanceChart data={balanceHistory || []} />
|
||||
</div>
|
||||
|
||||
{/* Open Positions */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Open Positions</h2>
|
||||
{openPositions && openPositions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{openPositions.map((position) => (
|
||||
<PositionCard key={position.id} position={position} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No open positions</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Trades */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Top Performing Trades</h2>
|
||||
{topTrades && topTrades.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Dates
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
P&L
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{topTrades.map((trade, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3 font-medium">{trade.symbol}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
|
||||
{trade.position_type}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(trade.open_date).toLocaleDateString()} →{' '}
|
||||
{trade.close_date
|
||||
? new Date(trade.close_date).toLocaleDateString()
|
||||
: 'Open'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
trade.realized_pnl >= 0 ? 'text-profit' : 'text-loss'
|
||||
}`}
|
||||
>
|
||||
${trade.realized_pnl.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No closed trades yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/ImportDropzone.tsx
Normal file
184
frontend/src/components/ImportDropzone.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { importApi } from '../api/client';
|
||||
|
||||
interface ImportDropzoneProps {
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload component with drag-and-drop support.
|
||||
*/
|
||||
export default function ImportDropzone({ accountId }: ImportDropzoneProps) {
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Upload mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => importApi.uploadCsv(accountId, file),
|
||||
onSuccess: (response) => {
|
||||
setImportResult(response.data);
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions', accountId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Filesystem import mutation
|
||||
const filesystemMutation = useMutation({
|
||||
mutationFn: () => importApi.importFromFilesystem(accountId),
|
||||
onSuccess: (response) => {
|
||||
setImportResult(response.data);
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions', accountId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics'] });
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setImportResult(null);
|
||||
uploadMutation.mutate(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[uploadMutation]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/csv': ['.csv'],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Upload Dropzone */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Upload CSV File</h2>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-robinhood-green bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{isDragActive ? (
|
||||
<p className="text-lg text-robinhood-green font-medium">
|
||||
Drop the CSV file here
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg text-gray-600">
|
||||
Drag and drop a Fidelity CSV file here, or click to select
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Only .csv files are accepted</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadMutation.isPending && (
|
||||
<div className="mt-4 text-center text-gray-600">Uploading and processing...</div>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
Error: {(uploadMutation.error as any)?.response?.data?.detail || 'Upload failed'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filesystem Import */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4">Import from Filesystem</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Import all CSV files from the <code className="bg-gray-100 px-2 py-1 rounded">/imports</code> directory
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setImportResult(null);
|
||||
filesystemMutation.mutate();
|
||||
}}
|
||||
disabled={filesystemMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{filesystemMutation.isPending ? 'Importing...' : 'Import from Filesystem'}
|
||||
</button>
|
||||
|
||||
{filesystemMutation.isError && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
Error: {(filesystemMutation.error as any)?.response?.data?.detail || 'Import failed'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import Results */}
|
||||
{importResult && (
|
||||
<div className="card bg-green-50 border border-green-200">
|
||||
<h3 className="text-lg font-semibold text-green-900 mb-4">Import Successful</h3>
|
||||
|
||||
{importResult.filename && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">File: {importResult.filename}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-700">{importResult.imported || importResult.total_imported}</div>
|
||||
<div className="text-sm text-gray-600">Imported</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-700">{importResult.skipped || 0}</div>
|
||||
<div className="text-sm text-gray-600">Skipped</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-700">{importResult.total_rows || 0}</div>
|
||||
<div className="text-sm text-gray-600">Total Rows</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-700">{importResult.positions_created}</div>
|
||||
<div className="text-sm text-gray-600">Positions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-800 mb-2">Errors:</p>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{importResult.errors.slice(0, 5).map((error: string, idx: number) => (
|
||||
<li key={idx}>• {error}</li>
|
||||
))}
|
||||
{importResult.errors.length > 5 && (
|
||||
<li>... and {importResult.errors.length - 5} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/MetricsCards.tsx
Normal file
82
frontend/src/components/MetricsCards.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { AccountStats } from '../types';
|
||||
|
||||
interface MetricsCardsProps {
|
||||
stats: AccountStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display key performance metrics in card format.
|
||||
*/
|
||||
export default function MetricsCards({ stats }: MetricsCardsProps) {
|
||||
// Safely convert values to numbers
|
||||
const safeNumber = (val: any): number => {
|
||||
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||
return isNaN(num) ? 0 : num;
|
||||
};
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Account Balance',
|
||||
value: `$${safeNumber(stats.current_balance).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
change: null,
|
||||
},
|
||||
{
|
||||
label: 'Total P&L',
|
||||
value: `$${safeNumber(stats.total_pnl).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
change: safeNumber(stats.total_pnl),
|
||||
},
|
||||
{
|
||||
label: 'Realized P&L',
|
||||
value: `$${safeNumber(stats.total_realized_pnl).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
change: safeNumber(stats.total_realized_pnl),
|
||||
},
|
||||
{
|
||||
label: 'Unrealized P&L',
|
||||
value: `$${safeNumber(stats.total_unrealized_pnl).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
change: safeNumber(stats.total_unrealized_pnl),
|
||||
},
|
||||
{
|
||||
label: 'Win Rate',
|
||||
value: `${safeNumber(stats.win_rate).toFixed(1)}%`,
|
||||
change: null,
|
||||
},
|
||||
{
|
||||
label: 'Open Positions',
|
||||
value: String(stats.open_positions || 0),
|
||||
change: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{metrics.map((metric, idx) => (
|
||||
<div key={idx} className="card">
|
||||
<div className="text-sm text-gray-500 mb-1">{metric.label}</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
metric.change !== null
|
||||
? metric.change >= 0
|
||||
? 'text-profit'
|
||||
: 'text-loss'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/PerformanceChart.tsx
Normal file
70
frontend/src/components/PerformanceChart.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { BalancePoint } from '../types';
|
||||
|
||||
interface PerformanceChartProps {
|
||||
data: BalancePoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Line chart showing account balance over time.
|
||||
*/
|
||||
export default function PerformanceChart({ data }: PerformanceChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||
No balance history available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format data for Recharts
|
||||
const chartData = data.map((point) => ({
|
||||
date: new Date(point.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
balance: point.balance,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6B7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6B7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
tickFormatter={(value) =>
|
||||
`$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) =>
|
||||
`$${value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #E5E7EB',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
stroke="#00C805"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/PositionCard.tsx
Normal file
76
frontend/src/components/PositionCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Position } from '../types';
|
||||
|
||||
interface PositionCardProps {
|
||||
position: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card displaying position information.
|
||||
*/
|
||||
export default function PositionCard({ position }: PositionCardProps) {
|
||||
const pnl = position.status === 'open' ? position.unrealized_pnl : position.realized_pnl;
|
||||
const isProfitable = pnl !== null && pnl >= 0;
|
||||
|
||||
return (
|
||||
<div className={`border-2 rounded-lg p-4 ${isProfitable ? 'border-green-200 bg-profit' : 'border-red-200 bg-loss'}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{position.symbol}</h3>
|
||||
<p className="text-sm text-gray-600 capitalize">
|
||||
{position.position_type}
|
||||
{position.option_symbol && ` • ${position.option_symbol}`}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
position.status === 'open'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{position.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mb-3">
|
||||
<div>
|
||||
<div className="text-gray-600">Quantity</div>
|
||||
<div className="font-medium">{position.total_quantity}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-600">Entry Price</div>
|
||||
<div className="font-medium">
|
||||
${typeof position.avg_entry_price === 'number' ? position.avg_entry_price.toFixed(2) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-600">Open Date</div>
|
||||
<div className="font-medium">
|
||||
{new Date(position.open_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{position.status === 'closed' && position.close_date && (
|
||||
<div>
|
||||
<div className="text-gray-600">Close Date</div>
|
||||
<div className="font-medium">
|
||||
{new Date(position.close_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pnl !== null && typeof pnl === 'number' && (
|
||||
<div className="pt-3 border-t border-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
{position.status === 'open' ? 'Unrealized P&L' : 'Realized P&L'}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${isProfitable ? 'text-profit' : 'text-loss'}`}>
|
||||
{isProfitable ? '+' : ''}${pnl.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/TimeframeFilter.tsx
Normal file
90
frontend/src/components/TimeframeFilter.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
interface TimeframeFilterProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export type TimeframeOption =
|
||||
| 'last30days'
|
||||
| 'last90days'
|
||||
| 'last180days'
|
||||
| 'last1year'
|
||||
| 'ytd'
|
||||
| 'all';
|
||||
|
||||
export interface TimeframeDates {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate date range based on timeframe selection
|
||||
*/
|
||||
export function getTimeframeDates(timeframe: TimeframeOption): TimeframeDates {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
switch (timeframe) {
|
||||
case 'last30days': {
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: todayStr,
|
||||
};
|
||||
}
|
||||
case 'last90days': {
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 90);
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: todayStr,
|
||||
};
|
||||
}
|
||||
case 'last180days': {
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 180);
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: todayStr,
|
||||
};
|
||||
}
|
||||
case 'last1year': {
|
||||
const startDate = new Date(today);
|
||||
startDate.setFullYear(startDate.getFullYear() - 1);
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: todayStr,
|
||||
};
|
||||
}
|
||||
case 'ytd': {
|
||||
const year = today.getFullYear();
|
||||
return {
|
||||
startDate: `${year}-01-01`,
|
||||
endDate: todayStr,
|
||||
};
|
||||
}
|
||||
case 'all':
|
||||
default:
|
||||
return {}; // No date filters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown filter for selecting timeframe
|
||||
*/
|
||||
export default function TimeframeFilter({ value, onChange }: TimeframeFilterProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="input max-w-xs"
|
||||
>
|
||||
<option value="all">All Time</option>
|
||||
<option value="last30days">Last 30 Days</option>
|
||||
<option value="last90days">Last 90 Days</option>
|
||||
<option value="last180days">Last 180 Days</option>
|
||||
<option value="last1year">Last 1 Year</option>
|
||||
<option value="ytd">Year to Date</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
399
frontend/src/components/TransactionDetailModal.tsx
Normal file
399
frontend/src/components/TransactionDetailModal.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transactionsApi } from '../api/client';
|
||||
|
||||
interface TransactionDetailModalProps {
|
||||
transactionId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: number;
|
||||
run_date: string;
|
||||
action: string;
|
||||
symbol: string;
|
||||
description: string | null;
|
||||
quantity: number | null;
|
||||
price: number | null;
|
||||
amount: number | null;
|
||||
commission: number | null;
|
||||
fees: number | null;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
id: number;
|
||||
symbol: string;
|
||||
option_symbol: string | null;
|
||||
position_type: string;
|
||||
status: string;
|
||||
open_date: string;
|
||||
close_date: string | null;
|
||||
total_quantity: number;
|
||||
avg_entry_price: number | null;
|
||||
avg_exit_price: number | null;
|
||||
realized_pnl: number | null;
|
||||
unrealized_pnl: number | null;
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
interface PositionDetails {
|
||||
position: Position;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal displaying full position details for a transaction.
|
||||
* Shows all related transactions, strategy type, and P&L.
|
||||
*/
|
||||
export default function TransactionDetailModal({
|
||||
transactionId,
|
||||
onClose,
|
||||
}: TransactionDetailModalProps) {
|
||||
const { data, isLoading, error } = useQuery<PositionDetails>({
|
||||
queryKey: ['transaction-details', transactionId],
|
||||
queryFn: async () => {
|
||||
const response = await transactionsApi.getPositionDetails(transactionId);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const parseOptionSymbol = (optionSymbol: string | null): string => {
|
||||
if (!optionSymbol) return '-';
|
||||
|
||||
// Extract components: -SYMBOL251017C6 -> YYMMDD + C/P + Strike
|
||||
const match = optionSymbol.match(/(\d{6})([CP])([\d.]+)$/);
|
||||
if (!match) return optionSymbol;
|
||||
|
||||
const [, dateStr, callPut, strike] = match;
|
||||
|
||||
// Parse date: YYMMDD
|
||||
const year = '20' + dateStr.substring(0, 2);
|
||||
const month = dateStr.substring(2, 4);
|
||||
const day = dateStr.substring(4, 6);
|
||||
|
||||
const date = new Date(`${year}-${month}-${day}`);
|
||||
const monthName = date.toLocaleDateString('en-US', { month: 'short' });
|
||||
const dayNum = date.getDate();
|
||||
const yearShort = dateStr.substring(0, 2);
|
||||
|
||||
return `${monthName} ${dayNum} '${yearShort} $${strike}${callPut}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold">Trade Details</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">
|
||||
{isLoading && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Loading trade details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">Failed to load trade details</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-6">
|
||||
{/* Position Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-3">Position Summary</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Symbol</p>
|
||||
<p className="font-semibold">{data.position.symbol}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Type</p>
|
||||
<p className="font-semibold capitalize">
|
||||
{data.position.position_type}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Strategy</p>
|
||||
<p className="font-semibold">{data.position.strategy}</p>
|
||||
</div>
|
||||
|
||||
{data.position.option_symbol && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Contract</p>
|
||||
<p className="font-semibold">
|
||||
{parseOptionSymbol(data.position.option_symbol)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Status</p>
|
||||
<p
|
||||
className={`font-semibold capitalize ${
|
||||
data.position.status === 'open'
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{data.position.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">Quantity</p>
|
||||
<p className="font-semibold">
|
||||
{Math.abs(data.position.total_quantity)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
Avg Entry Price
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
{data.position.avg_entry_price !== null
|
||||
? `$${data.position.avg_entry_price.toFixed(2)}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.position.avg_exit_price !== null && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
Avg Exit Price
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
${data.position.avg_exit_price.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase">P&L</p>
|
||||
<p
|
||||
className={`font-bold text-lg ${
|
||||
(data.position.realized_pnl || 0) >= 0
|
||||
? 'text-profit'
|
||||
: 'text-loss'
|
||||
}`}
|
||||
>
|
||||
{data.position.realized_pnl !== null
|
||||
? `$${data.position.realized_pnl.toFixed(2)}`
|
||||
: data.position.unrealized_pnl !== null
|
||||
? `$${data.position.unrealized_pnl.toFixed(2)}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Transaction History ({data.transactions.length})
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Quantity
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Fees
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data.transactions.map((txn) => (
|
||||
<tr key={txn.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{new Date(txn.run_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{txn.action}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{txn.quantity !== null ? txn.quantity : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{txn.price !== null
|
||||
? `$${txn.price.toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-sm text-right font-medium ${
|
||||
txn.amount !== null
|
||||
? txn.amount >= 0
|
||||
? 'text-profit'
|
||||
: 'text-loss'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{txn.amount !== null
|
||||
? `$${txn.amount.toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-500">
|
||||
{txn.commission || txn.fees
|
||||
? `$${(
|
||||
(txn.commission || 0) + (txn.fees || 0)
|
||||
).toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Timeline and Performance Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Trade Timeline */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">
|
||||
Trade Timeline
|
||||
</h4>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p>
|
||||
<span className="font-medium">Opened:</span>{' '}
|
||||
{new Date(data.position.open_date).toLocaleDateString()}
|
||||
</p>
|
||||
{data.position.close_date && (
|
||||
<p>
|
||||
<span className="font-medium">Closed:</span>{' '}
|
||||
{new Date(data.position.close_date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">Duration:</span>{' '}
|
||||
{data.position.close_date
|
||||
? Math.floor(
|
||||
(new Date(data.position.close_date).getTime() -
|
||||
new Date(data.position.open_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + ' days'
|
||||
: 'Ongoing'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annual Return Rate */}
|
||||
{data.position.close_date &&
|
||||
data.position.realized_pnl !== null &&
|
||||
data.position.avg_entry_price !== null && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-900 mb-2">
|
||||
Annual Return Rate
|
||||
</h4>
|
||||
<div className="text-sm text-green-800">
|
||||
{(() => {
|
||||
const daysHeld = Math.floor(
|
||||
(new Date(data.position.close_date).getTime() -
|
||||
new Date(data.position.open_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysHeld === 0) {
|
||||
return (
|
||||
<p className="text-gray-600">
|
||||
Trade held less than 1 day
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate capital invested
|
||||
const isOption =
|
||||
data.position.position_type === 'call' ||
|
||||
data.position.position_type === 'put';
|
||||
const multiplier = isOption ? 100 : 1;
|
||||
const capitalInvested =
|
||||
Math.abs(data.position.avg_entry_price) *
|
||||
Math.abs(data.position.total_quantity) *
|
||||
multiplier;
|
||||
|
||||
if (capitalInvested === 0) {
|
||||
return (
|
||||
<p className="text-gray-600">
|
||||
Unable to calculate (no capital invested)
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ARR = (Profit / Capital) × (365 / Days) × 100%
|
||||
const arr =
|
||||
(data.position.realized_pnl / capitalInvested) *
|
||||
(365 / daysHeld) *
|
||||
100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium">ARR:</span>{' '}
|
||||
<span
|
||||
className={`font-bold text-lg ${
|
||||
arr >= 0 ? 'text-profit' : 'text-loss'
|
||||
}`}
|
||||
>
|
||||
{arr.toFixed(2)}%
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-green-700">
|
||||
Based on {daysHeld} day
|
||||
{daysHeld !== 1 ? 's' : ''} held
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 flex justify-end">
|
||||
<button onClick={onClose} className="btn-secondary">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
frontend/src/components/TransactionTable.tsx
Normal file
202
frontend/src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { transactionsApi } from '../api/client';
|
||||
import TransactionDetailModal from './TransactionDetailModal';
|
||||
import TimeframeFilter, { TimeframeOption, getTimeframeDates } from './TimeframeFilter';
|
||||
|
||||
interface TransactionTableProps {
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table displaying transaction history with filtering.
|
||||
* Rows are clickable to show full trade details.
|
||||
*/
|
||||
export default function TransactionTable({ accountId }: TransactionTableProps) {
|
||||
const [symbol, setSymbol] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [timeframe, setTimeframe] = useState<TimeframeOption>('all');
|
||||
const [selectedTransactionId, setSelectedTransactionId] = useState<number | null>(null);
|
||||
const limit = 50;
|
||||
|
||||
// Helper to safely convert to number
|
||||
const toNumber = (val: any): number | null => {
|
||||
if (val === null || val === undefined) return null;
|
||||
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||
return isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
// Get date range based on timeframe
|
||||
const { startDate, endDate } = getTimeframeDates(timeframe);
|
||||
|
||||
// Fetch transactions
|
||||
const { data: transactions, isLoading } = useQuery({
|
||||
queryKey: ['transactions', accountId, symbol, timeframe, page],
|
||||
queryFn: async () => {
|
||||
const response = await transactionsApi.list({
|
||||
account_id: accountId,
|
||||
symbol: symbol || undefined,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
skip: page * limit,
|
||||
limit,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Transaction History</h2>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 uppercase mb-1">
|
||||
Symbol
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by symbol..."
|
||||
value={symbol}
|
||||
onChange={(e) => {
|
||||
setSymbol(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="input w-full max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 uppercase mb-1">
|
||||
Timeframe
|
||||
</label>
|
||||
<TimeframeFilter
|
||||
value={timeframe}
|
||||
onChange={(value) => {
|
||||
setTimeframe(value as TimeframeOption);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading transactions...</div>
|
||||
) : !transactions || transactions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">No transactions found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Quantity
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Balance
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{transactions.map((txn) => {
|
||||
const price = toNumber(txn.price);
|
||||
const amount = toNumber(txn.amount);
|
||||
const balance = toNumber(txn.cash_balance);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={txn.id}
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => setSelectedTransactionId(txn.id)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{new Date(txn.run_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{txn.symbol || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">
|
||||
{txn.action}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{txn.quantity !== null ? txn.quantity : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">
|
||||
{price !== null ? `$${price.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-sm text-right font-medium ${
|
||||
amount !== null
|
||||
? amount >= 0
|
||||
? 'text-profit'
|
||||
: 'text-loss'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{amount !== null ? `$${amount.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">
|
||||
{balance !== null
|
||||
? `$${balance.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!transactions || transactions.length < limit}
|
||||
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transaction Detail Modal */}
|
||||
{selectedTransactionId && (
|
||||
<TransactionDetailModal
|
||||
transactionId={selectedTransactionId}
|
||||
onClose={() => setSelectedTransactionId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/client.ts
Normal file
108
frontend/src/components/client.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* API client for communicating with the backend.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Account,
|
||||
Transaction,
|
||||
Position,
|
||||
AccountStats,
|
||||
BalancePoint,
|
||||
Trade,
|
||||
ImportResult,
|
||||
} from '../types';
|
||||
|
||||
// Configure axios instance
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Account APIs
|
||||
export const accountsApi = {
|
||||
list: () => api.get<Account[]>('/accounts'),
|
||||
get: (id: number) => api.get<Account>(`/accounts/${id}`),
|
||||
create: (data: {
|
||||
account_number: string;
|
||||
account_name: string;
|
||||
account_type: 'cash' | 'margin';
|
||||
}) => api.post<Account>('/accounts', data),
|
||||
update: (id: number, data: Partial<Account>) =>
|
||||
api.put<Account>(`/accounts/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/accounts/${id}`),
|
||||
};
|
||||
|
||||
// Transaction APIs
|
||||
export const transactionsApi = {
|
||||
list: (params?: {
|
||||
account_id?: number;
|
||||
symbol?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => api.get<Transaction[]>('/transactions', { params }),
|
||||
get: (id: number) => api.get<Transaction>(`/transactions/${id}`),
|
||||
getPositionDetails: (id: number) => api.get<any>(`/transactions/${id}/position-details`),
|
||||
};
|
||||
|
||||
// Position APIs
|
||||
export const positionsApi = {
|
||||
list: (params?: {
|
||||
account_id?: number;
|
||||
status?: 'open' | 'closed';
|
||||
symbol?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => api.get<Position[]>('/positions', { params }),
|
||||
get: (id: number) => api.get<Position>(`/positions/${id}`),
|
||||
rebuild: (accountId: number) =>
|
||||
api.post<{ positions_created: number }>(`/positions/${accountId}/rebuild`),
|
||||
};
|
||||
|
||||
// Analytics APIs
|
||||
export const analyticsApi = {
|
||||
getOverview: (accountId: number, params?: { refresh_prices?: boolean; max_api_calls?: number; start_date?: string; end_date?: string }) =>
|
||||
api.get<AccountStats>(`/analytics/overview/${accountId}`, { params }),
|
||||
getBalanceHistory: (accountId: number, days: number = 30) =>
|
||||
api.get<{ data: BalancePoint[] }>(`/analytics/balance-history/${accountId}`, {
|
||||
params: { days },
|
||||
}),
|
||||
getTopTrades: (accountId: number, limit: number = 10, startDate?: string, endDate?: string) =>
|
||||
api.get<{ data: Trade[] }>(`/analytics/top-trades/${accountId}`, {
|
||||
params: { limit, start_date: startDate, end_date: endDate },
|
||||
}),
|
||||
getWorstTrades: (accountId: number, limit: number = 10, startDate?: string, endDate?: string) =>
|
||||
api.get<{ data: Trade[] }>(`/analytics/worst-trades/${accountId}`, {
|
||||
params: { limit, start_date: startDate, end_date: endDate },
|
||||
}),
|
||||
updatePnL: (accountId: number) =>
|
||||
api.post<{ positions_updated: number }>(`/analytics/update-pnl/${accountId}`),
|
||||
refreshPrices: (accountId: number, params?: { max_api_calls?: number }) =>
|
||||
api.post<{ message: string; stats: any }>(`/analytics/refresh-prices/${accountId}`, null, { params }),
|
||||
refreshPricesBackground: (accountId: number, params?: { max_api_calls?: number }) =>
|
||||
api.post<{ message: string; account_id: number }>(`/analytics/refresh-prices-background/${accountId}`, null, { params }),
|
||||
};
|
||||
|
||||
// Import APIs
|
||||
export const importApi = {
|
||||
uploadCsv: (accountId: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post<ImportResult>(`/import/upload/${accountId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
importFromFilesystem: (accountId: number) =>
|
||||
api.post<{
|
||||
files: Record<string, Omit<ImportResult, 'filename'>>;
|
||||
total_imported: number;
|
||||
positions_created: number;
|
||||
}>(`/import/filesystem/${accountId}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
26
frontend/src/main.tsx
Normal file
26
frontend/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/tailwind.css';
|
||||
|
||||
// Create React Query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
61
frontend/src/styles/tailwind.css
Normal file
61
frontend/src/styles/tailwind.css
Normal file
@@ -0,0 +1,61 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-robinhood-bg text-gray-900 font-sans;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-robinhood-green text-white hover:bg-green-600 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-robinhood-red text-white hover:bg-red-600 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-profit {
|
||||
@apply text-robinhood-green;
|
||||
}
|
||||
|
||||
.text-loss {
|
||||
@apply text-robinhood-red;
|
||||
}
|
||||
|
||||
.bg-profit {
|
||||
@apply bg-green-50;
|
||||
}
|
||||
|
||||
.bg-loss {
|
||||
@apply bg-red-50;
|
||||
}
|
||||
}
|
||||
94
frontend/src/types/index.ts
Normal file
94
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* TypeScript type definitions for the application.
|
||||
*/
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
account_number: string;
|
||||
account_name: string;
|
||||
account_type: 'cash' | 'margin';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
account_id: number;
|
||||
run_date: string;
|
||||
action: string;
|
||||
symbol: string | null;
|
||||
description: string | null;
|
||||
transaction_type: string | null;
|
||||
price: number | null;
|
||||
quantity: number | null;
|
||||
commission: number | null;
|
||||
fees: number | null;
|
||||
amount: number | null;
|
||||
cash_balance: number | null;
|
||||
settlement_date: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
account_id: number;
|
||||
symbol: string;
|
||||
option_symbol: string | null;
|
||||
position_type: 'stock' | 'call' | 'put';
|
||||
status: 'open' | 'closed';
|
||||
open_date: string;
|
||||
close_date: string | null;
|
||||
total_quantity: number;
|
||||
avg_entry_price: number | null;
|
||||
avg_exit_price: number | null;
|
||||
realized_pnl: number | null;
|
||||
unrealized_pnl: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PriceUpdateStats {
|
||||
total: number;
|
||||
updated: number;
|
||||
cached: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface AccountStats {
|
||||
total_positions: number;
|
||||
open_positions: number;
|
||||
closed_positions: number;
|
||||
total_realized_pnl: number;
|
||||
total_unrealized_pnl: number;
|
||||
total_pnl: number;
|
||||
win_rate: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
current_balance: number;
|
||||
price_update_stats?: PriceUpdateStats;
|
||||
}
|
||||
|
||||
export interface BalancePoint {
|
||||
date: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
symbol: string;
|
||||
option_symbol: string | null;
|
||||
position_type: string;
|
||||
open_date: string;
|
||||
close_date: string | null;
|
||||
quantity: number;
|
||||
entry_price: number | null;
|
||||
exit_price: number | null;
|
||||
realized_pnl: number;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
filename: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
total_rows: number;
|
||||
positions_created: number;
|
||||
}
|
||||
21
frontend/tailwind.config.js
Normal file
21
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'robinhood-green': '#00C805',
|
||||
'robinhood-red': '#FF5000',
|
||||
'robinhood-bg': '#F8F9FA',
|
||||
'robinhood-dark': '#1E1E1E',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
frontend/vite.config.ts
Normal file
17
frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user