Files
stonks-oracle/frontend/src/pages/Dashboards.tsx
T

461 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useCompanies, useTrends, useRecommendations, usePositions, useModelPerformance, useModelFailures } from '../api/hooks';
import { useTradingMetrics, useTradingMetricsHistory } from '../api/tradingHooks';
import { TrendArrow, StatusBadge, ConfidenceBar, LoadingSpinner, DateRangeSelector, TickerFilter, Card } from '../components/ui';
import {
BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
LineChart, Line,
} 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 />}
{active === 'prediction-accuracy' && <PredictionAccuracy />}
{active === 'paper-pnl' && <PaperPnl />}
{active === 'model-quality' && <ModelQuality hours={hours} />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Symbol Overview (unchanged — already uses PostgreSQL hooks)
// ---------------------------------------------------------------------------
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>
);
}
// ---------------------------------------------------------------------------
// Sentiment Heatmap — powered by useTrends (PostgreSQL)
// ---------------------------------------------------------------------------
function SentimentHeatmap() {
const { data: trends, isLoading } = useTrends({ limit: 500 });
const { data: companies } = useCompanies();
if (isLoading) return <LoadingSpinner />;
if (!trends?.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
// Build sector lookup from companies
const sectorMap = new Map((companies ?? []).map((c) => [c.ticker, c.sector ?? 'Unknown']));
// Aggregate trend_strength by sector
const sectorAgg = new Map<string, { total: number; count: number }>();
for (const t of trends) {
const sector = sectorMap.get(t.entity_id) ?? 'Unknown';
const entry = sectorAgg.get(sector) ?? { total: 0, count: 0 };
entry.total += t.trend_strength;
entry.count += 1;
sectorAgg.set(sector, entry);
}
const sectorData = Array.from(sectorAgg.entries())
.map(([sector, { total, count }]) => ({
sector,
avgStrength: Math.round((total / count) * 100) / 100,
count,
}))
.sort((a, b) => b.avgStrength - a.avgStrength);
// Also build per-ticker chart
const tickerAgg = new Map<string, { total: number; count: number }>();
for (const t of trends) {
const entry = tickerAgg.get(t.entity_id) ?? { total: 0, count: 0 };
entry.total += t.trend_strength;
entry.count += 1;
tickerAgg.set(t.entity_id, entry);
}
const tickerData = Array.from(tickerAgg.entries())
.map(([ticker, { total, count }]) => ({
ticker,
strength: Math.round((total / count) * 100) / 100,
}))
.sort((a, b) => b.strength - a.strength)
.slice(0, 25);
return (
<div className="space-y-4">
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Average Trend Strength by Sector</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sectorData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="sector" tick={{ fill: '#6b7280', fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Bar dataKey="avgStrength" fill="#3b82f6" name="Avg Trend Strength" />
</BarChart>
</ResponsiveContainer>
</Card>
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Trend Strength by Ticker (Top 25)</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tickerData}>
<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="#8b5cf6" name="Trend Strength" />
</BarChart>
</ResponsiveContainer>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Prediction Accuracy — powered by useRecommendations (PostgreSQL)
// ---------------------------------------------------------------------------
function PredictionAccuracy() {
const { data: recs, isLoading } = useRecommendations({ limit: 500 });
if (isLoading) return <LoadingSpinner />;
if (!recs?.length) return <p className="text-sm text-gray-500">No recommendation data available</p>;
// Confidence distribution buckets
const buckets = [
{ label: '<0.40', min: 0, max: 0.4 },
{ label: '0.400.55', min: 0.4, max: 0.55 },
{ label: '0.550.75', min: 0.55, max: 0.75 },
{ label: '0.751.00', min: 0.75, max: 1.01 },
];
const confidenceData = buckets.map((b) => ({
bucket: b.label,
count: recs.filter((r) => r.confidence >= b.min && r.confidence < b.max).length,
}));
// Action distribution
const actionCounts = new Map<string, number>();
for (const r of recs) {
actionCounts.set(r.action, (actionCounts.get(r.action) ?? 0) + 1);
}
const actionData = Array.from(actionCounts.entries())
.map(([action, count]) => ({ action, count }))
.sort((a, b) => b.count - a.count);
return (
<div className="space-y-4">
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Confidence Distribution</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={confidenceData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="bucket" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Bar dataKey="count" fill="#3b82f6" name="Recommendations" />
</BarChart>
</ResponsiveContainer>
</Card>
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Action Distribution</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={actionData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="action" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Bar dataKey="count" fill="#22c55e" name="Count" />
</BarChart>
</ResponsiveContainer>
</Card>
<p className="text-xs text-gray-600">
Note: Showing confidence and action distributions from recommendations. Realized price move comparison will be available once outcome tracking is implemented.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Paper PnL — powered by trading engine API (PostgreSQL)
// ---------------------------------------------------------------------------
function PaperPnl() {
const { data: metrics, isLoading: metricsLoading } = useTradingMetrics();
const { data: snapshots, isLoading: historyLoading } = useTradingMetricsHistory();
if (metricsLoading || historyLoading) return <LoadingSpinner />;
const hasMetrics = !!metrics;
const hasSnapshots = snapshots && snapshots.length > 0;
if (!hasMetrics && !hasSnapshots) {
return (
<Card>
<p className="text-sm text-gray-500">Paper trading metrics will appear after the first trading day.</p>
</Card>
);
}
const equityCurve = (snapshots ?? [])
.map((s) => ({
date: s.snapshot_date,
value: s.portfolio_value,
dailyReturn: s.daily_return != null ? Math.round(s.daily_return * 10000) / 100 : 0,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const winRate = metrics ? (metrics.win_rate * 100).toFixed(1) : '—';
const winCount = metrics?.win_count ?? 0;
const lossCount = metrics?.loss_count ?? 0;
return (
<div className="space-y-4">
{/* Stat cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<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">{winCount}</div>
<div className="text-xs text-gray-500">Wins</div>
</Card>
<Card className="text-center">
<div className="text-xl font-bold text-red-400">{lossCount}</div>
<div className="text-xs text-gray-500">Losses</div>
</Card>
<Card className="text-center">
<div className={`text-xl font-bold ${(metrics?.unrealized_pnl ?? 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${(metrics?.unrealized_pnl ?? 0).toFixed(2)}
</div>
<div className="text-xs text-gray-500">Unrealized PnL</div>
</Card>
</div>
{/* More metrics */}
{hasMetrics && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="text-center">
<div className="text-lg font-bold text-gray-100">${metrics.total_portfolio_value.toFixed(0)}</div>
<div className="text-xs text-gray-500">Portfolio Value</div>
</Card>
<Card className="text-center">
<div className="text-lg font-bold text-gray-100">{metrics.profit_factor.toFixed(2)}</div>
<div className="text-xs text-gray-500">Profit Factor</div>
</Card>
<Card className="text-center">
<div className="text-lg font-bold text-gray-100">{metrics.sharpe_ratio.toFixed(2)}</div>
<div className="text-xs text-gray-500">Sharpe Ratio</div>
</Card>
<Card className="text-center">
<div className="text-lg font-bold text-red-400">{(metrics.max_drawdown * 100).toFixed(1)}%</div>
<div className="text-xs text-gray-500">Max Drawdown</div>
</Card>
</div>
)}
{/* Equity curve */}
{hasSnapshots && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Equity Curve</h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={equityCurve}>
<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="value" stroke="#3b82f6" dot={false} name="Portfolio Value" />
</LineChart>
</ResponsiveContainer>
</Card>
)}
{/* Daily returns */}
{hasSnapshots && equityCurve.some((d) => d.dailyReturn !== 0) && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily Returns (%)</h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={equityCurve}>
<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="dailyReturn" name="Daily Return %" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</Card>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Model Quality — powered by ops API (PostgreSQL)
// ---------------------------------------------------------------------------
function ModelQuality({ hours }: { hours: number }) {
const { data: perf, isLoading: perfLoading } = useModelPerformance(hours);
const { data: failures, isLoading: failLoading } = useModelFailures(hours);
if (perfLoading || failLoading) return <LoadingSpinner />;
const perfData = perf as Record<string, unknown> | undefined;
const failureList = (failures ?? []) as Array<Record<string, unknown>>;
const totalCalls = Number(perfData?.total_calls ?? 0);
const successCalls = Number(perfData?.successful_calls ?? 0);
const successRate = totalCalls > 0 ? ((successCalls / totalCalls) * 100).toFixed(1) : '—';
const avgLatency = Number(perfData?.avg_latency_ms ?? 0);
const avgRetries = Number(perfData?.avg_retries ?? 0);
const hasData = totalCalls > 0 || failureList.length > 0;
if (!hasData) return <p className="text-sm text-gray-500">No model metrics available</p>;
return (
<div className="space-y-4">
{/* Stat cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="text-center">
<div className="text-xl font-bold text-gray-100">{totalCalls}</div>
<div className="text-xs text-gray-500">Total Calls</div>
</Card>
<Card className="text-center">
<div className={`text-xl font-bold ${Number(successRate) >= 90 ? 'text-green-400' : Number(successRate) >= 70 ? 'text-yellow-400' : 'text-red-400'}`}>
{successRate}%
</div>
<div className="text-xs text-gray-500">Success Rate</div>
</Card>
<Card className="text-center">
<div className="text-xl font-bold text-gray-100">{Math.round(avgLatency)}ms</div>
<div className="text-xs text-gray-500">Avg Latency</div>
</Card>
<Card className="text-center">
<div className="text-xl font-bold text-gray-100">{avgRetries.toFixed(2)}</div>
<div className="text-xs text-gray-500">Avg Retries</div>
</Card>
</div>
{/* Recent failures table */}
{failureList.length > 0 && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Recent Failures ({failureList.length})</h2>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-surface-700 text-gray-500">
<th className="pb-2 pr-4">Time</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4">Error</th>
<th className="pb-2 pr-4">Retries</th>
</tr>
</thead>
<tbody>
{failureList.slice(0, 20).map((f, i) => (
<tr key={i} className="border-b border-surface-800">
<td className="py-1.5 pr-4 text-gray-400">{String(f.recorded_at ?? f.created_at ?? '').slice(0, 19)}</td>
<td className="py-1.5 pr-4 text-gray-300">{String(f.model_name ?? f.model ?? '—')}</td>
<td className="max-w-xs truncate py-1.5 pr-4 text-red-400">{String(f.error_message ?? f.error ?? '—')}</td>
<td className="py-1.5 pr-4 text-gray-400">{String(f.retry_count ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}