munger-engine / lib /munger-engine.ts
dromerosm's picture
feat: integrate real Alpaca orders in detail page and chart with layer toggles
bf9d545
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;
}
}
}