import { atrPercent, estimateRevisionsNet, calculateRvol } from './featureService.js'; /** * Scoring Engine * Generates testable signals. */ function gradeLabel(score) { if (score >= 80) return { label: 'A', color: 'emerald' }; if (score >= 60) return { label: 'B', color: 'blue' }; if (score >= 40) return { label: 'C', color: 'yellow' }; if (score >= 20) return { label: 'D', color: 'orange' }; return { label: 'F', color: 'red' }; } /** * Score a single stock entry * @returns {Object} { daytrade, shortTerm, longTerm } where each is { score, grade, drivers, dataAgeSec, stale, ... } */ export function scoreSuitability({ quote, technicals, fundamentals, regime, dataAgeSec }) { const q = quote || {}; const t = technicals || {}; const f = fundamentals || {}; const stale = dataAgeSec > 90; // stale if older than 90s // Compute raw features const rvol = calculateRvol(q.volume, f.avg_volume_10d, f.avg_volume_3m); const atrPct = t.candles ? atrPercent(t.candles, 14) : null; const revisionsNet = estimateRevisionsNet(f); const rsi = t.rsi_14 ?? null; const macdH = t.macd_histogram ?? null; const pctSma50 = t.pct_from_sma50 ?? null; const above200 = t.above_sma200 ?? null; const pctSma200 = t.pct_from_sma200 ?? null; const eg = f.earnings_growth ?? null; const pm = f.profit_margins ?? null; // ─── Daytrade Score (Logistic Regression) ─────────────────────────────────── // Weights from Phase 2 training (features: rsi/100, atrPct/10, rvol/5, macd/2, regime) const dtParts = []; // Safe values for dot product const safeRsi = rsi ?? 50; const safeAtr = atrPct ?? 1.0; const safeRvol = rvol ?? 1.0; const safeMacd = macdH ?? 0.0; const regimeFlag = regime?.trend === 'risk_on' ? 1 : 0; // Log-odds calculation (z) const intercept = -84.54; const wRsi = -50.36 * (safeRsi / 100); const wAtr = 13.90 * (safeAtr / 10); const wRvol = 37.27 * (safeRvol / 5); const wMacd = -395.0 * (safeMacd / 2); const wRegime = 41.80 * regimeFlag; const z = intercept + wRsi + wAtr + wRvol + wMacd + wRegime; // Sigmoid const pWin = 1 / (1 + Math.exp(-z)); // We use P(win) * 100 as the "score" for sorting, but we'll export it cleanly const dtScore = Math.round(pWin * 100); // Expose feature importance in the drivers for the UI dtParts.push({ key: 'rsi', label: 'RSI Impact', value: safeRsi, points: Math.round(wRsi), desc: `Oversold effect (w: ${wRsi.toFixed(1)})` }); dtParts.push({ key: 'atrPct', label: 'Volatility Impact', value: safeAtr, points: Math.round(wAtr), desc: `ATR push (w: ${wAtr.toFixed(1)})` }); dtParts.push({ key: 'rvol', label: 'Volume Impact', value: safeRvol, points: Math.round(wRvol), desc: `Relative Vol (w: ${wRvol.toFixed(1)})` }); dtParts.push({ key: 'macd', label: 'Trend Impact', value: safeMacd, points: Math.round(wMacd), desc: `MACD (w: ${wMacd.toFixed(1)})` }); if (regimeFlag) dtParts.push({ key: 'regime', label: 'Regime Impact', value: 1, points: Math.round(wRegime), desc: `Risk-On Boost` }); // ─── Short Term (Swing) Score ───────────────────────────────────────────── const stParts = []; let stMacdPts = 0, stMacdLbl = 'MACD neutral'; if (macdH != null) { if (macdH > 0.5) { stMacdPts = 30; stMacdLbl = `Strong MACD bullish`; } else if (macdH > 0) { stMacdPts = 22; stMacdLbl = `MACD bullish`; } else if (macdH > -0.2) { stMacdPts = 10; stMacdLbl = `MACD near crossover`; } } stParts.push({ key: 'macd', label: 'MACD setup', value: macdH, points: stMacdPts, desc: stMacdLbl }); let stRsiPts = 0, stRsiLbl = 'RSI extended'; if (rsi != null) { if (rsi >= 40 && rsi <= 60) { stRsiPts = 25; stRsiLbl = `RSI ${rsi.toFixed(0)} (swing zone)`; } else if (rsi >= 30 && rsi < 40) { stRsiPts = 20; stRsiLbl = `RSI ${rsi.toFixed(0)} (reset/dip)`; } else if (rsi > 60 && rsi <= 70) { stRsiPts = 15; stRsiLbl = `RSI ${rsi.toFixed(0)} (momentum)`; } else if (rsi <= 30) { stRsiPts = 10; stRsiLbl = `RSI oversold`; } else { stRsiPts = 2; } } stParts.push({ key: 'rsi', label: 'RSI sweet spot', value: rsi, points: stRsiPts, desc: stRsiLbl }); let stSmaPts = 0, stSmaLbl = 'Extended from SMA50'; if (pctSma50 != null) { const abs = Math.abs(pctSma50); if (abs <= 3) { stSmaPts = 25; stSmaLbl = `Near SMA50 (${pctSma50.toFixed(1)}%) - coiled`; } else if (abs <= 7) { stSmaPts = 18; stSmaLbl = `${pctSma50 > 0 ? 'Above' : 'Below'} SMA50 by ${abs.toFixed(1)}%`; } else if (abs <= 15) { stSmaPts = 8; stSmaLbl = `${abs.toFixed(1)}% from SMA50`; } } stParts.push({ key: 'dist50', label: 'SMA50 proximity', value: pctSma50, points: stSmaPts, desc: stSmaLbl }); // Revisions (Replacing Analyst Targets) let revPts = 0, revLbl = 'Revisions neutral'; if (revisionsNet > 0) { if (revisionsNet >= 5) { revPts = 20; revLbl = `Strong upward revisions`; } else if (revisionsNet >= 2) { revPts = 14; revLbl = `Net positive revisions`; } else { revPts = 8; revLbl = `Slight upward revisions`; } } else if (revisionsNet < 0) { revLbl = `Negative revisions`; } stParts.push({ key: 'revisions', label: 'Estimate revisions', value: revisionsNet, points: revPts, desc: revLbl }); const stScore = Math.min(100, Math.round(stParts.reduce((s, p) => s + p.points, 0))); // ─── Long Term Score ────────────────────────────────────────────────────── const ltParts = []; let ltSmaPts = 0, ltSmaLbl = 'Below SMA200'; if (above200 != null) { if (above200 && pctSma200 != null && pctSma200 > 10) { ltSmaPts = 30; ltSmaLbl = `${pctSma200.toFixed(0)}% above SMA200`; } else if (above200) { ltSmaPts = 22; ltSmaLbl = `Above SMA200`; } else if (pctSma200 != null && pctSma200 > -5) { ltSmaPts = 10; ltSmaLbl = `Near SMA200 support`; } } ltParts.push({ key: 'dist200', label: 'Macro trend', value: pctSma200, points: ltSmaPts, desc: ltSmaLbl }); let egPts = 0, egLbl = 'Negative growth'; if (eg != null) { if (eg >= 0.30) { egPts = 25; egLbl = `${(eg * 100).toFixed(0)}% earnings growth`; } else if (eg >= 0.15) { egPts = 18; egLbl = `${(eg * 100).toFixed(0)}% earnings growth`; } else if (eg >= 0.05) { egPts = 11; egLbl = `${(eg * 100).toFixed(0)}% growth`; } else if (eg >= 0) { egPts = 5; egLbl = `Flat growth`; } } ltParts.push({ key: 'epsGrowth', label: 'Earnings power', value: eg, points: egPts, desc: egLbl }); const totalRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0) + (f.rec_hold || 0) + (f.rec_sell || 0) + (f.rec_strong_sell || 0); const bullishRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0); let recPts = 0, recLbl = 'Bearish consensus'; if (totalRec > 0) { const bullPct = bullishRec / totalRec; if (bullPct >= 0.75) { recPts = 25; recLbl = `${(bullPct * 100).toFixed(0)}% bullish`; } else if (bullPct >= 0.55) { recPts = 16; recLbl = `${(bullPct * 100).toFixed(0)}% bullish`; } else if (bullPct >= 0.40) { recPts = 8; recLbl = `Mixed consensus`; } } ltParts.push({ key: 'conviction', label: 'Analyst conviction', value: bullishRec/totalRec, points: recPts, desc: recLbl }); let pmPts = 0, pmLbl = 'Unprofitable'; if (pm != null) { if (pm >= 0.25) { pmPts = 20; pmLbl = `${(pm * 100).toFixed(0)}% margin`; } else if (pm >= 0.15) { pmPts = 14; pmLbl = `${(pm * 100).toFixed(0)}% margin`; } else if (pm >= 0.05) { pmPts = 7; pmLbl = `${(pm * 100).toFixed(0)}% margin`; } else if (pm >= 0) { pmPts = 2; pmLbl = `Thin margin`; } } ltParts.push({ key: 'margin', label: 'Profit durability', value: pm, points: pmPts, desc: pmLbl }); const ltScore = Math.min(100, Math.round(ltParts.reduce((s, p) => s + p.points, 0))); // Raw features map for logging const featuresLog = { rvol, atrPct, rsi, macdHist: macdH, dist50: pctSma50, dist200: pctSma200, revisions: revisionsNet, epsGrowth: eg, margin: pm, beta: q.beta }; return { daytrade: { score: dtScore, grade: gradeLabel(dtScore), probability: pWin, drivers: dtParts.sort((a, b) => b.points - a.points), dataAgeSec, stale, features: featuresLog }, shortTerm: { score: stScore, grade: gradeLabel(stScore), drivers: stParts.sort((a, b) => b.points - a.points), dataAgeSec, stale, features: featuresLog }, longTerm: { score: ltScore, grade: gradeLabel(ltScore), drivers: ltParts.sort((a, b) => b.points - a.points), dataAgeSec, stale, features: featuresLog }, }; }