194 lines
8.7 KiB
JavaScript
194 lines
8.7 KiB
JavaScript
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
|
|
},
|
|
};
|
|
}
|