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:
parent
1f299d3e01
commit
412fa5ad7c
@ -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",
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user