From 5367fa29961d6119bfeed42f9ea691534d1e9668 Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Wed, 25 Mar 2026 20:51:33 +0100 Subject: [PATCH 1/2] feat: add stock sentiment insights card --- README.md | 15 +- __tests__/adanos.actions.test.ts | 124 +++++++++++++ app/(root)/stocks/[symbol]/page.tsx | 7 +- components/stocks/StockSentimentCard.tsx | 148 +++++++++++++++ lib/actions/adanos.actions.ts | 224 +++++++++++++++++++++++ scripts/check-env.mjs | 3 +- 6 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 __tests__/adanos.actions.test.ts create mode 100644 components/stocks/StockSentimentCard.tsx create mode 100644 lib/actions/adanos.actions.ts diff --git a/README.md b/README.md index 93c50ca..018a998 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Language composition - Stock details - TradingView symbol info, candlestick/advanced charts, baseline, technicals - Company profile and financials widgets + - Optional cross-source sentiment insights for Reddit, X.com, news, and Polymarket - Market overview - Heatmap, quotes, and top stories (TradingView widgets) - Personalized onboarding @@ -253,6 +254,10 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key FINNHUB_BASE_URL=https://finnhub.io/api/v1 +# Sentiment insights (optional) +ADANOS_API_KEY=your_adanos_api_key +# ADANOS_API_BASE_URL=https://api.adanos.org + # AI Provider (optional, default: "gemini") # Supported: "gemini", "minimax", "siray" # AI_PROVIDER=gemini @@ -290,6 +295,10 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key FINNHUB_BASE_URL=https://finnhub.io/api/v1 +# Sentiment insights (optional) +ADANOS_API_KEY=your_adanos_api_key +# ADANOS_API_BASE_URL=https://api.adanos.org + # AI Provider (optional, default: "gemini") # Supported: "gemini", "minimax", "siray" # AI_PROVIDER=gemini @@ -362,6 +371,11 @@ public/assets/images/ # logos and screenshots - Set `NEXT_PUBLIC_FINNHUB_API_KEY` and `FINNHUB_BASE_URL` (default: https://finnhub.io/api/v1). - Free tiers may return delayed quotes; respect rate limits and terms. +- Adanos sentiment insights (optional) + - Structured stock sentiment snapshots across Reddit, X.com, news, and Polymarket. + - Set `ADANOS_API_KEY`; optionally override the API host with `ADANOS_API_BASE_URL`. + - Used only for the stock detail sentiment card and does not replace Finnhub or TradingView. + - TradingView - Embeddable widgets used for charts, heatmap, quotes, and timelines. - External images from `i.ibb.co` are allowlisted in `next.config.ts`. @@ -451,4 +465,3 @@ Huge thanks to [Adrian Hajdin (JavaScript Mastery)](https://github.com/adrianhaj GitHub: [adrianhajdin](https://github.com/adrianhajdin) YouTube tutorial: [Stock Market App Tutorial](https://www.youtube.com/watch?v=gu4pafNCXng) YouTube channel: [JavaScript Mastery](https://www.youtube.com/@javascriptmastery) - diff --git a/__tests__/adanos.actions.test.ts b/__tests__/adanos.actions.test.ts new file mode 100644 index 0000000..31936da --- /dev/null +++ b/__tests__/adanos.actions.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildStockSentimentInsights, + getSourceAlignment, + normalizeSourceInsight, +} from '@/lib/actions/adanos.actions'; + +describe('normalizeSourceInsight', () => { + it('maps source-specific metrics for mentions and trades', () => { + const reddit = normalizeSourceInsight('reddit', { + ticker: 'TSLA', + buzz_score: 81.2, + bullish_pct: 46, + trend: 'rising', + mentions: 647, + }); + + const polymarket = normalizeSourceInsight('polymarket', { + ticker: 'TSLA', + buzz_score: 55.7, + bullish_pct: 72, + trend: 'stable', + trade_count: 3731, + }); + + expect(reddit).toMatchObject({ + label: 'Reddit', + companyName: null, + metricLabel: 'Mentions', + metricValue: 647, + buzzScore: 81.2, + bullishPct: 46, + }); + expect(polymarket).toMatchObject({ + label: 'Polymarket', + companyName: null, + metricLabel: 'Trades', + metricValue: 3731, + buzzScore: 55.7, + bullishPct: 72, + }); + }); + + it('returns null when required values are missing', () => { + expect( + normalizeSourceInsight('x', { + ticker: 'NVDA', + bullish_pct: 54, + mentions: 1200, + }), + ).toBeNull(); + + expect( + normalizeSourceInsight('news', { + ticker: 'NVDA', + buzz_score: 60, + bullish_pct: 54, + }), + ).toBeNull(); + }); +}); + +describe('getSourceAlignment', () => { + it('classifies wide divergence when sources materially disagree', () => { + expect(getSourceAlignment([31, 56, 48, 30])).toBe('Wide divergence'); + }); + + it('classifies bullish alignment when sources are tightly aligned and positive', () => { + expect(getSourceAlignment([61, 64, 67])).toBe('Bullish alignment'); + }); +}); + +describe('buildStockSentimentInsights', () => { + it('builds a compact aggregate summary from available sources', () => { + const insight = buildStockSentimentInsights('TSLA', [ + { + source: 'reddit', + label: 'Reddit', + companyName: 'Tesla, Inc.', + buzzScore: 74.1, + bullishPct: 31, + trend: 'rising', + metricLabel: 'Mentions', + metricValue: 647, + }, + { + source: 'x', + label: 'X.com', + companyName: 'Tesla, Inc.', + buzzScore: 86.1, + bullishPct: 56, + trend: 'falling', + metricLabel: 'Mentions', + metricValue: 2650, + }, + { + source: 'polymarket', + label: 'Polymarket', + companyName: 'Tesla, Inc.', + buzzScore: 83.3, + bullishPct: 30, + trend: 'falling', + metricLabel: 'Trades', + metricValue: 3731, + }, + null, + ]); + + expect(insight).toMatchObject({ + symbol: 'TSLA', + companyName: 'Tesla, Inc.', + averageBuzz: 81.2, + bullishAverage: 39, + sourceAlignment: 'Wide divergence', + availableSources: 3, + }); + expect(insight?.sources).toHaveLength(3); + }); + + it('returns null when no sources have usable data', () => { + expect(buildStockSentimentInsights('MSFT', [null, null])).toBeNull(); + }); +}); diff --git a/app/(root)/stocks/[symbol]/page.tsx b/app/(root)/stocks/[symbol]/page.tsx index 11874fd..87213e9 100644 --- a/app/(root)/stocks/[symbol]/page.tsx +++ b/app/(root)/stocks/[symbol]/page.tsx @@ -1,5 +1,6 @@ import TradingViewWidget from "@/components/TradingViewWidget"; import WatchlistButton from "@/components/WatchlistButton"; +import StockSentimentCard from "@/components/stocks/StockSentimentCard"; import { SYMBOL_INFO_WIDGET_CONFIG, CANDLE_CHART_WIDGET_CONFIG, @@ -12,6 +13,7 @@ import { import { auth } from '@/lib/better-auth/auth'; import { headers } from 'next/headers'; import { isStockInWatchlist } from '@/lib/actions/watchlist.actions'; +import { getStockSentimentInsights } from '@/lib/actions/adanos.actions'; import { formatSymbolForTradingView } from '@/lib/utils'; export default async function StockDetails({ params }: StockDetailsPageProps) { @@ -24,6 +26,7 @@ export default async function StockDetails({ params }: StockDetailsPageProps) { }); const userId = session?.user?.id; const isInWatchlist = userId ? await isStockInWatchlist(userId, symbol) : false; + const sentimentInsights = await getStockSentimentInsights(symbol); return (
@@ -64,6 +67,8 @@ export default async function StockDetails({ params }: StockDetailsPageProps) { />
+ + ); -} \ No newline at end of file +} diff --git a/components/stocks/StockSentimentCard.tsx b/components/stocks/StockSentimentCard.tsx new file mode 100644 index 0000000..48f698a --- /dev/null +++ b/components/stocks/StockSentimentCard.tsx @@ -0,0 +1,148 @@ +import type { StockSentimentInsights } from '@/lib/actions/adanos.actions'; + +interface StockSentimentCardProps { + insight: StockSentimentInsights | null; +} + +function formatScore(value: number | null, suffix: string): string { + if (value === null) return 'N/A'; + return `${value.toFixed(1)}${suffix}`; +} + +function formatCompactNumber(value: number): string { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); +} + +function getTrendClasses(trend: string | null): string { + if (trend === 'rising') return 'text-emerald-400'; + if (trend === 'falling') return 'text-rose-400'; + if (trend === 'stable') return 'text-amber-300'; + return 'text-gray-400'; +} + +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'; + return 'text-gray-300'; +} + +export default function StockSentimentCard({ insight }: StockSentimentCardProps) { + if (!insight) { + return null; + } + + return ( +
+
+
+
+

+ Sentiment Insights +

+

+ {insight.symbol} across social and public channels +

+ {insight.companyName ? ( +

+ {insight.companyName} +

+ ) : null} +

+ Structured sentiment snapshot across Reddit, X.com, news, and Polymarket. +

+
+ +
+
+

+ Avg. Buzz +

+

+ {formatScore(insight.averageBuzz, '/100')} +

+
+
+

+ Bullish Avg +

+

+ {formatScore(insight.bullishAverage, '%')} +

+
+
+

+ Source Alignment +

+

+ {insight.sourceAlignment} +

+
+
+

+ Coverage +

+

+ {insight.availableSources}/4 +

+
+
+
+ +
+ {insight.sources.map((source) => ( +
+
+

{source.label}

+ + {source.trend ?? 'No trend'} + +
+ +
+
+

+ Buzz +

+

+ {formatScore(source.buzzScore, '/100')} +

+
+
+

+ Bullish +

+

+ {formatScore(source.bullishPct, '%')} +

+
+
+

+ {source.metricLabel} +

+

+ {formatCompactNumber(source.metricValue)} +

+
+
+

+ Trend +

+

+ {source.trend ?? 'N/A'} +

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/lib/actions/adanos.actions.ts b/lib/actions/adanos.actions.ts new file mode 100644 index 0000000..c0142d8 --- /dev/null +++ b/lib/actions/adanos.actions.ts @@ -0,0 +1,224 @@ +'use server'; + +type SentimentSourceKey = 'reddit' | 'x' | 'news' | 'polymarket'; +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; +}; + +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 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, + }; +} + +async function fetchCompareSource( + source: SentimentSourceKey, + 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 }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + console.error(`Adanos ${source} compare failed for ${symbol}: ${response.status}`); + return null; + } + + const payload = (await response.json()) as SourceComparePayload; + const row = payload.stocks?.find((item) => item.ticker?.toUpperCase() === symbol.toUpperCase()) + ?? payload.stocks?.[0]; + + return normalizeSourceInsight(source, row); + } catch (error) { + console.error(`Adanos ${source} compare request failed for ${symbol}`, error); + return null; + } +} + +export async function getStockSentimentInsights( + symbol: string, + days: number = DEFAULT_LOOKBACK_DAYS, +): Promise { + if (!ADANOS_API_KEY || !symbol?.trim()) { + return null; + } + + const normalizedSymbol = symbol.trim().toUpperCase(); + const lookbackDays = Math.max(1, Math.min(days, 30)); + const sourceKeys = Object.keys(SOURCE_CONFIG) as SentimentSourceKey[]; + + const sources = await Promise.all( + sourceKeys.map((source) => fetchCompareSource(source, normalizedSymbol, lookbackDays)), + ); + + return buildStockSentimentInsights(normalizedSymbol, sources); +} diff --git a/scripts/check-env.mjs b/scripts/check-env.mjs index 5d3743c..17a7e44 100644 --- a/scripts/check-env.mjs +++ b/scripts/check-env.mjs @@ -31,6 +31,8 @@ const requiredVars = { const optionalVars = { 'FINNHUB_API_KEY': 'Legacy Finnhub key (deprecated, use NEXT_PUBLIC_FINNHUB_API_KEY)', + 'ADANOS_API_KEY': 'Optional Adanos API key for stock sentiment insights', + 'ADANOS_API_BASE_URL': 'Optional Adanos API base URL override', }; console.log('🔍 Checking Environment Variables...\n'); @@ -109,4 +111,3 @@ function maskValue(value) { } return value.substring(0, 4) + '***' + value.substring(value.length - 4); } - From f093e2117fabe267885f6191e7fe9cdf06cc7adf Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Wed, 25 Mar 2026 21:32:26 +0100 Subject: [PATCH 2/2] fix: harden sentiment insights integration --- __tests__/adanos.actions.test.ts | 92 +++++++++- app/(root)/stocks/[symbol]/page.tsx | 6 +- components/stocks/StockSentimentCard.tsx | 5 +- lib/actions/adanos.actions.ts | 207 ++++------------------- lib/actions/adanos.helpers.ts | 162 ++++++++++++++++++ scripts/check-env.mjs | 15 +- 6 files changed, 307 insertions(+), 180 deletions(-) create mode 100644 lib/actions/adanos.helpers.ts 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' }); } }