추세검색 수정

This commit is contained in:
Macbook
2026-05-27 00:28:09 +09:00
parent fe812389cc
commit c1bcf88c6c
10 changed files with 631 additions and 287 deletions
@@ -23,17 +23,36 @@ public class TrendSearchRequest {
/** 결과 목록에 포함할 최소 일치율(%) — 0=전체 랭킹, 50=50% 이상만 */
private int minMatchRate = 0;
// ── 카테고리 ON/OFF ──
private boolean maAlignEnabled = true;
private boolean trendEnabled = true;
// ── Ⅰ. 상승추세 검색그룹 (가중 점수화, 총 100점) ──
private boolean bullishTrendEnabled = true;
/** 이평선 정배열 EMA(20>60>120) 배점 */
private int weightMaAlignment = 30;
/** 이평선 기울기 (EMA20 5봉 전 대비) 배점 */
private int weightMaSlope = 15;
/** 추세강도 ADX (+DI>-DI, ADX≥threshold) 배점 */
private int weightAdxTrend = 25;
/** 모멘텀 MACD (MACD>Signal & MACD>0) 배점 */
private int weightMacdMomentum = 15;
/** 가격위치 (종가>EMA20) 배점 */
private int weightPricePosition = 15;
/** 상승추세 최소 합격 점수 (기본 70) */
private int minTrendScore = 70;
/** EMA 기울기 비교 봉 수 */
private int emaSlopeLookback = 5;
/** ADX 강한 추세 하한 */
private double adxTrendMin = 20.0;
// ── 카테고리 ON/OFF (레거시 필터) ──
private boolean maAlignEnabled = false;
private boolean trendEnabled = false;
private boolean volumePowerEnabled = false;
private boolean indicatorEnabled = false;
// . 정배열 (Moving Average Alignment)
/** 주가(종가) > 5·20·60일 이평 */
private boolean priceAboveMa = true;
private boolean priceAboveMa = false;
/** 5 > 20 > 60 > 120 정배열 */
private boolean maAlignment = true;
private boolean maAlignment = false;
/** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */
private boolean maConvergence = false;
/** 이평밀집도 허용 % (기본 3) */
@@ -41,10 +60,10 @@ public class TrendSearchRequest {
// Ⅱ. 상승세 (Trend & Momentum)
/** 20일 이평 연속 상승 봉 수 */
private boolean ma20SlopeUp = true;
private boolean ma20SlopeUp = false;
private int ma20SlopeBars = 2;
/** N일 신고가 돌파 또는 고가 대비 -near% 이내 */
private boolean newHighBreakout = true;
private boolean newHighBreakout = false;
private int newHighBreakoutDays = 20;
private double newHighNearPct = 5.0;
/** 일목 구름대(선행스팬) 상단 */
@@ -47,8 +47,8 @@ public class TrendSearchService {
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·MA120 기준) */
private static final int SCAN_MAX_BARS = 120;
/** 스캔 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·재스캔 대응) */
@@ -70,6 +70,8 @@ public class TrendSearchService {
Map<String, TickerSnap> tickers = fetchTickers(markets);
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++) {
@@ -84,13 +86,17 @@ public class TrendSearchService {
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
MetricValues m = computeMetrics(series, candles, tick, r);
m.trendStrength = computeTrendStrength(m);
m.matchScore = enabledFilters == 0
? m.trendStrength
: computeMatchScore(m, r);
// minMatchRate>0 일 때만 하한 필터 (기본 0 = 일치율 순 전체 랭킹)
if (minMatchRate > 0 && m.matchScore < minMatchRate) continue;
if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore;
if (minTrendScore > 0 && m.bullishTrendScore < minTrendScore) 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) {
@@ -99,15 +105,19 @@ public class TrendSearchService {
}
if (scored.isEmpty()) {
log.warn("[TrendSearch] scan empty tf={} markets={} enabledFilters={} (캔들·rate limit 확인)",
tf, markets.size(), enabledFilters);
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(Comparator.comparingDouble((ScoredMarket s) -> s.metrics.matchScore).reversed()
.thenComparingDouble(s -> s.metrics.trendStrength).reversed()
.thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed())
.sorted(byTrendStrength
.thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed()
.thenComparing(ScoredMarket::market))
.limit(limit)
.map(s -> toResult(s, r))
.collect(Collectors.toList());
@@ -127,9 +137,14 @@ public class TrendSearchService {
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
BarSeries series = toSeries(candles, tf);
MetricValues m = computeMetrics(series, candles, tick, r);
m.trendStrength = computeTrendStrength(m);
int enabled = countEnabledFilters(r);
m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength;
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);
}
@@ -203,6 +218,23 @@ public class TrendSearchService {
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,
@@ -210,7 +242,9 @@ public class TrendSearchService {
volPriorPct, volumeVsPrior200, turnover5dAvg, minTurnover5dAvg, volMa5Over20,
macdVal, signalVal, macdGoldenCross,
rsiVal, rsiBand,
pdi, mdi, adxVal, dmiBullish
pdi, mdi, adxVal, dmiBullish,
e20, e60, e120, e20Past,
bullishEmaAlignment, bullishEmaSlope, bullishAdxTrend, bullishMacdMomentum, bullishPricePosition
);
}
@@ -241,6 +275,10 @@ public class TrendSearchService {
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()) {
@@ -322,6 +360,17 @@ public class TrendSearchService {
return Math.round(matched * 100f / total);
}
/** 상승추세 검색그룹 — 가중 점수 합산 (0~100) */
private int computeBullishTrendScore(MetricValues m, TrendSearchRequest r) {
int score = 0;
if (m.bullishEmaAlignment) score += Math.max(0, r.getWeightMaAlignment());
if (m.bullishEmaSlope) score += Math.max(0, r.getWeightMaSlope());
if (m.bullishAdxTrend) score += Math.max(0, r.getWeightAdxTrend());
if (m.bullishMacdMomentum) score += Math.max(0, r.getWeightMacdMomentum());
if (m.bullishPricePosition) score += Math.max(0, r.getWeightPricePosition());
return Math.min(100, score);
}
/** 정배열·상승세·거래량 복합 추세 강도 (0~100) */
private int computeTrendStrength(MetricValues m) {
double score = 0;
@@ -366,9 +415,19 @@ public class TrendSearchService {
private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r) {
List<TrendSearchConditionDto> conditions = buildConditions(s.metrics, r);
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) : (int) s.metrics.matchScore;
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();
@@ -387,11 +446,35 @@ public class TrendSearchService {
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);
@@ -457,6 +540,32 @@ public class TrendSearchService {
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) {
@@ -689,8 +798,12 @@ public class TrendSearchService {
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,
@@ -699,7 +812,10 @@ public class TrendSearchService {
boolean minTurnover5dAvg, boolean volMa5Over20,
double macdVal, double signalVal, boolean macdGoldenCross,
double rsiVal, boolean rsiBand,
double pdi, double mdi, double adxVal, boolean dmiBullish) {
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;
@@ -723,6 +839,15 @@ public class TrendSearchService {
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;
}
}
}