Closes #ISSUE-008 - Overreaction Scanner Overhaul: GJR-GARCH rebound gauge, catalyst drawers, and Category C small-caps
This commit is contained in:
427
app/api/scanner/route.ts
Normal file
427
app/api/scanner/route.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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];
|
||||
|
||||
// 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));
|
||||
|
||||
// Fetch chart details for all tickers in parallel
|
||||
const rawCharts = await Promise.allSettled(
|
||||
tickersPool.map(t => fetchYahooChart(t))
|
||||
);
|
||||
|
||||
const parsedResults: TickerDetails[] = [];
|
||||
|
||||
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 fund = top15Symbols.has(item.ticker)
|
||||
? await fetchFMPFundamentalData(item.ticker, fmpApiKey)
|
||||
: getMockFundamentals(item.ticker);
|
||||
|
||||
return {
|
||||
...item,
|
||||
...fund,
|
||||
dividendYield: fund.dividendYield ? Number((fund.dividendYield * (top15Symbols.has(item.ticker) ? 1 : 100)).toFixed(2)) : 0
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const response = NextResponse.json({ results: finalResults });
|
||||
response.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user