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:
Chris
2026-01-22 14:27:43 -05:00
commit eea4469095
90 changed files with 14513 additions and 0 deletions

33
frontend/Dockerfile Normal file
View 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
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>myFidelityTracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
frontend/nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

118
frontend/src/App.tsx Normal file
View 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
View 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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" }]
}

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