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