Files
goldenChart/backend/src/main/java/com/goldenchart/service/TrendSearchService.java
T
2026-05-27 02:10:00 +09:00

900 lines
41 KiB
Java
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.
package com.goldenchart.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.ta4j.core.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.indicators.*;
import org.ta4j.core.indicators.adx.*;
import org.ta4j.core.indicators.averages.EMAIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.helpers.*;
import org.ta4j.core.indicators.ichimoku.*;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 암호화폐 추세검색 — 활성 조건 대비 일치율(%)을 산출해 높은 순으로 랭킹.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TrendSearchService {
private final HistoricalDataService historicalDataService;
private final WebClient.Builder webClientBuilder;
private final ObjectMapper objectMapper;
@Value("${upbit.api.base-url:https://api.upbit.com}")
private String upbitBaseUrl;
@Value("${upbit.api.request-delay-ms:110}")
private long upbitRequestDelayMs;
private static final int MIN_BARS_BASE = 65;
private static final int HIGH_MATCH_THRESHOLD = 90;
private static final int HISTORY_BARS = 365;
/** 스캔 1회당 최대 캔들 (업비트 rate limit·EMA120 기준) */
private static final int SCAN_MAX_BARS = 150;
private static final long CANDLE_CACHE_TTL_MS = 10 * 60 * 1000L;
/** market|tf → 일봉 캐시 (rate limit·재스캔 대응) */
private final Map<String, CandleCacheEntry> candleCache = new ConcurrentHashMap<>();
private record CandleCacheEntry(List<CandleBarDto> candles, long cachedAt) {}
public List<TrendSearchResultDto> scan(TrendSearchRequest req) {
TrendSearchRequest r = req != null ? req : new TrendSearchRequest();
int limit = Math.min(Math.max(r.getLimit(), 1), 100);
int scanLimit = Math.min(Math.max(r.getScanLimit(), 10), 200);
String tf = normalizeTimeframe(r.getTimeframe());
int wantBars = wantBarsForScan(r);
int minBars = requiredMinBars(r);
KrwMarketSlice slice = fetchKrwMarketsByVolumeSlice(scanLimit);
List<String> markets = slice.markets();
if (markets.isEmpty()) return List.of();
Map<String, TickerSnap> tickers = fetchTickers(markets, slice.koreanNames());
int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate()));
int enabledFilters = countEnabledFilters(r);
boolean useBullishScore = r.isBullishTrendEnabled();
int minTrendScore = Math.min(100, Math.max(0, r.getMinTrendScore()));
List<ScoredMarket> scored = new ArrayList<>();
for (int i = 0; i < markets.size(); i++) {
String market = markets.get(i);
try {
if (i > 0) throttleUpbit();
List<CandleBarDto> candles = loadHistoryForScan(market, tf, wantBars);
if (candles.size() < minBars) continue;
BarSeries series = toSeries(candles, tf);
if (series.getBarCount() < minBars) continue;
TickerSnap tick = tickers.getOrDefault(market,
TickerSnap.empty(market, slice.koreanNames().get(market)));
MetricValues m = computeMetrics(series, candles, tick, r);
if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore;
if (!TrendSearchBullishScoring.passesMinTrendScore(m.bullishTrendScore, r)) continue;
} else {
m.trendStrength = computeTrendStrength(m);
m.matchScore = enabledFilters == 0
? m.trendStrength
: computeMatchScore(m, r);
if (minMatchRate > 0 && m.matchScore < minMatchRate) continue;
}
scored.add(new ScoredMarket(market, tick, candles, m));
} catch (Exception e) {
log.debug("[TrendSearch] skip {}: {}", market, e.getMessage());
}
}
if (scored.isEmpty()) {
log.warn("[TrendSearch] scan empty tf={} markets={} bullish={} (캔들·rate limit 확인)",
tf, markets.size(), useBullishScore);
return List.of();
}
Comparator<ScoredMarket> byTrendStrength = useBullishScore
? Comparator.comparingInt((ScoredMarket s) -> s.metrics.bullishTrendScore).reversed()
: Comparator.comparingInt((ScoredMarket s) -> s.metrics.matchScore).reversed();
return scored.stream()
.sorted(byTrendStrength
.thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed()
.thenComparing(ScoredMarket::market))
.limit(limit)
.map(s -> toResult(s, r))
.collect(Collectors.toList());
}
public TrendSearchResultDto detail(String market, TrendSearchRequest req) {
TrendSearchRequest r = req != null ? req : new TrendSearchRequest();
String tf = normalizeTimeframe(r.getTimeframe());
int wantBars = Math.min(HISTORY_BARS, wantBarsForScan(r) + 60);
List<CandleBarDto> candles = loadHistory(market, tf, wantBars);
if (candles.isEmpty()) {
return TrendSearchResultDto.builder()
.market(market).matchRate(0).conditions(List.of()).build();
}
Map<String, String> koreanNames = fetchKoreanNamesForMarkets(List.of(market));
Map<String, TickerSnap> tickers = fetchTickers(List.of(market), koreanNames);
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market, koreanNames.get(market)));
BarSeries series = toSeries(candles, tf);
MetricValues m = computeMetrics(series, candles, tick, r);
if (r.isBullishTrendEnabled()) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore;
} else {
m.trendStrength = computeTrendStrength(m);
int enabled = countEnabledFilters(r);
m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength;
}
return toResult(new ScoredMarket(market, tick, candles, m), r);
}
// ── Metrics ─────────────────────────────────────────────────────────────
private MetricValues computeMetrics(BarSeries series, List<CandleBarDto> candles,
TickerSnap tick, TrendSearchRequest r) {
int end = series.getEndIndex();
ClosePriceIndicator close = new ClosePriceIndicator(series);
VolumeIndicator vol = new VolumeIndicator(series);
double price = close.getValue(end).doubleValue();
SMAIndicator ma5 = new SMAIndicator(close, 5);
SMAIndicator ma20 = new SMAIndicator(close, 20);
SMAIndicator ma60 = new SMAIndicator(close, 60);
SMAIndicator ma120 = new SMAIndicator(close, 120);
double m5 = safeVal(ma5, end);
double m20 = safeVal(ma20, end);
double m60 = safeVal(ma60, end);
double m120 = safeVal(ma120, end);
boolean priceAboveMa = end >= 60 && validMa(m5) && validMa(m20) && validMa(m60)
&& price > m5 && price > m20 && price > m60;
boolean maAlignment = end >= 120 && validMa(m5) && validMa(m20) && validMa(m60) && validMa(m120)
&& m5 > m20 && m20 > m60 && m60 > m120;
double maSpreadPct = maSpreadPct(m5, m20, m60);
boolean maConvergence = maSpreadPct <= Math.max(0.5, r.getMaConvergencePct());
int slopeBars = Math.max(2, Math.min(r.getMa20SlopeBars(), 5));
boolean ma20SlopeUp = ma20ConsecutiveRise(ma20, end, slopeBars);
int nhDays = Math.max(5, Math.min(r.getNewHighBreakoutDays(), 60));
double periodHigh = end >= nhDays ? maxHigh(series, end - 1, nhDays) : 0;
double nearPct = Math.max(0, r.getNewHighNearPct());
boolean newHighBreakout = periodHigh > 0 && price >= periodHigh * (1 - nearPct / 100.0);
boolean ichimokuAboveCloud = ichimokuAboveCloud(series, close, end);
double volPriorPct = volumeVsPriorPct(vol, end);
double volThreshold = Math.max(100, r.getVolumeVsPriorPct());
boolean volumeVsPrior200 = end >= 1 && volPriorPct >= volThreshold;
double turnover5dAvg = avgTurnover5d(candles, tick);
boolean minTurnover5dAvg = turnover5dAvg >= r.getMinTurnover5dAvgKrw();
SMAIndicator volMa5 = new SMAIndicator(vol, 5);
SMAIndicator volMa20 = new SMAIndicator(vol, 20);
double v5 = safeVal(volMa5, end);
double v20 = safeVal(volMa20, end);
boolean volMa5Over20 = end >= 20 && Double.isFinite(v5) && Double.isFinite(v20) && v5 > v20;
MACDIndicator macdLine = new MACDIndicator(close, 12, 26);
EMAIndicator macdSignal = new EMAIndicator(macdLine, 9);
double macdVal = end >= 35 ? macdLine.getValue(end).doubleValue() : 0;
double signalVal = end >= 35 ? macdSignal.getValue(end).doubleValue() : 0;
boolean macdGoldenCross = end >= 35 && (macdVal > signalVal || macdVal > 0);
RSIIndicator rsi = new RSIIndicator(close, 14);
double rsiVal = end >= 15 ? rsi.getValue(end).doubleValue() : 0;
double rsiLo = r.getRsiMin();
double rsiHi = r.getRsiMax();
boolean rsiBand = end >= 15 && rsiVal >= rsiLo && rsiVal <= rsiHi;
int diLen = 14;
PlusDIIndicator plusDi = new PlusDIIndicator(series, diLen);
MinusDIIndicator minusDi = new MinusDIIndicator(series, diLen);
ADXIndicator adx = new ADXIndicator(series, diLen, diLen);
double pdi = end >= diLen + 5 ? plusDi.getValue(end).doubleValue() : 0;
double mdi = end >= diLen + 5 ? minusDi.getValue(end).doubleValue() : 0;
double adxVal = end >= diLen + 5 ? adx.getValue(end).doubleValue() : 0;
boolean dmiBullish = pdi > mdi && adxVal >= r.getAdxMin();
EMAIndicator ema20 = new EMAIndicator(close, 20);
EMAIndicator ema60 = new EMAIndicator(close, 60);
EMAIndicator ema120 = new EMAIndicator(close, 120);
double e20 = safeEma(ema20, end);
double e60 = safeEma(ema60, end);
double e120 = safeEma(ema120, end);
boolean bullishEmaAlignment = end >= 120 && validMa(e20) && validMa(e60) && validMa(e120)
&& e20 > e60 && e60 > e120;
int slopeLookback = Math.max(1, Math.min(r.getEmaSlopeLookback(), 20));
double e20Past = safeEma(ema20, end - slopeLookback);
boolean bullishEmaSlope = end >= 20 + slopeLookback && validMa(e20) && validMa(e20Past)
&& e20 > e20Past;
double adxTrendMin = Math.max(10, r.getAdxTrendMin());
boolean bullishAdxTrend = end >= diLen + 5 && pdi > mdi && adxVal >= adxTrendMin;
boolean bullishMacdMomentum = end >= 35 && macdVal > signalVal && macdVal > 0;
boolean bullishPricePosition = end >= 20 && validMa(e20) && price > e20;
return new MetricValues(
priceAboveMa, maAlignment, maConvergence, maSpreadPct,
ma20SlopeUp, newHighBreakout, periodHigh, price,
ichimokuAboveCloud,
volPriorPct, volumeVsPrior200, turnover5dAvg, minTurnover5dAvg, volMa5Over20,
macdVal, signalVal, macdGoldenCross,
rsiVal, rsiBand,
pdi, mdi, adxVal, dmiBullish,
e20, e60, e120, e20Past,
bullishEmaAlignment, bullishEmaSlope, bullishAdxTrend, bullishMacdMomentum, bullishPricePosition
);
}
private int countEnabledFilters(TrendSearchRequest r) {
int n = 0;
if (r.isMaAlignEnabled()) {
if (r.isPriceAboveMa()) n++;
if (r.isMaAlignment()) n++;
if (r.isMaConvergence()) n++;
}
if (r.isTrendEnabled()) {
if (r.isMa20SlopeUp()) n++;
if (r.isNewHighBreakout()) n++;
if (r.isIchimokuAboveCloud()) n++;
}
if (r.isVolumePowerEnabled()) {
if (r.isVolumeVsPrior200()) n++;
if (r.isMinTurnover5dAvg()) n++;
if (r.isVolMa5Over20()) n++;
}
if (r.isIndicatorEnabled()) {
if (r.isMacdGoldenCross()) n++;
if (r.isRsiBand()) n++;
if (r.isDmiBullish()) n++;
}
return n;
}
private int requiredMinBars(TrendSearchRequest r) {
int need = MIN_BARS_BASE;
if (r.isBullishTrendEnabled()) {
int lookback = Math.max(1, Math.min(r.getEmaSlopeLookback(), 20));
need = Math.max(need, 120 + lookback + 5);
}
if (r.isMaAlignEnabled() && r.isMaAlignment()) need = Math.max(need, 120);
if (r.isTrendEnabled() && r.isIchimokuAboveCloud()) need = Math.max(need, 80);
if (r.isTrendEnabled() && r.isNewHighBreakout()) {
need = Math.max(need, Math.min(65, r.getNewHighBreakoutDays() + 5));
}
return need;
}
private int wantBarsForScan(TrendSearchRequest r) {
return Math.min(SCAN_MAX_BARS, requiredMinBars(r) + 5);
}
private void throttleUpbit() {
try {
Thread.sleep(Math.max(200, upbitRequestDelayMs));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** 스캔용 — 캐시·재시도·봉 수 제한 */
private List<CandleBarDto> loadHistoryForScan(String market, String tf, int wantBars) {
int count = Math.min(wantBars, SCAN_MAX_BARS);
String cacheKey = market + "|" + tf + "|" + count;
long now = System.currentTimeMillis();
CandleCacheEntry hit = candleCache.get(cacheKey);
if (hit != null && now - hit.cachedAt() < CANDLE_CACHE_TTL_MS && hit.candles().size() >= count / 2) {
return hit.candles();
}
List<CandleBarDto> candles = fetchHistoryWithRetry(market, tf, count, 3);
if (!candles.isEmpty()) {
candleCache.put(cacheKey, new CandleCacheEntry(candles, now));
}
return candles;
}
private List<CandleBarDto> fetchHistoryWithRetry(String market, String tf, int count, int attempts) {
for (int attempt = 0; attempt < attempts; attempt++) {
if (attempt > 0) {
try {
Thread.sleep(400L * attempt);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
List<CandleBarDto> bars = historicalDataService.getHistory(market, tf, null, count);
if (!bars.isEmpty()) return bars;
}
return List.of();
}
private int computeMatchScore(MetricValues m, TrendSearchRequest r) {
int total = 0;
int matched = 0;
if (r.isMaAlignEnabled()) {
if (r.isPriceAboveMa()) { total++; if (m.priceAboveMa) matched++; }
if (r.isMaAlignment()) { total++; if (m.maAlignment) matched++; }
if (r.isMaConvergence()) { total++; if (m.maConvergence) matched++; }
}
if (r.isTrendEnabled()) {
if (r.isMa20SlopeUp()) { total++; if (m.ma20SlopeUp) matched++; }
if (r.isNewHighBreakout()) { total++; if (m.newHighBreakout) matched++; }
if (r.isIchimokuAboveCloud()) { total++; if (m.ichimokuAboveCloud) matched++; }
}
if (r.isVolumePowerEnabled()) {
if (r.isVolumeVsPrior200()) { total++; if (m.volumeVsPrior200) matched++; }
if (r.isMinTurnover5dAvg()) { total++; if (m.minTurnover5dAvg) matched++; }
if (r.isVolMa5Over20()) { total++; if (m.volMa5Over20) matched++; }
}
if (r.isIndicatorEnabled()) {
if (r.isMacdGoldenCross()) { total++; if (m.macdGoldenCross) matched++; }
if (r.isRsiBand()) { total++; if (m.rsiBand) matched++; }
if (r.isDmiBullish()) { total++; if (m.dmiBullish) matched++; }
}
if (total == 0) return 100;
return Math.round(matched * 100f / total);
}
/** 상승추세 검색그룹 — 가중 점수 합산 (0~100) */
private int computeBullishTrendScore(MetricValues m, TrendSearchRequest r) {
return TrendSearchBullishScoring.computeScore(m, r);
}
/** 정배열·상승세·거래량 복합 추세 강도 (0~100) */
private int computeTrendStrength(MetricValues m) {
double score = 0;
if (m.priceAboveMa) score += 20;
if (m.maAlignment) score += 30;
if (m.ma20SlopeUp) score += 15;
if (m.newHighBreakout) score += 15;
if (m.ichimokuAboveCloud) score += 10;
if (m.volMa5Over20) score += 5;
if (m.macdGoldenCross) score += 3;
if (m.dmiBullish) score += 2;
return (int) Math.min(100, Math.round(score));
}
/** 상세 조회용 — 필요 시 페이징 (단일 종목) */
private List<CandleBarDto> loadHistory(String market, String tf, int wantBars) {
int page = Math.min(wantBars, SCAN_MAX_BARS);
List<CandleBarDto> all = new ArrayList<>(historicalDataService.getHistory(market, tf, null, page));
while (all.size() < wantBars && all.size() >= page && page >= SCAN_MAX_BARS) {
throttleUpbit();
CandleBarDto oldest = all.get(0);
String to = Instant.ofEpochSecond(oldest.getTime()).toString();
List<CandleBarDto> older = historicalDataService.getHistory(
market, tf, to, Math.min(SCAN_MAX_BARS, wantBars - all.size()));
if (older.isEmpty()) break;
Set<Long> seen = all.stream().map(CandleBarDto::getTime).collect(Collectors.toSet());
List<CandleBarDto> fresh = older.stream()
.filter(c -> !seen.contains(c.getTime()))
.toList();
if (fresh.isEmpty()) break;
List<CandleBarDto> merged = new ArrayList<>(fresh.size() + all.size());
merged.addAll(fresh);
merged.addAll(all);
merged.sort(Comparator.comparingLong(CandleBarDto::getTime));
all = merged;
}
if (all.size() > wantBars) {
return new ArrayList<>(all.subList(all.size() - wantBars, all.size()));
}
return all;
}
private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r) {
List<TrendSearchConditionDto> conditions = buildConditions(s.metrics, r);
final int matched;
final int total;
final int matchRate;
if (r.isBullishTrendEnabled()) {
matchRate = s.metrics.bullishTrendScore;
matched = matchRate;
total = 100;
} else {
matched = (int) conditions.stream().filter(c -> "match".equals(c.getStatus())).count();
total = conditions.size();
matchRate = total > 0 ? Math.round(matched * 100f / total) : s.metrics.matchScore;
}
s.metrics.matchScore = matchRate;
double lastClose = s.candles.get(s.candles.size() - 1).getClose();
return TrendSearchResultDto.builder()
.market(s.market)
.koreanName(s.tick.koreanName)
.currentPrice(s.tick.tradePrice > 0 ? s.tick.tradePrice : lastClose)
.changeRate(s.tick.changeRate)
.matchRate(matchRate)
.matchedCount(matched)
.totalConditions(total)
.highMatch(matchRate >= HIGH_MATCH_THRESHOLD)
.conditions(conditions)
.build();
}
private List<TrendSearchConditionDto> buildConditions(MetricValues m, TrendSearchRequest r) {
List<TrendSearchConditionDto> out = new ArrayList<>();
String catBullish = "상승추세";
String catMa = ". 정배열";
String catTrend = "Ⅱ. 상승세";
String catVol = "Ⅲ. 돈과 힘";
String catInd = "Ⅳ. 보조지표";
if (r.isBullishTrendEnabled()) {
addBullishScore(out, "bullishEmaAlignment", catBullish, "이평선 정배열",
m.bullishEmaAlignment, r.getWeightMaAlignment(),
"EMA20>60>120", round2(m.ema20), round2(m.ema60), round2(m.ema120));
addBullishScore(out, "bullishEmaSlope", catBullish, "이평선 기울기",
m.bullishEmaSlope, r.getWeightMaSlope(),
"EMA20 > " + r.getEmaSlopeLookback() + "봉 전",
round2(m.ema20), round2(m.ema20Past), null);
addBullishScore(out, "bullishAdxTrend", catBullish, "추세강도 (ADX)",
m.bullishAdxTrend, r.getWeightAdxTrend(),
"+DI>" + round2(m.mdi) + " ADX≥" + round2(r.getAdxTrendMin()),
round2(m.pdi), round2(m.adxVal), null);
addBullishScore(out, "bullishMacdMomentum", catBullish, "모멘텀 (MACD)",
m.bullishMacdMomentum, r.getWeightMacdMomentum(),
"MACD>Signal & MACD>0",
round2(m.macdVal), round2(m.signalVal), null);
addBullishScore(out, "bullishPricePosition", catBullish, "가격위치",
m.bullishPricePosition, r.getWeightPricePosition(),
"종가 > EMA20",
round2(m.price), round2(m.ema20), null);
return out;
}
if (r.isMaAlignEnabled()) {
addFilter(out, "priceAboveMa", catMa, "주가 > 5·20·60 이평", r.isPriceAboveMa(), m.priceAboveMa);
addFilter(out, "maAlignment", catMa, "5 > 20 > 60 > 120 정배열", r.isMaAlignment(), m.maAlignment);
if (r.isMaConvergence()) {
boolean pass = m.maConvergence;
out.add(cond("maConvergence", catMa, "이평밀집도(5·20·60)",
round2(m.maSpreadPct), "≤ " + round2(r.getMaConvergencePct()) + "%",
pass ? 100 : 35, pass ? "match" : "mismatch"));
}
}
if (r.isTrendEnabled()) {
addFilter(out, "ma20SlopeUp", catTrend,
r.getMa20SlopeBars() + "봉 연속 20일선 상승", r.isMa20SlopeUp(), m.ma20SlopeUp);
if (r.isNewHighBreakout()) {
double nearPct = Math.max(0, r.getNewHighNearPct());
out.add(cond("newHighBreakout", catTrend,
r.getNewHighBreakoutDays() + "일 고가 돌파/근접",
round2(m.price), "≥ " + round2(m.periodHigh * (1 - nearPct / 100)),
m.newHighBreakout ? 100 : 40, m.newHighBreakout ? "match" : "mismatch"));
}
addFilter(out, "ichimokuAboveCloud", catTrend, "구름대 상단(선행스팬)", r.isIchimokuAboveCloud(), m.ichimokuAboveCloud);
}
if (r.isVolumePowerEnabled()) {
if (r.isVolumeVsPrior200()) {
boolean pass = m.volumeVsPrior200;
out.add(cond("volumeVsPrior200", catVol, "직전 봉 대비 거래량",
round2(m.volPriorPct), "≥ " + round2(r.getVolumeVsPriorPct()) + "%",
pass ? 100 : 40, pass ? "match" : "mismatch"));
}
if (r.isMinTurnover5dAvg()) {
boolean pass = m.minTurnover5dAvg;
out.add(cond("minTurnover5dAvg", catVol, "5일 평균 거래대금",
round2(m.turnover5dAvg / 1e8), "≥ " + round2(r.getMinTurnover5dAvgKrw() / 1e8) + "억",
pass ? 100 : 40, pass ? "match" : "mismatch"));
}
addFilter(out, "volMa5Over20", catVol, "거래량 5일 이평 > 20일", r.isVolMa5Over20(), m.volMa5Over20);
}
if (r.isIndicatorEnabled()) {
if (r.isMacdGoldenCross()) {
out.add(cond("macdGoldenCross", catInd, "MACD 골든크로스",
round2(m.macdVal), "MACD>Signal 또는 MACD>0",
m.macdGoldenCross ? 100 : 40, m.macdGoldenCross ? "match" : "mismatch"));
}
if (r.isRsiBand()) {
boolean pass = m.rsiBand;
out.add(cond("rsiBand", catInd, "RSI 구간",
round2(m.rsiVal), r.getRsiMin() + " ≤ RSI ≤ " + r.getRsiMax(),
pass ? 100 : 40, pass ? "match" : "mismatch"));
}
if (r.isDmiBullish()) {
boolean pass = m.dmiBullish;
out.add(cond("dmiBullish", catInd, "+DI > -DI · ADX",
round2(m.adxVal), "+DI>" + round2(m.mdi) + " ADX≥" + round2(r.getAdxMin()),
pass ? 100 : 40, pass ? "match" : "mismatch"));
}
}
return out;
}
private void addFilter(List<TrendSearchConditionDto> out, String id, String cat, String label,
boolean enabled, boolean pass) {
if (!enabled) return;
out.add(cond(id, cat, label, pass ? 1.0 : 0.0, "필수 충족", pass ? 100 : 0, pass ? "match" : "mismatch"));
}
private void addBullishScore(List<TrendSearchConditionDto> out, String id, String cat, String label,
boolean pass, int maxPoints, String threshold,
double v1, double v2, Double v3) {
int earned = pass ? maxPoints : 0;
int progress = maxPoints > 0 ? Math.round(earned * 100f / maxPoints) : 0;
String currentLabel = v3 != null
? round2(v1) + " / " + round2(v2) + " / " + round2(v3)
: (id.equals("bullishEmaSlope")
? round2(v1) + " vs " + round2(v2)
: round2(v1) + (Double.isFinite(v2) && v2 != 0 ? " · " + round2(v2) : ""));
out.add(cond(id, cat, label + " (" + maxPoints + "점)",
pass ? (double) earned : 0.0,
threshold + " · " + currentLabel,
progress, pass ? "match" : "mismatch"));
}
private static double safeEma(EMAIndicator ema, int idx) {
if (idx < 0) return Double.NaN;
try {
double v = ema.getValue(idx).doubleValue();
return Double.isFinite(v) ? v : Double.NaN;
} catch (Exception e) {
return Double.NaN;
}
}
// ── Indicator helpers ───────────────────────────────────────────────────
private static double maSpreadPct(double m5, double m20, double m60) {
double avg = (m5 + m20 + m60) / 3.0;
if (avg <= 0) return 100;
double maxDev = Math.max(Math.abs(m5 - avg), Math.max(Math.abs(m20 - avg), Math.abs(m60 - avg)));
return (maxDev / avg) * 100;
}
private static boolean ma20ConsecutiveRise(SMAIndicator ma20, int end, int bars) {
if (end < 20 + bars) return false;
for (int i = 0; i < bars; i++) {
double cur = safeVal(ma20, end - i);
double prev = safeVal(ma20, end - i - 1);
if (!validMa(cur) || !validMa(prev) || cur <= prev) return false;
}
return true;
}
private static boolean ichimokuAboveCloud(BarSeries series, ClosePriceIndicator close, int end) {
if (end < 52 + 26) return false;
try {
int conv = 9, base = 26, span2 = 52, disp = 26;
IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(series, conv);
IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(series, base);
IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(series, tenkan, kijun, disp);
IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(series, span2, disp);
double price = close.getValue(end).doubleValue();
double a = spanA.getValue(end).doubleValue();
double b = spanB.getValue(end).doubleValue();
if (!Double.isFinite(a) || !Double.isFinite(b)) return false;
double cloudTop = Math.max(a, b);
return price > cloudTop;
} catch (Exception e) {
return false;
}
}
private static double volumeVsPriorPct(VolumeIndicator vol, int end) {
if (end < 1) return 0;
double prev = vol.getValue(end - 1).doubleValue();
double cur = vol.getValue(end).doubleValue();
return prev > 0 ? (cur / prev) * 100 : 0;
}
private static double avgTurnover5d(List<CandleBarDto> candles, TickerSnap tick) {
if (candles.size() >= 5) {
int end = candles.size() - 1;
double sum = 0;
for (int i = end - 4; i <= end; i++) {
CandleBarDto c = candles.get(i);
sum += c.getClose() * c.getVolume();
}
return sum / 5;
}
return tick.accTradePrice24h > 0 ? tick.accTradePrice24h : 0;
}
private static double maxHigh(BarSeries series, int end, int lookback) {
int start = Math.max(0, end - lookback + 1);
double max = 0;
for (int i = start; i <= end; i++) {
max = Math.max(max, series.getBar(i).getHighPrice().doubleValue());
}
return max;
}
private static double safeVal(SMAIndicator ma, int idx) {
if (idx < 0) return Double.NaN;
try {
double v = ma.getValue(idx).doubleValue();
return Double.isFinite(v) ? v : Double.NaN;
} catch (Exception e) {
return Double.NaN;
}
}
private static boolean validMa(double v) {
return Double.isFinite(v) && v > 0;
}
private static double safeVol(VolumeIndicator vol, int idx) {
if (idx < 0) return 0;
try {
return vol.getValue(idx).doubleValue();
} catch (Exception e) {
return 0;
}
}
private TrendSearchConditionDto cond(String id, String cat, String label, Double current,
String threshold, int progress, String status) {
return TrendSearchConditionDto.builder()
.id(id)
.category(cat)
.label(label)
.currentValue(current != null ? round2(current) : null)
.thresholdLabel(threshold)
.progress(Math.min(100, Math.max(0, progress)))
.status(status)
.build();
}
private double round2(double v) {
return Math.round(v * 100.0) / 100.0;
}
// ── Upbit helpers ───────────────────────────────────────────────────────
private record KrwMarketSlice(List<String> markets, Map<String, String> koreanNames) {}
/** 거래대금 상위 KRW 마켓 + 한글명 (market/all 기준) */
private KrwMarketSlice fetchKrwMarketsByVolumeSlice(int limit) {
try {
Map<String, String> allNames = fetchKoreanNamesFromMarketAll();
if (allNames.isEmpty()) return new KrwMarketSlice(List.of(), Map.of());
List<String> krw = new ArrayList<>(allNames.keySet());
Map<String, TickerSnap> tickers = fetchTickers(krw, allNames);
List<String> sorted = krw.stream()
.sorted(Comparator.comparingDouble((String m) ->
tickers.getOrDefault(m, TickerSnap.empty(m, allNames.get(m))).accTradePrice24h).reversed())
.limit(limit)
.collect(Collectors.toList());
Map<String, String> names = new HashMap<>();
for (String m : sorted) {
names.put(m, allNames.getOrDefault(m, m.replace("KRW-", "")));
}
return new KrwMarketSlice(sorted, names);
} catch (Exception e) {
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
List<String> fallback = List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
Map<String, String> names = new HashMap<>();
for (String m : fallback) names.put(m, m.replace("KRW-", ""));
return new KrwMarketSlice(fallback, names);
}
}
private Map<String, String> fetchKoreanNamesFromMarketAll() {
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
String marketsJson = client.get()
.uri("/v1/market/all?isDetails=false")
.retrieve()
.bodyToMono(String.class)
.block();
if (marketsJson == null) return Map.of();
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
Map<String, String> names = new HashMap<>();
for (Map<String, Object> m : all) {
String market = (String) m.get("market");
if (market == null || !market.startsWith("KRW-")) continue;
Object kn = m.get("korean_name");
if (kn != null && !kn.toString().isBlank()) {
names.put(market, kn.toString());
}
}
return names;
} catch (Exception e) {
log.warn("[TrendSearch] korean names fetch fail: {}", e.getMessage());
return Map.of();
}
}
private Map<String, String> fetchKoreanNamesForMarkets(List<String> markets) {
if (markets.isEmpty()) return Map.of();
Map<String, String> all = fetchKoreanNamesFromMarketAll();
Map<String, String> out = new HashMap<>();
for (String m : markets) {
out.put(m, all.getOrDefault(m, m.replace("KRW-", "")));
}
return out;
}
private Map<String, TickerSnap> fetchTickers(List<String> markets, Map<String, String> koreanNames) {
if (markets.isEmpty()) return Map.of();
try {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
Map<String, TickerSnap> map = new HashMap<>();
for (int i = 0; i < markets.size(); i += 100) {
List<String> batch = markets.subList(i, Math.min(i + 100, markets.size()));
String joined = String.join(",", batch);
String json = client.get()
.uri("/v1/ticker?markets=" + joined)
.retrieve()
.bodyToMono(String.class)
.block();
if (json == null) continue;
JsonNode arr = objectMapper.readTree(json);
for (JsonNode n : arr) {
String market = n.path("market").asText();
String ko = koreanNames != null
? koreanNames.getOrDefault(market, market.replace("KRW-", ""))
: market.replace("KRW-", "");
map.put(market, new TickerSnap(
market,
ko,
n.path("trade_price").asDouble(0),
n.path("signed_change_rate").asDouble(0) * 100,
n.path("acc_trade_price_24h").asDouble(0)
));
}
}
return map;
} catch (Exception e) {
log.warn("[TrendSearch] ticker fetch fail: {}", e.getMessage());
return Map.of();
}
}
private String normalizeTimeframe(String tf) {
if (tf == null || tf.isBlank()) return "1d";
return switch (tf) {
case "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d", "1w", "1M" -> tf;
case "1D" -> "1d";
case "15M", "15min" -> "15m";
default -> "1d";
};
}
private BarSeries toSeries(List<CandleBarDto> candles, String tf) {
Duration duration = Ta4jStorage.parseDuration(normalizeTimeframe(tf));
BarSeries series = new BaseBarSeriesBuilder()
.withName("trend")
.withBarBuilderFactory(new TimeBarBuilderFactory())
.withNumFactory(DoubleNumFactory.getInstance())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
for (CandleBarDto c : candles) {
try {
Instant endInstant = Ta4jStorage.epochToInstant(c.getTime()).plus(duration);
factory.createBarBuilder(series)
.timePeriod(duration)
.endTime(endInstant)
.openPrice(c.getOpen())
.highPrice(c.getHigh())
.lowPrice(c.getLow())
.closePrice(c.getClose())
.volume(c.getVolume())
.add();
} catch (Exception e) {
log.trace("[TrendSearch] Bar 추가 스킵: {}", e.getMessage());
}
}
return series;
}
private record TickerSnap(String market, String koreanName, double tradePrice,
double changeRate, double accTradePrice24h) {
static TickerSnap empty(String market, String koreanName) {
String ko = koreanName != null && !koreanName.isBlank()
? koreanName
: market.replace("KRW-", "");
return new TickerSnap(market, ko, 0, 0, 0);
}
}
private record ScoredMarket(String market, TickerSnap tick, List<CandleBarDto> candles, MetricValues metrics) {}
private static class MetricValues implements TrendSearchBullishScoring.BullishFlags {
final boolean priceAboveMa, maAlignment, maConvergence;
final double maSpreadPct;
final boolean ma20SlopeUp, newHighBreakout, ichimokuAboveCloud;
final double periodHigh, price;
final double volPriorPct;
final boolean volumeVsPrior200, minTurnover5dAvg, volMa5Over20;
final double turnover5dAvg;
final double macdVal, signalVal;
final boolean macdGoldenCross;
final double rsiVal;
final boolean rsiBand;
final double pdi, mdi, adxVal;
final boolean dmiBullish;
final double ema20, ema60, ema120, ema20Past;
final boolean bullishEmaAlignment, bullishEmaSlope, bullishAdxTrend;
final boolean bullishMacdMomentum, bullishPricePosition;
int matchScore;
int trendStrength;
int bullishTrendScore;
MetricValues(boolean priceAboveMa, boolean maAlignment, boolean maConvergence, double maSpreadPct,
boolean ma20SlopeUp, boolean newHighBreakout, double periodHigh, double price,
boolean ichimokuAboveCloud,
double volPriorPct, boolean volumeVsPrior200, double turnover5dAvg,
boolean minTurnover5dAvg, boolean volMa5Over20,
double macdVal, double signalVal, boolean macdGoldenCross,
double rsiVal, boolean rsiBand,
double pdi, double mdi, double adxVal, boolean dmiBullish,
double ema20, double ema60, double ema120, double ema20Past,
boolean bullishEmaAlignment, boolean bullishEmaSlope, boolean bullishAdxTrend,
boolean bullishMacdMomentum, boolean bullishPricePosition) {
this.priceAboveMa = priceAboveMa;
this.maAlignment = maAlignment;
this.maConvergence = maConvergence;
this.maSpreadPct = maSpreadPct;
this.ma20SlopeUp = ma20SlopeUp;
this.newHighBreakout = newHighBreakout;
this.periodHigh = periodHigh;
this.price = price;
this.ichimokuAboveCloud = ichimokuAboveCloud;
this.volPriorPct = volPriorPct;
this.volumeVsPrior200 = volumeVsPrior200;
this.turnover5dAvg = turnover5dAvg;
this.minTurnover5dAvg = minTurnover5dAvg;
this.volMa5Over20 = volMa5Over20;
this.macdVal = macdVal;
this.signalVal = signalVal;
this.macdGoldenCross = macdGoldenCross;
this.rsiVal = rsiVal;
this.rsiBand = rsiBand;
this.pdi = pdi;
this.mdi = mdi;
this.adxVal = adxVal;
this.dmiBullish = dmiBullish;
this.ema20 = ema20;
this.ema60 = ema60;
this.ema120 = ema120;
this.ema20Past = ema20Past;
this.bullishEmaAlignment = bullishEmaAlignment;
this.bullishEmaSlope = bullishEmaSlope;
this.bullishAdxTrend = bullishAdxTrend;
this.bullishMacdMomentum = bullishMacdMomentum;
this.bullishPricePosition = bullishPricePosition;
}
@Override public boolean bullishEmaAlignment() { return bullishEmaAlignment; }
@Override public boolean bullishEmaSlope() { return bullishEmaSlope; }
@Override public boolean bullishAdxTrend() { return bullishAdxTrend; }
@Override public boolean bullishMacdMomentum() { return bullishMacdMomentum; }
@Override public boolean bullishPricePosition() { return bullishPricePosition; }
}
}