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 ? (
+
+
+
+
+ | Ticker |
+ Status |
+ Entry |
+ Current/Exit |
+ P&L |
+ P&L % |
+ Hold (hrs) |
+
+
+
+ {report.position_performance.positions.map((p, i) => (
+
+ | {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 ? (
+
+
+
+
+ | Window |
+ Win Rate |
+ Dir. Accuracy |
+ IC |
+ ECE |
+ Brier |
+
+
+
+ {report.model_quality.windows.map((w, i) => (
+
+ | {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 });