254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|