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..4190634 --- /dev/null +++ b/__tests__/adanos.actions.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + getStockSentimentInsights, +} from '@/lib/actions/adanos.actions'; +import { + buildStockSentimentInsights, + getSourceAlignment, + normalizeSourceInsight, +} 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', () => { + 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(); + }); +}); + +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 11874fd..2bf4884 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) { @@ -23,7 +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 [isInWatchlist, sentimentInsights] = await Promise.all([ + userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false), + getStockSentimentInsights(symbol), + ]); return (
@@ -64,6 +69,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..7b4c608 --- /dev/null +++ b/components/stocks/StockSentimentCard.tsx @@ -0,0 +1,151 @@ +import type { StockSentimentInsights } from '@/lib/actions/adanos.helpers'; + +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'; + 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'; +} + +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..b518320 --- /dev/null +++ b/lib/actions/adanos.actions.ts @@ -0,0 +1,85 @@ +'use server'; + +import { + buildStockSentimentInsights, + normalizeSourceInsight, + SOURCE_CONFIG, + type SentimentSourceInsight, + type SentimentSourceKey, + type SourceComparePayload, + type StockSentimentInsights, +} from './adanos.helpers'; + +const DEFAULT_LOOKBACK_DAYS = 7; +const FETCH_TIMEOUT_MS = 5000; + +function getAdanosBaseUrl(): string { + return (process.env.ADANOS_API_BASE_URL || 'https://api.adanos.org').replace(/\/$/, ''); +} + +function getAdanosApiKey(): string { + return process.env.ADANOS_API_KEY ?? ''; +} + +async function fetchCompareSource( + source: SentimentSourceKey, + symbol: string, + days: number, +): Promise { + try { + 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; + } + + 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()); + + 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 (!getAdanosApiKey() || !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/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 5d3743c..655c4fe 100644 --- a/scripts/check-env.mjs +++ b/scripts/check-env.mjs @@ -29,10 +29,15 @@ 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', +}; + console.log('🔍 Checking Environment Variables...\n'); console.log('='.repeat(60)); @@ -50,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' }); } } @@ -109,4 +122,3 @@ function maskValue(value) { } return value.substring(0, 4) + '***' + value.substring(value.length - 4); } -