diff --git a/__tests__/adanos.actions.test.ts b/__tests__/adanos.actions.test.ts index 31936da..4190634 100644 --- a/__tests__/adanos.actions.test.ts +++ b/__tests__/adanos.actions.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getStockSentimentInsights, +} from '@/lib/actions/adanos.actions'; import { buildStockSentimentInsights, getSourceAlignment, normalizeSourceInsight, -} from '@/lib/actions/adanos.actions'; +} from '@/lib/actions/adanos.helpers'; + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.ADANOS_API_KEY; + delete process.env.ADANOS_API_BASE_URL; +}); describe('normalizeSourceInsight', () => { it('maps source-specific metrics for mentions and trades', () => { @@ -122,3 +131,82 @@ describe('buildStockSentimentInsights', () => { expect(buildStockSentimentInsights('MSFT', [null, null])).toBeNull(); }); }); + +describe('getStockSentimentInsights', () => { + it('returns a parsed result when compare data matches the requested ticker', async () => { + process.env.ADANOS_API_KEY = 'test-key'; + vi.spyOn(global, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/reddit/')) { + return new Response( + JSON.stringify({ + stocks: [{ ticker: 'TSLA', company_name: 'Tesla, Inc.', buzz_score: 80, bullish_pct: 40, trend: 'rising', mentions: 10 }], + }), + { status: 200 }, + ); + } + + if (url.includes('/x/')) { + return new Response( + JSON.stringify({ + stocks: [{ ticker: 'TSLA', company_name: 'Tesla, Inc.', buzz_score: 90, bullish_pct: 60, trend: 'falling', mentions: 20 }], + }), + { status: 200 }, + ); + } + + return new Response(JSON.stringify({ stocks: [] }), { status: 404 }); + }); + + const insight = await getStockSentimentInsights('TSLA'); + + expect(insight).toMatchObject({ + symbol: 'TSLA', + companyName: 'Tesla, Inc.', + averageBuzz: 85, + bullishAverage: 50, + availableSources: 2, + }); + expect(insight?.sources).toHaveLength(2); + }); + + it('returns null when the remote source returns 404 for all sources', async () => { + process.env.ADANOS_API_KEY = 'test-key'; + vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + + await expect(getStockSentimentInsights('TSLA')).resolves.toBeNull(); + }); + + it('returns null when the remote payload contains a different ticker only', async () => { + process.env.ADANOS_API_KEY = 'test-key'; + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + stocks: [{ ticker: 'MSFT', company_name: 'Microsoft Corporation', buzz_score: 70, bullish_pct: 55, trend: 'stable', mentions: 30 }], + }), + { status: 200 }, + ), + ); + + await expect(getStockSentimentInsights('TSLA')).resolves.toBeNull(); + }); + + it('returns null when the response body is invalid json', async () => { + process.env.ADANOS_API_KEY = 'test-key'; + vi.spyOn(global, 'fetch').mockResolvedValue({ + status: 200, + ok: true, + json: vi.fn().mockRejectedValue(new Error('invalid json')), + } as unknown as Response); + + await expect(getStockSentimentInsights('TSLA')).resolves.toBeNull(); + }); + + it('returns null when fetch fails', async () => { + process.env.ADANOS_API_KEY = 'test-key'; + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network failed')); + + await expect(getStockSentimentInsights('TSLA')).resolves.toBeNull(); + }); +}); diff --git a/app/(root)/stocks/[symbol]/page.tsx b/app/(root)/stocks/[symbol]/page.tsx index 87213e9..2bf4884 100644 --- a/app/(root)/stocks/[symbol]/page.tsx +++ b/app/(root)/stocks/[symbol]/page.tsx @@ -25,8 +25,10 @@ export default async function StockDetails({ params }: StockDetailsPageProps) { headers: await headers() }); const userId = session?.user?.id; - const isInWatchlist = userId ? await isStockInWatchlist(userId, symbol) : false; - const sentimentInsights = await getStockSentimentInsights(symbol); + const [isInWatchlist, sentimentInsights] = await Promise.all([ + userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false), + getStockSentimentInsights(symbol), + ]); return (
diff --git a/components/stocks/StockSentimentCard.tsx b/components/stocks/StockSentimentCard.tsx index 48f698a..7b4c608 100644 --- a/components/stocks/StockSentimentCard.tsx +++ b/components/stocks/StockSentimentCard.tsx @@ -1,4 +1,4 @@ -import type { StockSentimentInsights } from '@/lib/actions/adanos.actions'; +import type { StockSentimentInsights } from '@/lib/actions/adanos.helpers'; interface StockSentimentCardProps { insight: StockSentimentInsights | null; @@ -27,6 +27,9 @@ function getAlignmentClasses(alignment: string): string { if (alignment === 'Bullish alignment') return 'text-emerald-400'; if (alignment === 'Bearish alignment' || alignment === 'Wide divergence') return 'text-rose-400'; if (alignment === 'Tight alignment') return 'text-blue-300'; + if (alignment === 'Mixed') return 'text-amber-300'; + if (alignment === 'Single-source view') return 'text-slate-300'; + if (alignment === 'No sentiment mix') return 'text-zinc-400'; return 'text-gray-300'; } diff --git a/lib/actions/adanos.actions.ts b/lib/actions/adanos.actions.ts index c0142d8..b518320 100644 --- a/lib/actions/adanos.actions.ts +++ b/lib/actions/adanos.actions.ts @@ -1,170 +1,24 @@ 'use server'; -type SentimentSourceKey = 'reddit' | 'x' | 'news' | 'polymarket'; -type SentimentTrend = 'rising' | 'falling' | 'stable'; +import { + buildStockSentimentInsights, + normalizeSourceInsight, + SOURCE_CONFIG, + type SentimentSourceInsight, + type SentimentSourceKey, + type SourceComparePayload, + type StockSentimentInsights, +} from './adanos.helpers'; -type BaseCompareRow = { - ticker?: string; - company_name?: string | null; - buzz_score?: number | null; - trend?: SentimentTrend | null; - bullish_pct?: number | null; - trend_history?: number[] | null; -}; - -type SourceComparePayload = { - stocks?: BaseCompareRow[]; -}; - -const ADANOS_BASE_URL = (process.env.ADANOS_API_BASE_URL || 'https://api.adanos.org').replace(/\/$/, ''); -const ADANOS_API_KEY = process.env.ADANOS_API_KEY ?? ''; const DEFAULT_LOOKBACK_DAYS = 7; +const FETCH_TIMEOUT_MS = 5000; -const SOURCE_CONFIG = { - reddit: { - label: 'Reddit', - path: '/reddit/stocks/v1/compare', - metricLabel: 'Mentions', - metricField: 'mentions', - }, - x: { - label: 'X.com', - path: '/x/stocks/v1/compare', - metricLabel: 'Mentions', - metricField: 'mentions', - }, - news: { - label: 'News', - path: '/news/stocks/v1/compare', - metricLabel: 'Mentions', - metricField: 'mentions', - }, - polymarket: { - label: 'Polymarket', - path: '/polymarket/stocks/v1/compare', - metricLabel: 'Trades', - metricField: 'trade_count', - }, -} as const satisfies Record< - SentimentSourceKey, - { - label: string; - path: string; - metricLabel: string; - metricField: string; - } ->; - -type SourceSpecificRow = BaseCompareRow & Record; - -export interface SentimentSourceInsight { - source: SentimentSourceKey; - label: string; - companyName: string | null; - buzzScore: number; - bullishPct: number | null; - trend: SentimentTrend | null; - metricLabel: string; - metricValue: number; +function getAdanosBaseUrl(): string { + return (process.env.ADANOS_API_BASE_URL || 'https://api.adanos.org').replace(/\/$/, ''); } -export interface StockSentimentInsights { - symbol: string; - companyName: string | null; - averageBuzz: number; - bullishAverage: number | null; - sourceAlignment: string; - availableSources: number; - sources: SentimentSourceInsight[]; -} - -function toNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -} - -function roundTo(value: number, digits: number = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function average(values: number[]): number { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - -function normalizeTrend(value: unknown): SentimentTrend | null { - return value === 'rising' || value === 'falling' || value === 'stable' ? value : null; -} - -export function getSourceAlignment(bullishValues: number[]): string { - if (bullishValues.length === 0) return 'No sentiment mix'; - if (bullishValues.length === 1) return 'Single-source view'; - - const min = Math.min(...bullishValues); - const max = Math.max(...bullishValues); - const spread = max - min; - const avg = average(bullishValues); - - if (spread <= 12 && avg >= 60) return 'Bullish alignment'; - if (spread <= 12 && avg <= 40) return 'Bearish alignment'; - if (spread <= 12) return 'Tight alignment'; - if (spread >= 25) return 'Wide divergence'; - return 'Mixed'; -} - -export function normalizeSourceInsight( - source: SentimentSourceKey, - row: SourceSpecificRow | null | undefined, -): SentimentSourceInsight | null { - if (!row) return null; - - const buzzScore = toNumber(row.buzz_score); - const metricValue = toNumber(row[SOURCE_CONFIG[source].metricField]); - - if (buzzScore === null || metricValue === null) { - return null; - } - - return { - source, - label: SOURCE_CONFIG[source].label, - companyName: typeof row.company_name === 'string' ? row.company_name : null, - buzzScore: roundTo(buzzScore), - bullishPct: toNumber(row.bullish_pct), - trend: normalizeTrend(row.trend), - metricLabel: SOURCE_CONFIG[source].metricLabel, - metricValue: Math.round(metricValue), - }; -} - -export function buildStockSentimentInsights( - symbol: string, - sources: Array, -): StockSentimentInsights | null { - const availableSources = sources.filter((source): source is SentimentSourceInsight => Boolean(source)); - - if (availableSources.length === 0) { - return null; - } - - const buzzValues = availableSources.map((source) => source.buzzScore); - const bullishValues = availableSources - .map((source) => source.bullishPct) - .filter((value): value is number => value !== null); - - return { - symbol: symbol.toUpperCase(), - companyName: availableSources.find((source) => source.companyName)?.companyName ?? null, - averageBuzz: roundTo(average(buzzValues)), - bullishAverage: bullishValues.length ? roundTo(average(bullishValues)) : null, - sourceAlignment: getSourceAlignment(bullishValues), - availableSources: availableSources.length, - sources: availableSources, - }; +function getAdanosApiKey(): string { + return process.env.ADANOS_API_KEY ?? ''; } async function fetchCompareSource( @@ -172,17 +26,25 @@ async function fetchCompareSource( symbol: string, days: number, ): Promise { - const url = new URL(`${ADANOS_BASE_URL}${SOURCE_CONFIG[source].path}`); - url.searchParams.set('tickers', symbol.toUpperCase()); - url.searchParams.set('days', String(days)); - try { - const response = await fetch(url.toString(), { - headers: { - 'X-API-Key': ADANOS_API_KEY, - }, - next: { revalidate: 300 }, - }); + const url = new URL(`${getAdanosBaseUrl()}${SOURCE_CONFIG[source].path}`); + url.searchParams.set('tickers', symbol.toUpperCase()); + url.searchParams.set('days', String(days)); + + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(url.toString(), { + headers: { + 'X-API-Key': getAdanosApiKey(), + }, + signal: abortController.signal, + next: { revalidate: 300 }, + }); + } finally { + clearTimeout(timeout); + } if (response.status === 404) { return null; @@ -194,8 +56,7 @@ async function fetchCompareSource( } const payload = (await response.json()) as SourceComparePayload; - const row = payload.stocks?.find((item) => item.ticker?.toUpperCase() === symbol.toUpperCase()) - ?? payload.stocks?.[0]; + const row = payload.stocks?.find((item) => item.ticker?.toUpperCase() === symbol.toUpperCase()); return normalizeSourceInsight(source, row); } catch (error) { @@ -208,7 +69,7 @@ export async function getStockSentimentInsights( symbol: string, days: number = DEFAULT_LOOKBACK_DAYS, ): Promise { - if (!ADANOS_API_KEY || !symbol?.trim()) { + if (!getAdanosApiKey() || !symbol?.trim()) { return null; } diff --git a/lib/actions/adanos.helpers.ts b/lib/actions/adanos.helpers.ts new file mode 100644 index 0000000..44713de --- /dev/null +++ b/lib/actions/adanos.helpers.ts @@ -0,0 +1,162 @@ +export type SentimentSourceKey = 'reddit' | 'x' | 'news' | 'polymarket'; +export type SentimentTrend = 'rising' | 'falling' | 'stable'; + +type BaseCompareRow = { + ticker?: string; + company_name?: string | null; + buzz_score?: number | null; + trend?: SentimentTrend | null; + bullish_pct?: number | null; + trend_history?: number[] | null; +}; + +export type SourceComparePayload = { + stocks?: BaseCompareRow[]; +}; + +export const SOURCE_CONFIG = { + reddit: { + label: 'Reddit', + path: '/reddit/stocks/v1/compare', + metricLabel: 'Mentions', + metricField: 'mentions', + }, + x: { + label: 'X.com', + path: '/x/stocks/v1/compare', + metricLabel: 'Mentions', + metricField: 'mentions', + }, + news: { + label: 'News', + path: '/news/stocks/v1/compare', + metricLabel: 'Mentions', + metricField: 'mentions', + }, + polymarket: { + label: 'Polymarket', + path: '/polymarket/stocks/v1/compare', + metricLabel: 'Trades', + metricField: 'trade_count', + }, +} as const satisfies Record< + SentimentSourceKey, + { + label: string; + path: string; + metricLabel: string; + metricField: string; + } +>; + +type SourceSpecificRow = BaseCompareRow & Record; + +export interface SentimentSourceInsight { + source: SentimentSourceKey; + label: string; + companyName: string | null; + buzzScore: number; + bullishPct: number | null; + trend: SentimentTrend | null; + metricLabel: string; + metricValue: number; +} + +export interface StockSentimentInsights { + symbol: string; + companyName: string | null; + averageBuzz: number; + bullishAverage: number | null; + sourceAlignment: string; + availableSources: number; + sources: SentimentSourceInsight[]; +} + +function toNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function roundTo(value: number, digits: number = 1): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function average(values: number[]): number { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function normalizeTrend(value: unknown): SentimentTrend | null { + return value === 'rising' || value === 'falling' || value === 'stable' ? value : null; +} + +export function getSourceAlignment(bullishValues: number[]): string { + if (bullishValues.length === 0) return 'No sentiment mix'; + if (bullishValues.length === 1) return 'Single-source view'; + + const min = Math.min(...bullishValues); + const max = Math.max(...bullishValues); + const spread = max - min; + const avg = average(bullishValues); + + if (spread <= 12 && avg >= 60) return 'Bullish alignment'; + if (spread <= 12 && avg <= 40) return 'Bearish alignment'; + if (spread <= 12) return 'Tight alignment'; + if (spread >= 25) return 'Wide divergence'; + return 'Mixed'; +} + +export function normalizeSourceInsight( + source: SentimentSourceKey, + row: SourceSpecificRow | null | undefined, +): SentimentSourceInsight | null { + if (!row) return null; + + const buzzScore = toNumber(row.buzz_score); + const metricValue = toNumber(row[SOURCE_CONFIG[source].metricField]); + + if (buzzScore === null || metricValue === null) { + return null; + } + + return { + source, + label: SOURCE_CONFIG[source].label, + companyName: typeof row.company_name === 'string' ? row.company_name : null, + buzzScore: roundTo(buzzScore), + bullishPct: toNumber(row.bullish_pct), + trend: normalizeTrend(row.trend), + metricLabel: SOURCE_CONFIG[source].metricLabel, + metricValue: Math.round(metricValue), + }; +} + +export function buildStockSentimentInsights( + symbol: string, + sources: Array, +): StockSentimentInsights | null { + const availableSources = sources.filter((source): source is SentimentSourceInsight => Boolean(source)); + + if (availableSources.length === 0) { + return null; + } + + const buzzValues = availableSources.map((source) => source.buzzScore); + const bullishValues = availableSources + .map((source) => source.bullishPct) + .filter((value): value is number => value !== null); + + return { + symbol: symbol.toUpperCase(), + companyName: availableSources.find((source) => source.companyName)?.companyName ?? null, + averageBuzz: roundTo(average(buzzValues)), + bullishAverage: bullishValues.length ? roundTo(average(bullishValues)) : null, + sourceAlignment: getSourceAlignment(bullishValues), + availableSources: availableSources.length, + sources: availableSources, + }; +} diff --git a/scripts/check-env.mjs b/scripts/check-env.mjs index 17a7e44..655c4fe 100644 --- a/scripts/check-env.mjs +++ b/scripts/check-env.mjs @@ -29,8 +29,11 @@ const requiredVars = { 'NODEMAILER_PASSWORD': 'Gmail app password (not regular password)', }; -const optionalVars = { +const deprecatedVars = { 'FINNHUB_API_KEY': 'Legacy Finnhub key (deprecated, use NEXT_PUBLIC_FINNHUB_API_KEY)', +}; + +const optionalVars = { 'ADANOS_API_KEY': 'Optional Adanos API key for stock sentiment insights', 'ADANOS_API_BASE_URL': 'Optional Adanos API base URL override', }; @@ -52,11 +55,19 @@ for (const [key, description] of Object.entries(requiredVars)) { } } +// Check deprecated variables +for (const [key, description] of Object.entries(deprecatedVars)) { + const value = process.env[key]; + if (value) { + warnings.push({ key, description, message: 'This variable is deprecated' }); + } +} + // Check optional variables for (const [key, description] of Object.entries(optionalVars)) { const value = process.env[key]; if (value) { - warnings.push({ key, description, message: 'This variable is deprecated or optional' }); + warnings.push({ key, description, message: 'Optional integration enabled' }); } }