추세검색 화면 적용
This commit is contained in:
@@ -0,0 +1,704 @@
|
||||
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.indicators.*;
|
||||
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.volume.OnBalanceVolumeIndicator;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 암호화폐 추세검색 — 업비트 KRW 마켓 20개 조건 스캔.
|
||||
*/
|
||||
@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;
|
||||
|
||||
private static final int MIN_BARS = 60;
|
||||
private static final int HIGH_MATCH_THRESHOLD = 90;
|
||||
private static final int HISTORY_BARS = 365;
|
||||
|
||||
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 barCount = "1d".equals(tf) || "1w".equals(tf) ? HISTORY_BARS : 200;
|
||||
|
||||
List<String> markets = fetchKrwMarketsByVolume(scanLimit);
|
||||
if (markets.isEmpty()) return List.of();
|
||||
|
||||
Map<String, TickerSnap> tickers = fetchTickers(markets);
|
||||
Map<String, Double> bidAskRatios = r.isBidAskRatio() ? fetchBidAskRatios(markets) : Map.of();
|
||||
|
||||
List<CandleBarDto> btcCandles = List.of();
|
||||
if (r.isRelativeStrength()) {
|
||||
try {
|
||||
btcCandles = historicalDataService.getHistory("KRW-BTC", tf, null, barCount);
|
||||
} catch (Exception e) {
|
||||
log.debug("[TrendSearch] BTC benchmark skip: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
double btcRet3m = returnPct(btcCandles, 84);
|
||||
double btcRet6m = returnPct(btcCandles, 180);
|
||||
|
||||
List<ScoredMarket> scored = new ArrayList<>();
|
||||
for (String market : markets) {
|
||||
try {
|
||||
List<CandleBarDto> candles = historicalDataService.getHistory(market, tf, null, barCount);
|
||||
if (candles.size() < MIN_BARS) continue;
|
||||
|
||||
BarSeries series = toSeries(candles, tf);
|
||||
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
|
||||
MetricValues m = computeMetrics(series, candles, tick, bidAskRatios.get(market),
|
||||
btcRet3m, btcRet6m, r.getNewHighDays());
|
||||
|
||||
if (!passesFilters(m, r)) continue;
|
||||
|
||||
scored.add(new ScoredMarket(market, tick, candles, m));
|
||||
} catch (Exception e) {
|
||||
log.debug("[TrendSearch] skip {}: {}", market, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (scored.isEmpty()) return List.of();
|
||||
|
||||
Map<String, Double> sortScores = buildSortScores(scored, r);
|
||||
String sortKey = resolveSortKey(r);
|
||||
Comparator<ScoredMarket> cmp = Comparator
|
||||
.comparingDouble((ScoredMarket s) -> sortScores.getOrDefault(s.market, 0.0)).reversed()
|
||||
.thenComparingDouble(s -> s.metrics.matchScore).reversed();
|
||||
|
||||
List<TrendSearchResultDto> results = scored.stream()
|
||||
.sorted(cmp)
|
||||
.limit(limit)
|
||||
.map(s -> toResult(s, r, sortScores))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public TrendSearchResultDto detail(String market, TrendSearchRequest req) {
|
||||
TrendSearchRequest r = req != null ? req : new TrendSearchRequest();
|
||||
String tf = normalizeTimeframe(r.getTimeframe());
|
||||
int barCount = "1d".equals(tf) || "1w".equals(tf) ? HISTORY_BARS : 200;
|
||||
List<CandleBarDto> candles = historicalDataService.getHistory(market, tf, null, barCount);
|
||||
if (candles.isEmpty()) {
|
||||
return TrendSearchResultDto.builder()
|
||||
.market(market).matchRate(0).conditions(List.of()).build();
|
||||
}
|
||||
|
||||
Map<String, TickerSnap> tickers = fetchTickers(List.of(market));
|
||||
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
|
||||
Map<String, Double> bidAsk = r.isBidAskRatio() ? fetchBidAskRatios(List.of(market)) : Map.of();
|
||||
|
||||
double btcRet3m = 0, btcRet6m = 0;
|
||||
if (r.isRelativeStrength()) {
|
||||
List<CandleBarDto> btc = historicalDataService.getHistory("KRW-BTC", tf, null, barCount);
|
||||
btcRet3m = returnPct(btc, 84);
|
||||
btcRet6m = returnPct(btc, 180);
|
||||
}
|
||||
|
||||
BarSeries series = toSeries(candles, tf);
|
||||
MetricValues m = computeMetrics(series, candles, tick, bidAsk.get(market),
|
||||
btcRet3m, btcRet6m, r.getNewHighDays());
|
||||
ScoredMarket sm = new ScoredMarket(market, tick, candles, m);
|
||||
Map<String, Double> sortScores = Map.of(resolveSortKey(r), m.getSortValue(resolveSortKey(r)));
|
||||
return toResult(sm, r, sortScores);
|
||||
}
|
||||
|
||||
// ── Metrics ─────────────────────────────────────────────────────────────
|
||||
|
||||
private MetricValues computeMetrics(BarSeries series, List<CandleBarDto> candles, TickerSnap tick,
|
||||
Double bidAskRatio, double btcRet3m, double btcRet6m, int newHighDays) {
|
||||
int end = series.getEndIndex();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
VolumeIndicator vol = new VolumeIndicator(series);
|
||||
double price = close.getValue(end).doubleValue();
|
||||
|
||||
double high52w = maxHigh(series, end, Math.min(252, end + 1));
|
||||
double near52wHigh = high52w > 0 ? (price / high52w) * 100 : 0;
|
||||
|
||||
double ret3m = returnPct(candles, 84);
|
||||
double ret6m = returnPct(candles, 180);
|
||||
double rs = ret3m - btcRet3m;
|
||||
|
||||
SMAIndicator ma5 = new SMAIndicator(close, 5);
|
||||
SMAIndicator ma20 = new SMAIndicator(close, 20);
|
||||
SMAIndicator ma60 = new SMAIndicator(close, 60);
|
||||
SMAIndicator ma120 = new SMAIndicator(close, 120);
|
||||
SMAIndicator ma150 = new SMAIndicator(close, 150);
|
||||
SMAIndicator ma200 = new SMAIndicator(close, 200);
|
||||
|
||||
double ma20v = safeVal(ma20, end);
|
||||
double ma60v = safeVal(ma60, end);
|
||||
double dev20 = ma20v > 0 ? ((price - ma20v) / ma20v) * 100 : 0;
|
||||
double dev60 = ma60v > 0 ? ((price - ma60v) / ma60v) * 100 : 0;
|
||||
double maDeviation = Math.max(dev20, dev60);
|
||||
|
||||
boolean maFullAlign = end >= 200
|
||||
&& price > safeVal(ma5, end)
|
||||
&& safeVal(ma5, end) > safeVal(ma20, end)
|
||||
&& safeVal(ma20, end) > safeVal(ma60, end)
|
||||
&& safeVal(ma60, end) > safeVal(ma120, end)
|
||||
&& safeVal(ma120, end) > safeVal(ma200, end);
|
||||
|
||||
double ma200now = safeVal(ma200, end);
|
||||
double ma200prev = end >= 220 ? safeVal(ma200, end - 20) : ma200now;
|
||||
boolean stage2 = end >= 200
|
||||
&& price > safeVal(ma150, end)
|
||||
&& price > ma200now
|
||||
&& ma200now > ma200prev;
|
||||
|
||||
int nhDays = Math.max(5, Math.min(newHighDays, 60));
|
||||
int newHighCount = countNewHighs(series, end, nhDays);
|
||||
|
||||
double tradeAmount = tick.accTradePrice24h > 0 ? tick.accTradePrice24h : tradeAmountFromBar(candles);
|
||||
double volVs5dAvg = volVs5dAvg(candles);
|
||||
double turnover = turnoverRate(vol, end);
|
||||
|
||||
OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(series);
|
||||
double smartMoney = end >= 20
|
||||
? obv.getValue(end).doubleValue() - obv.getValue(end - 20).doubleValue()
|
||||
: 0;
|
||||
int instConsecutive = countInstConsecutive(candles);
|
||||
|
||||
RSIIndicator rsi = new RSIIndicator(close, 14);
|
||||
double rsiVal = end >= 15 ? rsi.getValue(end).doubleValue() : 0;
|
||||
|
||||
MACDIndicator macd = new MACDIndicator(close, 12, 26);
|
||||
double macdHist = end >= 35 ? macd.getValue(end).doubleValue() : 0;
|
||||
double macdPeak = macdPeakScore(macd, end);
|
||||
|
||||
boolean lightCredit = lightCreditPass(candles, vol, end);
|
||||
|
||||
return new MetricValues(
|
||||
near52wHigh, ret3m, ret6m, maDeviation, maFullAlign, stage2, rs, newHighCount,
|
||||
tradeAmount, volVs5dAvg, turnover, bidAskRatio != null ? bidAskRatio : 0,
|
||||
smartMoney, instConsecutive, rsiVal, macdPeak, macdHist, lightCredit
|
||||
);
|
||||
}
|
||||
|
||||
private boolean passesFilters(MetricValues m, TrendSearchRequest r) {
|
||||
if (r.isMaFullAlign() && r.isPriceEnabled() && !m.maFullAlign) return false;
|
||||
if (r.isStage2() && r.isPriceEnabled() && !m.stage2) return false;
|
||||
if (r.isLightCredit() && r.isFlowEnabled() && !m.lightCredit) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Map<String, Double> buildSortScores(List<ScoredMarket> scored, TrendSearchRequest r) {
|
||||
Map<String, List<Double>> buckets = new HashMap<>();
|
||||
for (ScoredMarket s : scored) {
|
||||
for (String id : enabledSortIds(r)) {
|
||||
buckets.computeIfAbsent(id, k -> new ArrayList<>()).add(s.metrics.getSortValue(id));
|
||||
}
|
||||
}
|
||||
Map<String, Double> out = new HashMap<>();
|
||||
for (ScoredMarket s : scored) {
|
||||
double sum = 0;
|
||||
int cnt = 0;
|
||||
for (String id : enabledSortIds(r)) {
|
||||
double pct = percentile(buckets.get(id), s.metrics.getSortValue(id));
|
||||
out.put(s.market + ":" + id, pct);
|
||||
sum += pct;
|
||||
cnt++;
|
||||
}
|
||||
s.metrics.matchScore = cnt > 0 ? sum / cnt : 0;
|
||||
}
|
||||
String sortKey = resolveSortKey(r);
|
||||
for (ScoredMarket s : scored) {
|
||||
out.put(s.market, s.metrics.getSortValue(sortKey));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<String> enabledSortIds(TrendSearchRequest r) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
if (r.isPriceEnabled()) {
|
||||
if (r.isNear52wHigh()) ids.add("near52wHigh");
|
||||
if (r.isRet3m()) ids.add("ret3m");
|
||||
if (r.isRet6m()) ids.add("ret6m");
|
||||
if (r.isMaDeviation()) ids.add("maDeviation");
|
||||
if (r.isRelativeStrength()) ids.add("relativeStrength");
|
||||
if (r.isNewHighCount()) ids.add("newHighCount");
|
||||
}
|
||||
if (r.isVolumeEnabled()) {
|
||||
if (r.isTradeAmount()) ids.add("tradeAmount");
|
||||
if (r.isVolVs5dAvg()) ids.add("volVs5dAvg");
|
||||
if (r.isTurnover()) ids.add("turnover");
|
||||
if (r.isBidAskRatio()) ids.add("bidAskRatio");
|
||||
}
|
||||
if (r.isFlowEnabled()) {
|
||||
if (r.isSmartMoney()) ids.add("smartMoney");
|
||||
if (r.isInstConsecutive()) ids.add("instConsecutive");
|
||||
if (r.isRsiHigh()) ids.add("rsiHigh");
|
||||
if (r.isMacdPeak()) ids.add("macdPeak");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private String resolveSortKey(TrendSearchRequest r) {
|
||||
String key = r.getSortBy() != null ? r.getSortBy() : "near52wHigh";
|
||||
if (enabledSortIds(r).contains(key)) return key;
|
||||
List<String> enabled = enabledSortIds(r);
|
||||
return enabled.isEmpty() ? "near52wHigh" : enabled.get(0);
|
||||
}
|
||||
|
||||
private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r, Map<String, Double> sortScores) {
|
||||
List<TrendSearchConditionDto> conditions = buildConditions(s.metrics, r, sortScores, s.market);
|
||||
int matched = (int) conditions.stream().filter(c -> "match".equals(c.getStatus())).count();
|
||||
int total = conditions.size();
|
||||
int matchRate = total > 0 ? Math.round(matched * 100f / total) : 0;
|
||||
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,
|
||||
Map<String, Double> sortScores, String market) {
|
||||
List<TrendSearchConditionDto> out = new ArrayList<>();
|
||||
|
||||
if (r.isPriceEnabled()) {
|
||||
addSort(out, "near52wHigh", "Ⅰ. 가격·탄력성", "52주 신고가 근접률", r.isNear52wHigh(),
|
||||
m.near52wHigh, "≥ 90%", pctStatus(sortScores, market, "near52wHigh", 90));
|
||||
addSort(out, "ret3m", "Ⅰ. 가격·탄력성", "3개월 수익률", r.isRet3m(),
|
||||
m.ret3m, "> 0%", pctStatus(sortScores, market, "ret3m", 70));
|
||||
addSort(out, "ret6m", "Ⅰ. 가격·탄력성", "6개월 수익률", r.isRet6m(),
|
||||
m.ret6m, "> 0%", pctStatus(sortScores, market, "ret6m", 70));
|
||||
addSort(out, "maDeviation", "Ⅰ. 가격·탄력성", "MA 이격도(20·60)", r.isMaDeviation(),
|
||||
m.maDeviation, "> 5%", pctStatus(sortScores, market, "maDeviation", 70));
|
||||
addFilter(out, "maFullAlign", "Ⅰ. 가격·탄력성", "MA 완전 정배열", r.isMaFullAlign(), m.maFullAlign);
|
||||
addFilter(out, "stage2", "Ⅰ. 가격·탄력성", "Stage 2 추세", r.isStage2(), m.stage2);
|
||||
addSort(out, "relativeStrength", "Ⅰ. 가격·탄력성", "RS vs BTC", r.isRelativeStrength(),
|
||||
m.relativeStrength, "> 0", pctStatus(sortScores, market, "relativeStrength", 70));
|
||||
addSort(out, "newHighCount", "Ⅰ. 가격·탄력성", "신고가 달성 횟수", r.isNewHighCount(),
|
||||
m.newHighCount, "≥ 3회", pctStatus(sortScores, market, "newHighCount", 70));
|
||||
}
|
||||
if (r.isVolumeEnabled()) {
|
||||
addSort(out, "tradeAmount", "Ⅱ. 거래량·대금", "당일 거래대금", r.isTradeAmount(),
|
||||
m.tradeAmount, "상위", pctStatus(sortScores, market, "tradeAmount", 70));
|
||||
addSort(out, "volVs5dAvg", "Ⅱ. 거래량·대금", "5일 대비 거래대금↑", r.isVolVs5dAvg(),
|
||||
m.volVs5dAvg, "> 120%", pctStatus(sortScores, market, "volVs5dAvg", 70));
|
||||
addSort(out, "turnover", "Ⅱ. 거래량·대금", "거래량 회전율", r.isTurnover(),
|
||||
m.turnover, "> 100%", pctStatus(sortScores, market, "turnover", 70));
|
||||
addSort(out, "bidAskRatio", "Ⅱ. 거래량·대금", "매수잔량 비율", r.isBidAskRatio(),
|
||||
m.bidAskRatio, "> 1.0", pctStatus(sortScores, market, "bidAskRatio", 70));
|
||||
}
|
||||
if (r.isFlowEnabled()) {
|
||||
addSort(out, "smartMoney", "Ⅲ. 수급·심리", "스마트머니(OBV)", r.isSmartMoney(),
|
||||
m.smartMoney, "누적↑", pctStatus(sortScores, market, "smartMoney", 70));
|
||||
addSort(out, "instConsecutive", "Ⅲ. 수급·심리", "연속 순매수 일수", r.isInstConsecutive(),
|
||||
m.instConsecutive, "≥ 3일", pctStatus(sortScores, market, "instConsecutive", 70));
|
||||
addSort(out, "rsiHigh", "Ⅲ. 수급·심리", "RSI(14)", r.isRsiHigh(),
|
||||
m.rsiHigh, "≥ 70", m.rsiHigh >= 70 ? "match" : m.rsiHigh >= 55 ? "pending" : "mismatch");
|
||||
addSort(out, "macdPeak", "Ⅲ. 수급·심리", "MACD 최고치", r.isMacdPeak(),
|
||||
m.macdPeak, "신고가", pctStatus(sortScores, market, "macdPeak", 70));
|
||||
addFilter(out, "lightCredit", "Ⅲ. 수급·심리", "거래량↓+주가↑", r.isLightCredit(), m.lightCredit);
|
||||
}
|
||||
if (r.isFundamentalEnabled()) {
|
||||
addUnsupported(out, "earningsGrowth", "Ⅳ. 펀더멘털", "영업이익 성장(YoY)", r.isEarningsGrowth());
|
||||
addUnsupported(out, "roe", "Ⅳ. 펀더멘털", "ROE", r.isRoe());
|
||||
addUnsupported(out, "opMargin", "Ⅳ. 펀더멘털", "영업이익률", r.isOpMargin());
|
||||
addUnsupported(out, "pegBelow1", "Ⅳ. 펀더멘털", "PEG ≤ 1.0", r.isPegBelow1());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addSort(List<TrendSearchConditionDto> out, String id, String cat, String label,
|
||||
boolean enabled, double value, String threshold, String status) {
|
||||
if (!enabled) return;
|
||||
int progress = status.equals("match") ? 100 : status.equals("pending") ? 65 : 35;
|
||||
out.add(cond(id, cat, label, value, threshold, progress, status));
|
||||
}
|
||||
|
||||
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 addUnsupported(List<TrendSearchConditionDto> out, String id, String cat, String label, boolean enabled) {
|
||||
if (!enabled) return;
|
||||
out.add(cond(id, cat, label, null, "코인 미지원", 0, "mismatch"));
|
||||
}
|
||||
|
||||
private String pctStatus(Map<String, Double> sortScores, String market, String id, double threshold) {
|
||||
Double pct = sortScores.get(market + ":" + id);
|
||||
if (pct != null) {
|
||||
if (pct >= threshold) return "match";
|
||||
if (pct >= threshold * 0.85) return "pending";
|
||||
return "mismatch";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
private static double tradeAmountFromBar(List<CandleBarDto> candles) {
|
||||
if (candles.isEmpty()) return 0;
|
||||
CandleBarDto c = candles.get(candles.size() - 1);
|
||||
return c.getClose() * c.getVolume();
|
||||
}
|
||||
|
||||
// ── Indicator helpers ───────────────────────────────────────────────────
|
||||
|
||||
private static double returnPct(List<CandleBarDto> candles, int barsBack) {
|
||||
if (candles.size() < 2) return 0;
|
||||
int end = candles.size() - 1;
|
||||
int start = Math.max(0, end - barsBack);
|
||||
double prev = candles.get(start).getClose();
|
||||
double cur = candles.get(end).getClose();
|
||||
return prev > 0 ? ((cur - prev) / prev) * 100 : 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 int countNewHighs(BarSeries series, int end, int days) {
|
||||
int start = Math.max(0, end - days + 1);
|
||||
int count = 0;
|
||||
double runningMax = 0;
|
||||
for (int i = 0; i < start; i++) {
|
||||
runningMax = Math.max(runningMax, series.getBar(i).getHighPrice().doubleValue());
|
||||
}
|
||||
for (int i = start; i <= end; i++) {
|
||||
double h = series.getBar(i).getHighPrice().doubleValue();
|
||||
if (h >= runningMax) {
|
||||
count++;
|
||||
runningMax = h;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static double safeVal(SMAIndicator ma, int idx) {
|
||||
if (idx < 0) return 0;
|
||||
try {
|
||||
return ma.getValue(idx).doubleValue();
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static double volVs5dAvg(List<CandleBarDto> candles) {
|
||||
if (candles.size() < 6) return 0;
|
||||
int end = candles.size() - 1;
|
||||
double today = candles.get(end).getClose() * candles.get(end).getVolume();
|
||||
double sum = 0;
|
||||
for (int i = end - 5; i <= end - 1; i++) {
|
||||
sum += candles.get(i).getClose() * candles.get(i).getVolume();
|
||||
}
|
||||
double avg = sum / 5;
|
||||
return avg > 0 ? (today / avg) * 100 : 0;
|
||||
}
|
||||
|
||||
private static double turnoverRate(VolumeIndicator vol, int end) {
|
||||
if (end < 20) return 0;
|
||||
double sum = 0;
|
||||
for (int i = end - 19; i <= end - 1; i++) sum += vol.getValue(i).doubleValue();
|
||||
double avg = sum / 20;
|
||||
double cur = vol.getValue(end).doubleValue();
|
||||
return avg > 0 ? (cur / avg) * 100 : 0;
|
||||
}
|
||||
|
||||
private static int countInstConsecutive(List<CandleBarDto> candles) {
|
||||
if (candles.size() < 3) return 0;
|
||||
int streak = 0;
|
||||
int max = 0;
|
||||
for (int i = candles.size() - 1; i >= 1; i--) {
|
||||
CandleBarDto c = candles.get(i);
|
||||
CandleBarDto p = candles.get(i - 1);
|
||||
boolean up = c.getClose() >= c.getOpen();
|
||||
boolean volUp = c.getVolume() >= p.getVolume();
|
||||
if (up && volUp) {
|
||||
streak++;
|
||||
max = Math.max(max, streak);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private static double macdPeakScore(MACDIndicator macd, int end) {
|
||||
if (end < 35) return 0;
|
||||
double cur = macd.getValue(end).doubleValue();
|
||||
double peak = Double.NEGATIVE_INFINITY;
|
||||
for (int i = Math.max(0, end - 19); i <= end; i++) {
|
||||
peak = Math.max(peak, macd.getValue(i).doubleValue());
|
||||
}
|
||||
return peak != 0 ? (cur / peak) * 100 : 0;
|
||||
}
|
||||
|
||||
private static boolean lightCreditPass(List<CandleBarDto> candles, VolumeIndicator vol, int end) {
|
||||
if (candles.size() < 10 || end < 9) return false;
|
||||
double volRecent = 0, volPrev = 0;
|
||||
for (int i = end - 4; i <= end; i++) volRecent += vol.getValue(i).doubleValue();
|
||||
for (int i = end - 9; i <= end - 5; i++) volPrev += vol.getValue(i).doubleValue();
|
||||
volRecent /= 5;
|
||||
volPrev /= 5;
|
||||
double priceNow = candles.get(end).getClose();
|
||||
double pricePrev = candles.get(end - 5).getClose();
|
||||
return volPrev > 0 && volRecent < volPrev * 0.95 && priceNow > pricePrev;
|
||||
}
|
||||
|
||||
private static double percentile(List<Double> values, double v) {
|
||||
if (values == null || values.isEmpty()) return 0;
|
||||
long below = values.stream().filter(x -> x <= v).count();
|
||||
return (below * 100.0) / values.size();
|
||||
}
|
||||
|
||||
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 List<String> fetchKrwMarketsByVolume(int limit) {
|
||||
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 List.of();
|
||||
|
||||
List<Map<String, Object>> all = objectMapper.readValue(marketsJson, new TypeReference<>() {});
|
||||
List<String> krw = all.stream()
|
||||
.map(m -> (String) m.get("market"))
|
||||
.filter(m -> m != null && m.startsWith("KRW-"))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (krw.isEmpty()) return List.of();
|
||||
|
||||
Map<String, TickerSnap> tickers = fetchTickers(krw);
|
||||
return krw.stream()
|
||||
.sorted(Comparator.comparingDouble((String m) ->
|
||||
tickers.getOrDefault(m, TickerSnap.empty(m)).accTradePrice24h).reversed())
|
||||
.limit(limit)
|
||||
.collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
log.warn("[TrendSearch] market fetch fail: {}", e.getMessage());
|
||||
return List.of("KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-ADA");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, TickerSnap> fetchTickers(List<String> markets) {
|
||||
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();
|
||||
map.put(market, new TickerSnap(
|
||||
market,
|
||||
n.path("korean_name").asText(market),
|
||||
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 Map<String, Double> fetchBidAskRatios(List<String> markets) {
|
||||
Map<String, Double> out = new HashMap<>();
|
||||
try {
|
||||
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||
for (int i = 0; i < markets.size(); i += 10) {
|
||||
List<String> batch = markets.subList(i, Math.min(i + 10, markets.size()));
|
||||
String joined = String.join(",", batch);
|
||||
String json = client.get()
|
||||
.uri("/v1/orderbook?markets=" + joined)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
if (json == null) continue;
|
||||
JsonNode arr = objectMapper.readTree(json);
|
||||
for (JsonNode ob : arr) {
|
||||
String market = ob.path("market").asText();
|
||||
double bidSum = 0, askSum = 0;
|
||||
for (JsonNode u : ob.path("orderbook_units")) {
|
||||
bidSum += u.path("bid_size").asDouble(0);
|
||||
askSum += u.path("ask_size").asDouble(0);
|
||||
}
|
||||
out.put(market, askSum > 0 ? bidSum / askSum : 0);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[TrendSearch] orderbook fetch fail: {}", e.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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(tf);
|
||||
BarSeries series = new BaseBarSeriesBuilder()
|
||||
.withName("trend")
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.build();
|
||||
|
||||
for (CandleBarDto c : candles) {
|
||||
try {
|
||||
Instant endInstant = Instant.ofEpochSecond(c.getTime()).plus(duration);
|
||||
Bar bar = series.barBuilder()
|
||||
.timePeriod(duration)
|
||||
.endTime(endInstant)
|
||||
.openPrice(c.getOpen())
|
||||
.highPrice(c.getHigh())
|
||||
.lowPrice(c.getLow())
|
||||
.closePrice(c.getClose())
|
||||
.volume(c.getVolume())
|
||||
.build();
|
||||
series.addBar(bar, true);
|
||||
} 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) {
|
||||
return new TickerSnap(market, market.replace("KRW-", ""), 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private record ScoredMarket(String market, TickerSnap tick, List<CandleBarDto> candles, MetricValues metrics) {}
|
||||
|
||||
private static class MetricValues {
|
||||
final double near52wHigh, ret3m, ret6m, maDeviation, relativeStrength;
|
||||
final int newHighCount;
|
||||
final double tradeAmount, volVs5dAvg, turnover, bidAskRatio;
|
||||
final double smartMoney;
|
||||
final int instConsecutive;
|
||||
final double rsiHigh, macdPeak, macdHist;
|
||||
final boolean maFullAlign, stage2, lightCredit;
|
||||
double matchScore;
|
||||
|
||||
MetricValues(double near52wHigh, double ret3m, double ret6m, double maDeviation,
|
||||
boolean maFullAlign, boolean stage2, double relativeStrength, int newHighCount,
|
||||
double tradeAmount, double volVs5dAvg, double turnover, double bidAskRatio,
|
||||
double smartMoney, int instConsecutive, double rsiHigh, double macdPeak,
|
||||
double macdHist, boolean lightCredit) {
|
||||
this.near52wHigh = near52wHigh;
|
||||
this.ret3m = ret3m;
|
||||
this.ret6m = ret6m;
|
||||
this.maDeviation = maDeviation;
|
||||
this.maFullAlign = maFullAlign;
|
||||
this.stage2 = stage2;
|
||||
this.relativeStrength = relativeStrength;
|
||||
this.newHighCount = newHighCount;
|
||||
this.tradeAmount = tradeAmount;
|
||||
this.volVs5dAvg = volVs5dAvg;
|
||||
this.turnover = turnover;
|
||||
this.bidAskRatio = bidAskRatio;
|
||||
this.smartMoney = smartMoney;
|
||||
this.instConsecutive = instConsecutive;
|
||||
this.rsiHigh = rsiHigh;
|
||||
this.macdPeak = macdPeak;
|
||||
this.macdHist = macdHist;
|
||||
this.lightCredit = lightCredit;
|
||||
}
|
||||
|
||||
double getSortValue(String id) {
|
||||
return switch (id) {
|
||||
case "near52wHigh" -> near52wHigh;
|
||||
case "ret3m" -> ret3m;
|
||||
case "ret6m" -> ret6m;
|
||||
case "maDeviation" -> maDeviation;
|
||||
case "relativeStrength" -> relativeStrength;
|
||||
case "newHighCount" -> newHighCount;
|
||||
case "tradeAmount" -> tradeAmount;
|
||||
case "volVs5dAvg" -> volVs5dAvg;
|
||||
case "turnover" -> turnover;
|
||||
case "bidAskRatio" -> bidAskRatio;
|
||||
case "smartMoney" -> smartMoney;
|
||||
case "instConsecutive" -> instConsecutive;
|
||||
case "rsiHigh" -> rsiHigh;
|
||||
case "macdPeak" -> macdPeak;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user