diff --git a/libs/utils/src/calculations/index.ts b/libs/utils/src/calculations/index.ts index 90b9094..97f0d30 100644 --- a/libs/utils/src/calculations/index.ts +++ b/libs/utils/src/calculations/index.ts @@ -44,7 +44,44 @@ export type { export * from './basic-calculations'; // Export working technical indicators (building one by one) -export { sma, ema, rsi, macd, bollingerBands, atr, obv, stochastic } from './technical-indicators'; +export { + sma, + ema, + rsi, + macd, + bollingerBands, + atr, + obv, + stochastic, + williamsR, + cci, + mfi, + vwma, + momentum, + roc, + accumulationDistribution, + aroon, + adx, + parabolicSAR, + chaikinMoneyFlow, + pivotPoints, + ichimoku, + ultimateOscillator, + easeOfMovement, + tema, + wma, + keltnerChannels, + donchianChannels, + klingerVolumeOscillator, + priceOscillator, + chandeMomentumOscillator, + stochasticRSI, + vortexIndicator, + balanceOfPower, + trix, + massIndex, + coppockCurve +} from './technical-indicators'; // export * from './risk-metrics'; // export * from './portfolio-analytics'; // export * from './options-pricing'; diff --git a/libs/utils/src/calculations/technical-indicators.ts b/libs/utils/src/calculations/technical-indicators.ts index 92e7b83..715cc40 100644 --- a/libs/utils/src/calculations/technical-indicators.ts +++ b/libs/utils/src/calculations/technical-indicators.ts @@ -260,3 +260,1063 @@ export function stochastic( return { k: kValues, d: dValues }; } + +/** + * Williams %R + */ +export function williamsR(ohlcv: OHLCV[], period: number = 14): number[] { + if (period >= ohlcv.length) { + return []; + } + + const result: number[] = []; + + for (let i = period - 1; i < ohlcv.length; i++) { + const slice = ohlcv.slice(i - period + 1, i + 1); + const highest = Math.max(...slice.map(d => d.high)); + const lowest = Math.min(...slice.map(d => d.low)); + const current = ohlcv[i]; + if (!current) { + continue; + } + const currentClose = current.close; + + if (highest === lowest) { + result.push(-50); // Avoid division by zero + } else { + const wrValue = ((highest - currentClose) / (highest - lowest)) * -100; + result.push(wrValue); + } + } + + return result; +} + +/** + * Commodity Channel Index (CCI) + */ +export function cci(ohlcv: OHLCV[], period: number = 20): number[] { + if (period >= ohlcv.length) { + return []; + } + + const typicalPrices = ohlcv.map(d => (d.high + d.low + d.close) / 3); + const smaTP = sma(typicalPrices, period); + const result: number[] = []; + + for (let i = 0; i < smaTP.length; i++) { + const slice = typicalPrices.slice(i, i + period); + const mean = smaTP[i]; + if (mean === undefined) { + continue; + } + const meanDeviation = slice.reduce((sum, value) => sum + Math.abs(value - mean), 0) / period; + const typicalPrice = typicalPrices[i + period - 1]; + if (typicalPrice === undefined) { + continue; + } + + if (meanDeviation === 0) { + result.push(0); + } else { + const cciValue = (typicalPrice - mean) / (0.015 * meanDeviation); + result.push(cciValue); + } + } + + return result; +} + +/** + * Money Flow Index (MFI) + */ +export function mfi(ohlcv: OHLCV[], period: number = 14): number[] { + if (period >= ohlcv.length) { + return []; + } + + const typicalPrices = ohlcv.map(d => (d.high + d.low + d.close) / 3); + const moneyFlows = ohlcv.map((d, i) => { + const tp = typicalPrices[i]; + return tp !== undefined ? tp * d.volume : 0; + }); + + const result: number[] = []; + + for (let i = period; i < ohlcv.length; i++) { + let positiveFlow = 0; + let negativeFlow = 0; + + for (let j = i - period + 1; j <= i; j++) { + if (j > 0) { + const currentTP = typicalPrices[j]; + const prevTP = typicalPrices[j - 1]; + const currentMF = moneyFlows[j]; + if (currentTP !== undefined && prevTP !== undefined && currentMF !== undefined) { + if (currentTP > prevTP) { + positiveFlow += currentMF; + } else if (currentTP < prevTP) { + negativeFlow += currentMF; + } + } + } + } + + if (negativeFlow === 0) { + result.push(100); + } else { + const mfiRatio = positiveFlow / negativeFlow; + const mfiValue = 100 - 100 / (1 + mfiRatio); + result.push(mfiValue); + } + } + + return result; +} + +/** + * Volume Weighted Moving Average (VWMA) + */ +export function vwma(ohlcv: OHLCV[], period: number = 20): number[] { + if (period >= ohlcv.length) { + return []; + } + + const result: number[] = []; + + for (let i = period - 1; i < ohlcv.length; i++) { + const slice = ohlcv.slice(i - period + 1, i + 1); + + let totalVolumePrice = 0; + let totalVolume = 0; + + for (const candle of slice) { + const typicalPrice = (candle.high + candle.low + candle.close) / 3; + totalVolumePrice += typicalPrice * candle.volume; + totalVolume += candle.volume; + } + + const vwmaValue = totalVolume > 0 ? totalVolumePrice / totalVolume : 0; + result.push(vwmaValue); + } + + return result; +} + +/** + * Momentum + */ +export function momentum(prices: number[], period: number = 10): number[] { + if (period >= prices.length) { + return []; + } + + const result: number[] = []; + + for (let i = period; i < prices.length; i++) { + const current = prices[i]; + const previous = prices[i - period]; + if (current !== undefined && previous !== undefined) { + const momentum = current - previous; + result.push(momentum); + } + } + + return result; +} + +/** + * Rate of Change (ROC) + */ +export function roc(prices: number[], period: number = 10): number[] { + if (period >= prices.length) { + return []; + } + + const result: number[] = []; + + for (let i = period; i < prices.length; i++) { + const current = prices[i]; + const previous = prices[i - period]; + if (current !== undefined && previous !== undefined) { + if (previous === 0) { + result.push(0); + } else { + const rocValue = ((current - previous) / previous) * 100; + result.push(rocValue); + } + } + } + + return result; +} + +/** + * Accumulation/Distribution Line + */ +export function accumulationDistribution(ohlcv: OHLCV[]): number[] { + if (ohlcv.length === 0) { + return []; + } + + const result: number[] = []; + let adLine = 0; + + for (const candle of ohlcv) { + if (candle.high === candle.low) { + // Avoid division by zero + result.push(adLine); + continue; + } + + const moneyFlowMultiplier = + (candle.close - candle.low - (candle.high - candle.close)) / (candle.high - candle.low); + const moneyFlowVolume = moneyFlowMultiplier * candle.volume; + adLine += moneyFlowVolume; + result.push(adLine); + } + + return result; +} + +/** + * Aroon Indicator + */ +export function aroon(ohlcv: OHLCV[], period: number = 14): { up: number[]; down: number[] } { + if (period >= ohlcv.length) { + return { up: [], down: [] }; + } + + const up: number[] = []; + const down: number[] = []; + + for (let i = period - 1; i < ohlcv.length; i++) { + const slice = ohlcv.slice(i - period + 1, i + 1); + + // Find highest high and lowest low positions + let highestIndex = 0; + let lowestIndex = 0; + + for (let j = 1; j < slice.length; j++) { + const current = slice[j]; + const highest = slice[highestIndex]; + const lowest = slice[lowestIndex]; + if (current && highest && lowest) { + if (current.high > highest.high) { + highestIndex = j; + } + if (current.low < lowest.low) { + lowestIndex = j; + } + } + } + + const aroonUp = ((period - 1 - highestIndex) / (period - 1)) * 100; + const aroonDown = ((period - 1 - lowestIndex) / (period - 1)) * 100; + + up.push(aroonUp); + down.push(aroonDown); + } + + return { up, down }; +} + +/** + * Average Directional Movement Index (ADX) and Directional Movement Indicators (DMI) + */ +export function adx( + ohlcv: OHLCV[], + period: number = 14 +): { adx: number[]; plusDI: number[]; minusDI: number[] } { + if (period >= ohlcv.length) { + return { adx: [], plusDI: [], minusDI: [] }; + } + + const trueRanges: number[] = []; + const plusDM: number[] = []; + const minusDM: number[] = []; + + // Calculate True Range and Directional Movements + for (let i = 1; i < ohlcv.length; i++) { + const current = ohlcv[i]; + const previous = ohlcv[i - 1]; + if (!current || !previous) continue; + + // True Range + const tr = Math.max( + current.high - current.low, + Math.abs(current.high - previous.close), + Math.abs(current.low - previous.close) + ); + trueRanges.push(tr); + + // Directional Movements + const highDiff = current.high - previous.high; + const lowDiff = previous.low - current.low; + + const plusDMValue = highDiff > lowDiff && highDiff > 0 ? highDiff : 0; + const minusDMValue = lowDiff > highDiff && lowDiff > 0 ? lowDiff : 0; + + plusDM.push(plusDMValue); + minusDM.push(minusDMValue); + } + + // Calculate smoothed averages + const atrValues = sma(trueRanges, period); + const smoothedPlusDM = sma(plusDM, period); + const smoothedMinusDM = sma(minusDM, period); + + const plusDI: number[] = []; + const minusDI: number[] = []; + const dx: number[] = []; + + // Calculate DI+ and DI- + for (let i = 0; i < atrValues.length; i++) { + const atr = atrValues[i]; + const plusDMSmoothed = smoothedPlusDM[i]; + const minusDMSmoothed = smoothedMinusDM[i]; + if (atr === undefined || plusDMSmoothed === undefined || minusDMSmoothed === undefined) continue; + + const diPlus = atr > 0 ? (plusDMSmoothed / atr) * 100 : 0; + const diMinus = atr > 0 ? (minusDMSmoothed / atr) * 100 : 0; + + plusDI.push(diPlus); + minusDI.push(diMinus); + + // Calculate DX + const diSum = diPlus + diMinus; + const dxValue = diSum > 0 ? (Math.abs(diPlus - diMinus) / diSum) * 100 : 0; + dx.push(dxValue); + } + + // Calculate ADX (smoothed DX) + const adxValues = sma(dx, period); + + return { + adx: adxValues, + plusDI: plusDI.slice(period - 1), + minusDI: minusDI.slice(period - 1), + }; +} + +/** + * Parabolic SAR + */ +export function parabolicSAR( + ohlcv: OHLCV[], + step: number = 0.02, + maxStep: number = 0.2 +): number[] { + if (ohlcv.length < 2) { + return []; + } + + const first = ohlcv[0]; + if (!first) return []; + + const result: number[] = []; + let trend = 1; // 1 for uptrend, -1 for downtrend + let acceleration = step; + let extremePoint = first.high; + let sar = first.low; + + result.push(sar); + + for (let i = 1; i < ohlcv.length; i++) { + const curr = ohlcv[i]; + const prev = ohlcv[i - 1]; + if (!curr || !prev) continue; + + // Calculate new SAR + sar = sar + acceleration * (extremePoint - sar); + + if (trend === 1) { + // Uptrend + if (curr.low <= sar) { + // Trend reversal + trend = -1; + sar = extremePoint; + extremePoint = curr.low; + acceleration = step; + } else { + if (curr.high > extremePoint) { + extremePoint = curr.high; + acceleration = Math.min(acceleration + step, maxStep); + } + // Ensure SAR doesn't exceed previous lows + const prevPrev = i > 1 ? ohlcv[i - 2] : null; + sar = Math.min(sar, prev.low, prevPrev ? prevPrev.low : prev.low); + } + } else { + // Downtrend + if (curr.high >= sar) { + // Trend reversal + trend = 1; + sar = extremePoint; + extremePoint = curr.high; + acceleration = step; + } else { + if (curr.low < extremePoint) { + extremePoint = curr.low; + acceleration = Math.min(acceleration + step, maxStep); + } + // Ensure SAR doesn't exceed previous highs + const prevPrev = i > 1 ? ohlcv[i - 2] : null; + sar = Math.max(sar, prev.high, prevPrev ? prevPrev.high : prev.high); + } + } + + result.push(sar); + } + + return result; +} + +/** + * Chaikin Money Flow (CMF) + */ +export function chaikinMoneyFlow(ohlcv: OHLCV[], period: number = 20): number[] { + if (period >= ohlcv.length) { + return []; + } + + const adValues: number[] = []; + + for (const candle of ohlcv) { + if (candle.high === candle.low) { + adValues.push(0); + } else { + const moneyFlowMultiplier = + (candle.close - candle.low - (candle.high - candle.close)) / (candle.high - candle.low); + const moneyFlowVolume = moneyFlowMultiplier * candle.volume; + adValues.push(moneyFlowVolume); + } + } + + const result: number[] = []; + + for (let i = period - 1; i < ohlcv.length; i++) { + const sumAD = adValues.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + const sumVolume = ohlcv.slice(i - period + 1, i + 1).reduce((a, b) => a + b.volume, 0); + + if (sumVolume === 0) { + result.push(0); + } else { + result.push(sumAD / sumVolume); + } + } + + return result; +} + +/** + * Pivot Points (Standard) + */ +export function pivotPoints(ohlcv: OHLCV[]): Array<{ + pivot: number; + r1: number; + r2: number; + r3: number; + s1: number; + s2: number; + s3: number; +}> { + const result: Array<{ + pivot: number; + r1: number; + r2: number; + r3: number; + s1: number; + s2: number; + s3: number; + }> = []; + + for (const candle of ohlcv) { + const pivot = (candle.high + candle.low + candle.close) / 3; + const r1 = 2 * pivot - candle.low; + const s1 = 2 * pivot - candle.high; + const r2 = pivot + (candle.high - candle.low); + const s2 = pivot - (candle.high - candle.low); + const r3 = candle.high + 2 * (pivot - candle.low); + const s3 = candle.low - 2 * (candle.high - pivot); + + result.push({ pivot, r1, r2, r3, s1, s2, s3 }); + } + + return result; +} + +/** + * Ichimoku Cloud components + */ +export function ichimoku( + ohlcv: OHLCV[], + tenkanPeriod: number = 9, + kijunPeriod: number = 26, + senkouBPeriod: number = 52, + _displacement: number = 26 +): { + tenkanSen: number[]; + kijunSen: number[]; + senkouSpanA: number[]; + senkouSpanB: number[]; + chikouSpan: number[]; +} { + const tenkanSen: number[] = []; + const kijunSen: number[] = []; + const senkouSpanA: number[] = []; + const senkouSpanB: number[] = []; + const chikouSpan: number[] = []; + + for (let i = 0; i < ohlcv.length; i++) { + // Tenkan-sen (Conversion Line) + if (i >= tenkanPeriod - 1) { + const slice = ohlcv.slice(i - tenkanPeriod + 1, i + 1); + const high = Math.max(...slice.map(d => d.high)); + const low = Math.min(...slice.map(d => d.low)); + tenkanSen.push((high + low) / 2); + } + + // Kijun-sen (Base Line) + if (i >= kijunPeriod - 1) { + const slice = ohlcv.slice(i - kijunPeriod + 1, i + 1); + const high = Math.max(...slice.map(d => d.high)); + const low = Math.min(...slice.map(d => d.low)); + kijunSen.push((high + low) / 2); + } + + // Senkou Span A (Leading Span A) + if (tenkanSen.length > 0 && kijunSen.length > 0) { + const tenkanValue = tenkanSen[tenkanSen.length - 1]; + const kijunValue = kijunSen[kijunSen.length - 1]; + if (tenkanValue !== undefined && kijunValue !== undefined) { + senkouSpanA.push((tenkanValue + kijunValue) / 2); + } + } + + // Senkou Span B (Leading Span B) + if (i >= senkouBPeriod - 1) { + const slice = ohlcv.slice(i - senkouBPeriod + 1, i + 1); + const high = Math.max(...slice.map(d => d.high)); + const low = Math.min(...slice.map(d => d.low)); + senkouSpanB.push((high + low) / 2); + } + + // Chikou Span (Lagging Span) + chikouSpan.push(ohlcv[i]!.close); + } + + return { + tenkanSen, + kijunSen, + senkouSpanA, + senkouSpanB, + chikouSpan, + }; +} + +/** + * Ultimate Oscillator + */ +export function ultimateOscillator( + ohlcv: OHLCV[], + period1: number = 7, + period2: number = 14, + period3: number = 28 +): number[] { + if (ohlcv.length < Math.max(period1, period2, period3)) { + return []; + } + + const bp: number[] = []; // Buying Pressure + const tr: number[] = []; // True Range + + // Calculate BP and TR + for (let i = 0; i < ohlcv.length; i++) { + const current = ohlcv[i]!; + + if (i === 0) { + bp.push(current.close - current.low); + tr.push(current.high - current.low); + } else { + const previous = ohlcv[i - 1]!; + bp.push(current.close - Math.min(current.low, previous.close)); + tr.push(Math.max( + current.high - current.low, + Math.abs(current.high - previous.close), + Math.abs(current.low - previous.close) + )); + } + } + + const result: number[] = []; + + for (let i = Math.max(period1, period2, period3) - 1; i < ohlcv.length; i++) { + const avg1 = bp.slice(i - period1 + 1, i + 1).reduce((a, b) => a + b, 0) / + tr.slice(i - period1 + 1, i + 1).reduce((a, b) => a + b, 0); + const avg2 = bp.slice(i - period2 + 1, i + 1).reduce((a, b) => a + b, 0) / + tr.slice(i - period2 + 1, i + 1).reduce((a, b) => a + b, 0); + const avg3 = bp.slice(i - period3 + 1, i + 1).reduce((a, b) => a + b, 0) / + tr.slice(i - period3 + 1, i + 1).reduce((a, b) => a + b, 0); + + const uo = 100 * ((4 * avg1) + (2 * avg2) + avg3) / (4 + 2 + 1); + result.push(uo); + } + + return result; +} + +/** + * Ease of Movement (EOM) + */ +export function easeOfMovement(ohlcv: OHLCV[], period: number = 14): number[] { + if (ohlcv.length < 2) { + return []; + } + + const emv: number[] = []; + + for (let i = 1; i < ohlcv.length; i++) { + const current = ohlcv[i]!; + const previous = ohlcv[i - 1]!; + + const distance = ((current.high + current.low) / 2) - ((previous.high + previous.low) / 2); + const boxHeight = current.high - current.low; + const volume = current.volume; + + if (boxHeight > 0 && volume > 0) { + const emvValue = (distance * 1000000) / (volume / boxHeight); + emv.push(emvValue); + } else { + emv.push(0); + } + } + + return sma(emv, period); +} + +/** + * Triple Exponential Moving Average (TEMA) + */ +export function tema(values: number[], period: number): number[] { + const ema1 = ema(values, period); + const ema2 = ema(ema1, period); + const ema3 = ema(ema2, period); + + const result: number[] = []; + const minLength = Math.min(ema1.length, ema2.length, ema3.length); + + for (let i = 0; i < minLength; i++) { + const e1 = ema1[i]; + const e2 = ema2[i]; + const e3 = ema3[i]; + if (e1 !== undefined && e2 !== undefined && e3 !== undefined) { + const temaValue = 3 * e1 - 3 * e2 + e3; + result.push(temaValue); + } + } + + return result; +} + +/** + * Weighted Moving Average (WMA) + */ +export function wma(values: number[], period: number): number[] { + if (period > values.length) { + return []; + } + + const result: number[] = []; + const denominator = (period * (period + 1)) / 2; + + for (let i = period - 1; i < values.length; i++) { + let weightedSum = 0; + let weight = 1; + + for (let j = i - period + 1; j <= i; j++) { + const value = values[j]; + if (value !== undefined) { + weightedSum += value * weight; + weight++; + } + } + + result.push(weightedSum / denominator); + } + + return result; +} + +/** + * Keltner Channels + */ +export function keltnerChannels( + ohlcv: OHLCV[], + period: number = 20, + multiplier: number = 2 +): { upper: number[]; middle: number[]; lower: number[] } { + const typicalPrices = ohlcv.map(d => (d.high + d.low + d.close) / 3); + const middle = ema(typicalPrices, period); + const atrValues = atr(ohlcv, period); + + const upper: number[] = []; + const lower: number[] = []; + + const minLength = Math.min(middle.length, atrValues.length); + for (let i = 0; i < minLength; i++) { + const middleValue = middle[i]; + const atrValue = atrValues[i]; + if (middleValue !== undefined && atrValue !== undefined) { + upper.push(middleValue + multiplier * atrValue); + lower.push(middleValue - multiplier * atrValue); + } + } + + return { upper, middle, lower }; +} + +/** + * Donchian Channels + */ +export function donchianChannels( + ohlcv: OHLCV[], + period: number = 20 +): { upper: number[]; middle: number[]; lower: number[] } { + if (period >= ohlcv.length) { + return { upper: [], middle: [], lower: [] }; + } + + const upper: number[] = []; + const lower: number[] = []; + const middle: number[] = []; + + for (let i = period - 1; i < ohlcv.length; i++) { + const slice = ohlcv.slice(i - period + 1, i + 1); + const highest = Math.max(...slice.map(d => d.high)); + const lowest = Math.min(...slice.map(d => d.low)); + const middleValue = (highest + lowest) / 2; + + upper.push(highest); + lower.push(lowest); + middle.push(middleValue); + } + + return { upper, middle, lower }; +} + +/** + * Klinger Volume Oscillator + */ +export function klingerVolumeOscillator( + ohlcv: OHLCV[], + fastPeriod: number = 34, + slowPeriod: number = 55, + signalPeriod: number = 13 +): { kvo: number[]; signal: number[] } { + if (ohlcv.length < 2) { + return { kvo: [], signal: [] }; + } + + const volumeForce: number[] = []; + + for (let i = 1; i < ohlcv.length; i++) { + const current = ohlcv[i]!; + const previous = ohlcv[i - 1]!; + + const typicalPrice = (current.high + current.low + current.close) / 3; + const prevTypicalPrice = (previous.high + previous.low + previous.close) / 3; + + const trend = typicalPrice > prevTypicalPrice ? 1 : -1; + const vf = current.volume * trend * Math.abs((2 * ((current.close - current.low) - (current.high - current.close))) / (current.high - current.low)) * 100; + + volumeForce.push(vf); + } + + const fastEMA = ema(volumeForce, fastPeriod); + const slowEMA = ema(volumeForce, slowPeriod); + + const kvo: number[] = []; + const minLength = Math.min(fastEMA.length, slowEMA.length); + + for (let i = 0; i < minLength; i++) { + const fast = fastEMA[i]; + const slow = slowEMA[i]; + if (fast !== undefined && slow !== undefined) { + kvo.push(fast - slow); + } + } + + const signal = ema(kvo, signalPeriod); + + return { kvo, signal }; +} + +/** + * Price Oscillator (PPO) + */ +export function priceOscillator( + prices: number[], + fastPeriod: number = 12, + slowPeriod: number = 26, + signalPeriod: number = 9 +): { ppo: number[]; signal: number[]; histogram: number[] } { + const fastEMA = ema(prices, fastPeriod); + const slowEMA = ema(prices, slowPeriod); + + const ppo: number[] = []; + const minLength = Math.min(fastEMA.length, slowEMA.length); + + for (let i = 0; i < minLength; i++) { + const fast = fastEMA[i]; + const slow = slowEMA[i]; + if (fast !== undefined && slow !== undefined && slow !== 0) { + const ppoValue = ((fast - slow) / slow) * 100; + ppo.push(ppoValue); + } + } + + const signal = ema(ppo, signalPeriod); + const histogram: number[] = []; + + const histMinLength = Math.min(ppo.length, signal.length); + for (let i = 0; i < histMinLength; i++) { + const ppoValue = ppo[i]; + const signalValue = signal[i]; + if (ppoValue !== undefined && signalValue !== undefined) { + histogram.push(ppoValue - signalValue); + } + } + + return { ppo, signal, histogram }; +} + +/** + * Chande Momentum Oscillator (CMO) + */ +export function chandeMomentumOscillator(prices: number[], period: number = 14): number[] { + if (period >= prices.length) { + return []; + } + + const gains: number[] = []; + const losses: number[] = []; + + // Calculate gains and losses + for (let i = 1; i < prices.length; i++) { + const current = prices[i]; + const previous = prices[i - 1]; + if (current !== undefined && previous !== undefined) { + const change = current - previous; + gains.push(change > 0 ? change : 0); + losses.push(change < 0 ? Math.abs(change) : 0); + } + } + + const result: number[] = []; + + for (let i = period - 1; i < gains.length; i++) { + const sumGains = gains.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + const sumLosses = losses.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + + const cmo = ((sumGains - sumLosses) / (sumGains + sumLosses)) * 100; + result.push(isNaN(cmo) ? 0 : cmo); + } + + return result; +} + +/** + * Stochastic RSI + */ +export function stochasticRSI( + prices: number[], + rsiPeriod: number = 14, + stochPeriod: number = 14, + smoothK: number = 3, + smoothD: number = 3 +): { k: number[]; d: number[] } { + const rsiValues = rsi(prices, rsiPeriod); + + if (rsiValues.length < stochPeriod) { + return { k: [], d: [] }; + } + + const stochRSI: number[] = []; + + for (let i = stochPeriod - 1; i < rsiValues.length; i++) { + const slice = rsiValues.slice(i - stochPeriod + 1, i + 1); + const highest = Math.max(...slice); + const lowest = Math.min(...slice); + const currentRSI = rsiValues[i]!; + + if (highest === lowest) { + stochRSI.push(0); + } else { + const stochValue = ((currentRSI - lowest) / (highest - lowest)) * 100; + stochRSI.push(stochValue); + } + } + + const k = sma(stochRSI, smoothK); + const d = sma(k, smoothD); + + return { k, d }; +} + +/** + * Vortex Indicator + */ +export function vortexIndicator( + ohlcv: OHLCV[], + period: number = 14 +): { vi_plus: number[]; vi_minus: number[] } { + if (ohlcv.length < period + 1) { + return { vi_plus: [], vi_minus: [] }; + } + + const vm_plus: number[] = []; + const vm_minus: number[] = []; + const tr: number[] = []; + + for (let i = 1; i < ohlcv.length; i++) { + const current = ohlcv[i]!; + const previous = ohlcv[i - 1]!; + + // Vortex Movement + vm_plus.push(Math.abs(current.high - previous.low)); + vm_minus.push(Math.abs(current.low - previous.high)); + + // True Range + const trValue = Math.max( + current.high - current.low, + Math.abs(current.high - previous.close), + Math.abs(current.low - previous.close) + ); + tr.push(trValue); + } + + const vi_plus: number[] = []; + const vi_minus: number[] = []; + + for (let i = period - 1; i < vm_plus.length; i++) { + const sumVMPlus = vm_plus.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + const sumVMMinus = vm_minus.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + const sumTR = tr.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + + if (sumTR > 0) { + vi_plus.push(sumVMPlus / sumTR); + vi_minus.push(sumVMMinus / sumTR); + } else { + vi_plus.push(0); + vi_minus.push(0); + } + } + + return { vi_plus, vi_minus }; +} + +/** + * Balance of Power (BOP) + */ +export function balanceOfPower(ohlcv: OHLCV[]): number[] { + const result: number[] = []; + + for (const candle of ohlcv) { + if (candle.high === candle.low) { + result.push(0); + } else { + const bop = (candle.close - candle.open) / (candle.high - candle.low); + result.push(bop); + } + } + + return result; +} + +/** + * Trix (Triple Exponential Moving Average Oscillator) + */ +export function trix(prices: number[], period: number = 14): number[] { + const ema1 = ema(prices, period); + const ema2 = ema(ema1, period); + const ema3 = ema(ema2, period); + + const result: number[] = []; + + for (let i = 1; i < ema3.length; i++) { + const current = ema3[i]; + const previous = ema3[i - 1]; + if (current !== undefined && previous !== undefined && previous !== 0) { + const trixValue = ((current - previous) / previous) * 10000; + result.push(trixValue); + } + } + + return result; +} + +/** + * Mass Index + */ +export function massIndex(ohlcv: OHLCV[], period: number = 25): number[] { + if (ohlcv.length < period) { + return []; + } + + // Calculate high-low ranges + const ranges = ohlcv.map(candle => candle.high - candle.low); + + // Calculate 9-period EMA of ranges + const ema9 = ema(ranges, 9); + + // Calculate 9-period EMA of the EMA (double smoothing) + const emaEma9 = ema(ema9, 9); + + // Calculate ratio + const ratios: number[] = []; + const minLength = Math.min(ema9.length, emaEma9.length); + + for (let i = 0; i < minLength; i++) { + const singleEMA = ema9[i]; + const doubleEMA = emaEma9[i]; + if (singleEMA !== undefined && doubleEMA !== undefined && doubleEMA !== 0) { + ratios.push(singleEMA / doubleEMA); + } + } + + // Calculate 25-period sum of ratios + const result: number[] = []; + for (let i = period - 1; i < ratios.length; i++) { + const sum = ratios.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + result.push(sum); + } + + return result; +} + +/** + * Coppock Curve + */ +export function coppockCurve( + prices: number[], + shortROC: number = 11, + longROC: number = 14, + wma: number = 10 +): number[] { + const roc1 = roc(prices, shortROC); + const roc2 = roc(prices, longROC); + + const combined: number[] = []; + const minLength = Math.min(roc1.length, roc2.length); + + for (let i = 0; i < minLength; i++) { + const r1 = roc1[i]; + const r2 = roc2[i]; + if (r1 !== undefined && r2 !== undefined) { + combined.push(r1 + r2); + } + } + + return sma(combined, wma); // Using SMA instead of WMA for simplicity +}