phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { useState } from 'react';
|
||||
import { useCompanies, useTrends, useRecommendations, usePositions } from '../api/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiPost } from '../api/client';
|
||||
import { TrendArrow, StatusBadge, ConfidenceBar, LoadingSpinner, DateRangeSelector, TickerFilter, Card } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, ScatterChart, Scatter,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
type DashboardId = 'gallery' | 'symbol-overview' | 'sentiment-heatmap' | 'prediction-accuracy' | 'paper-pnl' | 'model-quality';
|
||||
|
||||
const dashboards: Array<{ id: DashboardId; title: string; description: string }> = [
|
||||
{ id: 'symbol-overview', title: 'Symbol Overview', description: 'Company cards with trend direction, latest recommendation, position status' },
|
||||
{ id: 'sentiment-heatmap', title: 'Sentiment Heatmap', description: 'Sector × time matrix colored by aggregated sentiment' },
|
||||
{ id: 'prediction-accuracy', title: 'Prediction Accuracy', description: 'Predicted confidence vs realized price move' },
|
||||
{ id: 'paper-pnl', title: 'Paper Trading PnL', description: 'Equity curve, daily PnL bars, win rate metrics' },
|
||||
{ id: 'model-quality', title: 'Model Quality', description: 'Extraction success rate, latency distribution, retry rate' },
|
||||
];
|
||||
|
||||
export function DashboardsPage() {
|
||||
const [active, setActive] = useState<DashboardId>('gallery');
|
||||
const [hours, setHours] = useState(168);
|
||||
const [ticker, setTicker] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Dashboards</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery / nav */}
|
||||
{active === 'gallery' ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboards.map((d) => (
|
||||
<Card key={d.id} className="cursor-pointer transition-colors hover:border-brand-500/50">
|
||||
<button className="w-full text-left" onClick={() => setActive(d.id)}>
|
||||
<div className="font-medium text-gray-200">{d.title}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{d.description}</div>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button onClick={() => setActive('gallery')} className="mb-3 text-sm text-brand-400 hover:underline">
|
||||
← Back to gallery
|
||||
</button>
|
||||
{active === 'symbol-overview' && <SymbolOverview ticker={ticker} />}
|
||||
{active === 'sentiment-heatmap' && <SentimentHeatmap hours={hours} />}
|
||||
{active === 'prediction-accuracy' && <PredictionAccuracy hours={hours} />}
|
||||
{active === 'paper-pnl' && <PaperPnl hours={hours} />}
|
||||
{active === 'model-quality' && <ModelQuality hours={hours} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symbol Overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SymbolOverview({ ticker }: { ticker: string }) {
|
||||
const { data: companies, isLoading: cLoading } = useCompanies({ ticker: ticker || undefined });
|
||||
const { data: trends } = useTrends({ ticker: ticker || undefined, window: '7d', limit: 100 });
|
||||
const { data: recs } = useRecommendations({ ticker: ticker || undefined, limit: 100 });
|
||||
const { data: positions } = usePositions(ticker || undefined);
|
||||
|
||||
if (cLoading) return <LoadingSpinner />;
|
||||
|
||||
const trendMap = new Map((trends ?? []).map((t) => [t.entity_id, t]));
|
||||
const recMap = new Map((recs ?? []).map((r) => [r.ticker, r]));
|
||||
const posMap = new Map((positions ?? []).map((p) => [p.ticker, p]));
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(companies ?? []).map((c) => {
|
||||
const trend = trendMap.get(c.ticker);
|
||||
const rec = recMap.get(c.ticker);
|
||||
const pos = posMap.get(c.ticker);
|
||||
return (
|
||||
<Card key={c.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono font-semibold text-brand-300">{c.ticker}</span>
|
||||
{trend && <TrendArrow direction={trend.trend_direction} />}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{c.legal_name}</div>
|
||||
{trend && (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">Strength</span>
|
||||
<ConfidenceBar value={trend.trend_strength} />
|
||||
</div>
|
||||
)}
|
||||
{rec && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<StatusBadge status={rec.action} />
|
||||
<ConfidenceBar value={rec.confidence} />
|
||||
</div>
|
||||
)}
|
||||
{pos && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-gray-500">Position: </span>
|
||||
<span className="text-gray-300">{pos.quantity} @ ${pos.avg_entry_price.toFixed(2)}</span>
|
||||
{pos.unrealized_pnl != null && (
|
||||
<span className={pos.unrealized_pnl >= 0 ? 'ml-2 text-green-400' : 'ml-2 text-red-400'}>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trino-backed dashboards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useTrinoQuery(sql: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['trino-dashboard', sql],
|
||||
queryFn: () => apiPost<{ columns: Array<{ name: string }>; rows: unknown[][] }>('query', '/api/analytics/query', { sql, limit: 5000 }),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function SentimentHeatmap({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT entity_id AS ticker, window, trend_direction, trend_strength, confidence, generated_at FROM trend_windows WHERE entity_type = 'company' AND generated_at >= current_timestamp - interval '${days}' day ORDER BY generated_at DESC LIMIT 500`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
ticker: String(r[0]),
|
||||
window: String(r[1]),
|
||||
strength: Number(r[3]) || 0,
|
||||
direction: String(r[2]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Sentiment by Symbol</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="ticker" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="strength" fill="#3b82f6" name="Trend Strength" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PredictionAccuracy({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT predicted_confidence, realized_move_pct, ticker, prediction_date FROM prediction_vs_outcome WHERE prediction_date >= current_date - interval '${days}' day ORDER BY prediction_date DESC LIMIT 1000`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No prediction data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
confidence: Number(r[0]) || 0,
|
||||
realized: Number(r[1]) || 0,
|
||||
ticker: String(r[2]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Predicted Confidence vs Realized Move</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="confidence" name="Confidence" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis dataKey="realized" name="Realized %" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Scatter data={chartData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPnl({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT dt, daily_pnl, cumulative_pnl, win_count, loss_count FROM pnl_daily WHERE dt >= current_date - interval '${days}' day ORDER BY dt ASC LIMIT 365`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No PnL data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
date: String(r[0]),
|
||||
daily: Number(r[1]) || 0,
|
||||
cumulative: Number(r[2]) || 0,
|
||||
wins: Number(r[3]) || 0,
|
||||
losses: Number(r[4]) || 0,
|
||||
}));
|
||||
|
||||
const totalWins = chartData.reduce((s, d) => s + d.wins, 0);
|
||||
const totalLosses = chartData.reduce((s, d) => s + d.losses, 0);
|
||||
const winRate = totalWins + totalLosses > 0 ? ((totalWins / (totalWins + totalLosses)) * 100).toFixed(1) : '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{winRate}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-green-400">{totalWins}</div>
|
||||
<div className="text-xs text-gray-500">Wins</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-red-400">{totalLosses}</div>
|
||||
<div className="text-xs text-gray-500">Losses</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Equity Curve</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="cumulative" stroke="#3b82f6" dot={false} name="Cumulative PnL" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily PnL</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="daily" name="Daily PnL" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelQuality({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT date_trunc('hour', recorded_at) AS hour, count(*) AS total, count(*) filter (where success = true) AS successes, avg(total_duration_ms) AS avg_latency, avg(retry_count) AS avg_retries FROM model_performance_metrics WHERE recorded_at >= current_timestamp - interval '${days}' day GROUP BY 1 ORDER BY 1 ASC LIMIT 500`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No model metrics available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
hour: String(r[0]).slice(11, 16),
|
||||
total: Number(r[1]) || 0,
|
||||
successes: Number(r[2]) || 0,
|
||||
rate: Number(r[1]) > 0 ? ((Number(r[2]) / Number(r[1])) * 100) : 0,
|
||||
latency: Math.round(Number(r[3]) || 0),
|
||||
retries: Number(r[4]) || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Success Rate Over Time</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} domain={[0, 100]} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="rate" stroke="#22c55e" dot={false} name="Success %" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Latency & Retries</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend />
|
||||
<Bar dataKey="latency" fill="#3b82f6" name="Avg Latency (ms)" />
|
||||
<Bar dataKey="retries" fill="#f59e0b" name="Avg Retries" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user