fix: rewrite dashboards to use PostgreSQL API instead of empty Trino lakehouse

The Trino/Iceberg lakehouse has zero tables, so all Trino-backed
dashboards showed 'No data available'. Rewrote all four to use
existing PostgreSQL-backed API endpoints:

- Sentiment Heatmap: useTrends + useCompanies → sector and ticker
  trend strength bar charts (30k trend_windows in DB)
- Prediction Accuracy: useRecommendations → confidence distribution
  and action distribution charts (30k recommendations in DB)
- Paper PnL: useTradingMetrics + useTradingMetricsHistory → equity
  curve, daily returns, win/loss stats from trading engine
- Model Quality: useModelPerformance + useModelFailures → success
  rate, latency, retries, and failure table from ops API

Removed unused Trino query function and ScatterChart imports.
This commit is contained in:
Celes Renata
2026-04-16 00:58:18 +00:00
parent b5c0c6d7c9
commit 55512ca5a8
+262 -117
View File
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useCompanies, useTrends, useRecommendations, usePositions } from '../api/hooks';
import { useQuery } from '@tanstack/react-query';
import { apiPost } from '../api/client';
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 {
LineChart, Line, BarChart, Bar, ScatterChart, Scatter,
BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
LineChart, Line,
} from 'recharts';
type DashboardId = 'gallery' | 'symbol-overview' | 'sentiment-heatmap' | 'prediction-accuracy' | 'paper-pnl' | 'model-quality';
@@ -51,9 +51,9 @@ export function DashboardsPage() {
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 === 'sentiment-heatmap' && <SentimentHeatmap />}
{active === 'prediction-accuracy' && <PredictionAccuracy />}
{active === 'paper-pnl' && <PaperPnl />}
{active === 'model-quality' && <ModelQuality hours={hours} />}
</div>
)}
@@ -62,7 +62,7 @@ export function DashboardsPage() {
}
// ---------------------------------------------------------------------------
// Symbol Overview
// Symbol Overview (unchanged — already uses PostgreSQL hooks)
// ---------------------------------------------------------------------------
function SymbolOverview({ ticker }: { ticker: string }) {
@@ -121,195 +121,340 @@ function SymbolOverview({ ticker }: { ticker: string }) {
}
// ---------------------------------------------------------------------------
// Trino-backed dashboards
// Sentiment Heatmap — powered by useTrends (PostgreSQL)
// ---------------------------------------------------------------------------
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`
);
function SentimentHeatmap() {
const { data: trends, isLoading } = useTrends({ limit: 500 });
const { data: companies } = useCompanies();
if (isLoading) return <LoadingSpinner />;
if (!data?.rows.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
if (!trends?.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]),
}));
// 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">Sentiment by Symbol</h2>
<h2 className="mb-3 text-sm font-medium text-gray-400">Average Trend Strength by Sector</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<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="#3b82f6" name="Trend Strength" />
<Bar dataKey="strength" fill="#8b5cf6" name="Trend Strength" />
</BarChart>
</ResponsiveContainer>
</Card>
</div>
);
}
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`
);
// ---------------------------------------------------------------------------
// Prediction Accuracy — powered by useRecommendations (PostgreSQL)
// ---------------------------------------------------------------------------
function PredictionAccuracy() {
const { data: recs, isLoading } = useRecommendations({ limit: 500 });
if (isLoading) return <LoadingSpinner />;
if (!data?.rows.length) return <p className="text-sm text-gray-500">No prediction data available</p>;
if (!recs?.length) return <p className="text-sm text-gray-500">No recommendation data available</p>;
const chartData = data.rows.map((r) => ({
confidence: Number(r[0]) || 0,
realized: Number(r[1]) || 0,
ticker: String(r[2]),
// 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>
<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>
<p className="text-sm text-gray-500">Paper trading metrics will appear after the first trading day.</p>
</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`
);
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));
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) : '—';
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">
<div className="grid grid-cols-3 gap-3">
{/* 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">{totalWins}</div>
<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">{totalLosses}</div>
<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={chartData}>
<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="cumulative" stroke="#3b82f6" dot={false} name="Cumulative PnL" />
<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 PnL</h2>
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily Returns (%)</h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<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="daily" name="Daily PnL" fill="#3b82f6" />
<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 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`
);
const { data: perf, isLoading: perfLoading } = useModelPerformance(hours);
const { data: failures, isLoading: failLoading } = useModelFailures(hours);
if (isLoading) return <LoadingSpinner />;
if (!data?.rows.length) return <p className="text-sm text-gray-500">No model metrics available</p>;
if (perfLoading || failLoading) return <LoadingSpinner />;
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,
}));
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">
<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>
{/* 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">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>
<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>
);
}