Files
goldenChart/frontend/src/utils/customIndicators.ts
T
2026-06-09 01:32:27 +09:00

546 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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++) {
const v = values[j];
if (!Number.isFinite(v)) return NaN;
sum += v;
}
return sum / period;
});
}
function wma(values: number[], period: number): number[] {
return values.map((_, i) => {
if (i < period - 1) return NaN;
let wSum = 0;
let w = 0;
for (let j = 0; j < period; j++) {
const weight = j + 1;
wSum += values[i - period + 1 + j] * weight;
w += weight;
}
return wSum / w;
});
}
function ema(values: number[], period: number): number[] {
const out = new Array<number>(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<string, number | string | boolean>,
): Record<string, PlotData> {
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<string, number | string | boolean>,
): Record<string, PlotData> {
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<string, number | string | boolean>,
): Record<string, PlotData> {
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<string, number | string | boolean>,
): Record<string, PlotData> {
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<string, number | string | boolean>,
): Record<string, PlotData> {
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) };
}
/**
* RSI + 신호선 (업비트·TradingView Wilder RSI)
*/
export function calcRSI(
bars: OHLCVBar[],
params: Record<string, number | string | boolean>,
): Record<string, PlotData> {
const len = Math.max(1, Number(params.length ?? 14) || 14);
const maLen = Math.max(1, Number(params.maLength ?? 14) || 14);
const rawMa = String(params.maType ?? 'SMA');
const maType = rawMa === 'EMA' || rawMa === 'WMA' ? rawMa : (rawMa === 'None' ? 'None' : 'SMA');
const srcKey = String(params.src ?? 'close');
const prices = bars.map(b => sourceAt(b, srcKey));
const rsi = new Array<number>(prices.length).fill(NaN);
if (prices.length > len) {
let gain = 0;
let loss = 0;
for (let i = 1; i <= len; i++) {
const d = prices[i] - prices[i - 1];
if (d > 0) gain += d;
else loss -= d;
}
let avgG = gain / len;
let avgL = loss / len;
rsi[len] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
for (let i = len + 1; i < prices.length; i++) {
const d = prices[i] - prices[i - 1];
const g = d > 0 ? d : 0;
const l = d < 0 ? -d : 0;
avgG = (avgG * (len - 1) + g) / len;
avgL = (avgL * (len - 1) + l) / len;
rsi[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
}
}
let signal: number[];
if (maType === 'None') {
signal = rsi.map(() => NaN);
} else if (maType === 'EMA') {
signal = ema(rsi.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else if (maType === 'WMA') {
signal = wma(rsi.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else {
signal = sma(rsi, maLen);
}
return {
plot0: toPlotData(bars, rsi),
plot1: toPlotData(bars, signal),
};
}
/**
* CCI + 신호선 (업비트·TradingView)
* CCI = (src - SMA(src,N)) / (0.015 × MeanDeviation)
* src 기본 hlc3(고저종 평균), 신호선 = CCI의 SMA/EMA/WMA (기본 SMA 10)
*/
export function calcCCI(
bars: OHLCVBar[],
params: Record<string, number | string | boolean>,
): Record<string, PlotData> {
const len = Math.max(1, Number(params.length ?? 13) || 13);
const maLen = Math.max(1, Number(params.maLength ?? 10) || 10);
const rawMa = String(params.maType ?? 'SMA');
const maType = rawMa === 'EMA' || rawMa === 'WMA' ? rawMa : (rawMa === 'None' ? 'None' : 'SMA');
const srcKey = String(params.src ?? 'hlc3');
const prices = bars.map(b => sourceAt(b, srcKey));
const cci = prices.map((_, i) => {
if (i < len - 1) return NaN;
let sum = 0;
for (let j = i - len + 1; j <= i; j++) sum += prices[j];
const ma = sum / len;
let md = 0;
for (let j = i - len + 1; j <= i; j++) md += Math.abs(prices[j] - ma);
md /= len;
if (md === 0) return 0;
return (prices[i] - ma) / (0.015 * md);
});
let signal: number[];
if (maType === 'None') {
signal = cci.map(() => NaN);
} else if (maType === 'EMA') {
signal = ema(cci.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else if (maType === 'WMA') {
signal = wma(cci.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else {
signal = sma(cci, maLen);
}
return {
plot0: toPlotData(bars, cci),
plot1: toPlotData(bars, signal),
};
}
/**
* 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<string, number | string | boolean>,
): Record<string, PlotData> {
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<string, number | string | boolean>,
): Record<string, PlotData> {
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 = Number(bars[i].volume) || 0;
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 = wma(obv, maLen);
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<string, number | string | boolean>,
): Record<string, PlotData> {
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<number | null>, period: number): number[] {
const len = Math.max(1, Math.floor(period));
const alpha = 1 / len;
const out = new Array<number>(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<string, number | string | boolean>,
): Record<string, PlotData> {
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<number | null> = [];
const minusDM: Array<number | null> = [];
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<number | null> = 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),
};
}