추세검색 화면 적용

This commit is contained in:
Macbook
2026-05-26 00:51:07 +09:00
parent 1e950c7db4
commit 1e11884fef
29 changed files with 3753 additions and 71 deletions
@@ -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;
};
}
}
}