From c06b7d8dcd84414234ab4be845c1c17479a87194 Mon Sep 17 00:00:00 2001 From: Patrick Fanella Date: Wed, 22 Apr 2026 00:13:27 -0500 Subject: [PATCH 1/3] fix: restore dashboard data and clean frontend build failures --- internal/data/polygon/universe.go | 5 +- internal/data/polygon/universe_test.go | 52 ++++++++ .../backtests/backtest-equity-chart.test.tsx | 54 ++++++++ .../backtests/backtest-equity-chart.tsx | 10 +- .../dashboard/activity-feed.test.tsx | 66 ++++++++-- .../components/dashboard/activity-feed.tsx | 115 ++++++++++++++---- .../components/dashboard/recent-runs.test.tsx | 70 +++++++++++ web/src/components/dashboard/recent-runs.tsx | 81 ++++++++++++ .../pipeline/decision-inspector.test.tsx | 30 +++++ .../pipeline/decision-inspector.tsx | 2 +- .../universe/watchlist-table.test.tsx | 71 +++++++++++ .../components/universe/watchlist-table.tsx | 4 +- web/src/pages/dashboard-page.test.tsx | 56 ++++++++- web/src/pages/dashboard-page.tsx | 2 + web/src/pages/order-detail-page.tsx | 56 +++++++-- web/src/pages/pipeline-run-page.tsx | 26 ++-- web/src/pages/strategies-page.tsx | 45 +------ 17 files changed, 634 insertions(+), 111 deletions(-) create mode 100644 internal/data/polygon/universe_test.go create mode 100644 web/src/components/backtests/backtest-equity-chart.test.tsx create mode 100644 web/src/components/dashboard/recent-runs.test.tsx create mode 100644 web/src/components/dashboard/recent-runs.tsx create mode 100644 web/src/components/pipeline/decision-inspector.test.tsx create mode 100644 web/src/components/universe/watchlist-table.test.tsx diff --git a/internal/data/polygon/universe.go b/internal/data/polygon/universe.go index 403e7f16..740be979 100644 --- a/internal/data/polygon/universe.go +++ b/internal/data/polygon/universe.go @@ -120,11 +120,12 @@ func (c *Client) ListActiveTickers(ctx context.Context, market, tickerType strin return nil, fmt.Errorf("polygon: parse next_url: %w", err) } - // Rate limit pause: Polygon free tier allows 5 req/min. + // Rate limit pause: Polygon free tier allows 5 req/min, so paginated + // reference-ticker requests need roughly 12s spacing to avoid 429s. select { case <-ctx.Done(): return tickers, ctx.Err() - case <-time.After(250 * time.Millisecond): + case <-time.After(12 * time.Second): } } diff --git a/internal/data/polygon/universe_test.go b/internal/data/polygon/universe_test.go new file mode 100644 index 00000000..9a821cf2 --- /dev/null +++ b/internal/data/polygon/universe_test.go @@ -0,0 +1,52 @@ +package polygon + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListActiveTickersRespectsFreeTierRateLimit(t *testing.T) { + var firstRequestAt time.Time + var serverURL string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Query().Get("cursor") { + case "": + firstRequestAt = time.Now() + _, _ = fmt.Fprintf(w, `{"results":[{"ticker":"AAA","name":"Alpha","primary_exchange":"XNAS","type":"CS","active":true}],"next_url":"%s/v3/reference/tickers?cursor=page-2"}`, + serverURL, + ) + case "page-2": + if time.Since(firstRequestAt) < 11*time.Second { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"status":"ERROR","request_id":"req-rate","error":"rate limit exceeded"}`)) + return + } + _, _ = w.Write([]byte(`{"results":[{"ticker":"BBB","name":"Beta","primary_exchange":"XNYS","type":"CS","active":true}]}`)) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) + defer server.Close() + serverURL = server.URL + + client := NewClient("test-key", discardLogger()) + client.baseURL = server.URL + + tickers, err := client.ListActiveTickers(context.Background(), "stocks", "CS") + if err != nil { + t.Fatalf("ListActiveTickers() error = %v", err) + } + if len(tickers) != 2 { + t.Fatalf("ListActiveTickers() count = %d, want 2", len(tickers)) + } + if tickers[0].Ticker != "AAA" || tickers[1].Ticker != "BBB" { + t.Fatalf("ListActiveTickers() tickers = %#v, want AAA then BBB", tickers) + } +} diff --git a/web/src/components/backtests/backtest-equity-chart.test.tsx b/web/src/components/backtests/backtest-equity-chart.test.tsx new file mode 100644 index 00000000..104db90a --- /dev/null +++ b/web/src/components/backtests/backtest-equity-chart.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { BacktestEquityChart } from '@/components/backtests/backtest-equity-chart' + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + AreaChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Area: () =>
, + XAxis: () =>
, + YAxis: () =>
, + Tooltip: () =>
, +})) + +describe('BacktestEquityChart', () => { + it('renders non-empty chart data without crashing', () => { + render( + , + ) + + expect(screen.getByTestId('equity-chart')).toBeInTheDocument() + expect(screen.getByTestId('area-chart')).toBeInTheDocument() + }) + + it('renders empty state when there is no data', () => { + render() + expect(screen.getByTestId('equity-chart-empty')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/backtests/backtest-equity-chart.tsx b/web/src/components/backtests/backtest-equity-chart.tsx index e9b9ac4b..ba30c289 100644 --- a/web/src/components/backtests/backtest-equity-chart.tsx +++ b/web/src/components/backtests/backtest-equity-chart.tsx @@ -65,10 +65,12 @@ export function BacktestEquityChart({ data }: BacktestEquityChartProps) { borderRadius: '0.375rem', fontSize: '12px', }} - labelFormatter={formatDate} - formatter={(value: number, name: string) => { - if (name === 'drawdown_pct') return [`${(value * 100).toFixed(2)}%`, 'Drawdown'] - return [formatCurrency(value), 'Portfolio Value'] + labelFormatter={(label) => (typeof label === 'string' ? formatDate(label) : String(label ?? ''))} + formatter={(value, name) => { + const numericValue = typeof value === 'number' ? value : Number(value ?? 0) + const seriesName = String(name) + if (seriesName === 'drawdown_pct') return [`${(numericValue * 100).toFixed(2)}%`, 'Drawdown'] + return [formatCurrency(numericValue), 'Portfolio Value'] }} /> {children} +} + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } +} + describe('ActivityFeed', () => { beforeEach(() => { - vi.useFakeTimers() MockWebSocket.instances = [] vi.stubGlobal('WebSocket', MockWebSocket) + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(jsonResponse({ data: [], limit: 20, offset: 0 }))), + ) }) afterEach(() => { - vi.useRealTimers() + cleanup() vi.unstubAllGlobals() }) - it('shows empty state when no events received', () => { - render() + it('renders historical events from the API before live websocket updates arrive', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + jsonResponse({ + data: [ + { + id: 'evt-1', + pipeline_run_id: 'run-1', + strategy_id: 'strategy-1', + event_kind: 'pipeline_started', + title: 'Pipeline started', + summary: 'AAPL run kicked off', + created_at: '2026-04-22T00:00:00Z', + }, + ], + limit: 20, + offset: 0, + }), + ), + ), + ) + + render(, { wrapper: Wrapper }) + + expect(await screen.findByText('AAPL run kicked off')).toBeInTheDocument() + expect(screen.getAllByText('Pipeline started').length).toBeGreaterThan(0) + expect(screen.getByText(/Run: run-1/i)).toBeInTheDocument() + }) + + it('shows empty state when no events received', async () => { + render(, { wrapper: Wrapper }) expect(screen.getByTestId('activity-feed')).toBeInTheDocument() - expect(screen.getByTestId('activity-feed-empty')).toBeInTheDocument() + expect(await screen.findByTestId('activity-feed-empty')).toBeInTheDocument() }) it('shows connected badge after WebSocket opens', async () => { - render() + render(, { wrapper: Wrapper }) const ws = MockWebSocket.instances[0] expect(ws).toBeDefined() @@ -66,7 +114,7 @@ describe('ActivityFeed', () => { }) it('renders pipeline health websocket events with a human-friendly label', async () => { - render() + render(, { wrapper: Wrapper }) const ws = MockWebSocket.instances[0] expect(ws).toBeDefined() @@ -84,6 +132,6 @@ describe('ActivityFeed', () => { ) }) - expect(screen.getByText('Pipeline health')).toBeInTheDocument() + expect(screen.getAllByText('Pipeline health').length).toBeGreaterThan(0) }) }) diff --git a/web/src/components/dashboard/activity-feed.tsx b/web/src/components/dashboard/activity-feed.tsx index e228ec8c..86484549 100644 --- a/web/src/components/dashboard/activity-feed.tsx +++ b/web/src/components/dashboard/activity-feed.tsx @@ -1,64 +1,111 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Radio, Wifi, WifiOff } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useWebSocketClient } from '@/hooks/use-websocket-client'; -import type { WebSocketEventType, WebSocketMessage, WebSocketServerMessage } from '@/lib/api/types'; +import { apiClient } from '@/lib/api/client'; +import type { AgentEvent, WebSocketMessage, WebSocketServerMessage } from '@/lib/api/types'; const MAX_FEED_ITEMS = 50; interface FeedItem { id: string; - type: WebSocketEventType; + type: string; + title: string; + detail?: string; strategyId?: string; runId?: string; timestamp: string; - summary: string; } -function eventLabel(type: WebSocketEventType): string { - const labels: Record = { +function eventLabel(type: string): string { + const labels: Record = { pipeline_start: 'Pipeline started', + pipeline_started: 'Pipeline started', + pipeline_completed: 'Pipeline completed', + pipeline_failed: 'Pipeline failed', + agent_started: 'Agent started', + agent_completed: 'Agent completed', agent_decision: 'Agent decision', debate_round: 'Debate round', + debate_round_completed: 'Debate round', signal: 'Signal', + signal_produced: 'Signal produced', order_submitted: 'Order submitted', order_filled: 'Order filled', position_update: 'Position update', circuit_breaker: 'Circuit breaker', error: 'Error', pipeline_health: 'Pipeline health', + phase_started: 'Phase started', + phase_completed: 'Phase completed', }; - return labels[type] ?? type; + return labels[type] ?? type.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); } -function eventVariant( - type: WebSocketEventType, -): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' { +function eventVariant(type: string): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' { switch (type) { case 'signal': + case 'signal_produced': case 'order_filled': + case 'pipeline_completed': return 'success'; case 'circuit_breaker': case 'error': + case 'pipeline_failed': return 'destructive'; case 'order_submitted': case 'position_update': + case 'pipeline_health': return 'warning'; default: return 'secondary'; } } -function toFeedItem(msg: WebSocketMessage): FeedItem { +function summarizeLiveData(data: unknown) { + if (typeof data === 'string') { + return data; + } + + if (data && typeof data === 'object') { + if ('summary' in data && typeof data.summary === 'string') { + return data.summary; + } + if ('signal' in data && typeof data.signal === 'string') { + return `Signal ${String(data.signal).toUpperCase()}`; + } + if ('ticker' in data && typeof data.ticker === 'string') { + return `Ticker ${data.ticker}`; + } + } + + return undefined; +} + +function toHistoricalFeedItem(event: AgentEvent): FeedItem { return { - id: `${msg.type}-${msg.timestamp ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: event.id, + type: event.event_kind, + title: event.title || eventLabel(event.event_kind), + detail: event.summary, + strategyId: event.strategy_id, + runId: event.pipeline_run_id, + timestamp: event.created_at, + }; +} + +function toLiveFeedItem(msg: WebSocketMessage): FeedItem { + return { + id: `${msg.type}-${msg.run_id ?? 'none'}-${msg.timestamp ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: msg.type, + title: eventLabel(msg.type), + detail: summarizeLiveData(msg.data), strategyId: msg.strategy_id, runId: msg.run_id, timestamp: msg.timestamp ?? new Date().toISOString(), - summary: eventLabel(msg.type), }; } @@ -67,15 +114,20 @@ function isWebSocketMessage(msg: WebSocketServerMessage): msg is WebSocketMessag } export function ActivityFeed() { - const [items, setItems] = useState([]); + const [liveItems, setLiveItems] = useState([]); const subscribedRef = useRef(false); + const { data } = useQuery({ + queryKey: ['events', 'dashboard-activity-feed'], + queryFn: () => apiClient.listEvents({ limit: 20 }), + refetchInterval: 30_000, + }); const handleMessage = useCallback((msg: WebSocketServerMessage) => { if (!isWebSocketMessage(msg)) return; - const item = toFeedItem(msg); + const item = toLiveFeedItem(msg); - setItems((prev) => { + setLiveItems((prev) => { const next = [item, ...prev]; return next.length > MAX_FEED_ITEMS ? next.slice(0, MAX_FEED_ITEMS) : next; }); @@ -98,6 +150,21 @@ export function ActivityFeed() { } }, [isConnected, subscribeAll]); + const items = useMemo(() => { + const historicalItems = (data?.data ?? []).map(toHistoricalFeedItem); + const merged = [...liveItems, ...historicalItems]; + const byId = new Map(); + for (const item of merged) { + if (!byId.has(item.id)) { + byId.set(item.id, item); + } + } + + return Array.from(byId.values()) + .sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()) + .slice(0, MAX_FEED_ITEMS); + }, [data?.data, liveItems]); + return ( @@ -131,13 +198,17 @@ export function ActivityFeed() { className="flex items-start gap-3 rounded-lg border border-border p-3 text-sm transition-colors hover:bg-accent/45" > - {item.summary} + {eventLabel(item.type)} -
- {item.strategyId ? ( - Strategy: {item.strategyId.slice(0, 8)}… - ) : null} - {item.runId ? Run: {item.runId.slice(0, 8)}… : null} +
+

{item.title}

+ {item.detail ?

{item.detail}

: null} +
+ {item.strategyId ? ( + Strategy: {item.strategyId.slice(0, 8)}… + ) : null} + {item.runId ? Run: {item.runId} : null} +