461 lines
20 KiB
TypeScript
461 lines
20 KiB
TypeScript
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.40–0.55', min: 0.4, max: 0.55 },
|
||
{ label: '0.55–0.75', min: 0.55, max: 0.75 },
|
||
{ label: '0.75–1.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>
|
||
);
|
||
}
|