openstock/lib/actions/stock-analysis.helpers.ts

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