Spaces:
Running
Running
| 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<T>(fn: () => Promise<T>, retries = 3, delay = 2000): Promise<T> { | |
| 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<MungerStock | null> { | |
| 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<TradePlan> { | |
| // 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<MungerStock[]> { | |
| 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<any> { | |
| 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; | |
| } | |
| } | |
| } | |