546 lines
16 KiB
TypeScript
546 lines
16 KiB
TypeScript
/**
|
||
* 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),
|
||
};
|
||
}
|