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