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:
root 2026-06-01 17:11:45 +00:00
parent 8326133a70
commit 0ec58caa62
10 changed files with 688 additions and 12 deletions

View File

@ -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", () => {

View File

@ -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');
});
});

View File

@ -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

View File

@ -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>
);
}

View File

@ -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 [];

View File

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

View File

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

View File

@ -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);
}

View File

@ -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}}`

1
start.sh Executable file
View File

@ -0,0 +1 @@
docker compose up -d mongodb && docker compose up -d --build