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 {
|
||||
getProviderConfig,
|
||||
getFallbackProviderName,
|
||||
hasConfiguredAIProvider,
|
||||
callAIProvider,
|
||||
callAIProviderWithFallback,
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
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 WatchlistButton from "@/components/WatchlistButton";
|
||||
import StockAIAnalysisCard from "@/components/stocks/StockAIAnalysisCard";
|
||||
import StockSentimentCard from "@/components/stocks/StockSentimentCard";
|
||||
import {
|
||||
SYMBOL_INFO_WIDGET_CONFIG,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
import { auth } from '@/lib/better-auth/auth';
|
||||
import { headers } from 'next/headers';
|
||||
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';
|
||||
|
||||
export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||
|
|
@ -25,10 +26,11 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
|||
headers: await headers()
|
||||
});
|
||||
const userId = session?.user?.id;
|
||||
const [isInWatchlist, sentimentInsights] = await Promise.all([
|
||||
const [isInWatchlist, stockInsights] = await Promise.all([
|
||||
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
||||
getStockSentimentInsights(symbol),
|
||||
getStockDetailInsights(symbol),
|
||||
]);
|
||||
const { sentimentInsights, aiAnalysis } = stockInsights;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<WatchlistButton
|
||||
symbol={symbol.toUpperCase()}
|
||||
company={symbol.toUpperCase()}
|
||||
company={aiAnalysis?.companyName ?? sentimentInsights?.companyName ?? symbol.toUpperCase()}
|
||||
isInWatchlist={isInWatchlist}
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StockAIAnalysisCard analysis={aiAnalysis} />
|
||||
|
||||
<StockSentimentCard insight={sentimentInsights} />
|
||||
|
||||
<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;
|
||||
d?: number;
|
||||
dp?: number;
|
||||
h?: number;
|
||||
l?: number;
|
||||
o?: number;
|
||||
pc?: number;
|
||||
};
|
||||
|
||||
type FinnhubCompanyProfile = {
|
||||
country?: string;
|
||||
currency?: string;
|
||||
exchange?: string;
|
||||
finnhubIndustry?: string;
|
||||
ipo?: string;
|
||||
logo?: string;
|
||||
marketCapitalization?: number;
|
||||
name?: string;
|
||||
ticker?: string;
|
||||
weburl?: string;
|
||||
};
|
||||
|
||||
export type FinnhubBasicFinancials = {
|
||||
metric?: Record<string, number | string | null | undefined>;
|
||||
};
|
||||
|
||||
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[]) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,15 @@
|
|||
* AI Provider abstraction for OpenStock.
|
||||
*
|
||||
* Supports multiple LLM backends via the AI_PROVIDER environment variable:
|
||||
* - "gemini" (default) – Google Gemini REST API
|
||||
* - "minimax" – MiniMax (OpenAI-compatible)
|
||||
* - "siray" – Siray.ai (OpenAI-compatible)
|
||||
* - "gemini" (default) – Google Gemini REST API
|
||||
* - "minimax" – MiniMax (OpenAI-compatible)
|
||||
* - "siray" – Siray.ai (OpenAI-compatible)
|
||||
* - "deepseek" – DeepSeek (OpenAI-compatible)
|
||||
*
|
||||
* 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 {
|
||||
name: AIProviderName;
|
||||
|
|
@ -47,6 +48,15 @@ export function getProviderConfig(
|
|||
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":
|
||||
default:
|
||||
return {
|
||||
|
|
@ -67,12 +77,36 @@ export function getFallbackProviderName(
|
|||
primary: AIProviderName
|
||||
): AIProviderName {
|
||||
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.DEEPSEEK_API_KEY) return "deepseek";
|
||||
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 ──────────────────────────────────
|
||||
|
|
@ -157,7 +191,7 @@ export async function callAIProvider(
|
|||
if (config.name === "gemini") {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
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