package com.goldenchart.service; import com.goldenchart.dto.IndicatorResponse; import com.goldenchart.dto.IndicatorResponse.PlotPoint; import com.goldenchart.dto.OhlcvBar; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.ta4j.core.*; import org.ta4j.core.indicators.*; import org.ta4j.core.indicators.adx.*; import org.ta4j.core.indicators.aroon.*; import org.ta4j.core.indicators.averages.*; import org.ta4j.core.indicators.bollinger.*; import org.ta4j.core.indicators.donchian.*; import org.ta4j.core.indicators.helpers.*; import org.ta4j.core.indicators.ichimoku.*; import org.ta4j.core.indicators.keltner.*; import org.ta4j.core.indicators.numeric.NumericIndicator; import org.ta4j.core.indicators.numeric.UnaryOperationIndicator; import org.ta4j.core.indicators.statistics.*; import org.ta4j.core.indicators.volume.*; import org.ta4j.core.bars.TimeBarBuilderFactory; import org.ta4j.core.num.DoubleNumFactory; import org.ta4j.core.num.Num; import java.time.Duration; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.*; import java.util.stream.IntStream; /** * Ta4j 기술 지표 계산 서비스. * frontend indicatorRegistry.ts 에 등록된 지표를 동일 로직으로 구현. */ @Service @Slf4j public class IndicatorService { // ── Public API ──────────────────────────────────────────────────────────── public IndicatorResponse calculate(List bars, String type, Map params, String timeframe) { BarSeries series = buildSeries(bars, timeframe); int n = series.getBarCount(); Map p = params != null ? params : Map.of(); Map> result = switch (type) { // Moving Averages case "SMA" -> calcSMA(series, p); case "EMA" -> single("plot0", series, new EMAIndicator(src(series, p), intP(p, "length", 21))); case "WMA" -> single("plot0", series, new WMAIndicator(src(series, p), intP(p, "length", 20))); case "HMA" -> single("plot0", series, new HMAIndicator(src(series, p), intP(p, "length", 16))); case "VWMA" -> single("plot0", series, new VWMAIndicator(new ClosePriceIndicator(series), intP(p, "length", 20))); case "DEMA" -> single("plot0", series, new DoubleEMAIndicator(src(series, p), intP(p, "length", 21))); case "TEMA" -> single("plot0", series, new TripleEMAIndicator(src(series, p), intP(p, "length", 21))); case "RMA", "SMMA" -> single("plot0", series, new WildersMAIndicator(src(series, p), intP(p, "length", 14))); case "LSMA" -> single("plot0", series, new LSMAIndicator(src(series, p), intP(p, "length", 25))); case "MACross" -> calcMACross(series, p); // Bands case "BollingerBands" -> calcBB(series, p); case "KeltnerChannels" -> calcKC(series, p); case "DonchianChannels" -> calcDC(series, p); case "BBPercentB" -> calcBBPercentB(series, p); case "BBBandWidth" -> calcBBBandWidth(series, p); // Oscillators case "RSI" -> calcRSI(series, p); case "Stochastic" -> calcStochastic(series, p); case "StochRSI" -> calcStochRSI(series, p); case "CCI" -> calcCCI(series, p); case "WilliamsPercentRange" -> calcWilliamsR(series, p); case "AwesomeOscillator" -> calcAO(series); case "DPO" -> single("plot0", series, new DPOIndicator(src(series, p), intP(p, "length", 21))); // Momentum case "MACD" -> calcMACD(series, p); case "Momentum" -> calcMomentumInd(series, p); case "ROC" -> calcROC(series, p); case "TSI" -> calcTSI(series, p); case "TRIX" -> calcTRIX(series, p); // Trend case "ADX" -> calcADX(series, p); case "DMI" -> calcDMI(series, p); case "Aroon" -> calcAroon(series, p); case "IchimokuCloud"-> calcIchimoku(series, p); case "ParabolicSAR" -> calcParabolicSAR(series, p); case "Choppiness" -> calcChoppiness(series, p); case "MassIndex" -> calcMassIndex(series, p); // Volatility case "ATR" -> single("plot0", series, new ATRIndicator(series, intP(p, "length", 14))); case "StandardDeviation"-> single("plot0", series, new StandardDeviationIndicator(src(series, p), intP(p, "length", 20))); case "HistoricalVolatility" -> calcHV(series, p); // Volume case "OBV" -> calcOBV(series, p); case "MFI" -> single("plot0", series, new MoneyFlowIndexIndicator(series, intP(p, "length", 14))); case "ChaikinMF"-> single("plot0", series, new ChaikinMoneyFlowIndicator(series, intP(p, "length", 20))); case "VolumeOscillator" -> calcVolumeOscillator(series, p); case "VR" -> calcVR(series, p); case "Disparity" -> calcDisparity(series, p); case "Psychological" -> calcPsychological(series, p); case "NewPsychological" -> calcNewPsychological(series, p); case "InvestPsychological"-> calcInvestPsychological(series, p); default -> throw new IllegalArgumentException("지원하지 않는 지표: " + type); }; List times = IntStream.range(0, n) .mapToObj(i -> { Bar bar = series.getBar(i); return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); }) .toList(); return IndicatorResponse.builder() .indicatorType(type) .times(times) .values(result) .build(); } // ── Series Builder ──────────────────────────────────────────────────────── /** * OhlcvBar 목록으로 Ta4j BarSeries 를 생성한다. * * @param bars OHLCV 바 데이터 * @param timeframe 타임프레임 문자열 (1m/5m/15m/30m/1h/4h/1D/1W/1M). * null 또는 알 수 없는 값이면 1분으로 fallback. */ private BarSeries buildSeries(List bars, String timeframe) { BarSeries series = new BaseBarSeriesBuilder() .withNumFactory(DoubleNumFactory.getInstance()) .build(); TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); Duration period = timeframeToDuration(timeframe); for (OhlcvBar b : bars) { // b.getTime() 은 봉 시작 시각(Unix 초). // ta4j Bar 의 endTime 은 봉 종료 시각이므로 duration 을 더해야 한다. java.time.Instant endInst = java.time.Instant.ofEpochSecond(b.getTime()).plus(period); factory.createBarBuilder(series) .timePeriod(period) .endTime(endInst) .openPrice(b.getOpen()) .highPrice(b.getHigh()) .lowPrice(b.getLow()) .closePrice(b.getClose()) .volume(b.getVolume()) .add(); } return series; } /** * 타임프레임 문자열을 Duration 으로 변환. * frontend Timeframe 타입 (1m/5m/15m/30m/1h/4h/1D/1W/1M)과 1:1 대응. */ private static Duration timeframeToDuration(String tf) { if (tf == null) return Duration.ofMinutes(1); return switch (tf) { case "1m" -> Duration.ofMinutes(1); case "3m" -> Duration.ofMinutes(3); case "5m" -> Duration.ofMinutes(5); case "10m" -> Duration.ofMinutes(10); case "15m" -> Duration.ofMinutes(15); case "30m" -> Duration.ofMinutes(30); case "1h" -> Duration.ofHours(1); case "4h" -> Duration.ofHours(4); case "1D" -> Duration.ofDays(1); case "1W" -> Duration.ofDays(7); case "1M" -> Duration.ofDays(30); default -> Duration.ofMinutes(1); }; } // ── Helpers ─────────────────────────────────────────────────────────────── private int intP(Map p, String k, int def) { Object v = p.get(k); return v == null ? def : ((Number) v).intValue(); } private double dblP(Map p, String k, double def) { Object v = p.get(k); return v == null ? def : ((Number) v).doubleValue(); } private Indicator src(BarSeries s, Map p) { return switch (p.getOrDefault("src", "close").toString()) { case "open" -> new OpenPriceIndicator(s); case "high" -> new HighPriceIndicator(s); case "low" -> new LowPriceIndicator(s); case "hl2" -> new MedianPriceIndicator(s); case "hlc3" -> new TypicalPriceIndicator(s); default -> new ClosePriceIndicator(s); }; } private Double safe(Num n) { if (n == null) return null; double v = n.doubleValue(); return Double.isNaN(v) || Double.isInfinite(v) ? null : v; } private List toPlot(BarSeries s, Indicator ind) { int n = s.getBarCount(); List pts = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); // 프론트 차트의 time 축은 봉 시작 시각 기준. // endTime - timePeriod = 봉 시작 시각 long startEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); pts.add(PlotPoint.builder() .time(startEpoch) .value(safe(ind.getValue(i))) .build()); } return pts; } private Map> single(String id, BarSeries s, Indicator ind) { return Map.of(id, toPlot(s, ind)); } // ── Moving Averages ─────────────────────────────────────────────────────── /** MA1~MA11 기본 기간: 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 */ private static final int[] SMA_DEFAULT_PERIODS = { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 }; private Map> calcSMA(BarSeries s, Map p) { Indicator source = src(s, p); Map> result = new LinkedHashMap<>(); for (int i = 0; i < SMA_DEFAULT_PERIODS.length; i++) { String periodKey = "period" + (i + 1); int period = intP(p, periodKey, SMA_DEFAULT_PERIODS[i]); // 레거시 단일 len → MA1 if (i == 0 && p.containsKey("len")) { period = intP(p, "len", SMA_DEFAULT_PERIODS[0]); } period = Math.max(1, period); result.put("plot" + i, toPlot(s, new SMAIndicator(source, period))); } return result; } private Map> calcMACross(BarSeries s, Map p) { Indicator src = src(s, p); return Map.of( "plot0", toPlot(s, new EMAIndicator(src, intP(p, "fastLength", 9))), "plot1", toPlot(s, new EMAIndicator(src, intP(p, "slowLength", 21))) ); } // ── Bands ───────────────────────────────────────────────────────────────── private Map> calcBB(BarSeries s, Map p) { int len = intP(p, "length", 20); double mult = dblP(p, "mult", 2.0); int offset = intP(p, "offset", 0); // 업비트 BB: 종가 SMA + 모집단 표준편차 × 곱 ClosePriceIndicator close = new ClosePriceIndicator(s); SMAIndicator basis = new SMAIndicator(close, len); StandardDeviationIndicator std = StandardDeviationIndicator.ofPopulation(close, len); BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis); Num k = s.numFactory().numOf(mult); BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); return Map.of( "plot0", shiftPlotOffset(toPlot(s, mid), offset), "plot1", shiftPlotOffset(toPlot(s, upper), offset), "plot2", shiftPlotOffset(toPlot(s, lower), offset) ); } /** TradingView/업비트 오프셋: 양수 = 플롯을 시간축 오른쪽으로 이동 */ private List shiftPlotOffset(List pts, int offset) { if (offset == 0) return pts; int n = pts.size(); List out = new ArrayList<>(n); for (int i = 0; i < n; i++) { int src = i - offset; Double val = (src >= 0 && src < n) ? pts.get(src).getValue() : null; out.add(PlotPoint.builder().time(pts.get(i).getTime()).value(val).build()); } return out; } private Indicator maOf(Indicator source, String maType, int len, BarSeries s) { return switch (maType) { case "EMA" -> new EMAIndicator(source, len); case "WMA" -> new WMAIndicator(source, len); case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len); case "VWMA" -> new VWMAIndicator(source, len); default -> new SMAIndicator(source, len); }; } private Map> calcKC(BarSeries s, Map p) { int len = intP(p, "length", 20); double mult = dblP(p, "multiplier", 2.0); EMAIndicator ema = new EMAIndicator(new ClosePriceIndicator(s), len); KeltnerChannelMiddleIndicator mid = new KeltnerChannelMiddleIndicator(s, len); ATRIndicator atr = new ATRIndicator(s, len); KeltnerChannelUpperIndicator upper = new KeltnerChannelUpperIndicator(mid, atr, mult); KeltnerChannelLowerIndicator lower = new KeltnerChannelLowerIndicator(mid, atr, (double) mult); return Map.of( "plot0", toPlot(s, upper), "plot1", toPlot(s, ema), "plot2", toPlot(s, lower) ); } private Map> calcDC(BarSeries s, Map p) { int len = intP(p, "length", 20); DonchianChannelUpperIndicator upper = new DonchianChannelUpperIndicator(s, len); DonchianChannelLowerIndicator lower = new DonchianChannelLowerIndicator(s, len); // basis = (upper + lower) / 2 NumericIndicator basis = NumericIndicator.of(upper).plus(lower).dividedBy(2); return Map.of( "plot0", toPlot(s, upper), "plot1", toPlot(s, basis), "plot2", toPlot(s, lower) ); } private Map> calcBBPercentB(BarSeries s, Map p) { int len = intP(p, "length", 20); double mult = dblP(p, "mult", 2.0); Indicator src = src(s, p); SMAIndicator sma = new SMAIndicator(src, len); StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len); BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma); Num k = s.numFactory().numOf(mult); BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); // %B = (price - lower) / (upper - lower) NumericIndicator pctB = NumericIndicator.of(src) .minus(lower) .dividedBy(NumericIndicator.of(upper).minus(lower)); return single("plot0", s, pctB); } private Map> calcBBBandWidth(BarSeries s, Map p) { int len = intP(p, "length", 20); double mult = dblP(p, "mult", 2.0); Indicator src = src(s, p); SMAIndicator sma = new SMAIndicator(src, len); StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len); BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma); Num k = s.numFactory().numOf(mult); BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); BollingerBandWidthIndicator bw = new BollingerBandWidthIndicator(upper, mid, lower); return single("plot0", s, bw); } // ── Oscillators ─────────────────────────────────────────────────────────── /** * RSI + 선택적 신호선 (SMA / EMA / WMA). * maType == "None" 이면 plot1 을 null 로 채워 비활성 처리. */ private Map> calcRSI(BarSeries s, Map p) { int len = intP(p, "length", 14); int maLen = intP(p, "maLength", 14); String maType = p.getOrDefault("maType", "SMA").toString(); RSIIndicator rsi = new RSIIndicator(src(s, p), len); Indicator maInd = switch (maType) { case "EMA" -> new EMAIndicator(rsi, maLen); case "WMA" -> new WMAIndicator(rsi, maLen); case "None" -> null; default -> new SMAIndicator(rsi, maLen); }; if (maInd == null) { int n = s.getBarCount(); List empty = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); empty.add(PlotPoint.builder().time(t).value(null).build()); } return Map.of("plot0", toPlot(s, rsi), "plot1", empty); } return Map.of("plot0", toPlot(s, rsi), "plot1", toPlot(s, maInd)); } /** * CCI + 선택적 MA 오버레이 (업비트 동일: SMA / EMA / WMA). * maType == "None" 이면 MA 라인(plot1) 값을 null 로 채워 빈 시리즈로 반환. */ private Map> calcCCI(BarSeries s, Map p) { int len = intP(p, "length", 13); int maLen = intP(p, "maLength", 10); String maType = p.getOrDefault("maType", "SMA").toString(); Map norm = CciOnSourceIndicator.normalizeParams(p); CciOnSourceIndicator cci = new CciOnSourceIndicator(src(s, norm), len); Indicator maInd = switch (maType) { case "EMA" -> new EMAIndicator(cci, maLen); case "WMA" -> new WMAIndicator(cci, maLen); case "None" -> null; default -> new SMAIndicator(cci, maLen); // SMA }; if (maInd == null) { // MA 비활성 — plot1 을 null 값으로 채워 반환 int n = s.getBarCount(); List empty = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); empty.add(PlotPoint.builder().time(t).value(null).build()); } return Map.of("plot0", toPlot(s, cci), "plot1", empty); } return Map.of("plot0", toPlot(s, cci), "plot1", toPlot(s, maInd)); } private Map> calcStochastic(BarSeries s, Map p) { int kLen = intP(p, "kLength", 14); int smooth = intP(p, "smooth", 3); int dSmooth = intP(p, "dSmoothing", 3); StochasticOscillatorKIndicator k = new StochasticOscillatorKIndicator(s, kLen); SMAIndicator kSmooth = new SMAIndicator(k, smooth); SMAIndicator d = new SMAIndicator(kSmooth, dSmooth); return Map.of("plot0", toPlot(s, kSmooth), "plot1", toPlot(s, d)); } private Map> calcStochRSI(BarSeries s, Map p) { int rsiLen = intP(p, "lengthRSI", 14); int stochLen = intP(p, "lengthStoch", 14); int smoothK = intP(p, "smoothK", 3); int smoothD = intP(p, "smoothD", 3); RSIIndicator rsi = new RSIIndicator(src(s, p), rsiLen); StochasticRSIIndicator stochRsi = new StochasticRSIIndicator(rsi, stochLen); SMAIndicator k = new SMAIndicator(stochRsi, smoothK); SMAIndicator d = new SMAIndicator(k, smoothD); return Map.of("plot0", toPlot(s, k), "plot1", toPlot(s, d)); } private Map> calcAO(BarSeries s) { // AO = SMA(median,5) - SMA(median,34) MedianPriceIndicator median = new MedianPriceIndicator(s); NumericIndicator ao = NumericIndicator.of(new SMAIndicator(median, 5)) .minus(new SMAIndicator(median, 34)); return single("plot0", s, ao); } // ── Momentum ────────────────────────────────────────────────────────────── private Map> calcMACD(BarSeries s, Map p) { int fast = intP(p, "fastLength", 12); int slow = intP(p, "slowLength", 26); int signal = intP(p, "signalLength", 9); Indicator src = src(s, p); MACDIndicator macd = new MACDIndicator(src, fast, slow); EMAIndicator sigLine = new EMAIndicator(macd, signal); NumericIndicator histogram = NumericIndicator.of(macd).minus(sigLine); return Map.of( "plot0", toPlot(s, histogram), "plot1", toPlot(s, macd), "plot2", toPlot(s, sigLine) ); } private Map> calcMomentumInd(BarSeries s, Map p) { int len = intP(p, "length", 10); return single("plot0", s, new ROCIndicator(src(s, p), len)); } private Map> calcROC(BarSeries s, Map p) { int len = intP(p, "length", 9); NumericIndicator rocPct = NumericIndicator.of(new ROCIndicator(src(s, p), len)) .multipliedBy(100); return single("plot0", s, rocPct); } /** * TSI (True Strength Index) — 수동 구현 (Ta4j에 없음). * TSI = 100 * EMA(EMA(diff, longLen), shortLen) / EMA(EMA(absDiff, longLen), shortLen) */ private Map> calcTSI(BarSeries s, Map p) { int longLen = intP(p, "longLength", 25); int shortLen = intP(p, "shortLength", 13); int sigLen = intP(p, "signalLength", 13); int n = s.getBarCount(); ClosePriceIndicator close = new ClosePriceIndicator(s); // momentum = close - close[1] List tsiPts = new ArrayList<>(n); List sigPts = new ArrayList<>(n); double[] diff = new double[n]; double[] absDiff = new double[n]; for (int i = 0; i < n; i++) { if (i == 0) { diff[i] = 0; absDiff[i] = 0; } else { Double c1 = safe(close.getValue(i)); Double c0 = safe(close.getValue(i - 1)); diff[i] = (c1 != null && c0 != null) ? c1 - c0 : 0; absDiff[i] = Math.abs(diff[i]); } } double[] ema1d = ema(diff, longLen, n); double[] ema2d = ema(ema1d, shortLen, n); double[] ema1a = ema(absDiff, longLen, n); double[] ema2a = ema(ema1a, shortLen, n); double[] tsi = new double[n]; for (int i = 0; i < n; i++) { tsi[i] = ema2a[i] == 0 ? 0 : 100.0 * ema2d[i] / ema2a[i]; } double[] sig = ema(tsi, sigLen, n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); tsiPts.add(new PlotPoint(t, tsi[i], null)); sigPts.add(new PlotPoint(t, sig[i], null)); } return Map.of("plot0", tsiPts, "plot1", sigPts); } // ── Trend ───────────────────────────────────────────────────────────────── private Map> calcADX(BarSeries s, Map p) { int adxSmoothing = intP(p, "adxSmoothing", 14); int diLen = intP(p, "diLength", 14); return Map.of( "plot0", toPlot(s, new ADXIndicator(s, diLen, adxSmoothing)), "plot1", toPlot(s, new PlusDIIndicator(s, diLen)), "plot2", toPlot(s, new MinusDIIndicator(s, diLen)) ); } private Map> calcDMI(BarSeries s, Map p) { int diLen = intP(p, "diLength", intP(p, "length", 14)); int adxSmooth = intP(p, "adxSmoothing", 14); PlusDIIndicator plusDI = new PlusDIIndicator(s, diLen); MinusDIIndicator minusDI = new MinusDIIndicator(s, diLen); ADXIndicator adx = new ADXIndicator(s, diLen, adxSmooth); NumericIndicator dx = NumericIndicator.of(plusDI).minus(minusDI).abs() .dividedBy(NumericIndicator.of(plusDI).plus(minusDI)) .multipliedBy(100); List adxPlot = toPlot(s, adx); return Map.of( "plot0", toPlot(s, plusDI), "plot1", toPlot(s, minusDI), "plot2", toPlot(s, dx), "plot3", adxPlot, "plot4", calcAdxrFromAdx(adxPlot, adxSmooth) ); } /** ADXR = (ADX + ADX[n]) / 2 — Wilder·업비트 */ private List calcAdxrFromAdx(List adxPlot, int lag) { List out = new ArrayList<>(adxPlot.size()); for (int i = 0; i < adxPlot.size(); i++) { PlotPoint cur = adxPlot.get(i); if (i < lag) { out.add(PlotPoint.builder().time(cur.getTime()).value(Double.NaN).build()); continue; } Double a = cur.getValue(); Double b = adxPlot.get(i - lag).getValue(); Double v = (a != null && b != null && !a.isNaN() && !b.isNaN()) ? (a + b) / 2.0 : Double.NaN; out.add(PlotPoint.builder().time(cur.getTime()).value(v).build()); } return out; } private Map> calcAroon(BarSeries s, Map p) { int len = intP(p, "length", 14); return Map.of( "plot0", toPlot(s, new AroonUpIndicator(s, len)), "plot1", toPlot(s, new AroonDownIndicator(s, len)) ); } private Map> calcIchimoku(BarSeries s, Map p) { int conv = intP(p, "conversionPeriods", 9); int base = intP(p, "basePeriods", 26); int span2 = intP(p, "laggingSpan2Periods", 52); int senkouDisp = intP(p, "senkouDisplacement", intP(p, "displacement", 26)); int chikouDisp = intP(p, "chikouDisplacement", intP(p, "displacement", 26)); IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv); IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(s, base); IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, senkouDisp); IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, senkouDisp); IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, chikouDisp); return Map.of( "plot0", toPlot(s, tenkan), "plot1", toPlot(s, kijun), "plot2", toPlot(s, spanA), "plot3", toPlot(s, spanB), "plot4", toPlot(s, chikou) ); } private Map> calcParabolicSAR(BarSeries s, Map p) { double start = dblP(p, "start", 0.02); double inc = dblP(p, "increment", 0.02); double max = dblP(p, "maximum", 0.2); return single("plot0", s, new ParabolicSarIndicator(s, s.numFactory().numOf(start), s.numFactory().numOf(max), s.numFactory().numOf(inc))); } private Map> calcChoppiness(BarSeries s, Map p) { int len = intP(p, "length", 14); // Ta4j ChopIndicator(BarSeries, ciTimeFrame, scaleTo=100) ChopIndicator chop = new ChopIndicator(s, len, 100); return single("plot0", s, chop); } private Map> calcMassIndex(BarSeries s, Map p) { int emaLen = intP(p, "length2", 9); // inner EMA int total = intP(p, "length", 25); // total period return single("plot0", s, new MassIndexIndicator(s, emaLen, total)); } // ── Volatility ──────────────────────────────────────────────────────────── private Map> calcHV(BarSeries s, Map p) { int len = intP(p, "length", 20); int annualBars = intP(p, "annualBars", 252); int n = s.getBarCount(); ClosePriceIndicator close = new ClosePriceIndicator(s); List pts = new ArrayList<>(n); double[] logRet = new double[n]; for (int i = 0; i < n; i++) { if (i == 0) { logRet[i] = 0; continue; } Double c0 = safe(close.getValue(i - 1)); Double c1 = safe(close.getValue(i)); logRet[i] = (c0 != null && c0 > 0 && c1 != null) ? Math.log(c1 / c0) : 0; } double factor = Math.sqrt(annualBars) * 100; for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); if (i < len) { pts.add(new PlotPoint(t, null, null)); continue; } double mean = 0; for (int j = i - len + 1; j <= i; j++) mean += logRet[j]; mean /= len; double var = 0; for (int j = i - len + 1; j <= i; j++) var += Math.pow(logRet[j] - mean, 2); double hv = Math.sqrt(var / (len - 1)) * factor; pts.add(new PlotPoint(t, hv, null)); } return Map.of("plot0", pts); } // ── Williams %R / OBV / TRIX / Volume Osc / VR / 이격도 / 심리도 ───────── private Map> calcWilliamsR(BarSeries s, Map p) { int len = intP(p, "length", 14); // ta4j WilliamsR는 시고저 종가 기준 — 업비트와 동일 return single("plot0", s, new WilliamsRIndicator(s, len)); } /** 업비트 OBV: OBV + 신호선(SMA/EMA/WMA, 기본 SMA 9) */ private Map> calcOBV(BarSeries s, Map p) { OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s); String maType = p.getOrDefault("maType", "SMA").toString(); int maLen = intP(p, "maLength", 9); Indicator maInd = switch (maType) { case "EMA" -> new EMAIndicator(obv, maLen); case "WMA" -> new WMAIndicator(obv, maLen); default -> new SMAIndicator(obv, maLen); }; return Map.of("plot0", toPlot(s, obv), "plot1", toPlot(s, maInd)); } /** 업비트 TRIX: 종가 3×EMA ROC%, TRMA = TRIX EMA */ private Map> calcTRIX(BarSeries s, Map p) { int len = intP(p, "length", 12); int sigLen = intP(p, "signalLength", 9); ClosePriceIndicator close = new ClosePriceIndicator(s); EMAIndicator e3 = new EMAIndicator(new EMAIndicator(new EMAIndicator(close, len), len), len); PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100); EMAIndicator signal = new EMAIndicator(trix, sigLen); return Map.of("plot0", toPlot(s, trix), "plot1", toPlot(s, signal)); } private Map> calcVolumeOscillator(BarSeries s, Map p) { int shortLen = intP(p, "shortLength", 5); int longLen = intP(p, "longLength", 10); VolumeIndicator vol = new VolumeIndicator(s, 1); EMAIndicator shortEma = new EMAIndicator(vol, shortLen); EMAIndicator longEma = new EMAIndicator(vol, longLen); NumericIndicator osc = NumericIndicator.of(shortEma).minus(longEma) .dividedBy(longEma).multipliedBy(100); return single("plot0", s, osc); } private Map> calcVR(BarSeries s, Map p) { int period = intP(p, "length", 10); int n = s.getBarCount(); List pts = new ArrayList<>(n); ClosePriceIndicator close = new ClosePriceIndicator(s); VolumeIndicator vol = new VolumeIndicator(s, 1); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); if (i < period) { pts.add(PlotPoint.builder().time(t).value(null).build()); continue; } double upVol = 0, downVol = 0; for (int j = i - period + 1; j <= i; j++) { Double c = safe(close.getValue(j)); Double cPrev = safe(close.getValue(j - 1)); Double v = safe(vol.getValue(j)); if (c == null || cPrev == null || v == null) continue; if (c > cPrev) upVol += v; else if (c < cPrev) downVol += v; } Double val = downVol == 0 ? null : (upVol / downVol) * 100.0; pts.add(PlotPoint.builder().time(t).value(val).build()); } return Map.of("plot0", pts); } /** 업비트 이격도: 종가 ÷ SMA × 100 */ private Map> calcDisparity(BarSeries s, Map p) { Indicator source = src(s, p); Map> result = new LinkedHashMap<>(); result.put("plot0", disparityLine(s, source, intP(p, "ultraLength", 5))); result.put("plot1", disparityLine(s, source, intP(p, "shortLength", 10))); result.put("plot2", disparityLine(s, source, intP(p, "midLength", 20))); result.put("plot3", disparityLine(s, source, intP(p, "longLength", 60))); return result; } private List disparityLine(BarSeries s, Indicator source, int period) { SMAIndicator sma = new SMAIndicator(source, period); int n = s.getBarCount(); List pts = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); Double c = safe(source.getValue(i)); Double m = safe(sma.getValue(i)); if (c == null || m == null || m == 0) { pts.add(PlotPoint.builder().time(t).value(null).build()); } else { pts.add(PlotPoint.builder().time(t).value(c / m * 100.0).build()); } } return pts; } private Map> calcPsychological(BarSeries s, Map p) { int period = intP(p, "length", 12); return Map.of("plot0", psychologicalPoints(s, period, false)); } private Map> calcNewPsychological(BarSeries s, Map p) { int period = intP(p, "length", 10); return Map.of("plot0", newPsychologicalPoints(s, period)); } private Map> calcInvestPsychological(BarSeries s, Map p) { int period = intP(p, "length", 10); return Map.of("plot0", psychologicalPoints(s, period, false)); } private List psychologicalPoints(BarSeries s, int period, boolean volumeWeighted) { int n = s.getBarCount(); ClosePriceIndicator close = new ClosePriceIndicator(s); VolumeIndicator vol = new VolumeIndicator(s, 1); List pts = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); if (i < period) { pts.add(PlotPoint.builder().time(t).value(null).build()); continue; } double val; if (!volumeWeighted) { int up = 0; for (int j = i - period + 1; j <= i; j++) { Double c = safe(close.getValue(j)); Double cPrev = safe(close.getValue(j - 1)); if (c != null && cPrev != null && c > cPrev) up++; } val = (double) up / period * 100.0; } else { double upVol = 0, total = 0; for (int j = i - period + 1; j <= i; j++) { Double c = safe(close.getValue(j)); Double cPrev = safe(close.getValue(j - 1)); Double v = safe(vol.getValue(j)); if (v == null) continue; total += v; if (c != null && cPrev != null && c > cPrev) upVol += v; } val = total == 0 ? Double.NaN : upVol / total * 100.0; } pts.add(PlotPoint.builder().time(t).value(Double.isNaN(val) ? null : val).build()); } return pts; } /** 신심리도: [상승일수×상승폭비율 − 하락일수×하락폭비율] × 100 / N */ private List newPsychologicalPoints(BarSeries s, int period) { int n = s.getBarCount(); ClosePriceIndicator close = new ClosePriceIndicator(s); List pts = new ArrayList<>(n); for (int i = 0; i < n; i++) { Bar bar = s.getBar(i); long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); if (i < period) { pts.add(PlotPoint.builder().time(t).value(null).build()); continue; } double upSize = 0, downSize = 0; int upDay = 0, downDay = 0; for (int j = i - period + 1; j <= i; j++) { Double c = safe(close.getValue(j)); Double cPrev = safe(close.getValue(j - 1)); if (c == null || cPrev == null) continue; double diff = c - cPrev; if (diff > 0) { upSize += diff; upDay++; } else if (diff < 0) { downSize += -diff; downDay++; } } double total = upSize + downSize; Double val = total == 0 ? null : (upDay * (upSize / total) - downDay * (downSize / total)) * 100.0 / period; pts.add(PlotPoint.builder().time(t).value(val).build()); } return pts; } // ── EMA helper (double[], for TSI manual calc) ──────────────────────────── private double[] ema(double[] src, int len, int n) { double[] out = new double[n]; double k = 2.0 / (len + 1); out[0] = src[0]; for (int i = 1; i < n; i++) { out[i] = src[i] * k + out[i - 1] * (1 - k); } return out; } }