diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 77412ee..e209b94 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -19,6 +19,7 @@ import { Globe, BarChart3, Bot, + ClipboardList, } from 'lucide-react'; interface NavItem { @@ -40,6 +41,7 @@ const navItems: NavItem[] = [ { to: '/positions', label: 'Positions', icon: , group: 'Trading' }, { to: '/trading', label: 'Trading Controls', icon: , group: 'Trading' }, { to: '/trading/engine', label: 'Trading Engine', icon: , group: 'Trading' }, + { to: '/reports', label: 'Reports', icon: , group: 'Trading' }, { to: '/ops/pipeline', label: 'Pipeline', icon: , group: 'Ops' }, { to: '/ops/ingestion', label: 'Ingestion', icon: , group: 'Ops' }, { to: '/ops/model', label: 'Model Perf', icon: , group: 'Ops' }, diff --git a/frontend/src/pages/ReportDetail.tsx b/frontend/src/pages/ReportDetail.tsx new file mode 100644 index 0000000..73d7ff6 --- /dev/null +++ b/frontend/src/pages/ReportDetail.tsx @@ -0,0 +1,275 @@ +import { useParams, Link } from '@tanstack/react-router'; +import { useReport } from '../api/hooks'; +import { LoadingSpinner, StatusBadge, Card } from '../components/ui'; +import { ArrowLeft } from 'lucide-react'; + +interface PLSection { + realized_pnl: number; + unrealized_pnl: number; + daily_return: number; + cumulative_return: number; + win_count: number; + loss_count: number; + win_rate: number; + profit_factor: number; + sharpe_ratio: number; + summary: string; + validation_warnings?: { field_name: string; computed_value: number; snapshot_value: number; pct_difference: number }[]; +} + +interface PositionDetail { + ticker: string; + entry_price: number; + current_or_exit_price: number; + pnl: number; + pnl_pct: number; + hold_duration_hours: number; + status: string; +} + +interface RiskMetrics { + current_risk_tier: string; + portfolio_heat: number; + max_drawdown: number; + current_drawdown_pct: number; + reserve_pool_balance: number; + circuit_breaker_event_count: number; + summary: string; +} + +interface ModelWindow { + lookback: string; + win_rate: number | null; + directional_accuracy: number | null; + information_coefficient: number | null; + calibration_error: number | null; + brier_score: number | null; +} + +interface ReportData { + pnl: PLSection; + recommendation_accuracy: { + total_evaluated: number; + act_count: number; + skip_count: number; + acted_win_rate: number; + avg_confidence_acted: number; + avg_confidence_skipped: number; + summary: string; + validation_warnings?: { field_name: string; pct_difference: number }[]; + }; + position_performance: { + positions: PositionDetail[]; + summary: string; + }; + risk_metrics: RiskMetrics; + model_quality: { + windows: ModelWindow[]; + summary: string; + validation_warnings?: { field_name: string; pct_difference: number }[]; + }; + executive_summary: string; + validation_status: string; +} + +function MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function pct(v: number) { + return `${(v * 100).toFixed(2)}%`; +} + +function dollar(v: number) { + return v >= 0 ? `$${v.toFixed(2)}` : `-$${Math.abs(v).toFixed(2)}`; +} + +export function ReportDetailPage() { + const { id } = useParams({ from: '/reports/$id' }); + const { data, isLoading } = useReport(id); + + if (isLoading) return ; + if (!data) return
Report not found
; + + const report = data.report_data as unknown as ReportData; + + return ( +
+ {/* Header */} +
+ + + +
+

+ {data.report_type === 'daily' ? 'Daily' : 'Weekly'} Report +

+

+ {data.period_start === data.period_end + ? data.period_start + : `${data.period_start} → ${data.period_end}`} + {' · '} + +

+
+
+ + {/* Executive Summary */} + {report.executive_summary && ( + +

Executive Summary

+

+ {report.executive_summary} +

+
+ )} + + {/* P&L Section */} + +

P&L

+
+ + + + + + + +
+ {report.pnl.summary && ( +

{report.pnl.summary}

+ )} +
+ + {/* Recommendation Accuracy */} + +

Recommendation Accuracy

+
+ + + + + + +
+ {report.recommendation_accuracy.validation_warnings && report.recommendation_accuracy.validation_warnings.length > 0 && ( +
+ ⚠ Validation warnings: + {report.recommendation_accuracy.validation_warnings.map((w, i) => ( + {w.field_name} ({w.pct_difference.toFixed(1)}% off) + ))} +
+ )} + {report.recommendation_accuracy.summary && ( +

{report.recommendation_accuracy.summary}

+ )} +
+ + {/* Position Performance */} + +

+ Positions ({report.position_performance.positions.length}) +

+ {report.position_performance.positions.length > 0 ? ( +
+ + + + + + + + + + + + + + {report.position_performance.positions.map((p, i) => ( + + + + + + + + + + ))} + +
TickerStatusEntryCurrent/ExitP&LP&L %Hold (hrs)
{p.ticker}${p.entry_price.toFixed(2)}${p.current_or_exit_price.toFixed(2)}= 0 ? 'text-green-400' : 'text-red-400'}`}> + {dollar(p.pnl)} + = 0 ? 'text-green-400' : 'text-red-400'}`}> + {p.pnl_pct.toFixed(2)}% + {p.hold_duration_hours.toFixed(1)}
+
+ ) : ( +

No positions during this period.

+ )} + {report.position_performance.summary && ( +

{report.position_performance.summary}

+ )} +
+ + {/* Risk Metrics */} + +

Risk Metrics

+
+ + + + + + +
+ {report.risk_metrics.summary && ( +

{report.risk_metrics.summary}

+ )} +
+ + {/* Model Quality */} + +

Model Quality

+ {report.model_quality.windows.length > 0 ? ( +
+ + + + + + + + + + + + + {report.model_quality.windows.map((w, i) => ( + + + + + + + + + ))} + +
WindowWin RateDir. AccuracyICECEBrier
{w.lookback}{w.win_rate != null ? pct(w.win_rate) : '—'}{w.directional_accuracy != null ? pct(w.directional_accuracy) : '—'}{w.information_coefficient != null ? w.information_coefficient.toFixed(4) : '—'}{w.calibration_error != null ? w.calibration_error.toFixed(4) : '—'}{w.brier_score != null ? w.brier_score.toFixed(4) : '—'}
+
+ ) : ( +

No model quality data available.

+ )} + {report.model_quality.summary && ( +

{report.model_quality.summary}

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx new file mode 100644 index 0000000..9a897a5 --- /dev/null +++ b/frontend/src/pages/Reports.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useReports } from '../api/hooks'; +import { DataTable, type Column } from '../components/DataTable'; +import { StatusBadge, LoadingSpinner } from '../components/ui'; +import type { ReportListItem } from '../api/hooks'; + +export function ReportsPage() { + const navigate = useNavigate(); + const [reportType, setReportType] = useState(''); + const { data, isLoading } = useReports({ + report_type: reportType || undefined, + limit: 50, + }); + + const columns: Column[] = [ + { + key: 'report_type', + header: 'Type', + render: (r) => ( + + {r.report_type} + + ), + }, + { + key: 'period_start', + header: 'Period', + render: (r) => + r.period_start === r.period_end + ? r.period_start + : `${r.period_start} → ${r.period_end}`, + }, + { + key: 'validation_status', + header: 'Validation', + render: (r) => , + }, + { + key: 'generated_at', + header: 'Generated', + render: (r) => ( + + {new Date(r.generated_at).toLocaleString()} + + ), + }, + ]; + + if (isLoading) return ; + + return ( +
+
+

+ Trading Reports +

+ +
+ + data={data ?? []} + columns={columns} + keyField="id" + onRowClick={(row) => + navigate({ to: '/reports/$id', params: { id: row.id } }) + } + /> +
+ ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 29c53e2..fb2e72d 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -30,6 +30,8 @@ import { HomePage } from './pages/Home'; import { GlobalEventsPage } from './pages/GlobalEvents'; import { GlobalEventDetailPage } from './pages/GlobalEventDetail'; import { AgentsPage } from './pages/Agents'; +import { ReportsPage } from './pages/Reports'; +import { ReportDetailPage } from './pages/ReportDetail'; // Root route wraps everything in the app shell layout const rootRoute = createRootRoute({ @@ -167,6 +169,17 @@ const agentsRoute = createRoute({ component: AgentsPage, }); +const reportsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + component: ReportsPage, +}); +const reportDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/reports/$id', + component: ReportDetailPage, +}); + const routeTree = rootRoute.addChildren([ indexRoute, companiesRoute, @@ -192,6 +205,8 @@ const routeTree = rootRoute.addChildren([ globalEventsRoute, globalEventDetailRoute, agentsRoute, + reportsRoute, + reportDetailRoute, ]); export const router = createRouter({ routeTree });