feat: add stock sentiment insights card
This commit is contained in:
parent
b97d751c36
commit
5367fa2996
15
README.md
15
README.md
|
|
@ -114,6 +114,7 @@ Language composition
|
||||||
- Stock details
|
- Stock details
|
||||||
- TradingView symbol info, candlestick/advanced charts, baseline, technicals
|
- TradingView symbol info, candlestick/advanced charts, baseline, technicals
|
||||||
- Company profile and financials widgets
|
- Company profile and financials widgets
|
||||||
|
- Optional cross-source sentiment insights for Reddit, X.com, news, and Polymarket
|
||||||
- Market overview
|
- Market overview
|
||||||
- Heatmap, quotes, and top stories (TradingView widgets)
|
- Heatmap, quotes, and top stories (TradingView widgets)
|
||||||
- Personalized onboarding
|
- Personalized onboarding
|
||||||
|
|
@ -253,6 +254,10 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
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")
|
# AI Provider (optional, default: "gemini")
|
||||||
# Supported: "gemini", "minimax", "siray"
|
# Supported: "gemini", "minimax", "siray"
|
||||||
# AI_PROVIDER=gemini
|
# AI_PROVIDER=gemini
|
||||||
|
|
@ -290,6 +295,10 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
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")
|
# AI Provider (optional, default: "gemini")
|
||||||
# Supported: "gemini", "minimax", "siray"
|
# Supported: "gemini", "minimax", "siray"
|
||||||
# AI_PROVIDER=gemini
|
# 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).
|
- 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.
|
- 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
|
- TradingView
|
||||||
- Embeddable widgets used for charts, heatmap, quotes, and timelines.
|
- Embeddable widgets used for charts, heatmap, quotes, and timelines.
|
||||||
- External images from `i.ibb.co` are allowlisted in `next.config.ts`.
|
- 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)
|
GitHub: [adrianhajdin](https://github.com/adrianhajdin)
|
||||||
YouTube tutorial: [Stock Market App Tutorial](https://www.youtube.com/watch?v=gu4pafNCXng)
|
YouTube tutorial: [Stock Market App Tutorial](https://www.youtube.com/watch?v=gu4pafNCXng)
|
||||||
YouTube channel: [JavaScript Mastery](https://www.youtube.com/@javascriptmastery)
|
YouTube channel: [JavaScript Mastery](https://www.youtube.com/@javascriptmastery)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import TradingViewWidget from "@/components/TradingViewWidget";
|
import TradingViewWidget from "@/components/TradingViewWidget";
|
||||||
import WatchlistButton from "@/components/WatchlistButton";
|
import WatchlistButton from "@/components/WatchlistButton";
|
||||||
|
import StockSentimentCard from "@/components/stocks/StockSentimentCard";
|
||||||
import {
|
import {
|
||||||
SYMBOL_INFO_WIDGET_CONFIG,
|
SYMBOL_INFO_WIDGET_CONFIG,
|
||||||
CANDLE_CHART_WIDGET_CONFIG,
|
CANDLE_CHART_WIDGET_CONFIG,
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
import { auth } from '@/lib/better-auth/auth';
|
import { auth } from '@/lib/better-auth/auth';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { isStockInWatchlist } from '@/lib/actions/watchlist.actions';
|
import { isStockInWatchlist } from '@/lib/actions/watchlist.actions';
|
||||||
|
import { getStockSentimentInsights } from '@/lib/actions/adanos.actions';
|
||||||
import { formatSymbolForTradingView } from '@/lib/utils';
|
import { formatSymbolForTradingView } from '@/lib/utils';
|
||||||
|
|
||||||
export default async function StockDetails({ params }: StockDetailsPageProps) {
|
export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
|
|
@ -24,6 +26,7 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
});
|
});
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
const isInWatchlist = userId ? await isStockInWatchlist(userId, symbol) : false;
|
const isInWatchlist = userId ? await isStockInWatchlist(userId, symbol) : false;
|
||||||
|
const sentimentInsights = await getStockSentimentInsights(symbol);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen p-4 md:p-6 lg:p-8">
|
<div className="flex min-h-screen p-4 md:p-6 lg:p-8">
|
||||||
|
|
@ -64,6 +67,8 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StockSentimentCard insight={sentimentInsights} />
|
||||||
|
|
||||||
<TradingViewWidget
|
<TradingViewWidget
|
||||||
scriptUrl={`${scriptUrl}technical-analysis.js`}
|
scriptUrl={`${scriptUrl}technical-analysis.js`}
|
||||||
config={TECHNICAL_ANALYSIS_WIDGET_CONFIG(tvSymbol)}
|
config={TECHNICAL_ANALYSIS_WIDGET_CONFIG(tvSymbol)}
|
||||||
|
|
@ -85,4 +90,4 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">
|
||||||
|
Sentiment Insights
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{insight.symbol} across social and public channels
|
||||||
|
</h2>
|
||||||
|
{insight.companyName ? (
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-300">
|
||||||
|
{insight.companyName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-1 text-sm text-gray-400">
|
||||||
|
Structured sentiment snapshot across Reddit, X.com, news, and Polymarket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-2xl border border-gray-800 bg-black/20 p-4 md:min-w-[320px]">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Avg. Buzz
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{formatScore(insight.averageBuzz, '/100')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Bullish Avg
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{formatScore(insight.bullishAverage, '%')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Source Alignment
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-sm font-semibold ${getAlignmentClasses(insight.sourceAlignment)}`}>
|
||||||
|
{insight.sourceAlignment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Coverage
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{insight.availableSources}/4
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{insight.sources.map((source) => (
|
||||||
|
<article
|
||||||
|
key={source.source}
|
||||||
|
className="rounded-xl border border-gray-800 bg-black/20 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-white">{source.label}</h3>
|
||||||
|
<span className={`text-sm font-medium capitalize ${getTrendClasses(source.trend)}`}>
|
||||||
|
{source.trend ?? 'No trend'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/20 p-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Buzz
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{formatScore(source.buzzScore, '/100')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/20 p-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Bullish
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{formatScore(source.bullishPct, '%')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/20 p-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
{source.metricLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{formatCompactNumber(source.metricValue)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-black/20 p-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Trend
|
||||||
|
</p>
|
||||||
|
<p className={`mt-2 text-xl font-semibold capitalize ${getTrendClasses(source.trend)}`}>
|
||||||
|
{source.trend ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<SentimentSourceInsight | null>,
|
||||||
|
): 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<SentimentSourceInsight | null> {
|
||||||
|
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<StockSentimentInsights | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ const requiredVars = {
|
||||||
|
|
||||||
const optionalVars = {
|
const optionalVars = {
|
||||||
'FINNHUB_API_KEY': 'Legacy Finnhub key (deprecated, use NEXT_PUBLIC_FINNHUB_API_KEY)',
|
'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');
|
console.log('🔍 Checking Environment Variables...\n');
|
||||||
|
|
@ -109,4 +111,3 @@ function maskValue(value) {
|
||||||
}
|
}
|
||||||
return value.substring(0, 4) + '***' + value.substring(value.length - 4);
|
return value.substring(0, 4) + '***' + value.substring(value.length - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue