phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useCompanies } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner } from '../components/ui';
|
||||
import type { Company } from '../api/hooks';
|
||||
|
||||
const columns: Column<Company>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'legal_name', header: 'Name' },
|
||||
{ key: 'sector', header: 'Sector' },
|
||||
{
|
||||
key: 'active',
|
||||
header: 'Status',
|
||||
render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} />,
|
||||
},
|
||||
{
|
||||
key: 'active_source_count',
|
||||
header: 'Sources',
|
||||
render: (r) => <span>{r.active_source_count ?? '—'}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function CompaniesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useCompanies();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="text-red-400">Failed to load companies</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 text-xl font-semibold text-gray-100">Companies</h1>
|
||||
<DataTable<Company>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/companies/$id', params: { id: row.id } })}
|
||||
filterFn={(row, q) => {
|
||||
const lq = q.toLowerCase();
|
||||
return (
|
||||
row.ticker.toLowerCase().includes(lq) ||
|
||||
(row.legal_name ?? '').toLowerCase().includes(lq) ||
|
||||
(row.sector ?? '').toLowerCase().includes(lq)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { Source } from '../api/hooks';
|
||||
import type { Alias } from '../api/hooks';
|
||||
|
||||
const sourceCols: Column<Source>[] = [
|
||||
{ key: 'source_type', header: 'Type' },
|
||||
{ key: 'source_name', header: 'Name' },
|
||||
{ key: 'credibility_score', header: 'Credibility', render: (r) => <span>{(r.credibility_score * 100).toFixed(0)}%</span> },
|
||||
{ key: 'active', header: 'Status', render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} /> },
|
||||
];
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams({ from: '/companies/$id' });
|
||||
const { data: company, isLoading } = useCompany(id);
|
||||
const { data: sources } = useCompanySources(id);
|
||||
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{company.ticker}</h1>
|
||||
<StatusBadge status={company.active ? 'active' : 'disabled'} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Name</dt><dd className="text-gray-200">{company.legal_name}</dd></div>
|
||||
<div><dt className="text-gray-500">Exchange</dt><dd className="text-gray-200">{company.exchange ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sector</dt><dd className="text-gray-200">{company.sector ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Industry</dt><dd className="text-gray-200">{company.industry ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Market Cap</dt><dd className="text-gray-200">{company.market_cap_bucket ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sources</dt><dd className="text-gray-200">{company.active_source_count ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-surface-700">
|
||||
{(['sources', 'aliases'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sources' && (
|
||||
<div className="space-y-4">
|
||||
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
|
||||
<AddSourceForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'aliases' && (
|
||||
<div className="space-y-4">
|
||||
<AliasesList aliases={company.aliases ?? []} />
|
||||
<AddAliasForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasesList({ aliases }: { aliases: Alias[] }) {
|
||||
if (aliases.length === 0) return <p className="text-sm text-gray-500">No aliases configured</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aliases.map((a) => (
|
||||
<span key={a.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
{a.alias} <span className="text-gray-500">({a.alias_type})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAliasForm({ companyId }: { companyId: string }) {
|
||||
const [alias, setAlias] = useState('');
|
||||
const mutation = useCreateAlias(companyId);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (alias.trim()) mutation.mutate({ alias: alias.trim() }, { onSuccess: () => setAlias('') });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add alias…"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="New alias"
|
||||
/>
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceForm({ companyId }: { companyId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sourceType, setSourceType] = useState('market_api');
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
const [credibility, setCredibility] = useState(0.5);
|
||||
const mutation = useCreateSource(companyId);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button onClick={() => setOpen(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Source
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(
|
||||
{ source_type: sourceType, source_name: sourceName, credibility_score: credibility },
|
||||
{ onSuccess: () => { setOpen(false); setSourceName(''); } },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-type">Type</label>
|
||||
<select
|
||||
id="source-type"
|
||||
value={sourceType}
|
||||
onChange={(e) => setSourceType(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200"
|
||||
>
|
||||
{['market_api', 'news_api', 'filings_api', 'web_scrape', 'broker'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-name">Name</label>
|
||||
<input
|
||||
id="source-name"
|
||||
type="text"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500"
|
||||
placeholder="Source name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="credibility">Credibility: {(credibility * 100).toFixed(0)}%</label>
|
||||
<input
|
||||
id="credibility"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={credibility}
|
||||
onChange={(e) => setCredibility(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useDocument } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function DocumentDetailPage() {
|
||||
const { id } = useParams({ from: '/documents/$id' });
|
||||
const { data: doc, isLoading } = useDocument(id);
|
||||
|
||||
if (isLoading || !doc) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-100">{doc.title ?? 'Untitled Document'}</h1>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-gray-500">
|
||||
<StatusBadge status={doc.status} />
|
||||
<span>{doc.document_type}</span>
|
||||
<span>{doc.source_type}</span>
|
||||
{doc.publisher && <span>{doc.publisher}</span>}
|
||||
{doc.published_at && <span>{new Date(doc.published_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Metadata</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div><dt className="text-gray-500">URL</dt><dd className="truncate text-gray-300">{doc.url ? <a href={doc.url} target="_blank" rel="noopener noreferrer" className="text-brand-400 hover:underline">{doc.url}</a> : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Language</dt><dd className="text-gray-300">{doc.language ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Content Hash</dt><dd className="truncate font-mono text-xs text-gray-400">{doc.content_hash ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Parse Quality</dt><dd className="text-gray-300">{doc.parse_quality_score?.toFixed(2) ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Parse Confidence</dt><dd><StatusBadge status={doc.parse_confidence ?? 'unknown'} /></dd></div>
|
||||
<div><dt className="text-gray-500">Retrieved</dt><dd className="text-gray-300">{doc.retrieved_at ? new Date(doc.retrieved_at).toLocaleString() : '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Company Mentions */}
|
||||
{doc.company_mentions.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Company Mentions</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{doc.company_mentions.map((m, i) => (
|
||||
<span key={i} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
<span className="font-mono font-semibold text-brand-300">{m.ticker}</span> {m.legal_name}
|
||||
<span className="ml-1 text-gray-500">({m.mention_type}, {(m.confidence * 100).toFixed(0)}%)</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Intelligence Extraction */}
|
||||
{doc.intelligence ? (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Intelligence Extraction</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Summary</div>
|
||||
<p className="text-sm text-gray-200">{doc.intelligence.summary ?? '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Confidence</div>
|
||||
<ConfidenceBar value={doc.intelligence.confidence} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Validation</div>
|
||||
<StatusBadge status={doc.intelligence.validation_status} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Model</div>
|
||||
<span className="text-xs text-gray-400">{doc.intelligence.model_name ?? '—'} ({doc.intelligence.prompt_version})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.intelligence.macro_themes && doc.intelligence.macro_themes.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Macro Themes</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.intelligence.macro_themes.map((t, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-300">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.intelligence.extraction_warnings && doc.intelligence.extraction_warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Warnings</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.intelligence.extraction_warnings.map((w, i) => (
|
||||
<span key={i} className="rounded bg-yellow-900/30 px-2 py-0.5 text-xs text-yellow-400">{w}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Impacts */}
|
||||
{doc.intelligence.company_impacts && doc.intelligence.company_impacts.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-gray-500">Company Impacts</div>
|
||||
<div className="space-y-3">
|
||||
{doc.intelligence.company_impacts.map((imp, i) => (
|
||||
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{imp.ticker}</span>
|
||||
<StatusBadge status={imp.sentiment} />
|
||||
<ConfidenceBar value={imp.impact_score} />
|
||||
<span className="text-xs text-gray-500">{imp.catalyst_type}</span>
|
||||
<span className="text-xs text-gray-500">{imp.impact_horizon}</span>
|
||||
</div>
|
||||
{imp.key_facts && imp.key_facts.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500">Key Facts</div>
|
||||
<ul className="ml-4 list-disc text-xs text-gray-300">
|
||||
{imp.key_facts.map((f, j) => <li key={j}>{f}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{imp.risks && imp.risks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500">Risks</div>
|
||||
<ul className="ml-4 list-disc text-xs text-red-400">
|
||||
{imp.risks.map((r, j) => <li key={j}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">No intelligence extraction available</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Storage References */}
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Storage References</h2>
|
||||
<dl className="space-y-1 text-sm">
|
||||
<div><dt className="inline text-gray-500">Raw: </dt><dd className="inline truncate font-mono text-xs text-gray-400">{doc.raw_storage_ref ?? '—'}</dd></div>
|
||||
<div><dt className="inline text-gray-500">Normalized: </dt><dd className="inline truncate font-mono text-xs text-gray-400">{doc.normalized_storage_ref ?? '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useDocuments } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Document } from '../api/hooks';
|
||||
|
||||
export function DocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading, error } = useDocuments({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Document>[] = [
|
||||
{ key: 'title', header: 'Title', render: (r) => <span className="line-clamp-1 max-w-xs">{r.title ?? '—'}</span> },
|
||||
{ key: 'document_type', header: 'Type' },
|
||||
{ key: 'source_type', header: 'Source' },
|
||||
{ key: 'published_at', header: 'Published', render: (r) => <span className="text-xs">{r.published_at ? new Date(r.published_at).toLocaleDateString() : '—'}</span> },
|
||||
{ key: 'parse_confidence', header: 'Parse Quality', render: (r) => <StatusBadge status={r.parse_confidence ?? 'unknown'} /> },
|
||||
{ key: 'status', header: 'Status', render: (r) => <StatusBadge status={r.status} /> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="text-red-400">Failed to load documents</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Documents</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Document>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/documents/$id', params: { id: row.id } })}
|
||||
filterFn={(row, q) => {
|
||||
const lq = q.toLowerCase();
|
||||
return (row.title ?? '').toLowerCase().includes(lq) || row.document_type.toLowerCase().includes(lq);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePipelineHealth, useIngestionSummary, useRecommendations, useCompanies, useCoverageGaps } from '../api/hooks';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
import {
|
||||
Building2, FileText, TrendingUp, Lightbulb, ShoppingCart,
|
||||
Wallet, ShieldCheck, Activity, Download, Cpu, Radar,
|
||||
Terminal, LayoutDashboard, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const quickNav = [
|
||||
{ to: '/companies', label: 'Companies', icon: <Building2 size={20} />, color: 'text-blue-400' },
|
||||
{ to: '/documents', label: 'Documents', icon: <FileText size={20} />, color: 'text-green-400' },
|
||||
{ to: '/trends', label: 'Trends', icon: <TrendingUp size={20} />, color: 'text-purple-400' },
|
||||
{ to: '/recommendations', label: 'Recommendations', icon: <Lightbulb size={20} />, color: 'text-yellow-400' },
|
||||
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={20} />, color: 'text-orange-400' },
|
||||
{ to: '/positions', label: 'Positions', icon: <Wallet size={20} />, color: 'text-cyan-400' },
|
||||
{ to: '/trading', label: 'Trading', icon: <ShieldCheck size={20} />, color: 'text-red-400' },
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={20} />, color: 'text-emerald-400' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={20} />, color: 'text-teal-400' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={20} />, color: 'text-indigo-400' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={20} />, color: 'text-pink-400' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={20} />, color: 'text-amber-400' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={20} />, color: 'text-violet-400' },
|
||||
];
|
||||
|
||||
export function HomePage() {
|
||||
const { data: pipeline, isLoading: pLoading } = usePipelineHealth(24);
|
||||
const { data: ingestion } = useIngestionSummary(24);
|
||||
const { data: recs } = useRecommendations({ limit: 5 });
|
||||
const { data: companies } = useCompanies();
|
||||
const { data: gaps } = useCoverageGaps();
|
||||
|
||||
if (pLoading) return <LoadingSpinner />;
|
||||
|
||||
const ing = (ingestion ?? {}) as Record<string, unknown>;
|
||||
const staleCount = (gaps?.stale_sources?.length ?? 0) + (gaps?.missing_source_types?.length ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Stonks Oracle</h1>
|
||||
|
||||
{/* Alert banner */}
|
||||
{staleCount > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-yellow-700/50 bg-yellow-900/20 p-3" role="alert">
|
||||
<AlertTriangle size={18} className="text-yellow-400" />
|
||||
<span className="text-sm text-yellow-300">
|
||||
{staleCount} coverage issue{staleCount > 1 ? 's' : ''} detected — check{' '}
|
||||
<Link to="/ops/coverage" className="underline hover:text-yellow-200">Source Coverage</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard label="Active Companies" value={companies?.length ?? 0} />
|
||||
<MetricCard label="Ingestion Runs (24h)" value={ing.total_runs} />
|
||||
<MetricCard label="Items Fetched (24h)" value={ing.total_items_fetched} />
|
||||
<MetricCard label="Recommendations Today" value={recs?.length ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* Pipeline status */}
|
||||
{pipeline && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Pipeline Status (24h)</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
{((pipeline.document_stages ?? []) as Array<{ status: string; doc_count: number }>).map((s) => (
|
||||
<div key={s.status} className="text-center">
|
||||
<div className="text-lg font-bold text-gray-100">{s.doc_count}</div>
|
||||
<div className="text-xs capitalize text-gray-500">{s.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
{recs && recs.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Recent Recommendations</h2>
|
||||
<div className="space-y-2">
|
||||
{recs.map((r) => (
|
||||
<Link key={r.id} to="/recommendations/$id" params={{ id: r.id }} className="flex items-center justify-between rounded border border-surface-700 bg-surface-950 p-2 hover:bg-surface-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-brand-300">{r.ticker}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${r.action === 'buy' ? 'bg-green-900/40 text-green-400' : r.action === 'sell' ? 'bg-red-900/40 text-red-400' : 'bg-gray-800/40 text-gray-400'}`}>
|
||||
{r.action}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{r.thesis}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{new Date(r.generated_at).toLocaleString()}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick nav */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-5">
|
||||
{quickNav.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-surface-700 bg-surface-900 p-3 text-center transition-colors hover:border-brand-500/50 hover:bg-surface-800"
|
||||
>
|
||||
<span className={item.color}>{item.icon}</span>
|
||||
<span className="text-xs text-gray-400">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-100">{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCoverageGaps, useSymbolCoverage } from '../api/hooks';
|
||||
import { LoadingSpinner, StatusBadge, Card } from '../components/ui';
|
||||
|
||||
export function OpsCoveragePage() {
|
||||
const { data: gaps, isLoading: gapsLoading } = useCoverageGaps();
|
||||
const { data: coverage, isLoading: covLoading } = useSymbolCoverage();
|
||||
|
||||
if (gapsLoading || covLoading) return <LoadingSpinner />;
|
||||
|
||||
const missing = (gaps?.missing_source_types ?? []) as Array<Record<string, unknown>>;
|
||||
const stale = (gaps?.stale_sources ?? []) as Array<Record<string, unknown>>;
|
||||
const matrix = (coverage ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Source Coverage</h1>
|
||||
|
||||
{/* Coverage Matrix */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Company × Source Type Matrix</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||
<th className="px-3 py-2">Ticker</th>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2 text-center">Market</th>
|
||||
<th className="px-3 py-2 text-center">News</th>
|
||||
<th className="px-3 py-2 text-center">Filings</th>
|
||||
<th className="px-3 py-2 text-center">Web</th>
|
||||
<th className="px-3 py-2 text-center">Broker</th>
|
||||
<th className="px-3 py-2 text-center">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.map((row, i) => (
|
||||
<tr key={i} className="border-b border-surface-700/50">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{row.ticker as string}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{row.legal_name as string}</td>
|
||||
<CoverageCell count={row.market_sources as number} />
|
||||
<CoverageCell count={row.news_sources as number} />
|
||||
<CoverageCell count={row.filings_sources as number} />
|
||||
<CoverageCell count={row.web_scrape_sources as number} />
|
||||
<CoverageCell count={row.broker_sources as number} />
|
||||
<td className="px-3 py-2 text-center text-gray-300">{String(row.active_sources)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Missing Source Types */}
|
||||
{missing.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Missing Source Types ({missing.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{missing.map((m, i) => {
|
||||
const activeTypes = (m.active_types as string[]) ?? [];
|
||||
const expected = (m.expected_types as string[]) ?? [];
|
||||
const missingTypes = expected.filter((t) => !activeTypes.includes(t));
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 rounded border border-yellow-700/30 bg-yellow-900/10 p-2">
|
||||
<span className="font-mono font-semibold text-brand-300">{m.ticker as string}</span>
|
||||
<span className="text-xs text-gray-500">missing:</span>
|
||||
{missingTypes.map((t) => (
|
||||
<StatusBadge key={t} status={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stale Sources */}
|
||||
{stale.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Stale Sources ({stale.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{stale.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded border border-red-700/30 bg-red-900/10 p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{s.ticker as string}</span>
|
||||
<StatusBadge status={s.source_type as string} />
|
||||
<span className="text-xs text-gray-400">{s.source_name as string}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Last success: {s.last_success ? new Date(s.last_success as string).toLocaleString() : 'never'}
|
||||
{s.recent_failures ? ` | ${s.recent_failures} failures (24h)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageCell({ count }: { count: number }) {
|
||||
const color = count > 0 ? 'text-green-400' : 'text-red-400';
|
||||
return <td className={`px-3 py-2 text-center font-mono ${color}`}>{count}</td>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { useIngestionThroughput, useIngestionSummary } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, Card } from '../components/ui';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
export function OpsIngestionPage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const [bucket, setBucket] = useState('1h');
|
||||
const { data: throughput, isLoading: tpLoading } = useIngestionThroughput(hours, bucket);
|
||||
const { data: summary } = useIngestionSummary(hours);
|
||||
|
||||
if (tpLoading) return <LoadingSpinner />;
|
||||
|
||||
const chartData = (throughput ?? []).map((row: unknown) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return {
|
||||
time: r.bucket_start ? new Date(r.bucket_start as string).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
|
||||
completed: Number(r.completed ?? 0),
|
||||
failed: Number(r.failed ?? 0),
|
||||
items: Number(r.items_fetched ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
const s = (summary ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Ingestion Monitor</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group">
|
||||
{['15m', '1h', '6h', '1d'].map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBucket(b)}
|
||||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${bucket === b ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total Runs" value={s.total_runs} />
|
||||
<StatCard label="Completed" value={s.completed} color="text-green-400" />
|
||||
<StatCard label="Failed" value={s.failed} color="text-red-400" />
|
||||
<StatCard label="Items Fetched" value={s.total_items_fetched} />
|
||||
<StatCard label="New Items" value={s.total_items_new} />
|
||||
</div>
|
||||
|
||||
{/* Throughput chart */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Throughput</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="time" tick={{ fill: '#6b7280', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} labelStyle={{ color: '#9ca3af' }} />
|
||||
<Legend />
|
||||
<Bar dataKey="completed" fill="#22c55e" name="Completed" />
|
||||
<Bar dataKey="failed" fill="#ef4444" name="Failed" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* By source type */}
|
||||
{s.by_source_type && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">By Source Type</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||
<th className="px-3 py-2">Type</th>
|
||||
<th className="px-3 py-2">Runs</th>
|
||||
<th className="px-3 py-2">Completed</th>
|
||||
<th className="px-3 py-2">Failed</th>
|
||||
<th className="px-3 py-2">Items</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s.by_source_type as Array<Record<string, unknown>>).map((row, i) => (
|
||||
<tr key={i} className="border-b border-surface-700/50">
|
||||
<td className="px-3 py-2 text-gray-300">{row.source_type as string}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{String(row.runs)}</td>
|
||||
<td className="px-3 py-2 text-green-400">{String(row.completed)}</td>
|
||||
<td className="px-3 py-2 text-red-400">{String(row.failed)}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{String(row.items_fetched)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useModelPerformance, useModelFailures } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, StatusBadge, Card } from '../components/ui';
|
||||
|
||||
export function OpsModelPage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const { data: perf, isLoading } = useModelPerformance(hours);
|
||||
const { data: failures } = useModelFailures(hours);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
const p = (perf ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Model Performance</h1>
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total Extractions" value={p.total_extractions} />
|
||||
<StatCard label="Success Rate" value={p.success_rate != null ? `${((p.success_rate as number) * 100).toFixed(1)}%` : '—'} color="text-green-400" />
|
||||
<StatCard label="Avg Latency" value={p.avg_duration_ms != null ? `${Math.round(p.avg_duration_ms as number)}ms` : '—'} />
|
||||
<StatCard label="Retry Rate" value={p.retry_rate != null ? `${((p.retry_rate as number) * 100).toFixed(1)}%` : '—'} color="text-yellow-400" />
|
||||
<StatCard label="Avg Confidence" value={p.avg_confidence != null ? ((p.avg_confidence as number) * 100).toFixed(0) + '%' : '—'} />
|
||||
</div>
|
||||
|
||||
{/* Recent Failures */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Recent Failures ({(failures as unknown[])?.length ?? 0})
|
||||
</h2>
|
||||
{!(failures as unknown[])?.length ? (
|
||||
<p className="text-sm text-gray-500">No recent failures</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(failures as Array<Record<string, unknown>>).map((f, i) => (
|
||||
<div key={i} className="rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-brand-300">{f.ticker as string}</span>
|
||||
<StatusBadge status="failed" />
|
||||
<span className="text-xs text-gray-500">{f.model_name as string}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{f.recorded_at ? new Date(f.recorded_at as string).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{f.document_title as string} ({f.document_type as string})
|
||||
</div>
|
||||
{f.validation_errors && (
|
||||
<div className="mt-1 text-xs text-red-400">
|
||||
{JSON.stringify(f.validation_errors)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { usePipelineHealth } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, Card } from '../components/ui';
|
||||
|
||||
export function OpsPipelinePage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const { data, isLoading } = usePipelineHealth(hours);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
const stages = (data?.document_stages as Array<{ status: string; doc_count: number }>) ?? [];
|
||||
const parsing = (data?.parsing ?? {}) as Record<string, unknown>;
|
||||
const extraction = (data?.extraction ?? {}) as Record<string, unknown>;
|
||||
const aggregation = (data?.aggregation ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Pipeline Health</h1>
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{/* Document Stage Counts */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Document Stages</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stages.map((s) => (
|
||||
<div key={s.status} className="rounded-lg border border-surface-700 bg-surface-950 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-100">{s.doc_count}</div>
|
||||
<div className="text-xs capitalize text-gray-500">{s.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Parsing Quality */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Parsing Quality</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
<Stat label="Total Parsed" value={parsing.total_parsed} />
|
||||
<Stat label="High Confidence" value={parsing.high_confidence} color="text-green-400" />
|
||||
<Stat label="Medium" value={parsing.medium_confidence} color="text-yellow-400" />
|
||||
<Stat label="Low" value={parsing.low_confidence} color="text-red-400" />
|
||||
<Stat label="Avg Quality" value={parsing.avg_quality_score} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Extraction Stats */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Extraction Validation</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
<Stat label="Total" value={extraction.total_extractions} />
|
||||
<Stat label="Valid" value={extraction.valid} color="text-green-400" />
|
||||
<Stat label="Failed" value={extraction.failed} color="text-red-400" />
|
||||
<Stat label="Avg Confidence" value={extraction.avg_confidence} />
|
||||
<Stat label="Avg Retries" value={extraction.avg_retries} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Aggregation */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Trend Generation</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Stat label="Trends Generated" value={aggregation.trends_generated} />
|
||||
<Stat label="Symbols Covered" value={aggregation.symbols_covered} />
|
||||
<Stat label="Avg Confidence" value={aggregation.avg_trend_confidence} />
|
||||
<Stat label="Avg Contradiction" value={aggregation.avg_contradiction} />
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-700 bg-surface-950 p-3 text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useOrder } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function OrderDetailPage() {
|
||||
const { id } = useParams({ from: '/orders/$id' });
|
||||
const { data: order, isLoading } = useOrder(id);
|
||||
|
||||
if (isLoading || !order) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{order.ticker}</h1>
|
||||
<StatusBadge status={order.side} />
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Type</dt><dd className="text-gray-200">{order.order_type}</dd></div>
|
||||
<div><dt className="text-gray-500">Quantity</dt><dd className="text-gray-200">{order.quantity}</dd></div>
|
||||
<div><dt className="text-gray-500">Limit Price</dt><dd className="text-gray-200">{order.limit_price != null ? `$${order.limit_price.toFixed(2)}` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Fill Price</dt><dd className="text-gray-200">{order.fill_price != null ? `$${order.fill_price.toFixed(2)}` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Fill Qty</dt><dd className="text-gray-200">{order.fill_quantity ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Broker Order</dt><dd className="truncate font-mono text-xs text-gray-400">{order.broker_order_id ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Idempotency Key</dt><dd className="truncate font-mono text-xs text-gray-400">{order.idempotency_key ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Created</dt><dd className="text-gray-300">{new Date(order.created_at).toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Decision Trace */}
|
||||
{order.decision_trace && Object.keys(order.decision_trace).length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Decision Trace</h2>
|
||||
<pre className="overflow-x-auto rounded bg-surface-950 p-3 text-xs text-gray-300">
|
||||
{JSON.stringify(order.decision_trace, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Order Events Timeline */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Events ({order.events.length})</h2>
|
||||
{order.events.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No events</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{order.events.map((ev) => (
|
||||
<div key={ev.id} className="flex items-start gap-3 rounded border border-surface-700 bg-surface-950 p-2">
|
||||
<StatusBadge status={ev.event_type} />
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-400">{new Date(ev.created_at).toLocaleString()}</div>
|
||||
{ev.data && (
|
||||
<pre className="mt-1 text-xs text-gray-500">{JSON.stringify(ev.data, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Audit Trail */}
|
||||
{order.audit_trail && (order.audit_trail as unknown[]).length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Audit Trail</h2>
|
||||
<div className="space-y-1">
|
||||
{(order.audit_trail as Array<Record<string, unknown>>).map((entry, i) => (
|
||||
<div key={i} className="flex gap-3 text-xs">
|
||||
<span className="text-gray-500">{entry.created_at ? new Date(entry.created_at as string).toLocaleString() : ''}</span>
|
||||
<span className="text-gray-400">{entry.event_type as string}</span>
|
||||
<span className="text-gray-300">{entry.description as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useOrders } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Order } from '../api/hooks';
|
||||
|
||||
export function OrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading } = useOrders({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Order>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'side', header: 'Side', render: (r) => <StatusBadge status={r.side} /> },
|
||||
{ key: 'order_type', header: 'Type' },
|
||||
{ key: 'quantity', header: 'Qty' },
|
||||
{ key: 'status', header: 'Status', render: (r) => <StatusBadge status={r.status} /> },
|
||||
{ key: 'fill_price', header: 'Fill Price', render: (r) => <span>{r.fill_price != null ? `$${r.fill_price.toFixed(2)}` : '—'}</span> },
|
||||
{ key: 'created_at', header: 'Created', render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Orders</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Order>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/orders/$id', params: { id: row.id } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-200">{title}</h1>
|
||||
<p className="mt-2 text-gray-500">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { usePositions } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { LoadingSpinner } from '../components/ui';
|
||||
import type { Position } from '../api/hooks';
|
||||
|
||||
function fmtUsd(v: number | null | undefined) {
|
||||
if (v == null) return '—';
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function pnlColor(v: number | null | undefined) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
return v >= 0 ? 'text-green-400' : 'text-red-400';
|
||||
}
|
||||
|
||||
const columns: Column<Position>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'quantity', header: 'Qty' },
|
||||
{ key: 'avg_entry_price', header: 'Entry', render: (r) => <span>{fmtUsd(r.avg_entry_price)}</span> },
|
||||
{ key: 'current_price', header: 'Current', render: (r) => <span>{fmtUsd(r.current_price)}</span> },
|
||||
{ key: 'unrealized_pnl', header: 'Unrealized P&L', render: (r) => <span className={pnlColor(r.unrealized_pnl)}>{fmtUsd(r.unrealized_pnl)}</span> },
|
||||
{ key: 'realized_pnl', header: 'Realized P&L', render: (r) => <span className={pnlColor(r.realized_pnl)}>{fmtUsd(r.realized_pnl)}</span> },
|
||||
{ key: 'updated_at', header: 'Updated', render: (r) => <span className="text-xs">{new Date(r.updated_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
export function PositionsPage() {
|
||||
const { data, isLoading } = usePositions();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 text-xl font-semibold text-gray-100">Positions</h1>
|
||||
<DataTable<Position>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useRecommendation } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function RecommendationDetailPage() {
|
||||
const { id } = useParams({ from: '/recommendations/$id' });
|
||||
const { data: rec, isLoading } = useRecommendation(id);
|
||||
|
||||
if (isLoading || !rec) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{rec.ticker}</h1>
|
||||
<StatusBadge status={rec.action} />
|
||||
<StatusBadge status={rec.mode} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Confidence</dt><dd><ConfidenceBar value={rec.confidence} /></dd></div>
|
||||
<div><dt className="text-gray-500">Horizon</dt><dd className="text-gray-200">{rec.time_horizon}</dd></div>
|
||||
<div><dt className="text-gray-500">Risk</dt><dd><StatusBadge status={rec.risk_classification} /></dd></div>
|
||||
<div><dt className="text-gray-500">Generated</dt><dd className="text-gray-300">{new Date(rec.generated_at).toLocaleString()}</dd></div>
|
||||
<div><dt className="text-gray-500">Portfolio %</dt><dd className="text-gray-200">{rec.portfolio_pct != null ? `${(rec.portfolio_pct * 100).toFixed(1)}%` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Max Loss %</dt><dd className="text-gray-200">{rec.max_loss_pct != null ? `${(rec.max_loss_pct * 100).toFixed(2)}%` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Model</dt><dd className="text-xs text-gray-400">{rec.model_version ?? '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{rec.thesis && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Thesis</h2>
|
||||
<p className="text-sm text-gray-200">{rec.thesis}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{rec.invalidation_conditions && rec.invalidation_conditions.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Invalidation Conditions</h2>
|
||||
<ul className="ml-4 list-disc text-sm text-yellow-400">
|
||||
{rec.invalidation_conditions.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Risk Evaluation */}
|
||||
{rec.risk_evaluation && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Risk Evaluation</h2>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<StatusBadge status={rec.risk_evaluation.eligible ? 'approved' : 'rejected'} />
|
||||
<span className="text-gray-400">Allowed mode: {rec.risk_evaluation.allowed_mode}</span>
|
||||
</div>
|
||||
{rec.risk_evaluation.rejection_reasons && rec.risk_evaluation.rejection_reasons.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-sm text-red-400">
|
||||
{rec.risk_evaluation.rejection_reasons.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Evidence */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Evidence ({rec.evidence.length})</h2>
|
||||
{rec.evidence.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No evidence linked</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rec.evidence.map((ev) => (
|
||||
<div key={ev.id} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ev.evidence_type} />
|
||||
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type}</span>
|
||||
<span>{ev.source_type}</span>
|
||||
{ev.publisher && <span>{ev.publisher}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useRecommendations } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Recommendation } from '../api/hooks';
|
||||
|
||||
export function RecommendationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading } = useRecommendations({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Recommendation>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'action', header: 'Action', render: (r) => <StatusBadge status={r.action} /> },
|
||||
{ key: 'mode', header: 'Mode', render: (r) => <StatusBadge status={r.mode} /> },
|
||||
{ key: 'confidence', header: 'Confidence', render: (r) => <ConfidenceBar value={r.confidence} /> },
|
||||
{ key: 'thesis', header: 'Thesis', render: (r) => <span className="line-clamp-1 max-w-xs text-xs text-gray-400">{r.thesis ?? '—'}</span> },
|
||||
{ key: 'generated_at', header: 'Generated', render: (r) => <span className="text-xs">{new Date(r.generated_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Recommendations</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Recommendation>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/recommendations/$id', params: { id: row.id } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { apiGet, apiPost, apiDelete } from '../api/client';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, ScatterChart, Scatter,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
|
||||
interface QueryResult {
|
||||
columns: Array<{ name: string; type: string }>;
|
||||
rows: unknown[][];
|
||||
row_count: number;
|
||||
elapsed_ms: number;
|
||||
}
|
||||
|
||||
interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sql_text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SchemaInfo {
|
||||
catalog: string;
|
||||
schema: string;
|
||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string }> }>;
|
||||
}
|
||||
|
||||
type ChartType = 'none' | 'bar' | 'line' | 'scatter';
|
||||
|
||||
export function SqlExplorerPage() {
|
||||
const qc = useQueryClient();
|
||||
const [sql, setSql] = useState('SELECT 1 AS test');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [chartType, setChartType] = useState<ChartType>('none');
|
||||
const [xCol, setXCol] = useState(0);
|
||||
const [yCol, setYCol] = useState(1);
|
||||
|
||||
const { data: schema } = useQuery<SchemaInfo>({
|
||||
queryKey: ['trino-schema'],
|
||||
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/schema'),
|
||||
});
|
||||
|
||||
const { data: savedQueries } = useQuery<SavedQuery[]>({
|
||||
queryKey: ['saved-queries'],
|
||||
queryFn: () => apiGet<SavedQuery[]>('query', '/api/analytics/saved-queries'),
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: (sqlText: string) => apiPost<QueryResult>('query', '/api/analytics/query', { sql: sqlText, limit: 1000 }),
|
||||
onSuccess: (data) => { setResult(data); setError(null); },
|
||||
onError: (err: Error) => { setError(err.message); setResult(null); },
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: { name: string; sql_text: string }) => apiPost<SavedQuery>('query', '/api/analytics/saved-queries', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete<unknown>('query', `/api/analytics/saved-queries/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
|
||||
});
|
||||
|
||||
const handleExecute = useCallback(() => {
|
||||
if (sql.trim()) executeMutation.mutate(sql);
|
||||
}, [sql, executeMutation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const name = prompt('Query name:');
|
||||
if (name) saveMutation.mutate({ name, sql_text: sql });
|
||||
}, [sql, saveMutation]);
|
||||
|
||||
// Build chart data from result
|
||||
const chartData = result && result.columns.length >= 2
|
||||
? result.rows.map((row) => ({
|
||||
x: row[xCol],
|
||||
y: Number(row[yCol]) || 0,
|
||||
label: String(row[xCol]),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)] gap-4">
|
||||
{/* Schema browser sidebar */}
|
||||
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Schema</h2>
|
||||
{schema?.tables.map((t) => (
|
||||
<div key={t.name} className="mb-2">
|
||||
<button
|
||||
className="text-xs font-medium text-brand-300 hover:underline"
|
||||
onClick={() => setSql((prev) => prev + ` ${t.name}`)}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{t.columns.map((c) => (
|
||||
<div key={c.name} className="flex justify-between text-[10px]">
|
||||
<span className="text-gray-400">{c.name}</span>
|
||||
<span className="text-gray-600">{c.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Saved queries */}
|
||||
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Saved Queries</h2>
|
||||
{(savedQueries ?? []).map((sq) => (
|
||||
<div key={sq.id} className="mb-1 flex items-center justify-between">
|
||||
<button
|
||||
className="truncate text-xs text-gray-300 hover:text-brand-300"
|
||||
onClick={() => setSql(sq.sql_text)}
|
||||
title={sq.description || sq.name}
|
||||
>
|
||||
{sq.name}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-gray-600 hover:text-red-400"
|
||||
onClick={() => deleteMutation.mutate(sq.id)}
|
||||
aria-label={`Delete ${sq.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||
{/* Editor */}
|
||||
<div className="h-48 shrink-0 overflow-hidden rounded-lg border border-surface-700">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
value={sql}
|
||||
onChange={(v) => setSql(v ?? '')}
|
||||
theme="vs-dark"
|
||||
options={{ minimap: { enabled: false }, fontSize: 13, lineNumbers: 'on', scrollBeyondLastLine: false }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executeMutation.isPending}
|
||||
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{executeMutation.isPending ? 'Running…' : 'Execute'}
|
||||
</button>
|
||||
<button onClick={handleSave} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
||||
Save
|
||||
</button>
|
||||
{result && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{result.row_count} rows in {result.elapsed_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{executeMutation.isPending && <LoadingSpinner />}
|
||||
|
||||
{result && (
|
||||
<div className="flex-1 overflow-auto rounded-lg border border-surface-700">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-surface-900">
|
||||
<tr>
|
||||
{result.columns.map((col, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-left font-medium text-gray-400">
|
||||
{col.name} <span className="text-gray-600">({col.type})</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-surface-700/30 hover:bg-surface-800/30">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1 text-gray-300">
|
||||
{cell == null ? <span className="text-gray-600">NULL</span> : String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart builder */}
|
||||
{result && result.columns.length >= 2 && (
|
||||
<Card className="shrink-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Chart:</span>
|
||||
{(['none', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setChartType(ct)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{ct === 'none' ? 'Off' : ct}
|
||||
</button>
|
||||
))}
|
||||
{chartType !== 'none' && (
|
||||
<>
|
||||
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
|
||||
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-gray-600">→</span>
|
||||
<select value={yCol} onChange={(e) => setYCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="Y axis column">
|
||||
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{chartType !== 'none' && chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="y" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
) : chartType === 'line' ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" 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="y" stroke="#3b82f6" dot={false} />
|
||||
</LineChart>
|
||||
) : (
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[xCol]?.name} />
|
||||
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[yCol]?.name} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Scatter data={chartData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useTradingConfig,
|
||||
useSetTradingMode,
|
||||
usePendingApprovals,
|
||||
useReviewApproval,
|
||||
useActiveLockouts,
|
||||
} from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function TradingPage() {
|
||||
const { data: config, isLoading: configLoading } = useTradingConfig();
|
||||
const { data: approvals } = usePendingApprovals();
|
||||
const { data: lockouts } = useActiveLockouts();
|
||||
const setMode = useSetTradingMode();
|
||||
const reviewApproval = useReviewApproval();
|
||||
const [confirmMode, setConfirmMode] = useState<string | null>(null);
|
||||
|
||||
if (configLoading) return <LoadingSpinner />;
|
||||
|
||||
const currentMode = config?.trading_mode ?? 'paper';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Trading Controls</h1>
|
||||
|
||||
{/* Trading Mode */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Trading Mode</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{['paper', 'live', 'disabled'].map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
if (mode === currentMode) return;
|
||||
if (mode === 'live') { setConfirmMode(mode); return; }
|
||||
setMode.mutate(mode);
|
||||
}}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors ${
|
||||
currentMode === mode
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={currentMode === mode}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for live mode */}
|
||||
{confirmMode && (
|
||||
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
|
||||
<p className="text-sm text-orange-300">
|
||||
Are you sure you want to switch to <span className="font-semibold">{confirmMode}</span> mode?
|
||||
This enables real order execution.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => { setMode.mutate(confirmMode); setConfirmMode(null); }}
|
||||
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmMode(null)}
|
||||
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Pending Approvals */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Pending Approvals ({approvals?.length ?? 0})
|
||||
</h2>
|
||||
{!approvals?.length ? (
|
||||
<p className="text-sm text-gray-500">No pending approvals</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{approvals.map((a) => (
|
||||
<ApprovalRow key={a.id} approval={a} onReview={(approved, note) => reviewApproval.mutate({ id: a.id, approved, review_note: note })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active Lockouts */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Active Lockouts ({lockouts?.length ?? 0})
|
||||
</h2>
|
||||
{!lockouts?.length ? (
|
||||
<p className="text-sm text-gray-500">No active lockouts</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lockouts.map((l) => {
|
||||
const expiresIn = l.expires_at ? Math.max(0, Math.round((new Date(l.expires_at).getTime() - Date.now()) / 60000)) : 0;
|
||||
return (
|
||||
<div key={l.id} className="flex items-center justify-between rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{l.ticker}</span>
|
||||
<StatusBadge status={l.lockout_type} />
|
||||
<span className="text-sm text-gray-400">{l.reason}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ approval, onReview }: {
|
||||
approval: { id: string; ticker: string; side: string; quantity: number; estimated_value: number | null; requested_at: string };
|
||||
onReview: (approved: boolean, note: string) => void;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
return (
|
||||
<div className="rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{approval.ticker}</span>
|
||||
<StatusBadge status={approval.side} />
|
||||
<span className="text-sm text-gray-300">qty: {approval.quantity}</span>
|
||||
{approval.estimated_value != null && (
|
||||
<span className="text-sm text-gray-500">${approval.estimated_value.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{new Date(approval.requested_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Review note…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500"
|
||||
aria-label="Review note"
|
||||
/>
|
||||
<button onClick={() => onReview(true, note)} className="rounded bg-green-700 px-3 py-1 text-xs font-medium text-white hover:bg-green-600">
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={() => onReview(false, note)} className="rounded bg-red-700 px-3 py-1 text-xs font-medium text-white hover:bg-red-600">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useTrend, useTrendEvidence } from '../api/hooks';
|
||||
import { TrendArrow, ConfidenceBar, StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function TrendDetailPage() {
|
||||
const { id } = useParams({ from: '/trends/$id' });
|
||||
const { data: trend, isLoading } = useTrend(id);
|
||||
const { data: evidenceData } = useTrendEvidence(id);
|
||||
|
||||
if (isLoading || !trend) return <LoadingSpinner />;
|
||||
|
||||
const evidence = (evidenceData?.evidence ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{trend.entity_id}</h1>
|
||||
<TrendArrow direction={trend.trend_direction} />
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{trend.window}</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-gray-500">Direction</dt>
|
||||
<dd className="flex items-center gap-1 text-gray-200">
|
||||
<TrendArrow direction={trend.trend_direction} /> {trend.trend_direction}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Strength</dt>
|
||||
<dd><ConfidenceBar value={trend.trend_strength} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Confidence</dt>
|
||||
<dd><ConfidenceBar value={trend.confidence} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Contradiction</dt>
|
||||
<dd className={`font-mono ${trend.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{(trend.contradiction_score * 100).toFixed(0)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Generated</dt>
|
||||
<dd className="text-gray-300">{new Date(trend.generated_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{trend.dominant_catalysts && trend.dominant_catalysts.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Dominant Catalysts</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trend.dominant_catalysts.map((c, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-300">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{trend.material_risks && trend.material_risks.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Material Risks</h2>
|
||||
<ul className="ml-4 list-disc text-sm text-red-400">
|
||||
{trend.material_risks.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Evidence drill-down */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Contributing Evidence ({evidence.length})</h2>
|
||||
{evidence.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No evidence records</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{evidence.map((ev, i) => (
|
||||
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ev.evidence_type as string} />
|
||||
<span className="text-sm text-gray-200">{(ev.title as string) ?? 'Untitled'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type as string}</span>
|
||||
<span>{ev.source_type as string}</span>
|
||||
{ev.publisher && <span>{ev.publisher as string}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at as string).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
{ev.intelligence && (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<span className="text-gray-500">Summary: </span>
|
||||
{((ev.intelligence as Record<string, unknown>).summary as string) ?? '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTrends } from '../api/hooks';
|
||||
import { TrendArrow, ConfidenceBar, LoadingSpinner, TickerFilter, Card } from '../components/ui';
|
||||
import type { TrendSummary } from '../api/hooks';
|
||||
|
||||
const WINDOWS = ['intraday', '1d', '7d', '30d', '90d'];
|
||||
|
||||
export function TrendsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const [window, setWindow] = useState<string | undefined>(undefined);
|
||||
const { data, isLoading } = useTrends({ ticker: ticker || undefined, window, limit: 100 });
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Trends</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Window selector">
|
||||
<button
|
||||
onClick={() => setWindow(undefined)}
|
||||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${!window ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setWindow(w)}
|
||||
className={`px-2 py-1 text-xs font-medium last:rounded-r-md ${window === w ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(data ?? []).map((trend) => (
|
||||
<TrendCard key={trend.id} trend={trend} onClick={() => navigate({ to: '/trends/$id', params: { id: trend.id } })} />
|
||||
))}
|
||||
{data?.length === 0 && <p className="col-span-full text-center text-gray-500">No trends found</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCard({ trend, onClick }: { trend: TrendSummary; onClick: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="cursor-pointer transition-colors hover:border-brand-500/50">
|
||||
<div onClick={onClick}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-brand-300">{trend.entity_id}</span>
|
||||
<TrendArrow direction={trend.trend_direction} />
|
||||
</div>
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{trend.window}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Strength</span>
|
||||
<div className="h-1.5 w-24 rounded-full bg-surface-700">
|
||||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${trend.trend_strength * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Confidence</span>
|
||||
<ConfidenceBar value={trend.confidence} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Contradiction</span>
|
||||
<span className={`font-mono ${trend.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{(trend.contradiction_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trend.dominant_catalysts && trend.dominant_catalysts.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{trend.dominant_catalysts.map((c, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable evidence preview */}
|
||||
{(trend.top_supporting_evidence?.length || trend.top_opposing_evidence?.length) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||
className="mt-2 text-xs text-brand-400 hover:underline"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} evidence ({(trend.top_supporting_evidence?.length ?? 0) + (trend.top_opposing_evidence?.length ?? 0)})
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
{trend.top_supporting_evidence?.map((e, i) => (
|
||||
<div key={i} className="truncate text-green-400">+ {e}</div>
|
||||
))}
|
||||
{trend.top_opposing_evidence?.map((e, i) => (
|
||||
<div key={i} className="truncate text-red-400">− {e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useWatchlists, useWatchlistMembers, useCreateWatchlist } from '../api/hooks';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function WatchlistsPage() {
|
||||
const { data: watchlists, isLoading } = useWatchlists();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Watchlists</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
||||
New Watchlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && <CreateWatchlistForm onClose={() => setShowCreate(false)} />}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(watchlists ?? []).map((wl) => (
|
||||
<Card
|
||||
key={wl.id}
|
||||
className={`cursor-pointer transition-colors hover:border-brand-500/50 ${selected === wl.id ? 'border-brand-500' : ''}`}
|
||||
>
|
||||
<button className="w-full text-left" onClick={() => setSelected(selected === wl.id ? null : wl.id)}>
|
||||
<div className="font-medium text-gray-200">{wl.name}</div>
|
||||
{wl.description && <div className="mt-1 text-xs text-gray-500">{wl.description}</div>}
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && <WatchlistMembers watchlistId={selected} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistMembers({ watchlistId }: { watchlistId: string }) {
|
||||
const { data: members, isLoading } = useWatchlistMembers(watchlistId);
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!members?.length) return <p className="text-sm text-gray-500">No members in this watchlist</p>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Members</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{members.map((m) => (
|
||||
<span key={m.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
{m.ticker} — {m.legal_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWatchlistForm({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const mutation = useCreateWatchlist();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) mutation.mutate({ name: name.trim(), description: desc || undefined }, { onSuccess: onClose });
|
||||
}}
|
||||
>
|
||||
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist name" />
|
||||
<input type="text" placeholder="Description (optional)" value={desc} onChange={(e) => setDesc(e.target.value)} className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist description" />
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">Create</button>
|
||||
<button type="button" onClick={onClose} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user