Spaces:
Running
Running
| /** | |
| * @module trendDetector | |
| * Multi-factor trend analysis combining EMA alignment, slope, | |
| * market structure, and price position. | |
| * Operates on arrays of candle objects: { time, open, high, low, close, volume } | |
| */ | |
| import { ema } from './indicators.js'; | |
| import { detectStructure } from './marketStructure.js'; | |
| /** | |
| * @typedef {Object} TrendAnalysis | |
| * @property {'strong_bullish'|'bullish'|'ranging'|'bearish'|'strong_bearish'} direction | |
| * @property {number} strength - 0-100 | |
| * @property {'bullish'|'bearish'|'mixed'} emaAlignment | |
| * @property {'bullish'|'bearish'|'ranging'} structureTrend | |
| * @property {'increasing'|'decreasing'|'neutral'} momentum | |
| * @property {string} details - Human-readable summary | |
| */ | |
| /** | |
| * Analyse trend on the given candle data. | |
| * | |
| * Scoring (100 total): | |
| * 25 pts – EMA alignment (perfect stack = full marks) | |
| * 25 pts – EMA slope (last 5 values directional) | |
| * 25 pts – Market structure (HH+HL / LH+LL) | |
| * 25 pts – Price position vs EMA 200 | |
| * | |
| * @param {{ time: number, open: number, high: number, low: number, close: number, volume: number }[]} candles | |
| * @returns {TrendAnalysis} | |
| */ | |
| export function analyzeTrend(candles) { | |
| const defaultResult = { | |
| direction: /** @type {const} */ ('ranging'), | |
| strength: 0, | |
| emaAlignment: /** @type {const} */ ('mixed'), | |
| structureTrend: /** @type {const} */ ('ranging'), | |
| momentum: /** @type {const} */ ('neutral'), | |
| details: 'Insufficient data for trend analysis.', | |
| }; | |
| if (!candles || candles.length < 200) { | |
| // We need at least 200 candles for EMA 200 | |
| // Gracefully degrade: try with what we have | |
| if (!candles || candles.length < 50) return defaultResult; | |
| } | |
| const closes = candles.map(c => c.close); | |
| const lastClose = closes[closes.length - 1]; | |
| // --- 1. EMA calculations --- | |
| const ema9 = ema(closes, 9); | |
| const ema21 = ema(closes, 21); | |
| const ema50 = ema(closes, 50); | |
| const ema200 = candles.length >= 200 ? ema(closes, 200) : null; | |
| const lastIdx = closes.length - 1; | |
| const e9 = ema9[lastIdx]; | |
| const e21 = ema21[lastIdx]; | |
| const e50 = ema50[lastIdx]; | |
| const e200 = ema200 ? ema200[lastIdx] : null; | |
| // --- Score tracking: positive = bullish, negative = bearish --- | |
| let bullScore = 0; | |
| let bearScore = 0; | |
| // --- Factor 1: EMA alignment (25 pts) --- | |
| let emaAlignment = /** @type {'bullish'|'bearish'|'mixed'} */ ('mixed'); | |
| if (!isNaN(e9) && !isNaN(e21) && !isNaN(e50)) { | |
| let bullAlignCount = 0; | |
| let bearAlignCount = 0; | |
| // Check pairwise ordering | |
| if (e9 > e21) bullAlignCount++; else if (e9 < e21) bearAlignCount++; | |
| if (e21 > e50) bullAlignCount++; else if (e21 < e50) bearAlignCount++; | |
| if (e200 !== null && !isNaN(e200)) { | |
| if (e50 > e200) bullAlignCount++; else if (e50 < e200) bearAlignCount++; | |
| // Perfect alignment = 3 pairs correct | |
| if (bullAlignCount === 3) { | |
| emaAlignment = 'bullish'; | |
| bullScore += 25; | |
| } else if (bearAlignCount === 3) { | |
| emaAlignment = 'bearish'; | |
| bearScore += 25; | |
| } else { | |
| // Partial | |
| bullScore += Math.round((bullAlignCount / 3) * 25); | |
| bearScore += Math.round((bearAlignCount / 3) * 25); | |
| } | |
| } else { | |
| // No EMA200 — score from 2 pairs | |
| if (bullAlignCount === 2) { | |
| emaAlignment = 'bullish'; | |
| bullScore += 20; | |
| } else if (bearAlignCount === 2) { | |
| emaAlignment = 'bearish'; | |
| bearScore += 20; | |
| } else { | |
| bullScore += Math.round((bullAlignCount / 2) * 20); | |
| bearScore += Math.round((bearAlignCount / 2) * 20); | |
| } | |
| } | |
| } | |
| // --- Factor 2: EMA slope — last 5 values of EMA21 (25 pts) --- | |
| let momentum = /** @type {'increasing'|'decreasing'|'neutral'} */ ('neutral'); | |
| if (lastIdx >= 5) { | |
| const slopeWindow = 5; | |
| const recentEma = ema21.slice(lastIdx - slopeWindow + 1, lastIdx + 1); | |
| const validEma = recentEma.filter(v => !isNaN(v)); | |
| if (validEma.length >= 3) { | |
| let rising = 0; | |
| let falling = 0; | |
| for (let i = 1; i < validEma.length; i++) { | |
| if (validEma[i] > validEma[i - 1]) rising++; | |
| else if (validEma[i] < validEma[i - 1]) falling++; | |
| } | |
| const total = validEma.length - 1; | |
| if (rising / total >= 0.7) { | |
| momentum = 'increasing'; | |
| bullScore += 25; | |
| } else if (falling / total >= 0.7) { | |
| momentum = 'decreasing'; | |
| bearScore += 25; | |
| } else { | |
| // Partial credit | |
| bullScore += Math.round((rising / total) * 25); | |
| bearScore += Math.round((falling / total) * 25); | |
| } | |
| } | |
| } | |
| // --- Factor 3: Market structure (25 pts) --- | |
| const structure = detectStructure(candles); | |
| const structureTrend = structure.trend; | |
| if (structureTrend === 'bullish') { | |
| bullScore += 25; | |
| } else if (structureTrend === 'bearish') { | |
| bearScore += 25; | |
| } else { | |
| // Partial based on individual flags | |
| if (structure.higherHighs) bullScore += 8; | |
| if (structure.higherLows) bullScore += 8; | |
| if (structure.lowerHighs) bearScore += 8; | |
| if (structure.lowerLows) bearScore += 8; | |
| } | |
| // --- Factor 4: Price position vs EMA200 (25 pts) --- | |
| if (e200 !== null && !isNaN(e200)) { | |
| if (lastClose > e200) { | |
| // Distance matters: the further above, the more bullish | |
| const distPct = ((lastClose - e200) / e200) * 100; | |
| bullScore += Math.min(25, Math.round(Math.min(distPct, 5) / 5 * 25)); | |
| } else { | |
| const distPct = ((e200 - lastClose) / e200) * 100; | |
| bearScore += Math.min(25, Math.round(Math.min(distPct, 5) / 5 * 25)); | |
| } | |
| } | |
| // --- Final direction and strength --- | |
| const netScore = bullScore - bearScore; // positive = bullish, negative = bearish | |
| const totalScore = Math.abs(netScore); | |
| const strength = Math.min(100, totalScore); | |
| let direction = /** @type {'strong_bullish'|'bullish'|'ranging'|'bearish'|'strong_bearish'} */ ('ranging'); | |
| if (netScore > 0) { | |
| if (strength >= 80) direction = 'strong_bullish'; | |
| else if (strength >= 55) direction = 'bullish'; | |
| else direction = 'ranging'; | |
| } else if (netScore < 0) { | |
| if (strength >= 80) direction = 'strong_bearish'; | |
| else if (strength >= 55) direction = 'bearish'; | |
| else direction = 'ranging'; | |
| } | |
| // --- Build details string --- | |
| const details = [ | |
| `Trend: ${direction} (strength ${strength}/100)`, | |
| `EMA alignment: ${emaAlignment} | EMA9=${e9?.toFixed(2)} EMA21=${e21?.toFixed(2)} EMA50=${e50?.toFixed(2)}${e200 !== null ? ' EMA200=' + e200?.toFixed(2) : ''}`, | |
| `Market structure: ${structureTrend} (HH:${structure.higherHighs} HL:${structure.higherLows} LH:${structure.lowerHighs} LL:${structure.lowerLows})`, | |
| `Momentum: ${momentum}`, | |
| `Bull score: ${bullScore} | Bear score: ${bearScore}`, | |
| ].join('\n'); | |
| return { | |
| direction, | |
| strength, | |
| emaAlignment, | |
| structureTrend, | |
| momentum, | |
| details, | |
| }; | |
| } | |