feat: add AI-powered stock analysis with real-time insights
- Add StockAIAnalysisCard component for displaying AI analysis results - Add stock-analysis server actions and helper utilities - Integrate Finnhub data into AI analysis pipeline - Update AI provider with enhanced stock analysis capabilities - Add Inngest prompt templates for stock analysis - Wire AI analysis into stock symbol page - Add unit tests for stock analysis helpers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8326133a70
commit
0ec58caa62
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
getProviderConfig,
|
getProviderConfig,
|
||||||
getFallbackProviderName,
|
getFallbackProviderName,
|
||||||
|
hasConfiguredAIProvider,
|
||||||
callAIProvider,
|
callAIProvider,
|
||||||
callAIProviderWithFallback,
|
callAIProviderWithFallback,
|
||||||
type AIProviderName,
|
type AIProviderName,
|
||||||
|
|
@ -97,6 +98,34 @@ describe("getFallbackProviderName", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasConfiguredAIProvider", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the primary provider has a key", () => {
|
||||||
|
process.env.AI_PROVIDER = "gemini";
|
||||||
|
process.env.GEMINI_API_KEY = "g";
|
||||||
|
expect(hasConfiguredAIProvider()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when only the fallback provider has a key", () => {
|
||||||
|
process.env.AI_PROVIDER = "gemini";
|
||||||
|
delete process.env.GEMINI_API_KEY;
|
||||||
|
process.env.MINIMAX_API_KEY = "m";
|
||||||
|
expect(hasConfiguredAIProvider()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when neither primary nor fallback has a key", () => {
|
||||||
|
process.env.AI_PROVIDER = "minimax";
|
||||||
|
delete process.env.MINIMAX_API_KEY;
|
||||||
|
delete process.env.GEMINI_API_KEY;
|
||||||
|
expect(hasConfiguredAIProvider()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── callAIProvider ─────────────────────────────────────────────────
|
// ── callAIProvider ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("callAIProvider", () => {
|
describe("callAIProvider", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildStockAnalysisPrompt,
|
||||||
|
parseStockAnalysisResponse,
|
||||||
|
} from '@/lib/actions/stock-analysis.helpers';
|
||||||
|
|
||||||
|
describe('stock-analysis.helpers', () => {
|
||||||
|
it('builds a prompt with consolidated stock context', () => {
|
||||||
|
const prompt = buildStockAnalysisPrompt({
|
||||||
|
symbol: 'AAPL',
|
||||||
|
quote: {
|
||||||
|
c: 198.12,
|
||||||
|
d: 2.11,
|
||||||
|
dp: 1.08,
|
||||||
|
h: 199.2,
|
||||||
|
l: 195.8,
|
||||||
|
o: 196.1,
|
||||||
|
pc: 196.01,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: 'Apple Inc.',
|
||||||
|
exchange: 'NASDAQ',
|
||||||
|
finnhubIndustry: 'Technology',
|
||||||
|
marketCapitalization: 3100000,
|
||||||
|
country: 'US',
|
||||||
|
currency: 'USD',
|
||||||
|
ipo: '1980-12-12',
|
||||||
|
weburl: 'https://apple.com',
|
||||||
|
},
|
||||||
|
basicFinancials: {
|
||||||
|
metric: {
|
||||||
|
peBasicExclExtraTTM: 31.2,
|
||||||
|
epsTTM: 6.4,
|
||||||
|
beta: 1.1,
|
||||||
|
'52WeekHigh': 200,
|
||||||
|
'52WeekLow': 164,
|
||||||
|
'52WeekPriceReturnDaily': 12.5,
|
||||||
|
targetPrice: 210,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sentimentInsights: {
|
||||||
|
symbol: 'AAPL',
|
||||||
|
companyName: 'Apple Inc.',
|
||||||
|
averageBuzz: 74.2,
|
||||||
|
bullishAverage: 62.1,
|
||||||
|
sourceAlignment: 'Bullish alignment',
|
||||||
|
availableSources: 3,
|
||||||
|
sources: [],
|
||||||
|
},
|
||||||
|
recentHeadlines: [
|
||||||
|
{
|
||||||
|
headline: 'Apple expands services bundle',
|
||||||
|
summary: 'Investors are watching recurring revenue trends.',
|
||||||
|
source: 'Reuters',
|
||||||
|
datetime: 1717171717,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('"symbol": "AAPL"');
|
||||||
|
expect(prompt).toContain('"companyName": "Apple Inc."');
|
||||||
|
expect(prompt).toContain('"currentPrice": "$198.12"');
|
||||||
|
expect(prompt).toContain('"peRatio": "31.20x"');
|
||||||
|
expect(prompt).toContain('"sourceAlignment": "Bullish alignment"');
|
||||||
|
expect(prompt).toContain('Apple expands services bundle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses fenced json responses', () => {
|
||||||
|
const analysis = parseStockAnalysisResponse(
|
||||||
|
'```json\n{"stance":"Bullish","confidence":"High","summary":"Momentum and fundamentals remain supportive.","keyDrivers":["Revenue mix is improving"],"risks":["Valuation is elevated"],"watchItems":["Next earnings execution"]}\n```',
|
||||||
|
'AAPL',
|
||||||
|
'Apple Inc.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(analysis).not.toBeNull();
|
||||||
|
expect(analysis?.stance).toBe('Bullish');
|
||||||
|
expect(analysis?.confidence).toBe('High');
|
||||||
|
expect(analysis?.keyDrivers).toEqual(['Revenue mix is improving']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when the model does not return json', () => {
|
||||||
|
const analysis = parseStockAnalysisResponse(
|
||||||
|
'The setup is balanced: momentum is positive, but valuation limits upside.',
|
||||||
|
'AAPL',
|
||||||
|
'Apple Inc.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(analysis).not.toBeNull();
|
||||||
|
expect(analysis?.stance).toBe('Neutral');
|
||||||
|
expect(analysis?.confidence).toBe('Low');
|
||||||
|
expect(analysis?.summary).toContain('valuation limits upside');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 StockAIAnalysisCard from "@/components/stocks/StockAIAnalysisCard";
|
||||||
import StockSentimentCard from "@/components/stocks/StockSentimentCard";
|
import StockSentimentCard from "@/components/stocks/StockSentimentCard";
|
||||||
import {
|
import {
|
||||||
SYMBOL_INFO_WIDGET_CONFIG,
|
SYMBOL_INFO_WIDGET_CONFIG,
|
||||||
|
|
@ -13,7 +14,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 { getStockDetailInsights } from '@/lib/actions/stock-analysis.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) {
|
||||||
|
|
@ -25,10 +26,11 @@ 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, sentimentInsights] = await Promise.all([
|
const [isInWatchlist, stockInsights] = await Promise.all([
|
||||||
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
||||||
getStockSentimentInsights(symbol),
|
getStockDetailInsights(symbol),
|
||||||
]);
|
]);
|
||||||
|
const { sentimentInsights, aiAnalysis } = stockInsights;
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -63,12 +65,14 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<WatchlistButton
|
<WatchlistButton
|
||||||
symbol={symbol.toUpperCase()}
|
symbol={symbol.toUpperCase()}
|
||||||
company={symbol.toUpperCase()}
|
company={aiAnalysis?.companyName ?? sentimentInsights?.companyName ?? symbol.toUpperCase()}
|
||||||
isInWatchlist={isInWatchlist}
|
isInWatchlist={isInWatchlist}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StockAIAnalysisCard analysis={aiAnalysis} />
|
||||||
|
|
||||||
<StockSentimentCard insight={sentimentInsights} />
|
<StockSentimentCard insight={sentimentInsights} />
|
||||||
|
|
||||||
<TradingViewWidget
|
<TradingViewWidget
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import type { StockAIAnalysis } from '@/lib/actions/stock-analysis.helpers';
|
||||||
|
|
||||||
|
interface StockAIAnalysisCardProps {
|
||||||
|
analysis: StockAIAnalysis | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStanceClasses(stance: StockAIAnalysis['stance']) {
|
||||||
|
if (stance === 'Bullish') return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
||||||
|
if (stance === 'Bearish') return 'border-rose-500/30 bg-rose-500/10 text-rose-300';
|
||||||
|
return 'border-amber-500/30 bg-amber-500/10 text-amber-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(title: string, items: string[]) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-black/20 p-4">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-400">{title}</h3>
|
||||||
|
{items.length ? (
|
||||||
|
<ul className="mt-3 space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item} className="flex gap-3 text-sm text-gray-200">
|
||||||
|
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-yellow-400" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-gray-400">No additional signals generated.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockAIAnalysisCard({ analysis }: StockAIAnalysisCardProps) {
|
||||||
|
if (!analysis) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">AI Stock Analysis</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">Professional research note unavailable</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-gray-400">
|
||||||
|
The stock dashboard data loaded, but an AI research note could not be generated right now.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<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">
|
||||||
|
AI Stock Analysis
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{analysis.companyName ? `${analysis.companyName} (${analysis.symbol})` : analysis.symbol}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-gray-300">{analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${getStanceClasses(analysis.stance)}`}
|
||||||
|
>
|
||||||
|
{analysis.stance}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-gray-700 bg-black/20 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-gray-200">
|
||||||
|
{analysis.confidence} confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{renderList('Key drivers', analysis.keyDrivers)}
|
||||||
|
{renderList('Risks', analysis.risks)}
|
||||||
|
{renderList('What to watch', analysis.watchItems)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Educational analysis only. Use it as a synthesis of the dashboard inputs, not as personal
|
||||||
|
financial advice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,15 +11,27 @@ type FinnhubQuote = {
|
||||||
c?: number;
|
c?: number;
|
||||||
d?: number;
|
d?: number;
|
||||||
dp?: number;
|
dp?: number;
|
||||||
|
h?: number;
|
||||||
|
l?: number;
|
||||||
|
o?: number;
|
||||||
|
pc?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FinnhubCompanyProfile = {
|
type FinnhubCompanyProfile = {
|
||||||
|
country?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
|
finnhubIndustry?: string;
|
||||||
|
ipo?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
marketCapitalization?: number;
|
marketCapitalization?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
|
weburl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinnhubBasicFinancials = {
|
||||||
|
metric?: Record<string, number | string | null | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SearchStockCandidate = FinnhubSearchResult & {
|
type SearchStockCandidate = FinnhubSearchResult & {
|
||||||
|
|
@ -87,6 +99,39 @@ export async function getCompanyProfile(symbol: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBasicFinancials(symbol: string) {
|
||||||
|
try {
|
||||||
|
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
||||||
|
const url = `${FINNHUB_BASE_URL}/stock/metric?symbol=${encodeURIComponent(symbol)}&metric=all&token=${token}`;
|
||||||
|
// Cache basic financial metrics for 1 hour
|
||||||
|
return await fetchJSON<FinnhubBasicFinancials>(url, 3600);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching basic financials for', symbol, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyNews(symbol: string, days: number = 5): Promise<MarketNewsArticle[]> {
|
||||||
|
try {
|
||||||
|
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('FINNHUB API key is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = getDateRange(days);
|
||||||
|
const url = `${FINNHUB_BASE_URL}/company-news?symbol=${encodeURIComponent(symbol)}&from=${range.from}&to=${range.to}&token=${token}`;
|
||||||
|
const articles = await fetchJSON<RawNewsArticle[]>(url, 300);
|
||||||
|
|
||||||
|
return (articles || [])
|
||||||
|
.filter(validateArticle)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((article, index) => formatArticle(article, true, symbol, index));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching company news for', symbol, e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWatchlistData(symbols: string[]) {
|
export async function getWatchlistData(symbols: string[]) {
|
||||||
if (!symbols || symbols.length === 0) return [];
|
if (!symbols || symbols.length === 0) return [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { unstable_cache } from 'next/cache';
|
||||||
|
import { callAIProviderWithFallback, hasConfiguredAIProvider } from '@/lib/ai-provider';
|
||||||
|
import { getStockSentimentInsights } from './adanos.actions';
|
||||||
|
import { getBasicFinancials, getCompanyNews, getCompanyProfile, getQuote } from './finnhub.actions';
|
||||||
|
import {
|
||||||
|
buildStockAnalysisPrompt,
|
||||||
|
parseStockAnalysisResponse,
|
||||||
|
type StockAIAnalysis,
|
||||||
|
} from './stock-analysis.helpers';
|
||||||
|
import type { StockSentimentInsights } from './adanos.helpers';
|
||||||
|
|
||||||
|
export interface StockDetailInsights {
|
||||||
|
sentimentInsights: StockSentimentInsights | null;
|
||||||
|
aiAnalysis: StockAIAnalysis | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeadline(article: MarketNewsArticle) {
|
||||||
|
return {
|
||||||
|
headline: article.headline,
|
||||||
|
summary: article.summary,
|
||||||
|
source: article.source,
|
||||||
|
datetime: article.datetime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateStockDetailInsights(normalizedSymbol: string): Promise<StockDetailInsights> {
|
||||||
|
const sentimentPromise = getStockSentimentInsights(normalizedSymbol);
|
||||||
|
|
||||||
|
const [quote, profile, basicFinancials, news, sentimentInsights] = await Promise.all([
|
||||||
|
getQuote(normalizedSymbol),
|
||||||
|
getCompanyProfile(normalizedSymbol),
|
||||||
|
getBasicFinancials(normalizedSymbol),
|
||||||
|
getCompanyNews(normalizedSymbol),
|
||||||
|
sentimentPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!hasConfiguredAIProvider()) {
|
||||||
|
return {
|
||||||
|
sentimentInsights,
|
||||||
|
aiAnalysis: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnalysisContext =
|
||||||
|
Boolean(quote) ||
|
||||||
|
Boolean(profile) ||
|
||||||
|
Boolean(sentimentInsights) ||
|
||||||
|
Boolean(basicFinancials?.metric && Object.keys(basicFinancials.metric).length > 0) ||
|
||||||
|
news.length > 0;
|
||||||
|
|
||||||
|
if (!hasAnalysisContext) {
|
||||||
|
return {
|
||||||
|
sentimentInsights,
|
||||||
|
aiAnalysis: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await callAIProviderWithFallback(
|
||||||
|
buildStockAnalysisPrompt({
|
||||||
|
symbol: normalizedSymbol,
|
||||||
|
quote,
|
||||||
|
profile,
|
||||||
|
basicFinancials,
|
||||||
|
sentimentInsights,
|
||||||
|
recentHeadlines: news.slice(0, 4).map(normalizeHeadline),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sentimentInsights,
|
||||||
|
aiAnalysis: parseStockAnalysisResponse(
|
||||||
|
response,
|
||||||
|
normalizedSymbol,
|
||||||
|
profile?.name ?? sentimentInsights?.companyName ?? null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate AI stock analysis for ${normalizedSymbol}`, error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sentimentInsights,
|
||||||
|
aiAnalysis: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStockDetailInsights(symbol: string): Promise<StockDetailInsights> {
|
||||||
|
const normalizedSymbol = symbol.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!normalizedSymbol) {
|
||||||
|
return {
|
||||||
|
sentimentInsights: null,
|
||||||
|
aiAnalysis: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return unstable_cache(() => generateStockDetailInsights(normalizedSymbol), ['stock-detail-insights', normalizedSymbol], {
|
||||||
|
revalidate: 900,
|
||||||
|
tags: [`stock-analysis:${normalizedSymbol}`],
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { STOCK_DETAIL_ANALYSIS_PROMPT } from '@/lib/inngest/prompts';
|
||||||
|
import { formatChangePercent, formatCurrency, formatMarketCapValue } from '@/lib/utils';
|
||||||
|
import type { FinnhubBasicFinancials } from './finnhub.actions';
|
||||||
|
import type { StockSentimentInsights } from './adanos.helpers';
|
||||||
|
|
||||||
|
type PriceSnapshot = {
|
||||||
|
c?: number;
|
||||||
|
d?: number;
|
||||||
|
dp?: number;
|
||||||
|
h?: number;
|
||||||
|
l?: number;
|
||||||
|
o?: number;
|
||||||
|
pc?: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type CompanyProfile = {
|
||||||
|
country?: string;
|
||||||
|
currency?: string;
|
||||||
|
exchange?: string;
|
||||||
|
finnhubIndustry?: string;
|
||||||
|
ipo?: string;
|
||||||
|
marketCapitalization?: number;
|
||||||
|
name?: string;
|
||||||
|
weburl?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type StockHeadline = {
|
||||||
|
headline: string;
|
||||||
|
summary: string;
|
||||||
|
source: string;
|
||||||
|
datetime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StockAIAnalysis {
|
||||||
|
symbol: string;
|
||||||
|
companyName: string | null;
|
||||||
|
stance: 'Bullish' | 'Neutral' | 'Bearish';
|
||||||
|
confidence: 'Low' | 'Medium' | 'High';
|
||||||
|
summary: string;
|
||||||
|
keyDrivers: string[];
|
||||||
|
risks: string[];
|
||||||
|
watchItems: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockAnalysisContext {
|
||||||
|
symbol: string;
|
||||||
|
quote: PriceSnapshot;
|
||||||
|
profile: CompanyProfile;
|
||||||
|
basicFinancials: FinnhubBasicFinancials | null;
|
||||||
|
sentimentInsights: StockSentimentInsights | null;
|
||||||
|
recentHeadlines: StockHeadline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: number | string | null | undefined): 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 formatOptionalCurrency(value: number | null): string {
|
||||||
|
return value === null ? 'Unavailable' : formatCurrency(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalMultiple(value: number | null): string {
|
||||||
|
return value === null ? 'Unavailable' : `${value.toFixed(2)}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalPercent(value: number | null): string {
|
||||||
|
return value === null ? 'Unavailable' : `${value.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampList(items: unknown, maxItems: number = 4): string[] {
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map((item) => item.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStance(value: unknown): StockAIAnalysis['stance'] {
|
||||||
|
return value === 'Bullish' || value === 'Neutral' || value === 'Bearish' ? value : 'Neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfidence(value: unknown): StockAIAnalysis['confidence'] {
|
||||||
|
return value === 'Low' || value === 'Medium' || value === 'High' ? value : 'Medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonPayload(raw: string): string | null {
|
||||||
|
const fencedMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fencedMatch?.[1]) {
|
||||||
|
return fencedMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstBrace = raw.indexOf('{');
|
||||||
|
const lastBrace = raw.lastIndexOf('}');
|
||||||
|
|
||||||
|
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.slice(firstBrace, lastBrace + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptContext(context: StockAnalysisContext) {
|
||||||
|
const metrics = context.basicFinancials?.metric ?? {};
|
||||||
|
const profile = context.profile;
|
||||||
|
const quote = context.quote;
|
||||||
|
const marketCapUsd =
|
||||||
|
typeof profile?.marketCapitalization === 'number' ? profile.marketCapitalization * 1000000 : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stock: {
|
||||||
|
symbol: context.symbol,
|
||||||
|
companyName: profile?.name ?? context.sentimentInsights?.companyName ?? null,
|
||||||
|
exchange: profile?.exchange ?? 'Unavailable',
|
||||||
|
industry: profile?.finnhubIndustry ?? 'Unavailable',
|
||||||
|
country: profile?.country ?? 'Unavailable',
|
||||||
|
ipo: profile?.ipo ?? 'Unavailable',
|
||||||
|
currency: profile?.currency ?? 'Unavailable',
|
||||||
|
website: profile?.weburl ?? 'Unavailable',
|
||||||
|
},
|
||||||
|
marketSnapshot: {
|
||||||
|
currentPrice: formatOptionalCurrency(toFiniteNumber(quote?.c)),
|
||||||
|
dayChange: formatChangePercent(toFiniteNumber(quote?.dp) ?? undefined) || 'Unavailable',
|
||||||
|
changeAmount: formatOptionalCurrency(toFiniteNumber(quote?.d)),
|
||||||
|
open: formatOptionalCurrency(toFiniteNumber(quote?.o)),
|
||||||
|
previousClose: formatOptionalCurrency(toFiniteNumber(quote?.pc)),
|
||||||
|
dayHigh: formatOptionalCurrency(toFiniteNumber(quote?.h)),
|
||||||
|
dayLow: formatOptionalCurrency(toFiniteNumber(quote?.l)),
|
||||||
|
},
|
||||||
|
fundamentals: {
|
||||||
|
marketCap: marketCapUsd === null ? 'Unavailable' : formatMarketCapValue(marketCapUsd),
|
||||||
|
peRatio: formatOptionalMultiple(toFiniteNumber(metrics.peBasicExclExtraTTM)),
|
||||||
|
epsTtm: formatOptionalCurrency(toFiniteNumber(metrics.epsTTM)),
|
||||||
|
beta: toFiniteNumber(metrics.beta)?.toFixed(2) ?? 'Unavailable',
|
||||||
|
fiftyTwoWeekHigh: formatOptionalCurrency(toFiniteNumber(metrics['52WeekHigh'])),
|
||||||
|
fiftyTwoWeekLow: formatOptionalCurrency(toFiniteNumber(metrics['52WeekLow'])),
|
||||||
|
fiftyTwoWeekReturn: formatOptionalPercent(toFiniteNumber(metrics['52WeekPriceReturnDaily'])),
|
||||||
|
analystTargetPrice: formatOptionalCurrency(toFiniteNumber(metrics.targetPrice)),
|
||||||
|
},
|
||||||
|
sentiment: context.sentimentInsights
|
||||||
|
? {
|
||||||
|
averageBuzz: `${context.sentimentInsights.averageBuzz}/100`,
|
||||||
|
bullishAverage:
|
||||||
|
context.sentimentInsights.bullishAverage === null
|
||||||
|
? 'Unavailable'
|
||||||
|
: `${context.sentimentInsights.bullishAverage.toFixed(1)}%`,
|
||||||
|
sourceAlignment: context.sentimentInsights.sourceAlignment,
|
||||||
|
sources: context.sentimentInsights.sources.map((source) => ({
|
||||||
|
source: source.label,
|
||||||
|
buzzScore: source.buzzScore,
|
||||||
|
bullishPct: source.bullishPct,
|
||||||
|
trend: source.trend ?? 'Unavailable',
|
||||||
|
metricLabel: source.metricLabel,
|
||||||
|
metricValue: source.metricValue,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: 'Unavailable',
|
||||||
|
recentHeadlines: context.recentHeadlines.length
|
||||||
|
? context.recentHeadlines.map((headline) => ({
|
||||||
|
source: headline.source,
|
||||||
|
headline: headline.headline,
|
||||||
|
summary: headline.summary,
|
||||||
|
publishedAt: new Date(headline.datetime * 1000).toISOString(),
|
||||||
|
}))
|
||||||
|
: 'Unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStockAnalysisPrompt(context: StockAnalysisContext) {
|
||||||
|
return STOCK_DETAIL_ANALYSIS_PROMPT.replace(
|
||||||
|
'{{stockContext}}',
|
||||||
|
JSON.stringify(buildPromptContext(context), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStockAnalysisResponse(
|
||||||
|
rawResponse: string,
|
||||||
|
symbol: string,
|
||||||
|
companyName: string | null,
|
||||||
|
): StockAIAnalysis | null {
|
||||||
|
const cleanedResponse = rawResponse.trim();
|
||||||
|
if (!cleanedResponse) return null;
|
||||||
|
|
||||||
|
const jsonPayload = extractJsonPayload(cleanedResponse);
|
||||||
|
|
||||||
|
if (!jsonPayload) {
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
companyName,
|
||||||
|
stance: 'Neutral',
|
||||||
|
confidence: 'Low',
|
||||||
|
summary: cleanedResponse.replace(/\s+/g, ' ').trim(),
|
||||||
|
keyDrivers: [],
|
||||||
|
risks: [],
|
||||||
|
watchItems: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonPayload) as {
|
||||||
|
stance?: unknown;
|
||||||
|
confidence?: unknown;
|
||||||
|
summary?: unknown;
|
||||||
|
keyDrivers?: unknown;
|
||||||
|
risks?: unknown;
|
||||||
|
watchItems?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
typeof parsed.summary === 'string' ? parsed.summary.replace(/\s+/g, ' ').trim() : '';
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
companyName,
|
||||||
|
stance: normalizeStance(parsed.stance),
|
||||||
|
confidence: normalizeConfidence(parsed.confidence),
|
||||||
|
summary,
|
||||||
|
keyDrivers: clampList(parsed.keyDrivers),
|
||||||
|
risks: clampList(parsed.risks),
|
||||||
|
watchItems: clampList(parsed.watchItems),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to parse stock analysis response for ${symbol}`, error);
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
companyName,
|
||||||
|
stance: 'Neutral',
|
||||||
|
confidence: 'Low',
|
||||||
|
summary: cleanedResponse.replace(/\s+/g, ' ').trim(),
|
||||||
|
keyDrivers: [],
|
||||||
|
risks: [],
|
||||||
|
watchItems: [],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,12 @@
|
||||||
* - "gemini" (default) – Google Gemini REST API
|
* - "gemini" (default) – Google Gemini REST API
|
||||||
* - "minimax" – MiniMax (OpenAI-compatible)
|
* - "minimax" – MiniMax (OpenAI-compatible)
|
||||||
* - "siray" – Siray.ai (OpenAI-compatible)
|
* - "siray" – Siray.ai (OpenAI-compatible)
|
||||||
|
* - "deepseek" – DeepSeek (OpenAI-compatible)
|
||||||
*
|
*
|
||||||
* Each provider returns a plain-text string from the model.
|
* Each provider returns a plain-text string from the model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AIProviderName = "gemini" | "minimax" | "siray";
|
export type AIProviderName = "gemini" | "minimax" | "siray" | "deepseek";
|
||||||
|
|
||||||
export interface AIProviderConfig {
|
export interface AIProviderConfig {
|
||||||
name: AIProviderName;
|
name: AIProviderName;
|
||||||
|
|
@ -47,6 +48,15 @@ export function getProviderConfig(
|
||||||
model: "siray-1.0-ultra",
|
model: "siray-1.0-ultra",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case "deepseek":
|
||||||
|
return {
|
||||||
|
name: "deepseek",
|
||||||
|
apiKey: process.env.DEEPSEEK_API_KEY || "",
|
||||||
|
baseUrl:
|
||||||
|
process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com/v1",
|
||||||
|
model: process.env.DEEPSEEK_MODEL || "deepseek-chat",
|
||||||
|
};
|
||||||
|
|
||||||
case "gemini":
|
case "gemini":
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,12 +77,36 @@ export function getFallbackProviderName(
|
||||||
primary: AIProviderName
|
primary: AIProviderName
|
||||||
): AIProviderName {
|
): AIProviderName {
|
||||||
if (primary === "gemini") {
|
if (primary === "gemini") {
|
||||||
// Prefer MiniMax as fallback when a key is available, else Siray
|
// Prefer MiniMax, then DeepSeek, then Siray as fallbacks
|
||||||
if (process.env.MINIMAX_API_KEY) return "minimax";
|
if (process.env.MINIMAX_API_KEY) return "minimax";
|
||||||
|
if (process.env.DEEPSEEK_API_KEY) return "deepseek";
|
||||||
if (process.env.SIRAY_API_KEY) return "siray";
|
if (process.env.SIRAY_API_KEY) return "siray";
|
||||||
return "minimax"; // caller will see missing-key error
|
return "deepseek"; // caller will see missing-key error
|
||||||
}
|
}
|
||||||
return "gemini";
|
// For non-Gemini primaries, fall back to Gemini
|
||||||
|
if (process.env.GEMINI_API_KEY) return "gemini";
|
||||||
|
// If no Gemini key either, try DeepSeek
|
||||||
|
if (process.env.DEEPSEEK_API_KEY) return "deepseek";
|
||||||
|
return "gemini"; // caller will see missing-key error
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConfiguredAIProvider(
|
||||||
|
provider?: AIProviderName
|
||||||
|
): boolean {
|
||||||
|
const primaryName =
|
||||||
|
provider ||
|
||||||
|
(process.env.AI_PROVIDER as AIProviderName) ||
|
||||||
|
"gemini";
|
||||||
|
const primaryConfig = getProviderConfig(primaryName);
|
||||||
|
|
||||||
|
if (primaryConfig.apiKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackConfig = getProviderConfig(
|
||||||
|
getFallbackProviderName(primaryName)
|
||||||
|
);
|
||||||
|
return Boolean(fallbackConfig.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Provider call implementations ──────────────────────────────────
|
// ── Provider call implementations ──────────────────────────────────
|
||||||
|
|
@ -157,7 +191,7 @@ export async function callAIProvider(
|
||||||
if (config.name === "gemini") {
|
if (config.name === "gemini") {
|
||||||
return callGemini(prompt, config);
|
return callGemini(prompt, config);
|
||||||
}
|
}
|
||||||
// MiniMax and Siray both use OpenAI-compatible endpoints
|
// MiniMax, Siray, and DeepSeek all use OpenAI-compatible endpoints
|
||||||
return callOpenAICompatible(prompt, config);
|
return callOpenAICompatible(prompt, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,3 +228,31 @@ EXAMPLES:
|
||||||
- Barclays PLC (BARC.L) from Finnhub → {"tradingViewSymbol": "LSE:BARC", "confidence": "high", "reasoning": "Barclays trades on London Stock Exchange as BARC"}
|
- Barclays PLC (BARC.L) from Finnhub → {"tradingViewSymbol": "LSE:BARC", "confidence": "high", "reasoning": "Barclays trades on London Stock Exchange as BARC"}
|
||||||
|
|
||||||
Your response must be valid JSON only. Do not include any other text.`
|
Your response must be valid JSON only. Do not include any other text.`
|
||||||
|
|
||||||
|
export const STOCK_DETAIL_ANALYSIS_PROMPT = `You are a senior equity research analyst writing a concise professional stock note for an investor dashboard.
|
||||||
|
|
||||||
|
You will receive structured market data, sentiment, and recent headlines for one stock.
|
||||||
|
|
||||||
|
ANALYSIS RULES:
|
||||||
|
1. Use ONLY the provided information. Do not invent facts, catalysts, valuations, or financial metrics.
|
||||||
|
2. Synthesize the information into an investor-friendly view that connects price action, fundamentals, sentiment, and recent news.
|
||||||
|
3. Be balanced. If the evidence is mixed, say so clearly.
|
||||||
|
4. Keep the tone professional and specific, not promotional.
|
||||||
|
5. Mention missing or limited data implicitly only when it affects confidence.
|
||||||
|
6. This is educational market analysis, not personal financial advice.
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
Return ONLY valid JSON with this exact shape:
|
||||||
|
{
|
||||||
|
"stance": "Bullish" | "Neutral" | "Bearish",
|
||||||
|
"confidence": "Low" | "Medium" | "High",
|
||||||
|
"summary": "90-140 word paragraph explaining the current stock setup and why",
|
||||||
|
"keyDrivers": ["2-4 short bullet strings about the main supporting factors"],
|
||||||
|
"risks": ["2-4 short bullet strings about the main risks or counterpoints"],
|
||||||
|
"watchItems": ["2-4 short bullet strings about what the investor should monitor next"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Keep each bullet string under 160 characters.
|
||||||
|
|
||||||
|
Stock context:
|
||||||
|
{{stockContext}}`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue