fix: harden sentiment insights integration
This commit is contained in:
parent
5367fa2996
commit
f093e2117f
|
|
@ -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 {
|
import {
|
||||||
buildStockSentimentInsights,
|
buildStockSentimentInsights,
|
||||||
getSourceAlignment,
|
getSourceAlignment,
|
||||||
normalizeSourceInsight,
|
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', () => {
|
describe('normalizeSourceInsight', () => {
|
||||||
it('maps source-specific metrics for mentions and trades', () => {
|
it('maps source-specific metrics for mentions and trades', () => {
|
||||||
|
|
@ -122,3 +131,82 @@ describe('buildStockSentimentInsights', () => {
|
||||||
expect(buildStockSentimentInsights('MSFT', [null, null])).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
headers: await headers()
|
headers: await headers()
|
||||||
});
|
});
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
const isInWatchlist = userId ? await isStockInWatchlist(userId, symbol) : false;
|
const [isInWatchlist, sentimentInsights] = await Promise.all([
|
||||||
const sentimentInsights = await getStockSentimentInsights(symbol);
|
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
||||||
|
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">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { StockSentimentInsights } from '@/lib/actions/adanos.actions';
|
import type { StockSentimentInsights } from '@/lib/actions/adanos.helpers';
|
||||||
|
|
||||||
interface StockSentimentCardProps {
|
interface StockSentimentCardProps {
|
||||||
insight: StockSentimentInsights | null;
|
insight: StockSentimentInsights | null;
|
||||||
|
|
@ -27,6 +27,9 @@ function getAlignmentClasses(alignment: string): string {
|
||||||
if (alignment === 'Bullish alignment') return 'text-emerald-400';
|
if (alignment === 'Bullish alignment') return 'text-emerald-400';
|
||||||
if (alignment === 'Bearish alignment' || alignment === 'Wide divergence') return 'text-rose-400';
|
if (alignment === 'Bearish alignment' || alignment === 'Wide divergence') return 'text-rose-400';
|
||||||
if (alignment === 'Tight alignment') return 'text-blue-300';
|
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';
|
return 'text-gray-300';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,24 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
type SentimentSourceKey = 'reddit' | 'x' | 'news' | 'polymarket';
|
import {
|
||||||
type SentimentTrend = 'rising' | 'falling' | 'stable';
|
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 DEFAULT_LOOKBACK_DAYS = 7;
|
||||||
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
const SOURCE_CONFIG = {
|
function getAdanosBaseUrl(): string {
|
||||||
reddit: {
|
return (process.env.ADANOS_API_BASE_URL || 'https://api.adanos.org').replace(/\/$/, '');
|
||||||
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 {
|
function getAdanosApiKey(): string {
|
||||||
symbol: string;
|
return process.env.ADANOS_API_KEY ?? '';
|
||||||
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(
|
async function fetchCompareSource(
|
||||||
|
|
@ -172,17 +26,25 @@ async function fetchCompareSource(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
days: number,
|
days: number,
|
||||||
): Promise<SentimentSourceInsight | null> {
|
): Promise<SentimentSourceInsight | null> {
|
||||||
const url = new URL(`${ADANOS_BASE_URL}${SOURCE_CONFIG[source].path}`);
|
try {
|
||||||
|
const url = new URL(`${getAdanosBaseUrl()}${SOURCE_CONFIG[source].path}`);
|
||||||
url.searchParams.set('tickers', symbol.toUpperCase());
|
url.searchParams.set('tickers', symbol.toUpperCase());
|
||||||
url.searchParams.set('days', String(days));
|
url.searchParams.set('days', String(days));
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url.toString(), {
|
response = await fetch(url.toString(), {
|
||||||
headers: {
|
headers: {
|
||||||
'X-API-Key': ADANOS_API_KEY,
|
'X-API-Key': getAdanosApiKey(),
|
||||||
},
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
next: { revalidate: 300 },
|
next: { revalidate: 300 },
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -194,8 +56,7 @@ async function fetchCompareSource(
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as SourceComparePayload;
|
const payload = (await response.json()) as SourceComparePayload;
|
||||||
const row = payload.stocks?.find((item) => item.ticker?.toUpperCase() === symbol.toUpperCase())
|
const row = payload.stocks?.find((item) => item.ticker?.toUpperCase() === symbol.toUpperCase());
|
||||||
?? payload.stocks?.[0];
|
|
||||||
|
|
||||||
return normalizeSourceInsight(source, row);
|
return normalizeSourceInsight(source, row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -208,7 +69,7 @@ export async function getStockSentimentInsights(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
days: number = DEFAULT_LOOKBACK_DAYS,
|
days: number = DEFAULT_LOOKBACK_DAYS,
|
||||||
): Promise<StockSentimentInsights | null> {
|
): Promise<StockSentimentInsights | null> {
|
||||||
if (!ADANOS_API_KEY || !symbol?.trim()) {
|
if (!getAdanosApiKey() || !symbol?.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -29,8 +29,11 @@ const requiredVars = {
|
||||||
'NODEMAILER_PASSWORD': 'Gmail app password (not regular password)',
|
'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)',
|
'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_KEY': 'Optional Adanos API key for stock sentiment insights',
|
||||||
'ADANOS_API_BASE_URL': 'Optional Adanos API base URL override',
|
'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
|
// Check optional variables
|
||||||
for (const [key, description] of Object.entries(optionalVars)) {
|
for (const [key, description] of Object.entries(optionalVars)) {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
if (value) {
|
if (value) {
|
||||||
warnings.push({ key, description, message: 'This variable is deprecated or optional' });
|
warnings.push({ key, description, message: 'Optional integration enabled' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue