@@ -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 ;
} ;
}
}
}