import YahooFinance from 'yahoo-finance2'; import { MungerStock, TradePlan } from './types'; import Alpaca from '@alpacahq/alpaca-trade-api'; // Suppress excessive logging from specific modules if needed // Suppress excessive logging from specific modules if needed const yahooFinance = new (YahooFinance as any)({ suppressNotices: ['yahooSurvey'] }); // Constants const BATCH_SIZE = 5; // Reduced from 50 to avoid 429 errors // Config for Advanced Engine const RISK_CONFIG = { atrPeriod: 14, kAtr: 2.0, rr: 3.0, riskPct: 0.005, // 0.5% maxPositionPct: 0.10, // 10% entryLimitBufferBps: 75.0 }; // Retry helper async function withRetry(fn: () => Promise, retries = 3, delay = 2000): Promise { try { return await fn(); } catch (e: any) { if (retries > 0 && (e.message?.includes('429') || e.message?.includes('Too Many Requests'))) { console.warn(`Hit 429, retrying in ${delay}ms... (${retries} left)`); await new Promise(r => setTimeout(r, delay)); return withRetry(fn, retries - 1, delay * 2); } throw e; } } export class MungerEngine { /** * Calculates the date of the last Sunday */ private static getLastSunday(): Date { const d = new Date(); const day = d.getDay(); const diff = d.getDate() - day; const sunday = new Date(d.setDate(diff)); sunday.setHours(23, 59, 59, 999); return sunday; } /** * Core analysis logic for a single stock */ private static async analyzeStock(symbol: string, refDate: Date, portfolioSymbols: string[] = []): Promise { try { const queryOptions = { period1: '2019-01-01', period2: refDate, interval: '1wk' as const }; // Fetch chart data let history: any; try { history = await withRetry(() => yahooFinance.chart(symbol, queryOptions)); } catch (e) { console.warn(`Failed to fetch chart for ${symbol}: ${(e as Error).message}`); return null; } const quotes = history.quotes.filter((q: any) => q.close && q.open); if (quotes.length < 205) return null; const latestIndex = quotes.length - 1; const currentPrice = quotes[latestIndex].close as number; // Simple MA 200 Calculation const calculateMA200 = (endIndex: number) => { if (endIndex < 199) return 0; const slice = quotes.slice(endIndex - 199, endIndex + 1).map((q: any) => q.close as number); return slice.reduce((a: number, b: number) => a + b, 0) / slice.length; }; const MA200 = calculateMA200(latestIndex); // Fetch fundamental data let summary: any; try { summary = await withRetry(() => yahooFinance.quoteSummary(symbol, { modules: ['financialData', 'price', 'summaryProfile'] })); } catch (e) { console.warn(`Failed to fetch summary for ${symbol}: ${(e as Error).message}`); return null; } const roe = summary.financialData?.returnOnEquity || 0; const debtToEquity = summary.financialData?.debtToEquity || 999; const marketCap = summary.price?.marketCap || 0; const sector = summary.summaryProfile?.sector || 'Unknown'; // Quality Check (Refined from requirements) // Note: Requirements say ROE > 15% (0.15) and Debt/Equity < 50 const isQuality = roe > 0.15 && debtToEquity < 50; // Strategy Logic let touchedWMA = false; for (let i = 0; i < 4; i++) { const idx = latestIndex - i; if (idx < 0) continue; const candle = quotes[idx]; const ma = calculateMA200(idx); if (candle.low <= ma) touchedWMA = true; } const lastCandle = quotes[latestIndex]; const isGreen = lastCandle.close > lastCandle.open; let signal: 'BUY_TRIGGER' | 'WATCHLIST' | 'IGNORE' | 'WAIT' | 'PORTFOLIO' = 'WATCHLIST'; if (!isQuality) { signal = 'IGNORE'; } else if (touchedWMA) { if (isGreen) { if (lastCandle.close > MA200) signal = 'BUY_TRIGGER'; else signal = 'WATCHLIST'; // Green and touched, but closed below WMA } else { signal = 'WAIT'; } } else { const dist = (currentPrice - MA200) / MA200; if (dist > 0 && dist < 0.05) signal = 'WATCHLIST'; // Within 5% else if (dist < 0) signal = 'WAIT'; else signal = 'IGNORE'; } // Mark symbols in portfolio - PORTFOLIO has priority over all signals const isInPortfolio = portfolioSymbols.includes(symbol); if (isInPortfolio) { signal = 'PORTFOLIO'; } // Details string let details = ''; if (signal === 'PORTFOLIO') { details = 'In Portfolio'; } else if (!isQuality) { details = 'Low Quality'; } else if (touchedWMA) { details = isGreen ? 'Rebound Confirm' : 'Testing Support'; } else { details = `Dist: ${(((currentPrice - MA200) / MA200) * 100).toFixed(1)}%`; } return { symbol, price: currentPrice, wma200: MA200, roe, debtToEquity, isQuality, lastCandleColor: isGreen ? 'GREEN' : 'RED', touchedWMA, signal, details, sector, marketCap, lastUpdated: new Date().toISOString(), tradePlan: (signal === 'BUY_TRIGGER' || isInPortfolio) ? await this.buildTradePlan(symbol, currentPrice, lastCandle.high) : undefined }; } catch (error: any) { console.error(`Error analyzing ${symbol}:`, error.message); return null; } } /** * Wilder's ATR Calculation */ private static calculateWilderATR(quotes: any[], period: number): number { if (quotes.length < period + 1) return 0; // 1. Calculate TRs const trs = []; for (let i = 1; i < quotes.length; i++) { const high = quotes[i].high; const low = quotes[i].low; const prevClose = quotes[i - 1].close; trs.push(Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose))); } if (trs.length < period) return 0; // 2. Initial SMA let atr = trs.slice(0, period).reduce((a, b) => a + b, 0) / period; // 3. Wilder Smoothing for (let i = period; i < trs.length; i++) { atr = (atr * (period - 1) + trs[i]) / period; } return atr; } /** * Get Alpaca Portfolio State */ private static async getAlpacaState(): Promise<{ equity: number; buyingPower: number; positions: any[] }> { const alpaca = new Alpaca({ keyId: process.env.ALPACA_KEY, secretKey: process.env.ALPACA_SECRET, paper: true, }); const account = await alpaca.getAccount(); const positions = await alpaca.getPositions(); return { equity: Number(account.equity), buyingPower: Number(account.buying_power), positions }; } /** * Constructs a deterministic trade plan based on the "Rebound Confirm" playbook */ private static async buildTradePlan(symbol: string, currentPrice: number, triggerHigh: number): Promise { // Fetch Daily Bars for ATR (Yahoo currently used for simplicity and consistency) // User requested Daily bars. analyzeStock used weekly. const dailyHistory = await yahooFinance.chart(symbol, { period1: '2023-01-01', // Enough for 14 ATR interval: '1d' }); const dailyQuotes = dailyHistory.quotes.filter((q: any) => q.close && q.open); const atr = this.calculateWilderATR(dailyQuotes, RISK_CONFIG.atrPeriod); // Fetch Alpaca State let alpacaState = { equity: 100000, buyingPower: 100000, positions: [] as any[] }; try { if (process.env.ALPACA_KEY) { alpacaState = await this.getAlpacaState(); } } catch (e) { console.warn('Failed to fetch Alpaca state, using defaults:', e); } // Logic const tick = 0.01; const triggerLevel = triggerHigh; const entryStop = Number((triggerLevel + tick).toFixed(2)); const entryLimit = Number((entryStop * (1 + RISK_CONFIG.entryLimitBufferBps / 10000)).toFixed(2)); const R = RISK_CONFIG.kAtr * atr; const stopLoss = Number((entryStop - R).toFixed(2)); const takeProfit = Number((entryStop + RISK_CONFIG.rr * R).toFixed(2)); // Sizing const riskBudget = alpacaState.equity * RISK_CONFIG.riskPct; const riskPerShare = entryStop - stopLoss; let qty = 0; let qtyByRisk = 0; let qtyByNotional = 0; let qtyByBuyingPower = 0; if (riskPerShare > 0) { qtyByRisk = Math.floor(riskBudget / riskPerShare); qtyByNotional = Math.floor((alpacaState.equity * RISK_CONFIG.maxPositionPct) / entryStop); qtyByBuyingPower = Math.floor(alpacaState.buyingPower / entryStop); qty = Math.max(0, Math.min(qtyByRisk, qtyByNotional, qtyByBuyingPower)); } const recommendation = qty >= 1 ? 'BUY' : 'NO_TRADE'; return { symbol, recommendation, playbook: 'BUY_TRIGGER_REBOUND_CONFIRM', riskModel: { atrPeriod: RISK_CONFIG.atrPeriod, atr: Number(atr.toFixed(2)), kAtr: RISK_CONFIG.kAtr, rr: RISK_CONFIG.rr }, levels: { entry: entryStop, entryLimit, stopLoss, takeProfit }, sizing: { equity: alpacaState.equity, buyingPower: alpacaState.buyingPower, riskPct: RISK_CONFIG.riskPct, riskBudget: Number(riskBudget.toFixed(2)), riskPerShare: Number(riskPerShare.toFixed(2)), qty, caps: { maxPositionPct: RISK_CONFIG.maxPositionPct, qtyByRisk, qtyByNotional, qtyByBuyingPower } }, orders: [ { role: 'ENTRY', type: 'STOP_LIMIT', side: 'BUY', stopPrice: entryStop, limitPrice: entryLimit, timeInForce: 'DAY', qty, expireAfterDays: 5 }, { role: 'EXIT_AFTER_FILL', type: 'OCO', side: 'SELL', takeProfitLimit: takeProfit, stopLossStop: stopLoss, qty } ], reasons: [ `signal=BUY_TRIGGER`, `playbook=REBOUND_CONFIRM`, `ATR_Risk_Model=1:${RISK_CONFIG.rr}` ], followUp: [ "Submit stop-limit entry.", "When filled, submit OCO exits (TP + SL).", "Cancel entry if not filled after 5 trading days." ] }; } /** * Processes a list of symbols in batches */ static async processBatch(symbols: string[], portfolioSymbols: string[] = [], onProgress?: (analyzed: number, total: number) => void): Promise { const lastSunday = this.getLastSunday(); const results: MungerStock[] = []; let processedCount = 0; // Chunking for (let i = 0; i < symbols.length; i += BATCH_SIZE) { const chunk = symbols.slice(i, i + BATCH_SIZE); console.log(`Processing batch ${i / BATCH_SIZE + 1} / ${Math.ceil(symbols.length / BATCH_SIZE)}...`); const promises = chunk.map(symbol => this.analyzeStock(symbol, lastSunday, portfolioSymbols)); const chunkResults = await Promise.all(promises); chunkResults.forEach(res => { if (res) results.push(res); }); processedCount += chunk.length; if (onProgress) { onProgress(Math.min(processedCount, symbols.length), symbols.length); } // Small delay to be polite to the API await new Promise(r => setTimeout(r, 500)); } return results; } /** * Deep dive method for single ticker */ static async deepDive(symbol: string): Promise { try { const [quote, summary, fundamentalsTimeSeries] = await Promise.all([ yahooFinance.quote(symbol), yahooFinance.quoteSummary(symbol, { modules: [ 'assetProfile', // Financial statements deprecated in quoteSummary, using fundamentalsTimeSeries instead 'calendarEvents', 'defaultKeyStatistics', 'earnings', 'earningsHistory', 'earningsTrend', 'financialData', 'fundOwnership', 'indexTrend', 'industryTrend', 'insiderHolders', 'insiderTransactions', 'institutionOwnership', 'majorDirectHolders', 'majorHoldersBreakdown', 'netSharePurchaseActivity', 'price', 'quoteType', 'recommendationTrend', 'secFilings', 'sectorTrend', 'summaryDetail', 'summaryProfile', 'upgradeDowngradeHistory' ] }).catch((e: any) => { console.warn(`Partial summary fail for ${symbol}: ${e.message}`); return {}; }), // Disable validation as Yahoo API frequently changes schema causing library errors yahooFinance.fundamentalsTimeSeries(symbol, { period1: '2020-01-01', module: 'all' }, { validateResult: false }).catch((e: any) => { console.warn(`Fundamentals fail for ${symbol}: ${e.message}`); return []; }) ]); return { quote, fundamentalsTimeSeries, ...summary }; } catch (e: any) { console.error(`Deep dive failed for ${symbol}`, e); throw e; } } }