Skip to content

Commit e6458a4

Browse files
ralyodioclaude
andauthored
Feat/finance finviz fundamentals (#114)
* feat(finance): Finviz fundamentals snapshot on ticker page Scrape the full Finviz quote-page snapshot (valuation, performance, technical, fund and dividend metrics) plus the company/fund description and render it as a generic Fundamentals grid on /finance/ticker/:ticker. - finviz.ts: getFinvizFundamentals() + testable parseFinvizSnapshot() (linkedom; browser UA since Finviz blocks the default undici UA) - Fundamentals/FundamentalMetric types (ordered label/value/tone pairs, rendered generically so new source rows appear without code changes) - GET /api/finance/fundamentals: paid-gated, soft-fails to null, read-through cached at FUNDAMENTALS_TTL_SECONDS=6h (reuses finance_quotes_cache — no migration) - Stat gains an optional up/down tone prop - Tests: parser + route Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(file-transcoding): skip streaming tests when ffmpeg is absent The stream-as-available tests spawn the real ffmpeg binary; on runners without ffmpeg, spawn('ffmpeg') ENOENT crashes the whole vitest process. Guard those tests behind a HAS_FFMPEG check instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 38fadad commit e6458a4

9 files changed

Lines changed: 402 additions & 4 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Tests for GET /api/finance/fundamentals
3+
*
4+
* Verifies paid gating runs first, symbol validation, and that a fetch/parse
5+
* failure soft-fails to `{ fundamentals: null }` rather than breaking the page.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { NextRequest, NextResponse } from 'next/server';
10+
import { GET } from './route';
11+
12+
const mockRequireActiveSubscription = vi.fn();
13+
vi.mock('@/lib/subscription/guard', () => ({
14+
requireActiveSubscription: (req: NextRequest) => mockRequireActiveSubscription(req),
15+
}));
16+
17+
const mockGetFinvizFundamentals = vi.fn();
18+
vi.mock('@/lib/finance/market-data', () => ({
19+
getFinvizFundamentals: (symbol: string) => mockGetFinvizFundamentals(symbol),
20+
}));
21+
22+
// Read-through cache: pass through to the fetcher so we exercise the provider.
23+
vi.mock('@/lib/finance/market-data/cache', () => ({
24+
FUNDAMENTALS_TTL_SECONDS: 21600,
25+
readThrough: <T,>(_s: string, _k: string, _ttl: number, fetcher: () => Promise<T>) => fetcher(),
26+
}));
27+
28+
function req(symbol?: string): NextRequest {
29+
const url = symbol
30+
? `http://localhost/api/finance/fundamentals?symbol=${symbol}`
31+
: 'http://localhost/api/finance/fundamentals';
32+
return new NextRequest(url);
33+
}
34+
35+
describe('GET /api/finance/fundamentals', () => {
36+
beforeEach(() => vi.clearAllMocks());
37+
38+
it('returns the gate response when the subscription check fails (paid-gated)', async () => {
39+
mockRequireActiveSubscription.mockResolvedValueOnce(
40+
NextResponse.json({ error: 'unauthorized' }, { status: 401 }),
41+
);
42+
const res = await GET(req('SPY'));
43+
expect(res.status).toBe(401);
44+
expect(mockGetFinvizFundamentals).not.toHaveBeenCalled();
45+
});
46+
47+
it('400s on an invalid symbol', async () => {
48+
mockRequireActiveSubscription.mockResolvedValueOnce(null);
49+
const res = await GET(req('!!!'));
50+
expect(res.status).toBe(400);
51+
});
52+
53+
it('returns the fundamentals for a paid user', async () => {
54+
mockRequireActiveSubscription.mockResolvedValueOnce(null);
55+
mockGetFinvizFundamentals.mockResolvedValueOnce({
56+
symbol: 'SPY',
57+
source: 'finviz',
58+
metrics: [{ label: 'Beta', value: '1.01', tone: null }],
59+
description: null,
60+
asOf: 1,
61+
});
62+
const res = await GET(req('spy'));
63+
expect(res.status).toBe(200);
64+
const body = await res.json();
65+
expect(body.fundamentals.symbol).toBe('SPY');
66+
expect(mockGetFinvizFundamentals).toHaveBeenCalledWith('SPY');
67+
});
68+
69+
it('soft-fails to null when the source throws (never breaks the page)', async () => {
70+
mockRequireActiveSubscription.mockResolvedValueOnce(null);
71+
mockGetFinvizFundamentals.mockRejectedValueOnce(new Error('403'));
72+
const res = await GET(req('SPY'));
73+
expect(res.status).toBe(200);
74+
const body = await res.json();
75+
expect(body.fundamentals).toBeNull();
76+
});
77+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* GET /api/finance/fundamentals?symbol=SPY
3+
*
4+
* Finviz snapshot for the ticker page: the full valuation / performance /
5+
* technical / fund / dividend table plus the company description. Paid-gated
6+
* and read-through cached (fundamentals move slowly). This is public market
7+
* data, sourced independently of the user's broker, so the cache key is fixed.
8+
*/
9+
10+
import { NextRequest, NextResponse } from 'next/server';
11+
import { requireActiveSubscription } from '@/lib/subscription/guard';
12+
import { getFinvizFundamentals } from '@/lib/finance/market-data';
13+
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
14+
import { readThrough, FUNDAMENTALS_TTL_SECONDS } from '@/lib/finance/market-data/cache';
15+
import type { Fundamentals } from '@/lib/finance/market-data/types';
16+
17+
export const dynamic = 'force-dynamic';
18+
19+
const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/;
20+
21+
export async function GET(request: NextRequest): Promise<NextResponse> {
22+
const gate = await requireActiveSubscription(request);
23+
if (gate) return gate;
24+
25+
const symbol = normalizeSymbol(request.nextUrl.searchParams.get('symbol') ?? '');
26+
if (!SYMBOL_RE.test(symbol)) {
27+
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
28+
}
29+
30+
try {
31+
const fundamentals = await readThrough<Fundamentals | null>(
32+
symbol,
33+
'fundamentals:finviz',
34+
FUNDAMENTALS_TTL_SECONDS,
35+
() => getFinvizFundamentals(symbol),
36+
);
37+
return NextResponse.json({ fundamentals });
38+
} catch (error) {
39+
console.error('[finance/fundamentals] error:', error);
40+
// Soft-fail: the section is supplementary, so never break the page.
41+
return NextResponse.json({ fundamentals: null });
42+
}
43+
}

src/app/finance/ticker/[ticker]/ticker-view.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ReportPanel } from './report-panel';
1515
import { NewsSection } from '@/components/news';
1616
// Import from the SDK-free `types` module (not the index) so the Alpaca SDK is
1717
// never pulled into the client bundle.
18-
import { TICKER_RANGES, type AssetInfo, type Candle, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
18+
import { TICKER_RANGES, type AssetInfo, type Candle, type Fundamentals, type Quote, type TickerRange } from '@/lib/finance/market-data/types';
1919
import type { WatchlistChanges } from '@/lib/finance/performance';
2020

2121
const RECENT_KEY = 'finance:recent';
@@ -62,6 +62,7 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
6262
const [holding, setHolding] = useState<Holding | null>(null);
6363
const [changes, setChanges] = useState<WatchlistChanges | null>(null);
6464
const [asset, setAsset] = useState<AssetInfo | null>(null);
65+
const [fundamentals, setFundamentals] = useState<Fundamentals | null>(null);
6566

6667
useEffect(() => {
6768
rememberRecent(symbol);
@@ -166,6 +167,21 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
166167
};
167168
}, [symbol]);
168169

170+
// Fundamentals / snapshot (Finviz: valuation, performance, technicals, etc.).
171+
useEffect(() => {
172+
let cancelled = false;
173+
setFundamentals(null);
174+
fetch(`/api/finance/fundamentals?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' })
175+
.then((res) => (res.ok ? res.json() : { fundamentals: null }))
176+
.then((body: { fundamentals?: Fundamentals | null }) => {
177+
if (!cancelled) setFundamentals(body.fundamentals ?? null);
178+
})
179+
.catch(() => undefined);
180+
return () => {
181+
cancelled = true;
182+
};
183+
}, [symbol]);
184+
169185
const toggleWatchlist = useCallback(async () => {
170186
const next = !inWatchlist;
171187
setInWatchlist(next);
@@ -287,6 +303,25 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
287303
</section>
288304
) : null}
289305

306+
{/* Fundamentals snapshot (Finviz): full valuation / performance /
307+
technical / fund / dividend table, rendered generically. */}
308+
{fundamentals && fundamentals.metrics.length > 0 ? (
309+
<section className="mt-8">
310+
<div className="mb-3 flex items-baseline justify-between">
311+
<h2 className="text-lg font-semibold text-text-primary">Fundamentals</h2>
312+
<span className="text-xs text-text-muted">via Finviz</span>
313+
</div>
314+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
315+
{fundamentals.metrics.map((m) => (
316+
<Stat key={m.label} label={m.label} value={m.value} tone={m.tone} />
317+
))}
318+
</div>
319+
{fundamentals.description ? (
320+
<p className="mt-4 text-sm leading-relaxed text-text-secondary">{fundamentals.description}</p>
321+
) : null}
322+
</section>
323+
) : null}
324+
290325
{/* AI report area — never auto-runs; the Analyze button is the cost boundary. */}
291326
<ReportPanel symbol={symbol} />
292327

@@ -301,11 +336,21 @@ export function TickerView({ symbol }: { symbol: string }): React.ReactElement {
301336
);
302337
}
303338

304-
function Stat({ label, value }: { label: string; value: string }): React.ReactElement {
339+
function Stat({
340+
label,
341+
value,
342+
tone = null,
343+
}: {
344+
label: string;
345+
value: string;
346+
tone?: 'positive' | 'negative' | null;
347+
}): React.ReactElement {
348+
const valueColor =
349+
tone === 'positive' ? 'text-green-400' : tone === 'negative' ? 'text-red-400' : 'text-text-primary';
305350
return (
306351
<div className="card p-3">
307352
<div className="text-xs uppercase tracking-wider text-text-muted">{label}</div>
308-
<div className="mt-1 text-base font-semibold text-text-primary">{value}</div>
353+
<div className={`mt-1 text-base font-semibold ${valueColor}`}>{value}</div>
309354
</div>
310355
);
311356
}

src/lib/file-transcoding/file-transcoding.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
99
import { createWriteStream } from 'node:fs';
10+
import { spawnSync } from 'node:child_process';
1011
import { Readable } from 'node:stream';
1112
import { mkdir, rm, access } from 'node:fs/promises';
1213
import { join } from 'node:path';
@@ -19,6 +20,12 @@ import {
1920
TEMP_DIR,
2021
} from './file-transcoding';
2122

23+
// The streaming-transcode tests below spawn the real `ffmpeg` binary. CI runners
24+
// don't have ffmpeg installed, so a `spawn('ffmpeg')` ENOENT crashes the whole
25+
// vitest process with an uncaught exception. Skip those tests when ffmpeg is
26+
// unavailable instead of failing the entire suite.
27+
const HAS_FFMPEG = spawnSync('ffmpeg', ['-version']).status === 0;
28+
2229
describe('File-based Transcoding Service', () => {
2330
describe('getTempFilePath', () => {
2431
it('should generate correct temp file path', () => {
@@ -175,7 +182,7 @@ describe('File-based Transcoding Service', () => {
175182
});
176183
});
177184

178-
describe('FileTranscodingService - downloadAndTranscode (stream-as-available)', () => {
185+
describe.skipIf(!HAS_FFMPEG)('FileTranscodingService - downloadAndTranscode (stream-as-available)', () => {
179186
let service: FileTranscodingService;
180187

181188
afterEach(async () => {

src/lib/finance/market-data/cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const CACHE_TABLE = 'finance_quotes_cache';
1414
/** Default freshness windows (seconds). EOD data changes at most daily. */
1515
export const QUOTE_TTL_SECONDS = 5 * 60;
1616
export const CANDLES_TTL_SECONDS = 60 * 60;
17+
/** Fundamentals (valuation/perf/technicals) move slowly — cache for hours. */
18+
export const FUNDAMENTALS_TTL_SECONDS = 6 * 60 * 60;
1719

1820
interface CacheRow<T> {
1921
payload: T;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Tests for the Finviz fundamentals parser.
3+
*
4+
* Uses a trimmed fixture that mirrors the real quote-page markup
5+
* (table.snapshot-table2 with .snapshot-td-label / .snapshot-td-content cells
6+
* and .is-positive / .is-negative color spans, plus .quote_profile-bio).
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { parseFinvizSnapshot, getFinvizFundamentals } from './finviz';
11+
12+
const FIXTURE = `
13+
<html><body>
14+
<table class="js-snapshot-table snapshot-table2 screener_snapshot-table-body">
15+
<tr>
16+
<td class="snapshot-td2"><div class="snapshot-td-label">P/E</div></td>
17+
<td class="snapshot-td2"><div class="snapshot-td-content"><b>24.50</b></div></td>
18+
<td class="snapshot-td2"><div class="snapshot-td-label">Perf Year</div></td>
19+
<td class="snapshot-td2"><div class="snapshot-td-content"><b><span class="color-text is-positive">24.00%</span></b></div></td>
20+
</tr>
21+
<tr>
22+
<td class="snapshot-td2"><div class="snapshot-td-label">Perf Week</div></td>
23+
<td class="snapshot-td2"><div class="snapshot-td-content"><b><span class="color-text is-negative">-1.25%</span></b></div></td>
24+
<td class="snapshot-td2"><div class="snapshot-td-label">Beta</div></td>
25+
<td class="snapshot-td2"><div class="snapshot-td-content"><b>1.01</b></div></td>
26+
</tr>
27+
<tr>
28+
<td class="snapshot-td2"><div class="snapshot-td-label">Short Float</div></td>
29+
<td class="snapshot-td2"><div class="snapshot-td-content"><b>-</b></div></td>
30+
</tr>
31+
</table>
32+
<div class="quote_profile-bio">The fund seeks to provide investment results
33+
that correspond to the S&amp;P 500 Index.</div>
34+
</body></html>
35+
`;
36+
37+
describe('parseFinvizSnapshot', () => {
38+
it('extracts ordered label/value metrics with tone and description', () => {
39+
const result = parseFinvizSnapshot(FIXTURE, 'spy');
40+
expect(result).not.toBeNull();
41+
const f = result!;
42+
43+
expect(f.symbol).toBe('SPY');
44+
expect(f.source).toBe('finviz');
45+
expect(typeof f.asOf).toBe('number');
46+
47+
// Order is preserved; the "-" placeholder row is dropped.
48+
expect(f.metrics).toEqual([
49+
{ label: 'P/E', value: '24.50', tone: null },
50+
{ label: 'Perf Year', value: '24.00%', tone: 'positive' },
51+
{ label: 'Perf Week', value: '-1.25%', tone: 'negative' },
52+
{ label: 'Beta', value: '1.01', tone: null },
53+
]);
54+
55+
expect(f.description).toContain('S&P 500 Index');
56+
// Whitespace is collapsed.
57+
expect(f.description).not.toMatch(/\s{2,}/);
58+
});
59+
60+
it('returns null when the snapshot table is missing', () => {
61+
expect(parseFinvizSnapshot('<html><body>no table</body></html>', 'SPY')).toBeNull();
62+
});
63+
});
64+
65+
describe('getFinvizFundamentals', () => {
66+
it('sends a browser UA and parses the response', async () => {
67+
const fetchFn = vi.fn().mockResolvedValue({
68+
ok: true,
69+
status: 200,
70+
text: async () => FIXTURE,
71+
});
72+
73+
const result = await getFinvizFundamentals('spy', { fetchFn });
74+
expect(result?.symbol).toBe('SPY');
75+
expect(result?.metrics.length).toBe(4);
76+
77+
const [url, init] = fetchFn.mock.calls[0];
78+
expect(url).toContain('t=SPY');
79+
expect(init.headers['User-Agent']).toMatch(/Mozilla/);
80+
});
81+
82+
it('throws on a non-ok response (caller renders defensively)', async () => {
83+
const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => '' });
84+
await expect(getFinvizFundamentals('SPY', { fetchFn })).rejects.toThrow(/403/);
85+
});
86+
});

0 commit comments

Comments
 (0)