diff --git a/__tests__/ai-provider.test.ts b/__tests__/ai-provider.test.ts index 1559c2f..ca9983f 100644 --- a/__tests__/ai-provider.test.ts +++ b/__tests__/ai-provider.test.ts @@ -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", () => { diff --git a/__tests__/stock-analysis.helpers.test.ts b/__tests__/stock-analysis.helpers.test.ts new file mode 100644 index 0000000..5307f73 --- /dev/null +++ b/__tests__/stock-analysis.helpers.test.ts @@ -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'); + }); +}); diff --git a/app/(root)/stocks/[symbol]/page.tsx b/app/(root)/stocks/[symbol]/page.tsx index 2bf4884..3b46ff8 100644 --- a/app/(root)/stocks/[symbol]/page.tsx +++ b/app/(root)/stocks/[symbol]/page.tsx @@ -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 (
@@ -63,12 +65,14 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
+ + +

{title}

+ {items.length ? ( +
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+ ) : ( +

No additional signals generated.

+ )} +
+ ); +} + +export default function StockAIAnalysisCard({ analysis }: StockAIAnalysisCardProps) { + if (!analysis) { + return ( +
+

AI Stock Analysis

+

Professional research note unavailable

+

+ The stock dashboard data loaded, but an AI research note could not be generated right now. +

+
+ ); + } + + return ( +
+
+
+
+

+ AI Stock Analysis +

+

+ {analysis.companyName ? `${analysis.companyName} (${analysis.symbol})` : analysis.symbol} +

+

{analysis.summary}

+
+ +
+ + {analysis.stance} + + + {analysis.confidence} confidence + +
+
+ +
+ {renderList('Key drivers', analysis.keyDrivers)} + {renderList('Risks', analysis.risks)} + {renderList('What to watch', analysis.watchItems)} +
+ +

+ Educational analysis only. Use it as a synthesis of the dashboard inputs, not as personal + financial advice. +

+
+
+ ); +} diff --git a/lib/actions/finnhub.actions.ts b/lib/actions/finnhub.actions.ts index 0c9fcb7..af6df13 100644 --- a/lib/actions/finnhub.actions.ts +++ b/lib/actions/finnhub.actions.ts @@ -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; }; 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(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 { + 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(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 []; diff --git a/lib/actions/stock-analysis.actions.ts b/lib/actions/stock-analysis.actions.ts new file mode 100644 index 0000000..2439b02 --- /dev/null +++ b/lib/actions/stock-analysis.actions.ts @@ -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 { + 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 { + 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}`], + })(); +} diff --git a/lib/actions/stock-analysis.helpers.ts b/lib/actions/stock-analysis.helpers.ts new file mode 100644 index 0000000..08b41d5 --- /dev/null +++ b/lib/actions/stock-analysis.helpers.ts @@ -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(), + }; + } +} diff --git a/lib/ai-provider.ts b/lib/ai-provider.ts index f9cc20d..5c68309 100644 --- a/lib/ai-provider.ts +++ b/lib/ai-provider.ts @@ -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); } diff --git a/lib/inngest/prompts.ts b/lib/inngest/prompts.ts index f6890f6..7a81dd5 100644 --- a/lib/inngest/prompts.ts +++ b/lib/inngest/prompts.ts @@ -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}}` diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..37be40d --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +docker compose up -d mongodb && docker compose up -d --build