Files
investment-sandbox/app/api/scanner/route.ts

673 lines
24 KiB
TypeScript

import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
interface TickerDetails {
ticker: string;
name: string;
currentPrice: number;
peakPrice: number;
priceChange: number;
dayChange: number;
maDeviation: number;
dist52w: number;
rsi14: number;
returns: number[];
marketCap?: number;
trailingPE?: number;
forwardPE?: number;
peg?: number;
priceToBook?: number;
dividendYield?: number;
peadSector?: string;
announcementDate?: string;
daysElapsed?: number;
epsActual?: number;
epsConsensus?: number;
surprisePercent?: number;
driftStatus?: string;
isLiveApi?: boolean;
}
// 14-day Welles Wilder RSI solver
function calculateRSI14(prices: number[]): number {
if (prices.length < 15) return 50;
let gains = 0;
let losses = 0;
for (let i = 1; i <= 14; i++) {
const diff = prices[i] - prices[i - 1];
if (diff > 0) {
gains += diff;
} else {
losses -= diff;
}
}
let avgGain = gains / 14;
let avgLoss = losses / 14;
for (let i = 15; i < prices.length; i++) {
const diff = prices[i] - prices[i - 1];
const gain = diff > 0 ? diff : 0;
const loss = diff < 0 ? -diff : 0;
avgGain = (avgGain * 13 + gain) / 14;
avgLoss = (avgLoss * 13 + loss) / 14;
}
if (avgLoss === 0) return 100;
const rs = avgGain / avgLoss;
return 100 - 100 / (1 + rs);
}
// Predefined core universes
const US_MEGA_MID = [
'AAPL', 'MSFT', 'NVDA', 'TSLA', 'AMD', 'SMCI', 'NFLX', 'AMZN', 'GOOGL', 'META',
'WMT', 'JNJ', 'PG', 'MRK', 'PLTR', 'BABA', 'CVX', 'XOM', 'BAC', 'JPM',
'COST', 'DIS', 'ADBE', 'CRM', 'AVGO', 'QCOM', 'TXN', 'INTC', 'MU', 'AMAT',
'LRCX', 'NKE', 'SBUX', 'MCD', 'PEP', 'KO', 'GE', 'HON', 'CAT', 'DE',
'LMT', 'RTX', 'UNH', 'LLY', 'ABBV', 'MRNA', 'PFE', 'GILD', 'AMGN', 'V',
'MA', 'AXP', 'GS', 'MS', 'BLK', 'PYPL', 'SQ', 'ABNB', 'BKNG', 'WFC'
];
const EU_MEGA_MID = [
'ASML', 'SAP', 'MC.PA', 'OR.PA', 'NESN', 'NOVOB', 'SHEL', 'BP', 'HSBC', 'ALV.DE',
'VOW3.DE', 'BMW.DE', 'SIE.DE', 'DTE.DE', 'MBG.DE', 'BAS.DE', 'SAN.MC', 'BBVA.MC',
'ORANGE.PA', 'AIR.PA', 'BAYN.DE', 'BASF.DE', 'MUV2.DE', 'ENGI.PA', 'CS.PA'
];
const CRYPTO_UNIVERSE = [
'BTC-USD', 'ETH-USD', 'SOL-USD', 'ADA-USD', 'XRP-USD', 'DOGE-USD', 'DOT-USD',
'LINK-USD', 'LTC-USD', 'AVAX-USD', 'BNB-USD', 'TRX-USD', 'NEAR-USD'
];
// Predefined Category C small-cap tickers as defensive fallback
const US_SMALL_CAPS = [
'BMEA', 'RIG', 'RUN', 'GDRX', 'UPST', 'NKLA', 'MARA', 'RIOT', 'HUT', 'PLUG',
'FUBO', 'SOFI', 'PTON', 'OPEN', 'CLSK', 'CHPT', 'NIO', 'SPCE', 'LCID', 'BLNK',
'GPRO', 'WKHS', 'FCEL', 'BE', 'CLNE', 'VLN', 'SPWR', 'SUNW', 'OCGN', 'SENS'
];
const EU_SMALL_CAPS = [
'TOM2.AS', 'TIE.HE', 'ROO.L', 'S4.L', 'AO.L', 'CROP.L', 'GILD.AS'
];
// Fundamental mock dictionary covering fallback data
const MOCK_FUNDAMENTALS: Record<string, {
marketCap: number;
trailingPE: number;
forwardPE: number;
peg: number;
priceToBook: number;
dividendYield: number;
}> = {
'AAPL': { marketCap: 3000e9, trailingPE: 30.5, forwardPE: 26.8, peg: 1.5, priceToBook: 40.8, dividendYield: 0.005 },
'MSFT': { marketCap: 3200e9, trailingPE: 35.2, forwardPE: 30.1, peg: 1.8, priceToBook: 12.4, dividendYield: 0.007 },
'NVDA': { marketCap: 2800e9, trailingPE: 65.4, forwardPE: 32.5, peg: 0.9, priceToBook: 45.2, dividendYield: 0.0002 },
'TSLA': { marketCap: 600e9, trailingPE: 55.0, forwardPE: 42.0, peg: 2.1, priceToBook: 8.5, dividendYield: 0.0 },
'AMD': { marketCap: 250e9, trailingPE: 45.2, forwardPE: 28.5, peg: 1.4, priceToBook: 4.8, dividendYield: 0.0 },
'SMCI': { marketCap: 25e9, trailingPE: 22.4, forwardPE: 15.2, peg: 0.6, priceToBook: 6.2, dividendYield: 0.0 },
'NFLX': { marketCap: 280e9, trailingPE: 38.5, forwardPE: 29.2, peg: 1.3, priceToBook: 11.5, dividendYield: 0.0 },
'AMZN': { marketCap: 1800e9, trailingPE: 40.2, forwardPE: 32.1, peg: 1.2, priceToBook: 8.2, dividendYield: 0.0 },
'GOOGL':{ marketCap: 2100e9, trailingPE: 25.4, forwardPE: 21.2, peg: 1.1, priceToBook: 6.8, dividendYield: 0.0 },
'META': { marketCap: 1200e9, trailingPE: 28.1, forwardPE: 22.4, peg: 1.0, priceToBook: 7.5, dividendYield: 0.0 },
'WMT': { marketCap: 500e9, trailingPE: 26.5, forwardPE: 23.1, peg: 2.5, priceToBook: 5.4, dividendYield: 0.014 },
'JNJ': { marketCap: 380e9, trailingPE: 15.4, forwardPE: 14.2, peg: 2.8, priceToBook: 5.1, dividendYield: 0.032 },
'PG': { marketCap: 390e9, trailingPE: 24.2, forwardPE: 22.1, peg: 3.1, priceToBook: 7.2, dividendYield: 0.025 },
'MRK': { marketCap: 300e9, trailingPE: 16.8, forwardPE: 14.5, peg: 1.9, priceToBook: 4.9, dividendYield: 0.028 },
'PLTR': { marketCap: 85e9, trailingPE: 80.2, forwardPE: 55.4, peg: 1.7, priceToBook: 14.2, dividendYield: 0.0 },
'BABA': { marketCap: 180e9, trailingPE: 9.5, forwardPE: 8.2, peg: 0.8, priceToBook: 1.1, dividendYield: 0.012 },
'CVX': { marketCap: 290e9, trailingPE: 12.1, forwardPE: 11.2, peg: 2.0, priceToBook: 1.7, dividendYield: 0.042 },
'XOM': { marketCap: 480e9, trailingPE: 13.4, forwardPE: 12.2, peg: 1.8, priceToBook: 2.1, dividendYield: 0.037 },
'BAC': { marketCap: 310e9, trailingPE: 11.5, forwardPE: 10.4, peg: 1.5, priceToBook: 1.0, dividendYield: 0.024 },
'JPM': { marketCap: 550e9, trailingPE: 12.4, forwardPE: 11.5, peg: 1.6, priceToBook: 1.6, dividendYield: 0.022 }
};
// Generates typical small cap metrics based on ticker
function getMockFundamentals(ticker: string): {
marketCap: number;
trailingPE: number;
forwardPE: number;
peg: number;
priceToBook: number;
dividendYield: number;
} {
if (MOCK_FUNDAMENTALS[ticker]) {
return MOCK_FUNDAMENTALS[ticker];
}
if (ticker.endsWith('-USD')) {
return { marketCap: 5e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 };
}
// Generate deterministic mock small-cap variables
let hash = 0;
for (let i = 0; i < ticker.length; i++) {
hash = ticker.charCodeAt(i) + ((hash << 5) - hash);
}
const seedCap = Math.abs(hash % 18) + 1; // 1 to 18
const marketCap = seedCap * 100 * 1000000; // $100M to $1.8B
const trailingPE = 10 + Math.abs(hash % 30); // 10 to 40
const forwardPE = trailingPE * 0.82;
const peg = 0.5 + (Math.abs(hash % 15) / 10); // 0.5 to 2.0
const priceToBook = 1.0 + (Math.abs(hash % 40) / 10); // 1.0 to 5.0
const dividendYield = (Math.abs(hash % 6) / 2) / 100; // 0.0% to 3.0%
return {
marketCap,
trailingPE,
forwardPE,
peg,
priceToBook,
dividendYield
};
}
async function fetchFmpScreener(apiKey: string): Promise<string[]> {
const url = `https://financialmodelingprep.com/api/v3/stock-screener?marketCapLessThan=2000000000&volumeMoreThan=100000&limit=30&apikey=${apiKey}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (res.status === 429) {
throw new Error('RATE_LIMIT');
}
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
return data.map((item: any) => item.symbol).filter(Boolean);
}
}
} catch (err) {
console.warn("FMP Screener fetch failed or was rate limited, falling back. Reason:", err);
}
return [];
}
async function fetchYahooChart(ticker: string): Promise<TickerDetails | null> {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=1y&interval=1d`;
try {
const res = await fetch(url, {
cache: 'no-store',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
signal: AbortSignal.timeout(4000)
});
if (!res.ok) return null;
const data = await res.json();
const result = data.chart?.result?.[0];
if (!result) return null;
const closePrices = result.indicators?.quote?.[0]?.close || [];
const validPrices = closePrices.filter((p: any): p is number => typeof p === 'number' && p > 0);
if (validPrices.length < 15) return null;
const currentPrice = validPrices[validPrices.length - 1];
const prevPrice = validPrices[validPrices.length - 2] || currentPrice;
const dayChange = (currentPrice - prevPrice) / prevPrice;
const slice50 = validPrices.slice(-50);
const sma50 = slice50.reduce((a: number, b: number) => a + b, 0) / slice50.length;
const maDeviation = (currentPrice - sma50) / sma50;
const peak52w = Math.max(...validPrices);
const dist52w = (currentPrice - peak52w) / peak52w;
const rsi14 = calculateRSI14(validPrices);
const returns: number[] = [];
for (let i = 1; i < validPrices.length; i++) {
returns.push((validPrices[i] - validPrices[i - 1]) / validPrices[i - 1]);
}
const slice90 = validPrices.slice(-90);
const peak90 = Math.max(...slice90);
const priceChange = (currentPrice - peak90) / peak90;
return {
ticker,
name: result.meta?.longName || result.meta?.shortName || `${ticker} Corp.`,
currentPrice,
peakPrice: peak90,
priceChange,
dayChange,
maDeviation,
dist52w,
rsi14,
returns: returns.slice(-90)
};
} catch (err) {
return null;
}
}
// Generate simulated data in case of full Yahoo block/timeout
function generateSimulatedChart(ticker: string, mode: string): TickerDetails {
const isCrypto = ticker.endsWith('-USD');
const defaults = getMockFundamentals(ticker);
// Base price based on ticker length
let price = 50 + (ticker.charCodeAt(0) % 150);
if (isCrypto) price = ticker.startsWith('BTC') ? 65000 : ticker.startsWith('ETH') ? 3400 : 150;
// Make returns series
const returns: number[] = [];
let currentPrice = price;
const prices: number[] = [currentPrice];
const volSeed = isCrypto ? 0.04 : defaults.marketCap < 2e9 ? 0.03 : 0.015;
// Simulate 90 days
for (let i = 0; i < 90; i++) {
const shock = (Math.random() - 0.52) * volSeed * 2; // slightly downward biased
returns.push(shock);
currentPrice = currentPrice * (1 + shock);
prices.push(currentPrice);
}
// Create deviation triggers depending on selected mode to ensure we have volatile candidates
let dayChange = returns[returns.length - 1];
let dist52w = (currentPrice - Math.max(...prices)) / Math.max(...prices);
const slice50 = prices.slice(-50);
const sma50 = slice50.reduce((a: number, b: number) => a + b, 0) / slice50.length;
let maDeviation = (currentPrice - sma50) / sma50;
let rsi14 = 20 + (ticker.charCodeAt(0) % 35); // oversold region
// Apply a specific trigger shock to match the active tab mode
if (mode === 'day_crash') {
dayChange = -0.06 - (ticker.charCodeAt(0) % 10) / 100; // -6% to -16% drop
currentPrice = currentPrice * (1 + dayChange);
prices[prices.length - 1] = currentPrice;
} else if (mode === 'ma_drop') {
maDeviation = -0.15 - (ticker.charCodeAt(0) % 15) / 100; // -15% to -30% deviation
} else if (mode === '52w_dist') {
dist52w = -0.35 - (ticker.charCodeAt(0) % 25) / 100; // -35% to -60% distance
} else if (mode === 'rsi_oversold') {
rsi14 = 15 + (ticker.charCodeAt(0) % 15); // 15 to 30 RSI
}
const slice90 = prices.slice(-90);
const peak90 = Math.max(...slice90);
const priceChange = (currentPrice - peak90) / peak90;
return {
ticker,
name: isCrypto ? `${ticker.replace('-USD', '')} Protocol` : `${ticker} Corporation`,
currentPrice,
peakPrice: peak90,
priceChange,
dayChange,
maDeviation,
dist52w,
rsi14,
returns
};
}
function getSimulatedSloan(ticker: string): { sloanRatio: number; sloanRegime: 'SAFE' | 'ANOMALY' } {
let hash = 0;
for (let i = 0; i < ticker.length; i++) {
hash = ticker.charCodeAt(i) + ((hash << 5) - hash);
}
const sloanRatio = parseFloat(((hash % 170) / 10).toFixed(2)); // range: 0.0 to 17.0
const sloanRegime = (sloanRatio > 10 || sloanRatio < -10) ? ('ANOMALY' as const) : ('SAFE' as const);
return { sloanRatio, sloanRegime };
}
async function fetchFmpSloanRatio(ticker: string, apiKey: string): Promise<{ sloanRatio: number; sloanRegime: 'SAFE' | 'ANOMALY' }> {
if (ticker.includes('-USD') || ticker.includes('BTC') || ticker.includes('ETH')) {
return getSimulatedSloan(ticker);
}
try {
const incUrl = `https://financialmodelingprep.com/api/v3/income-statement/${ticker}?period=quarter&limit=1&apikey=${apiKey}`;
const balUrl = `https://financialmodelingprep.com/api/v3/balance-sheet-statement/${ticker}?period=quarter&limit=1&apikey=${apiKey}`;
const cfUrl = `https://financialmodelingprep.com/api/v3/cash-flow-statement/${ticker}?period=quarter&limit=1&apikey=${apiKey}`;
const [incRes, balRes, cfRes] = await Promise.all([
fetch(incUrl, { signal: AbortSignal.timeout(2000) }),
fetch(balUrl, { signal: AbortSignal.timeout(2000) }),
fetch(cfUrl, { signal: AbortSignal.timeout(2000) })
]);
if (incRes.ok && balRes.ok && cfRes.ok) {
const incData = await incRes.json();
const balData = await balRes.json();
const cfData = await cfRes.json();
const inc = incData?.[0] || {};
const bal = balData?.[0] || {};
const cf = cfData?.[0] || {};
const netIncome = inc.netIncome || 0;
const cfo = cf.netCashProvidedByOperatingActivities || cf.operatingCashFlow || 0;
const cfi = cf.netCashUsedForInvestingActivites || cf.netCashUsedForInvestingActivities || cf.investingCashFlow || 0;
const totalAssets = bal.totalAssets || 0;
const accruals = netIncome - (cfo + cfi);
const sloanRatio = totalAssets > 0 ? (accruals / totalAssets) * 100 : 0;
const sloanRegime = (sloanRatio > 10 || sloanRatio < -10) ? ('ANOMALY' as const) : ('SAFE' as const);
return {
sloanRatio: Number(sloanRatio.toFixed(2)),
sloanRegime
};
}
} catch (err) {
console.warn(`Error fetching FMP Sloan data for ${ticker}:`, err);
}
return getSimulatedSloan(ticker);
}
async function fetchFMPFundamentalData(ticker: string, apiKey: string) {
try {
const profileUrl = `https://financialmodelingprep.com/stable/profile?symbol=${ticker}&apikey=${apiKey}`;
const ratiosUrl = `https://financialmodelingprep.com/stable/ratios-ttm?symbol=${ticker}&apikey=${apiKey}`;
const [profRes, ratRes] = await Promise.all([
fetch(profileUrl, { signal: AbortSignal.timeout(2000) }),
fetch(ratiosUrl, { signal: AbortSignal.timeout(2000) })
]);
if (profRes.status === 429 || ratRes.status === 429) {
throw new Error('RATE_LIMIT');
}
if (profRes.ok && ratRes.ok) {
const profData = await profRes.json();
const ratData = await ratRes.json();
const profile = profData?.[0] || {};
const ratios = ratData?.[0] || {};
const marketCap = profile.marketCap || getMockFundamentals(ticker).marketCap;
const trailingPE = ratios.priceToEarningsRatioTTM || getMockFundamentals(ticker).trailingPE;
const peg = ratios.priceToEarningsGrowthRatioTTM || getMockFundamentals(ticker).peg;
const priceToBook = ratios.priceToBookRatioTTM || getMockFundamentals(ticker).priceToBook;
const dividendYield = ratios.dividendYieldTTM || getMockFundamentals(ticker).dividendYield;
return {
marketCap,
trailingPE: Number(trailingPE.toFixed(2)),
forwardPE: Number((trailingPE * 0.9).toFixed(2)),
peg: Number(peg.toFixed(2)),
priceToBook: Number(priceToBook.toFixed(2)),
dividendYield: Number((dividendYield * 100).toFixed(2))
};
}
} catch (_) {}
const mock = getMockFundamentals(ticker);
return { ...mock, dividendYield: mock.dividendYield * 100 };
}
function getSimulatedPEAD(ticker: string): {
peadSector: string;
announcementDate: string;
daysElapsed: number;
epsActual: number;
epsConsensus: number;
surprisePercent: number;
driftStatus: 'Active Drift' | 'Consolidating';
isLiveApi: boolean;
} {
const getSector = (t: string) => {
const tech = ['AAPL', 'MSFT', 'NVDA', 'AMD', 'SMCI', 'ADBE', 'CRM', 'AVGO', 'QCOM', 'TXN', 'INTC', 'MU', 'AMAT', 'LRCX', 'PLTR'];
const consumer = ['TSLA', 'NKE', 'SBUX', 'MCD', 'ABNB', 'BKNG', 'DIS', 'WMT', 'PG', 'COST', 'PEP', 'KO'];
const financial = ['BAC', 'JPM', 'GS', 'MS', 'BLK', 'PYPL', 'SQ', 'V', 'MA', 'AXP', 'WFC'];
const healthcare = ['JNJ', 'MRK', 'UNH', 'LLY', 'ABBV', 'MRNA', 'PFE', 'GILD', 'AMGN'];
const energy = ['CVX', 'XOM', 'SHEL', 'BP'];
if (tech.includes(t)) return 'Technology';
if (consumer.includes(t)) return 'Consumer Goods';
if (financial.includes(t)) return 'Financial Services';
if (healthcare.includes(t)) return 'Healthcare';
if (energy.includes(t)) return 'Energy';
return 'Conglomerate';
};
const sector = getSector(ticker);
if (ticker.includes('-USD') || ticker.includes('BTC') || ticker.includes('ETH') || ticker.includes('SOL')) {
return {
peadSector: 'Cryptocurrency',
announcementDate: 'N/A',
daysElapsed: 0,
epsActual: 0,
epsConsensus: 0,
surprisePercent: 0,
driftStatus: 'Consolidating',
isLiveApi: false
};
}
const charCodeSum = ticker.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const daysElapsed = (charCodeSum % 85) + 1; // 1 to 85
const today = new Date('2026-06-14');
today.setDate(today.getDate() - daysElapsed);
const announcementDate = today.toISOString().split('T')[0];
const consensusSeed = (charCodeSum % 4) + 0.5;
const epsConsensus = Number((consensusSeed + (charCodeSum % 10) / 10).toFixed(2));
const surprisePercent = Number((((charCodeSum % 30) - 12) + (charCodeSum % 10) / 10).toFixed(2));
const epsActual = Number((epsConsensus * (1 + surprisePercent / 100)).toFixed(2));
const driftStatus = (Math.abs(surprisePercent) > 4.5 && daysElapsed < 45) ? 'Active Drift' : 'Consolidating';
return {
peadSector: sector,
announcementDate,
daysElapsed,
epsActual,
epsConsensus,
surprisePercent,
driftStatus,
isLiveApi: false
};
}
async function fetchFmpEarningsSurprise(ticker: string, apiKey: string): Promise<{
peadSector: string;
announcementDate: string;
daysElapsed: number;
epsActual: number;
epsConsensus: number;
surprisePercent: number;
driftStatus: 'Active Drift' | 'Consolidating';
isLiveApi: boolean;
}> {
const getSector = (t: string) => {
const tech = ['AAPL', 'MSFT', 'NVDA', 'AMD', 'SMCI', 'ADBE', 'CRM', 'AVGO', 'QCOM', 'TXN', 'INTC', 'MU', 'AMAT', 'LRCX', 'PLTR'];
const consumer = ['TSLA', 'NKE', 'SBUX', 'MCD', 'ABNB', 'BKNG', 'DIS', 'WMT', 'PG', 'COST', 'PEP', 'KO'];
const financial = ['BAC', 'JPM', 'GS', 'MS', 'BLK', 'PYPL', 'SQ', 'V', 'MA', 'AXP', 'WFC'];
const healthcare = ['JNJ', 'MRK', 'UNH', 'LLY', 'ABBV', 'MRNA', 'PFE', 'GILD', 'AMGN'];
const energy = ['CVX', 'XOM', 'SHEL', 'BP'];
if (tech.includes(t)) return 'Technology';
if (consumer.includes(t)) return 'Consumer Goods';
if (financial.includes(t)) return 'Financial Services';
if (healthcare.includes(t)) return 'Healthcare';
if (energy.includes(t)) return 'Energy';
return 'Conglomerate';
};
const sector = getSector(ticker);
if (ticker.includes('-USD') || ticker.includes('BTC') || ticker.includes('ETH') || ticker.includes('SOL')) {
return {
peadSector: 'Cryptocurrency',
announcementDate: 'N/A',
daysElapsed: 0,
epsActual: 0,
epsConsensus: 0,
surprisePercent: 0,
driftStatus: 'Consolidating',
isLiveApi: false
};
}
try {
const url = `https://financialmodelingprep.com/api/v3/earnings-surprises/${ticker}?apikey=${apiKey}`;
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
const latest = data[0];
const dateStr = latest.date;
const epsActual = latest.actualEarningResult !== null ? latest.actualEarningResult : 0;
const epsConsensus = latest.estimatedEarning !== null ? latest.estimatedEarning : 0;
// Calculate days elapsed since announcement
const annDate = new Date(dateStr);
const today = new Date('2026-06-14'); // System date lock
const diffTime = Math.abs(today.getTime() - annDate.getTime());
const daysElapsed = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Surprise % calculation
const estDenom = epsConsensus === 0 ? 0.01 : Math.abs(epsConsensus);
const surprisePercent = Number((((epsActual - epsConsensus) / estDenom) * 100).toFixed(2));
const driftStatus = (Math.abs(surprisePercent) > 4.5 && daysElapsed < 45) ? 'Active Drift' : 'Consolidating';
return {
peadSector: sector,
announcementDate: dateStr,
daysElapsed,
epsActual,
epsConsensus,
surprisePercent,
driftStatus,
isLiveApi: true
};
}
}
} catch (err) {
console.warn(`Error fetching earnings surprise for ${ticker}:`, err);
}
// Fallback to deterministic simulation
return {
...getSimulatedPEAD(ticker),
isLiveApi: false
};
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const mode = searchParams.get('mode') || 'day_crash';
const region = searchParams.get('region') || 'us';
const fmpApiKey = process.env.FMP_API_KEY || 'U6lOXaOFPye7oc1D235kyAqJeQaiTAWc';
const isDevMode = process.env.DEV_MODE === 'true';
let tickersPool: string[] = [];
if (region === 'eu') {
tickersPool = [...EU_MEGA_MID, ...EU_SMALL_CAPS];
} else if (region === 'crypto') {
tickersPool = CRYPTO_UNIVERSE;
} else {
// US region: Large/Mid pool + Small Cap pool
tickersPool = [...US_MEGA_MID];
if (isDevMode) {
tickersPool.push(...US_SMALL_CAPS);
} else {
// Try to load FMP Small Caps or use static Small-Caps fallback list
let fmpSmallCaps: string[] = [];
try {
fmpSmallCaps = await fetchFmpScreener(fmpApiKey);
} catch (_) {}
if (fmpSmallCaps.length > 0) {
tickersPool.push(...fmpSmallCaps);
} else {
tickersPool.push(...US_SMALL_CAPS);
}
}
}
// De-duplicate tickers pool
tickersPool = Array.from(new Set(tickersPool));
const parsedResults: TickerDetails[] = [];
if (isDevMode) {
// Bypass Yahoo fetches completely, generate simulated charts directly!
tickersPool.forEach((ticker) => {
parsedResults.push(generateSimulatedChart(ticker, mode));
});
} else {
// Fetch chart details for all tickers in parallel
const rawCharts = await Promise.allSettled(
tickersPool.map(t => fetchYahooChart(t))
);
tickersPool.forEach((ticker, idx) => {
const res = rawCharts[idx];
if (res.status === 'fulfilled' && res.value) {
parsedResults.push(res.value);
} else {
// Fetch failed, use high-fidelity simulation
parsedResults.push(generateSimulatedChart(ticker, mode));
}
});
}
// Fetch fundamentals for top 15 candidates to reduce payload weight
// Sort candidates first based on mode to isolate top 15
let sortedCandidates = [...parsedResults];
if (mode === 'ma_drop') {
sortedCandidates.sort((a, b) => a.maDeviation - b.maDeviation);
} else if (mode === '52w_dist') {
sortedCandidates.sort((a, b) => a.dist52w - b.dist52w);
} else if (mode === 'rsi_oversold') {
sortedCandidates.sort((a, b) => a.rsi14 - b.rsi14);
} else {
sortedCandidates.sort((a, b) => a.dayChange - b.dayChange);
}
const top15Symbols = new Set(sortedCandidates.slice(0, 15).map(c => c.ticker));
// Overlay fundamentals
const finalResults = await Promise.all(
sortedCandidates.map(async (item) => {
const useMock = isDevMode || !top15Symbols.has(item.ticker);
const fund = useMock
? getMockFundamentals(item.ticker)
: await fetchFMPFundamentalData(item.ticker, fmpApiKey);
const sloan = useMock
? getSimulatedSloan(item.ticker)
: await fetchFmpSloanRatio(item.ticker, fmpApiKey);
const pead = useMock
? getSimulatedPEAD(item.ticker)
: await fetchFmpEarningsSurprise(item.ticker, fmpApiKey);
return {
...item,
...fund,
dividendYield: fund.dividendYield ? Number((fund.dividendYield * (useMock ? 100 : 1)).toFixed(2)) : 0,
...sloan,
...pead
};
})
);
const response = NextResponse.json({
results: finalResults,
isShieldActive: isDevMode
});
response.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
if (isDevMode) {
response.headers.set('X-Shield-Active', 'true');
}
return response;
}