/** * lightweight-charts-indicators 미등록 / 확장 지표 계산 * (업비트 차트 기준 공식) */ import type { OHLCVBar } from '../types'; import type { PlotData } from './indicatorRegistry'; import { applyPlotOffset, normalizeBollingerParams } from './bollingerConfig'; function sma(values: number[], period: number): number[] { return values.map((_, i) => { if (i < period - 1) return NaN; let sum = 0; for (let j = i - period + 1; j <= i; j++) sum += values[j]; return sum / period; }); } function ema(values: number[], period: number): number[] { const out = new Array(values.length).fill(NaN); if (values.length < period) return out; // 선행 NaN(1차 EMA 워밍업 등) 이후 첫 유효 구간에서 SMA 시드 let start = -1; for (let i = period - 1; i < values.length; i++) { let ok = true; for (let j = i - period + 1; j <= i; j++) { if (!Number.isFinite(values[j])) { ok = false; break; } } if (ok) { start = i; break; } } if (start < 0) return out; let sum = 0; for (let j = start - period + 1; j <= start; j++) sum += values[j]; let prev = sum / period; out[start] = prev; const k = 2 / (period + 1); for (let i = start + 1; i < values.length; i++) { const v = values[i]; if (!Number.isFinite(v)) { out[i] = NaN; continue; } prev = v * k + prev * (1 - k); out[i] = prev; } return out; } function sourceAt(bar: OHLCVBar, src: string): number { switch (src) { case 'open': return bar.open; case 'high': return bar.high; case 'low': return bar.low; case 'hl2': return (bar.high + bar.low) / 2; case 'hlc3': return (bar.high + bar.low + bar.close) / 3; case 'ohlc4': return (bar.open + bar.high + bar.low + bar.close) / 4; default: return bar.close; } } function toPlotData(bars: OHLCVBar[], values: number[]): PlotData { return bars.map((b, i) => ({ time: b.time, value: Number.isFinite(values[i]) ? values[i] : NaN, })); } /** 업비트 이격도: 종가 ÷ 이동평균 × 100 */ export function calcDisparityUpbit( bars: OHLCVBar[], params: Record, ): Record { const src = String(params.src ?? 'close'); const closes = bars.map(b => sourceAt(b, src)); const ultra = Number(params.ultraLength ?? 5); const short = Number(params.shortLength ?? 10); const mid = Number(params.midLength ?? 20); const long = Number(params.longLength ?? 60); const plot = (period: number, id: string) => { const ma = sma(closes, period); const vals = closes.map((c, i) => Number.isFinite(ma[i]) && ma[i] !== 0 ? (c / ma[i]) * 100 : NaN, ); return { [id]: toPlotData(bars, vals) }; }; return { ...plot(ultra, 'plot0'), ...plot(short, 'plot1'), ...plot(mid, 'plot2'), ...plot(long, 'plot3'), }; } /** VR: 상승일 거래량 합 / 하락일 거래량 합 × 100 */ export function calcVR( bars: OHLCVBar[], params: Record, ): Record { const period = Number(params.length ?? 10); const closes = bars.map(b => b.close); const volumes = bars.map(b => b.volume); const vals = closes.map((_, i) => { if (i < period) return NaN; let upVol = 0; let downVol = 0; for (let j = i - period + 1; j <= i; j++) { if (closes[j] > closes[j - 1]) upVol += volumes[j]; else if (closes[j] < closes[j - 1]) downVol += volumes[j]; } if (downVol === 0) return NaN; return (upVol / downVol) * 100; }); return { plot0: toPlotData(bars, vals) }; } /** 심리도: N일 중 전일 대비 상승일 비율 × 100 */ export function calcPsychological( bars: OHLCVBar[], params: Record, ): Record { const period = Number(params.length ?? 12); const closes = bars.map(b => b.close); const vals = closes.map((_, i) => { if (i < period) return NaN; let up = 0; for (let j = i - period + 1; j <= i; j++) { if (closes[j] > closes[j - 1]) up++; } return (up / period) * 100; }); return { plot0: toPlotData(bars, vals) }; } /** * 신심리도(업비트): [상승일수×상승폭비율 − 하락일수×하락폭비율] × 100 / N * 상승폭비율 = 상승폭합 / (상승폭합+하락폭합) */ export function calcNewPsychological( bars: OHLCVBar[], params: Record, ): Record { const period = Number(params.length ?? 10); const closes = bars.map(b => b.close); const vals = closes.map((_, i) => { if (i < period) return NaN; let upSize = 0; let downSize = 0; let upDay = 0; let downDay = 0; for (let j = i - period + 1; j <= i; j++) { const diff = closes[j] - closes[j - 1]; if (diff > 0) { upSize += diff; upDay++; } else if (diff < 0) { downSize += -diff; downDay++; } } const total = upSize + downSize; if (total === 0) return NaN; return (upDay * (upSize / total) - downDay * (downSize / total)) * 100 / period; }); return { plot0: toPlotData(bars, vals) }; } /** 투자심리도(업비트): N일 중 전일 대비 상승일 비율 × 100 */ export function calcInvestPsychological( bars: OHLCVBar[], params: Record, ): Record { const period = Number(params.length ?? 10); const closes = bars.map(b => b.close); const vals = closes.map((_, i) => { if (i < period) return NaN; let up = 0; for (let j = i - period + 1; j <= i; j++) { if (closes[j] > closes[j - 1]) up++; } return (up / period) * 100; }); return { plot0: toPlotData(bars, vals) }; } /** * TRIX + TRMA(시그널) — 업비트·키움 HTS * EMA3 = EMA(EMA(EMA(종가, N), N), N) * TRIX = (EMA3 - EMA3[1]) / EMA3[1] × 100 * TRMA = TRIX의 EMA(M) */ export function calcTRIXWithSignal( bars: OHLCVBar[], params: Record, ): Record { const len = Number(params.length ?? 12); const sigLen = Number(params.signalLength ?? 9); const closes = bars.map(b => b.close); const e1 = ema(closes, len); const e2 = ema(e1.map(v => (Number.isFinite(v) ? v : NaN)), len); const e3 = ema(e2.map(v => (Number.isFinite(v) ? v : NaN)), len); const trix = e3.map((v, i) => { if (i === 0 || !Number.isFinite(v) || !Number.isFinite(e3[i - 1]) || e3[i - 1] === 0) return NaN; return (v - e3[i - 1]) / e3[i - 1] * 100; }); const signal = ema(trix.map(v => (Number.isFinite(v) ? v : NaN)), sigLen); return { plot0: toPlotData(bars, trix), plot1: toPlotData(bars, signal), }; } /** OBV + 신호선 MA (업비트: 단순·지수·가중, 기본 단순 9) */ export function calcOBVWithMA( bars: OHLCVBar[], params: Record, ): Record { const raw = String(params.maType ?? 'SMA'); const maType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA'; const maLen = Math.max(1, Number(params.maLength ?? 9) || 9); const obv: number[] = []; let acc = 0; for (let i = 0; i < bars.length; i++) { if (i === 0) { obv.push(0); continue; } const vol = bars[i].volume; if (bars[i].close > bars[i - 1].close) acc += vol; else if (bars[i].close < bars[i - 1].close) acc -= vol; obv.push(acc); } let ma: number[]; switch (maType) { case 'EMA': ma = ema(obv, maLen); break; case 'WMA': ma = obv.map((_, i) => { if (i < maLen - 1) return NaN; let wSum = 0; let w = 0; for (let j = 0; j < maLen; j++) { const weight = j + 1; wSum += obv[i - maLen + 1 + j] * weight; w += weight; } return wSum / w; }); break; default: ma = sma(obv, maLen); } return { plot0: toPlotData(bars, obv), plot1: toPlotData(bars, ma), }; } /** * 표준편차 (TradingView ta.stdev / 업비트 BB) * - Pine v5 ta.stdev: 모집단 분산 (N으로 나눔) */ function stdevBb(values: number[], period: number): number[] { const avg = sma(values, period); return values.map((_, i) => { if (i < period - 1) return NaN; let sumSq = 0; for (let j = i - period + 1; j <= i; j++) { const d = values[j] - avg[i]; sumSq += d * d; } return Math.sqrt(sumSq / period); }); } /** 볼린저밴드 (업비트): SMA(종가) ± mult × σ, 오프셋 적용 */ export function calcBollingerBands( bars: OHLCVBar[], params: Record, ): Record { const p = normalizeBollingerParams(params); const period = p.length as number; const mult = p.mult as number; const offset = p.offset as number; const src = String(p.src ?? 'close'); const values = bars.map(b => sourceAt(b, src)); const basis = sma(values, period); const dev = stdevBb(values, period).map(d => (Number.isFinite(d) ? d * mult : NaN)); const upper = basis.map((m, i) => Number.isFinite(m) && Number.isFinite(dev[i]) ? m + dev[i] : NaN, ); const lower = basis.map((m, i) => Number.isFinite(m) && Number.isFinite(dev[i]) ? m - dev[i] : NaN, ); let plot0 = toPlotData(bars, basis); let plot1 = toPlotData(bars, upper); let plot2 = toPlotData(bars, lower); if (offset) { plot0 = applyPlotOffset(plot0, offset); plot1 = applyPlotOffset(plot1, offset); plot2 = applyPlotOffset(plot2, offset); } return { plot0, plot1, plot2 }; } /** Wilder RMA — TradingView ta.rma / oakscriptjs 와 동일 시드·유지 */ function rmaWilder(values: Array, period: number): number[] { const len = Math.max(1, Math.floor(period)); const alpha = 1 / len; const out = new Array(values.length).fill(NaN); let initSum = 0; let validCount = 0; let firstIdx = -1; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v != null && Number.isFinite(v)) { initSum += v; validCount++; if (validCount === len) { firstIdx = i; break; } } } let rmaVal = validCount > 0 ? initSum / validCount : NaN; const initialized = firstIdx >= 0; for (let i = 0; i < values.length; i++) { if (!initialized || i < firstIdx) { out[i] = NaN; } else if (i === firstIdx) { out[i] = rmaVal; } else { const v = values[i]; if (v != null && Number.isFinite(v)) { rmaVal = alpha * v + (1 - alpha) * rmaVal; } out[i] = rmaVal; } } return out; } /** ADXR = (ADX + ADX[n]) / 2 (Wilder·업비트) */ function calcAdxr(adx: number[], lag: number): number[] { return adx.map((cur, i) => { if (!Number.isFinite(cur) || i < lag) return NaN; const prev = adx[i - lag]; return Number.isFinite(prev) ? (cur + prev) / 2 : NaN; }); } /** * DMI (업비트) — +DI, -DI, DX, ADX, ADXR * 파라미터: diLength(DI 길이), adxSmoothing(ADX 스무딩) */ export function calcDMI( bars: OHLCVBar[], params: Record, ): Record { const diLength = Math.max(1, Number(params.diLength ?? params.length ?? 14)); const adxSmoothing = Math.max(1, Number(params.adxSmoothing ?? 14)); const len = bars.length; const plusDM: Array = []; const minusDM: Array = []; for (let i = 0; i < len; i++) { if (i === 0) { plusDM.push(null); minusDM.push(null); } else { const upMove = bars[i].high - bars[i - 1].high; const downMove = bars[i - 1].low - bars[i].low; plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0); minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0); } } const tr: Array = bars.map((b, i) => { if (i === 0) return null; const hl = b.high - b.low; const hc = Math.abs(b.high - bars[i - 1].close); const lc = Math.abs(b.low - bars[i - 1].close); return Math.max(hl, hc, lc); }); const smPlusDM = rmaWilder(plusDM, diLength); const smMinusDM = rmaWilder(minusDM, diLength); const smTR = rmaWilder(tr, diLength); let lastPlus = 0; let lastMinus = 0; const plusDI: number[] = []; const minusDI: number[] = []; for (let i = 0; i < len; i++) { const t = smTR[i]; const pDm = smPlusDM[i]; const mDm = smMinusDM[i]; if (!Number.isFinite(t) || t === 0 || !Number.isFinite(pDm)) { plusDI.push(lastPlus); } else { const val = (100 * pDm) / t; plusDI.push(val); lastPlus = val; } if (!Number.isFinite(t) || t === 0 || !Number.isFinite(mDm)) { minusDI.push(lastMinus); } else { const val = (100 * mDm) / t; minusDI.push(val); lastMinus = val; } } const dx: number[] = plusDI.map((p, i) => { const m = minusDI[i]; const sum = p + m; return sum === 0 ? 0 : (100 * Math.abs(p - m)) / sum; }); const adx = rmaWilder(dx, adxSmoothing); const adxr = calcAdxr(adx, adxSmoothing); return { plot0: toPlotData(bars, plusDI), plot1: toPlotData(bars, minusDI), plot2: toPlotData(bars, dx), plot3: toPlotData(bars, adx), plot4: toPlotData(bars, adxr), }; }