900 lines
41 KiB
Java
900 lines
41 KiB
Java
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; }
|
||
}
|
||
}
|