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