fix(audit): close agent follow-up gaps

Centralize the HomeView research profile fetch so the ticker header and research cards share one FMP profile request, preserving the B3 company-name behavior without doubling profile traffic. Wire the FMP cache regression into the backend test script and fix the stale API-contract shared import so backend tests can run through the new cache check.

Refs: docs/AUDIT_REDESIGN.md items B2, B3, and C2.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 16:18:21 -07:00
parent 1f299d3e01
commit 412fa5ad7c
4 changed files with 98 additions and 44 deletions

View File

@ -5,7 +5,7 @@
"description": "ByteLyst Trading backend and execution control service", "description": "ByteLyst Trading backend and execution control service",
"main": "index.js", "main": "index.js",
"scripts": { "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", "dev": "node --import tsx src/bootstrap.ts",
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@ -29,6 +29,7 @@
"check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts", "check:session-rule-normalization": "node --import tsx testSessionRuleNormalization.ts",
"check:api-contract": "node --import tsx verifyApiContract.ts", "check:api-contract": "node --import tsx verifyApiContract.ts",
"check:audit-repository": "node --import tsx verifyAuditRepository.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", "check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts",
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts", "coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
"coverage:full": "npm run coverage:integration", "coverage:full": "npm run coverage:integration",

View File

@ -19,12 +19,12 @@ import {
type BacktestFeatureFlags, type BacktestFeatureFlags,
type TabFeatureFlags, type TabFeatureFlags,
type TradingFeatureFlagsResponse, type TradingFeatureFlagsResponse,
} from './src/../shared/feature-flags.js'; } from '../shared/feature-flags.js';
import { import {
buildTradingSocketOptions, buildTradingSocketOptions,
isUnauthorizedSocketError, isUnauthorizedSocketError,
SOCKET_NAMESPACES, SOCKET_NAMESPACES,
} from './src/../shared/realtime.js'; } from '../shared/realtime.js';
import { validateWebsocketContract } from './src/scripts/verifyWebsocketContract.js'; import { validateWebsocketContract } from './src/scripts/verifyWebsocketContract.js';
import type { BotState } from './src/services/apiServer.js'; import type { BotState } from './src/services/apiServer.js';

View File

@ -1,22 +1,29 @@
// @vitest-environment jsdom // @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 { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AppContext, type AppContextValue } from '../context/AppContext'; 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(), fetchResearchProfileMock: vi.fn(),
})); }));
vi.mock('../lib/marketApi', async () => { vi.mock('../lib/marketApi', () => ({
const actual = await vi.importActual<typeof import('../lib/marketApi')>('../lib/marketApi'); fetchChartBars: fetchChartBarsMock,
return { fetchResearchEarnings: fetchResearchEarningsMock,
...actual, fetchResearchMetrics: fetchResearchMetricsMock,
fetchResearchProfile: fetchResearchProfileMock, fetchResearchProfile: fetchResearchProfileMock,
}; }));
});
const appContext: AppContextValue = { const appContext: AppContextValue = {
botState: { botState: {
@ -51,7 +58,7 @@ function renderTickerHeader() {
<AppContext.Provider value={appContext}> <AppContext.Provider value={appContext}>
<MemoryRouter initialEntries={['/']}> <MemoryRouter initialEntries={['/']}>
<Routes> <Routes>
<Route path="/" element={<TickerHeader symbol="AAPL" />} /> <Route path="/" element={<HomeView />} />
<Route path="/watchlist" element={<div>Watchlist route</div>} /> <Route path="/watchlist" element={<div>Watchlist route</div>} />
<Route path="/alerts" element={<div>Alerts route</div>} /> <Route path="/alerts" element={<div>Alerts route</div>} />
</Routes> </Routes>
@ -61,13 +68,20 @@ function renderTickerHeader() {
} }
describe('TickerHeader', () => { describe('TickerHeader', () => {
beforeEach(() => {
fetchChartBarsMock.mockResolvedValue([]);
fetchResearchEarningsMock.mockResolvedValue([]);
fetchResearchMetricsMock.mockResolvedValue({});
fetchResearchProfileMock.mockReset();
});
it('loads the company name from the research profile', async () => { it('loads the company name from the research profile', async () => {
fetchResearchProfileMock.mockResolvedValueOnce({ companyName: 'Apple Inc.' }); fetchResearchProfileMock.mockResolvedValueOnce({ companyName: 'Apple Inc.' });
renderTickerHeader(); renderTickerHeader();
expect(screen.getByRole('heading', { name: 'AAPL' })).toBeInTheDocument(); 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 () => { 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 })); await user.click(screen.getByRole('button', { name: /open alerts/i }));
expect(screen.getByText('Alerts route')).toBeInTheDocument(); 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);
});
}); });

View File

@ -29,6 +29,17 @@ interface ChartPoint {
macdHistogram?: number; 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 }> = [ const INDICATORS: Array<{ key: IndicatorKey; label: string; hint: string }> = [
{ key: 'rsi', label: 'RSI', hint: '14-period momentum' }, { key: 'rsi', label: 'RSI', hint: '14-period momentum' },
{ key: 'macd', label: 'MACD', hint: '12/26 EMA trend' }, { 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 }; return { upper, middle, lower };
} }
function normalizeResearchProfile(profile: any): any { function normalizeResearchProfile(profile: any): ResearchProfile | null {
return Array.isArray(profile) ? profile[0] : profile; return Array.isArray(profile) ? profile[0] : profile;
} }
// ─── Ticker header ──────────────────────────────────────────────────────────── // ─── Ticker header ────────────────────────────────────────────────────────────
export function TickerHeader({ symbol }: { symbol: string }) { export function TickerHeader({ symbol, profile }: { symbol: string; profile?: ResearchProfile | null }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { botState } = useAppContext(); const { botState } = useAppContext();
const data = botState.symbols?.[symbol]; const data = botState.symbols?.[symbol];
@ -146,22 +157,8 @@ export function TickerHeader({ symbol }: { symbol: string }) {
const change = data?.changeToday ?? 0; const change = data?.changeToday ?? 0;
const changePct = price > 0 ? (change / (price - change)) * 100 : 0; const changePct = price > 0 ? (change / (price - change)) * 100 : 0;
const positive = change >= 0; const positive = change >= 0;
const [companyName, setCompanyName] = useState(symbol); const company = profile?.companyName;
const companyName = typeof company === 'string' && company.trim() ? company.trim() : 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]);
return ( return (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@ -561,8 +558,15 @@ const fmtBig = (n: number | undefined) => {
return `$${n.toFixed(2)}`; return `$${n.toFixed(2)}`;
}; };
function ResearchCards({ symbol }: { symbol: string }) { function ResearchCards({
const [profile, setProfile] = useState<any>(null); symbol,
profile,
profileLoading,
}: {
symbol: string;
profile: ResearchProfile | null;
profileLoading: boolean;
}) {
const [metrics, setMetrics] = useState<any>(null); const [metrics, setMetrics] = useState<any>(null);
const [earnings, setEarnings] = useState<any[]>([]); const [earnings, setEarnings] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -570,14 +574,12 @@ function ResearchCards({ symbol }: { symbol: string }) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setProfile(null); setMetrics(null); setEarnings([]); setMetrics(null); setEarnings([]);
Promise.allSettled([ Promise.allSettled([
fetchResearchProfile(symbol),
fetchResearchMetrics(symbol), fetchResearchMetrics(symbol),
fetchResearchEarnings(symbol), fetchResearchEarnings(symbol),
]).then(([p, m, e]) => { ]).then(([m, e]) => {
if (cancelled) return; 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 (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value);
if (e.status === 'fulfilled') setEarnings(e.value ?? []); if (e.status === 'fulfilled') setEarnings(e.value ?? []);
setLoading(false); setLoading(false);
@ -607,7 +609,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}> <div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
📋 Company 📋 Company
</div> </div>
{loading ? ( {profileLoading ? (
<div style={{ color: '#9CA3AF', fontSize: 12 }}>Loading</div> <div style={{ color: '#9CA3AF', fontSize: 12 }}>Loading</div>
) : profile ? ( ) : profile ? (
<> <>
@ -644,7 +646,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
padding: '5px 0', borderBottom: '1px solid #F9FAFB', padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}> }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span> <span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{loading ? '…' : val}</span> <span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{loading || profileLoading ? '…' : val}</span>
</div> </div>
))} ))}
</div> </div>
@ -658,7 +660,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)], ['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)],
['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'], ['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'],
['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'], ['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'],
['Exchange', loading ? '…' : profile?.exchangeShortName ?? '—'], ['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label} style={{ <div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
@ -727,15 +729,40 @@ function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) {
// ─── HomeView ───────────────────────────────────────────────────────────────── // ─── HomeView ─────────────────────────────────────────────────────────────────
export function HomeView() { export function HomeView() {
const { activeSymbol, setActiveSymbol } = useAppContext(); const { activeSymbol, setActiveSymbol } = useAppContext();
const [profile, setProfile] = useState<ResearchProfile | null>(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 <EmptyState onSelect={setActiveSymbol} />; if (!activeSymbol) return <EmptyState onSelect={setActiveSymbol} />;
return ( return (
<div> <div>
<TickerHeader symbol={activeSymbol} /> <TickerHeader symbol={activeSymbol} profile={profile} />
<StockChart symbol={activeSymbol} /> <StockChart symbol={activeSymbol} />
<QuickStats symbol={activeSymbol} /> <QuickStats symbol={activeSymbol} />
<ResearchCards symbol={activeSymbol} /> <ResearchCards symbol={activeSymbol} profile={profile} profileLoading={profileLoading} />
</div> </div>
); );
} }