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' });
}
}