diff --git a/backend/package.json b/backend/package.json index 118c2ac..d3c810c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "description": "ByteLyst Trading backend and execution control service", "main": "index.js", "scripts": { - "test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository", + "test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository && npm run check:fmp-cache", "dev": "node --import tsx src/bootstrap.ts", "build": "tsc", "typecheck": "tsc --noEmit", @@ -29,6 +29,7 @@ "check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts", "check:api-contract": "node --import tsx verifyApiContract.ts", "check:audit-repository": "node --import tsx verifyAuditRepository.ts", + "check:fmp-cache": "node --import tsx testFmpCache.ts", "check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts", "coverage:run": "node --loader ts-node/esm runCoverageSuite.ts", "coverage:full": "npm run coverage:integration", diff --git a/backend/verifyApiContract.ts b/backend/verifyApiContract.ts index 7e7f12a..afb28c2 100644 --- a/backend/verifyApiContract.ts +++ b/backend/verifyApiContract.ts @@ -19,12 +19,12 @@ import { type BacktestFeatureFlags, type TabFeatureFlags, type TradingFeatureFlagsResponse, -} from './src/../shared/feature-flags.js'; +} from '../shared/feature-flags.js'; import { buildTradingSocketOptions, isUnauthorizedSocketError, SOCKET_NAMESPACES, -} from './src/../shared/realtime.js'; +} from '../shared/realtime.js'; import { validateWebsocketContract } from './src/scripts/verifyWebsocketContract.js'; import type { BotState } from './src/services/apiServer.js'; diff --git a/web/src/views/HomeView.dom.test.tsx b/web/src/views/HomeView.dom.test.tsx index 1875da6..0ff67ae 100644 --- a/web/src/views/HomeView.dom.test.tsx +++ b/web/src/views/HomeView.dom.test.tsx @@ -1,22 +1,29 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { AppContext, type AppContextValue } from '../context/AppContext'; -import { TickerHeader } from './HomeView'; +import { HomeView } from './HomeView'; -const { fetchResearchProfileMock } = vi.hoisted(() => ({ +const { + fetchChartBarsMock, + fetchResearchEarningsMock, + fetchResearchMetricsMock, + fetchResearchProfileMock, +} = vi.hoisted(() => ({ + fetchChartBarsMock: vi.fn(), + fetchResearchEarningsMock: vi.fn(), + fetchResearchMetricsMock: vi.fn(), fetchResearchProfileMock: vi.fn(), })); -vi.mock('../lib/marketApi', async () => { - const actual = await vi.importActual('../lib/marketApi'); - return { - ...actual, - fetchResearchProfile: fetchResearchProfileMock, - }; -}); +vi.mock('../lib/marketApi', () => ({ + fetchChartBars: fetchChartBarsMock, + fetchResearchEarnings: fetchResearchEarningsMock, + fetchResearchMetrics: fetchResearchMetricsMock, + fetchResearchProfile: fetchResearchProfileMock, +})); const appContext: AppContextValue = { botState: { @@ -51,7 +58,7 @@ function renderTickerHeader() { - } /> + } /> Watchlist route} /> Alerts route} /> @@ -61,13 +68,20 @@ function renderTickerHeader() { } describe('TickerHeader', () => { + beforeEach(() => { + fetchChartBarsMock.mockResolvedValue([]); + fetchResearchEarningsMock.mockResolvedValue([]); + fetchResearchMetricsMock.mockResolvedValue({}); + fetchResearchProfileMock.mockReset(); + }); + it('loads the company name from the research profile', async () => { fetchResearchProfileMock.mockResolvedValueOnce({ companyName: 'Apple Inc.' }); renderTickerHeader(); expect(screen.getByRole('heading', { name: 'AAPL' })).toBeInTheDocument(); - await waitFor(() => expect(screen.getByText('Apple Inc.')).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByText('Apple Inc.').length).toBeGreaterThan(0)); }); it('opens watchlist and alerts routes from header actions', async () => { @@ -84,4 +98,16 @@ describe('TickerHeader', () => { await user.click(screen.getByRole('button', { name: /open alerts/i })); expect(screen.getByText('Alerts route')).toBeInTheDocument(); }); + + it('shares the research profile request between header and research cards', async () => { + fetchResearchProfileMock.mockResolvedValueOnce({ + companyName: 'Apple Inc.', + sector: 'Technology', + }); + + renderTickerHeader(); + + await waitFor(() => expect(screen.getAllByText('Apple Inc.').length).toBeGreaterThan(0)); + expect(fetchResearchProfileMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx index 1267f83..4f063b5 100644 --- a/web/src/views/HomeView.tsx +++ b/web/src/views/HomeView.tsx @@ -29,6 +29,17 @@ interface ChartPoint { macdHistogram?: number; } +interface ResearchProfile { + companyName?: string; + sector?: string; + industry?: string; + description?: string; + website?: string; + mktCap?: number; + revenue?: number; + exchangeShortName?: string; +} + const INDICATORS: Array<{ key: IndicatorKey; label: string; hint: string }> = [ { key: 'rsi', label: 'RSI', hint: '14-period momentum' }, { key: 'macd', label: 'MACD', hint: '12/26 EMA trend' }, @@ -133,12 +144,12 @@ function calculateBollingerBands(closes: number[], period = 20, deviations = 2) return { upper, middle, lower }; } -function normalizeResearchProfile(profile: any): any { +function normalizeResearchProfile(profile: any): ResearchProfile | null { return Array.isArray(profile) ? profile[0] : profile; } // ─── Ticker header ──────────────────────────────────────────────────────────── -export function TickerHeader({ symbol }: { symbol: string }) { +export function TickerHeader({ symbol, profile }: { symbol: string; profile?: ResearchProfile | null }) { const navigate = useNavigate(); const { botState } = useAppContext(); const data = botState.symbols?.[symbol]; @@ -146,22 +157,8 @@ export function TickerHeader({ symbol }: { symbol: string }) { const change = data?.changeToday ?? 0; const changePct = price > 0 ? (change / (price - change)) * 100 : 0; const positive = change >= 0; - const [companyName, setCompanyName] = useState(symbol); - - useEffect(() => { - let cancelled = false; - setCompanyName(symbol); - fetchResearchProfile(symbol) - .then(profile => { - if (cancelled) return; - const company = normalizeResearchProfile(profile)?.companyName; - setCompanyName(typeof company === 'string' && company.trim() ? company.trim() : symbol); - }) - .catch(() => { - if (!cancelled) setCompanyName(symbol); - }); - return () => { cancelled = true; }; - }, [symbol]); + const company = profile?.companyName; + const companyName = typeof company === 'string' && company.trim() ? company.trim() : symbol; return (
@@ -561,8 +558,15 @@ const fmtBig = (n: number | undefined) => { return `$${n.toFixed(2)}`; }; -function ResearchCards({ symbol }: { symbol: string }) { - const [profile, setProfile] = useState(null); +function ResearchCards({ + symbol, + profile, + profileLoading, +}: { + symbol: string; + profile: ResearchProfile | null; + profileLoading: boolean; +}) { const [metrics, setMetrics] = useState(null); const [earnings, setEarnings] = useState([]); const [loading, setLoading] = useState(true); @@ -570,14 +574,12 @@ function ResearchCards({ symbol }: { symbol: string }) { useEffect(() => { let cancelled = false; setLoading(true); - setProfile(null); setMetrics(null); setEarnings([]); + setMetrics(null); setEarnings([]); Promise.allSettled([ - fetchResearchProfile(symbol), fetchResearchMetrics(symbol), fetchResearchEarnings(symbol), - ]).then(([p, m, e]) => { + ]).then(([m, e]) => { if (cancelled) return; - if (p.status === 'fulfilled') setProfile(normalizeResearchProfile(p.value)); if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value); if (e.status === 'fulfilled') setEarnings(e.value ?? []); setLoading(false); @@ -607,7 +609,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
📋 Company
- {loading ? ( + {profileLoading ? (
Loading…
) : profile ? ( <> @@ -644,7 +646,7 @@ function ResearchCards({ symbol }: { symbol: string }) { padding: '5px 0', borderBottom: '1px solid #F9FAFB', }}> {label} - {loading ? '…' : val} + {loading || profileLoading ? '…' : val}
))} @@ -658,7 +660,7 @@ function ResearchCards({ symbol }: { symbol: string }) { ['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)], ['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'], ['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'], - ['Exchange', loading ? '…' : profile?.exchangeShortName ?? '—'], + ['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'], ].map(([label, val]) => (
void }) { // ─── HomeView ───────────────────────────────────────────────────────────────── export function HomeView() { const { activeSymbol, setActiveSymbol } = useAppContext(); + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + + useEffect(() => { + if (!activeSymbol) { + setProfile(null); + setProfileLoading(false); + return; + } + + let cancelled = false; + setProfile(null); + setProfileLoading(true); + fetchResearchProfile(activeSymbol) + .then(data => { + if (!cancelled) setProfile(normalizeResearchProfile(data)); + }) + .catch(() => { + if (!cancelled) setProfile(null); + }) + .finally(() => { + if (!cancelled) setProfileLoading(false); + }); + return () => { cancelled = true; }; + }, [activeSymbol]); if (!activeSymbol) return ; return (
- + - +
); }