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",
"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",

View File

@ -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';

View File

@ -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<typeof import('../lib/marketApi')>('../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() {
<AppContext.Provider value={appContext}>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<TickerHeader symbol="AAPL" />} />
<Route path="/" element={<HomeView />} />
<Route path="/watchlist" element={<div>Watchlist route</div>} />
<Route path="/alerts" element={<div>Alerts route</div>} />
</Routes>
@ -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);
});
});

View File

@ -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 (
<div style={{ marginBottom: 16 }}>
@ -561,8 +558,15 @@ const fmtBig = (n: number | undefined) => {
return `$${n.toFixed(2)}`;
};
function ResearchCards({ symbol }: { symbol: string }) {
const [profile, setProfile] = useState<any>(null);
function ResearchCards({
symbol,
profile,
profileLoading,
}: {
symbol: string;
profile: ResearchProfile | null;
profileLoading: boolean;
}) {
const [metrics, setMetrics] = useState<any>(null);
const [earnings, setEarnings] = useState<any[]>([]);
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 }) {
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
📋 Company
</div>
{loading ? (
{profileLoading ? (
<div style={{ color: '#9CA3AF', fontSize: 12 }}>Loading</div>
) : profile ? (
<>
@ -644,7 +646,7 @@ function ResearchCards({ symbol }: { symbol: string }) {
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<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>
@ -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]) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
@ -727,15 +729,40 @@ function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) {
// ─── HomeView ─────────────────────────────────────────────────────────────────
export function HomeView() {
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} />;
return (
<div>
<TickerHeader symbol={activeSymbol} />
<TickerHeader symbol={activeSymbol} profile={profile} />
<StockChart symbol={activeSymbol} />
<QuickStats symbol={activeSymbol} />
<ResearchCards symbol={activeSymbol} />
<ResearchCards symbol={activeSymbol} profile={profile} profileLoading={profileLoading} />
</div>
);
}