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",
|
"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",
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user