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