From 7cf43609dc85e9529b4b484a576cf69ce8a72d2a Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 26 May 2026 13:04:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EC=84=B8=EA=B2=80=EC=83=89=EA=B8=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../goldenchart/dto/TrendSearchRequest.java | 83 +- .../service/TrendSearchService.java | 824 +++++++++--------- .../trendSearch/TrendSearchFilterPanel.tsx | 228 +++-- .../TrendSearchResultsCardGrid.tsx | 2 +- .../virtual/VirtualSignalEqualizer.tsx | 26 +- frontend/src/styles/trendSearchDashboard.css | 4 + .../src/styles/virtualTradingDashboard.css | 33 +- frontend/src/utils/backendApi.ts | 118 +-- frontend/src/utils/trendSearchConditions.ts | 369 ++++---- 9 files changed, 895 insertions(+), 792 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java index a6abf7f..9e41a01 100644 --- a/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java @@ -3,7 +3,7 @@ package com.goldenchart.dto; import lombok.Data; /** - * 추세검색 스캔 요청 — 좌측 필터 패널 (20개 조건). + * 추세검색 스캔 요청 — 정배열 / 상승세 / 거래량·유동성 / 보조지표 4개 카테고리. */ @Data public class TrendSearchRequest { @@ -15,46 +15,59 @@ public class TrendSearchRequest { private int limit = 20; /** 스캔 대상 종목 수 (거래대금 상위) */ - private int scanLimit = 80; + private int scanLimit = 60; - /** 주 정렬 기준 (조건 id) */ - private String sortBy = "near52wHigh"; + /** 주 정렬 (matchRate 권장) */ + private String sortBy = "matchRate"; - /** 신고가 달성 횟수 산출 기간 (거래일) */ - private int newHighDays = 20; + /** 결과 목록에 포함할 최소 일치율(%) — 0=전체 랭킹, 50=50% 이상만 */ + private int minMatchRate = 0; // ── 카테고리 ON/OFF ── - private boolean priceEnabled = true; - private boolean volumeEnabled = true; - private boolean flowEnabled = true; - private boolean fundamentalEnabled = false; + private boolean maAlignEnabled = true; + private boolean trendEnabled = true; + private boolean volumePowerEnabled = false; + private boolean indicatorEnabled = false; - // Ⅰ. 가격 및 탄력성 - private boolean near52wHigh = true; - private boolean ret3m = false; - private boolean ret6m = false; - private boolean maDeviation = true; - private boolean maFullAlign = true; - private boolean stage2 = false; - private boolean relativeStrength = true; - private boolean newHighCount = false; + // Ⅰ. 정배열 (Moving Average Alignment) + /** 주가(종가) > 5·20·60일 이평 */ + private boolean priceAboveMa = true; + /** 5 > 20 > 60 > 120 정배열 */ + private boolean maAlignment = true; + /** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */ + private boolean maConvergence = false; + /** 이평밀집도 허용 % (기본 3) */ + private double maConvergencePct = 3.0; - // Ⅱ. 거래량 및 대금 - private boolean tradeAmount = false; - private boolean volVs5dAvg = true; - private boolean turnover = false; - private boolean bidAskRatio = false; + // Ⅱ. 상승세 (Trend & Momentum) + /** 20일 이평 연속 상승 봉 수 */ + private boolean ma20SlopeUp = true; + private int ma20SlopeBars = 2; + /** N일 신고가 돌파 또는 고가 대비 -near% 이내 */ + private boolean newHighBreakout = true; + private int newHighBreakoutDays = 20; + private double newHighNearPct = 5.0; + /** 일목 구름대(선행스팬) 상단 */ + private boolean ichimokuAboveCloud = false; - // Ⅲ. 메이저 수급 및 심리 - private boolean smartMoney = false; - private boolean instConsecutive = false; - private boolean rsiHigh = true; - private boolean macdPeak = false; - private boolean lightCredit = false; + // Ⅲ. 돈과 힘 (Volume & Liquidity) + /** 직전 봉 대비 거래량 % (일봉=전일, 분봉=직전 봉) */ + private boolean volumeVsPrior200 = false; + private double volumeVsPriorPct = 150.0; + /** 최근 5일 평균 거래대금 하한 (KRW) */ + private boolean minTurnover5dAvg = false; + private long minTurnover5dAvgKrw = 1_000_000_000L; + /** 5일 거래량 이평 > 20일 거래량 이평 */ + private boolean volMa5Over20 = false; - // Ⅳ. 펀더멘털 (주식 전용 — 코인 미지원) - private boolean earningsGrowth = false; - private boolean roe = false; - private boolean opMargin = false; - private boolean pegBelow1 = false; + // Ⅳ. 보조지표 + /** MACD Line > Signal 또는 MACD > 0 */ + private boolean macdGoldenCross = false; + /** RSI 구간 (기본 50~70) */ + private boolean rsiBand = false; + private double rsiMin = 50.0; + private double rsiMax = 75.0; + /** +DI > -DI AND ADX >= adxMin */ + private boolean dmiBullish = false; + private double adxMin = 18.0; } diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java index 9b12265..3252742 100644 --- a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java @@ -11,21 +11,23 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.ta4j.core.*; +import org.ta4j.core.bars.TimeBarBuilderFactory; import org.ta4j.core.indicators.*; +import org.ta4j.core.indicators.adx.*; import org.ta4j.core.indicators.averages.EMAIndicator; import org.ta4j.core.indicators.averages.SMAIndicator; import org.ta4j.core.indicators.helpers.*; -import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator; -import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.indicators.ichimoku.*; import org.ta4j.core.num.DoubleNumFactory; import java.time.Duration; import java.time.Instant; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** - * 암호화폐 추세검색 — 업비트 KRW 마켓 20개 조건 스캔. + * 암호화폐 추세검색 — 활성 조건 대비 일치율(%)을 산출해 높은 순으로 랭킹. */ @Service @RequiredArgsConstructor @@ -39,46 +41,56 @@ public class TrendSearchService { @Value("${upbit.api.base-url:https://api.upbit.com}") private String upbitBaseUrl; - private static final int MIN_BARS = 60; + @Value("${upbit.api.request-delay-ms:110}") + private long upbitRequestDelayMs; + + private static final int MIN_BARS_BASE = 65; private static final int HIGH_MATCH_THRESHOLD = 90; private static final int HISTORY_BARS = 365; + /** 스캔 1회당 최대 캔들 (업비트 rate limit·MA120 기준) */ + private static final int SCAN_MAX_BARS = 120; + private static final long CANDLE_CACHE_TTL_MS = 10 * 60 * 1000L; + + /** market|tf → 일봉 캐시 (rate limit·재스캔 대응) */ + private final Map candleCache = new ConcurrentHashMap<>(); + + private record CandleCacheEntry(List candles, long cachedAt) {} public List 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; + int wantBars = wantBarsForScan(r); + int minBars = requiredMinBars(r); List markets = fetchKrwMarketsByVolume(scanLimit); if (markets.isEmpty()) return List.of(); Map tickers = fetchTickers(markets); - Map bidAskRatios = r.isBidAskRatio() ? fetchBidAskRatios(markets) : Map.of(); - - List 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); + int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate())); + int enabledFilters = countEnabledFilters(r); List scored = new ArrayList<>(); - for (String market : markets) { + for (int i = 0; i < markets.size(); i++) { + String market = markets.get(i); try { - List candles = historicalDataService.getHistory(market, tf, null, barCount); - if (candles.size() < MIN_BARS) continue; + if (i > 0) throttleUpbit(); + List candles = loadHistoryForScan(market, tf, wantBars); + if (candles.size() < minBars) 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 (series.getBarCount() < minBars) continue; - if (!passesFilters(m, r)) continue; + 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; scored.add(new ScoredMarket(market, tick, candles, m)); } catch (Exception e) { @@ -86,28 +98,26 @@ public class TrendSearchService { } } - if (scored.isEmpty()) return List.of(); + if (scored.isEmpty()) { + log.warn("[TrendSearch] scan empty tf={} markets={} enabledFilters={} (캔들·rate limit 확인)", + tf, markets.size(), enabledFilters); + return List.of(); + } - Map sortScores = buildSortScores(scored, r); - String sortKey = resolveSortKey(r); - Comparator cmp = Comparator - .comparingDouble((ScoredMarket s) -> sortScores.getOrDefault(s.market, 0.0)).reversed() - .thenComparingDouble(s -> s.metrics.matchScore).reversed(); - - List results = scored.stream() - .sorted(cmp) + return scored.stream() + .sorted(Comparator.comparingDouble((ScoredMarket s) -> s.metrics.matchScore).reversed() + .thenComparingDouble(s -> s.metrics.trendStrength).reversed() + .thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed()) .limit(limit) - .map(s -> toResult(s, r, sortScores)) + .map(s -> toResult(s, r)) .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 candles = historicalDataService.getHistory(market, tf, null, barCount); + int wantBars = Math.min(HISTORY_BARS, wantBarsForScan(r) + 60); + List candles = loadHistory(market, tf, wantBars); if (candles.isEmpty()) { return TrendSearchResultDto.builder() .market(market).matchRate(0).conditions(List.of()).build(); @@ -115,165 +125,251 @@ public class TrendSearchService { Map tickers = fetchTickers(List.of(market)); TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); - Map bidAsk = r.isBidAskRatio() ? fetchBidAskRatios(List.of(market)) : Map.of(); - - double btcRet3m = 0, btcRet6m = 0; - if (r.isRelativeStrength()) { - List 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 sortScores = Map.of(resolveSortKey(r), m.getSortValue(resolveSortKey(r))); - return toResult(sm, r, sortScores); + MetricValues m = computeMetrics(series, candles, tick, r); + m.trendStrength = computeTrendStrength(m); + int enabled = countEnabledFilters(r); + m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength; + return toResult(new ScoredMarket(market, tick, candles, m), r); } // ── Metrics ───────────────────────────────────────────────────────────── - private MetricValues computeMetrics(BarSeries series, List candles, TickerSnap tick, - Double bidAskRatio, double btcRet3m, double btcRet6m, int newHighDays) { + private MetricValues computeMetrics(BarSeries series, List candles, + TickerSnap tick, TrendSearchRequest r) { int end = series.getEndIndex(); ClosePriceIndicator close = new ClosePriceIndicator(series); VolumeIndicator vol = new VolumeIndicator(series); double price = close.getValue(end).doubleValue(); - 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); + double m5 = safeVal(ma5, end); + double m20 = safeVal(ma20, end); + double m60 = safeVal(ma60, end); + double m120 = safeVal(ma120, end); - 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); + boolean priceAboveMa = end >= 60 && validMa(m5) && validMa(m20) && validMa(m60) + && price > m5 && price > m20 && price > m60; + boolean maAlignment = end >= 120 && validMa(m5) && validMa(m20) && validMa(m60) && validMa(m120) + && m5 > m20 && m20 > m60 && m60 > m120; + double maSpreadPct = maSpreadPct(m5, m20, m60); + boolean maConvergence = maSpreadPct <= Math.max(0.5, r.getMaConvergencePct()); - 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 slopeBars = Math.max(2, Math.min(r.getMa20SlopeBars(), 5)); + boolean ma20SlopeUp = ma20ConsecutiveRise(ma20, end, slopeBars); - int nhDays = Math.max(5, Math.min(newHighDays, 60)); - int newHighCount = countNewHighs(series, end, nhDays); + int nhDays = Math.max(5, Math.min(r.getNewHighBreakoutDays(), 60)); + double periodHigh = end >= nhDays ? maxHigh(series, end - 1, nhDays) : 0; + double nearPct = Math.max(0, r.getNewHighNearPct()); + boolean newHighBreakout = periodHigh > 0 && price >= periodHigh * (1 - nearPct / 100.0); - double tradeAmount = tick.accTradePrice24h > 0 ? tick.accTradePrice24h : tradeAmountFromBar(candles); - double volVs5dAvg = volVs5dAvg(candles); - double turnover = turnoverRate(vol, end); + boolean ichimokuAboveCloud = ichimokuAboveCloud(series, close, end); - OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(series); - double smartMoney = end >= 20 - ? obv.getValue(end).doubleValue() - obv.getValue(end - 20).doubleValue() - : 0; - int instConsecutive = countInstConsecutive(candles); + double volPriorPct = volumeVsPriorPct(vol, end); + double volThreshold = Math.max(100, r.getVolumeVsPriorPct()); + boolean volumeVsPrior200 = end >= 1 && volPriorPct >= volThreshold; + + double turnover5dAvg = avgTurnover5d(candles, tick); + boolean minTurnover5dAvg = turnover5dAvg >= r.getMinTurnover5dAvgKrw(); + + SMAIndicator volMa5 = new SMAIndicator(vol, 5); + SMAIndicator volMa20 = new SMAIndicator(vol, 20); + double v5 = safeVal(volMa5, end); + double v20 = safeVal(volMa20, end); + boolean volMa5Over20 = end >= 20 && Double.isFinite(v5) && Double.isFinite(v20) && v5 > v20; + + MACDIndicator macdLine = new MACDIndicator(close, 12, 26); + EMAIndicator macdSignal = new EMAIndicator(macdLine, 9); + double macdVal = end >= 35 ? macdLine.getValue(end).doubleValue() : 0; + double signalVal = end >= 35 ? macdSignal.getValue(end).doubleValue() : 0; + boolean macdGoldenCross = end >= 35 && (macdVal > signalVal || macdVal > 0); RSIIndicator rsi = new RSIIndicator(close, 14); double rsiVal = end >= 15 ? rsi.getValue(end).doubleValue() : 0; + double rsiLo = r.getRsiMin(); + double rsiHi = r.getRsiMax(); + boolean rsiBand = end >= 15 && rsiVal >= rsiLo && rsiVal <= rsiHi; - 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); + int diLen = 14; + PlusDIIndicator plusDi = new PlusDIIndicator(series, diLen); + MinusDIIndicator minusDi = new MinusDIIndicator(series, diLen); + ADXIndicator adx = new ADXIndicator(series, diLen, diLen); + double pdi = end >= diLen + 5 ? plusDi.getValue(end).doubleValue() : 0; + double mdi = end >= diLen + 5 ? minusDi.getValue(end).doubleValue() : 0; + double adxVal = end >= diLen + 5 ? adx.getValue(end).doubleValue() : 0; + boolean dmiBullish = pdi > mdi && adxVal >= r.getAdxMin(); return new MetricValues( - near52wHigh, ret3m, ret6m, maDeviation, maFullAlign, stage2, rs, newHighCount, - tradeAmount, volVs5dAvg, turnover, bidAskRatio != null ? bidAskRatio : 0, - smartMoney, instConsecutive, rsiVal, macdPeak, macdHist, lightCredit + priceAboveMa, maAlignment, maConvergence, maSpreadPct, + ma20SlopeUp, newHighBreakout, periodHigh, price, + ichimokuAboveCloud, + volPriorPct, volumeVsPrior200, turnover5dAvg, minTurnover5dAvg, volMa5Over20, + macdVal, signalVal, macdGoldenCross, + rsiVal, rsiBand, + pdi, mdi, adxVal, dmiBullish ); } - 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 int countEnabledFilters(TrendSearchRequest r) { + int n = 0; + if (r.isMaAlignEnabled()) { + if (r.isPriceAboveMa()) n++; + if (r.isMaAlignment()) n++; + if (r.isMaConvergence()) n++; + } + if (r.isTrendEnabled()) { + if (r.isMa20SlopeUp()) n++; + if (r.isNewHighBreakout()) n++; + if (r.isIchimokuAboveCloud()) n++; + } + if (r.isVolumePowerEnabled()) { + if (r.isVolumeVsPrior200()) n++; + if (r.isMinTurnover5dAvg()) n++; + if (r.isVolMa5Over20()) n++; + } + if (r.isIndicatorEnabled()) { + if (r.isMacdGoldenCross()) n++; + if (r.isRsiBand()) n++; + if (r.isDmiBullish()) n++; + } + return n; } - private Map buildSortScores(List scored, TrendSearchRequest r) { - Map> buckets = new HashMap<>(); - for (ScoredMarket s : scored) { - for (String id : enabledSortIds(r)) { - buckets.computeIfAbsent(id, k -> new ArrayList<>()).add(s.metrics.getSortValue(id)); + private int requiredMinBars(TrendSearchRequest r) { + int need = MIN_BARS_BASE; + if (r.isMaAlignEnabled() && r.isMaAlignment()) need = Math.max(need, 120); + if (r.isTrendEnabled() && r.isIchimokuAboveCloud()) need = Math.max(need, 80); + if (r.isTrendEnabled() && r.isNewHighBreakout()) { + need = Math.max(need, Math.min(65, r.getNewHighBreakoutDays() + 5)); + } + return need; + } + + private int wantBarsForScan(TrendSearchRequest r) { + return Math.min(SCAN_MAX_BARS, requiredMinBars(r) + 5); + } + + private void throttleUpbit() { + try { + Thread.sleep(Math.max(200, upbitRequestDelayMs)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** 스캔용 — 캐시·재시도·봉 수 제한 */ + private List loadHistoryForScan(String market, String tf, int wantBars) { + int count = Math.min(wantBars, SCAN_MAX_BARS); + String cacheKey = market + "|" + tf + "|" + count; + long now = System.currentTimeMillis(); + + CandleCacheEntry hit = candleCache.get(cacheKey); + if (hit != null && now - hit.cachedAt() < CANDLE_CACHE_TTL_MS && hit.candles().size() >= count / 2) { + return hit.candles(); + } + + List candles = fetchHistoryWithRetry(market, tf, count, 3); + if (!candles.isEmpty()) { + candleCache.put(cacheKey, new CandleCacheEntry(candles, now)); + } + return candles; + } + + private List fetchHistoryWithRetry(String market, String tf, int count, int attempts) { + for (int attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0) { + try { + Thread.sleep(400L * attempt); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } } + List bars = historicalDataService.getHistory(market, tf, null, count); + if (!bars.isEmpty()) return bars; } - Map 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; + return List.of(); } - private List enabledSortIds(TrendSearchRequest r) { - List 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"); + private int computeMatchScore(MetricValues m, TrendSearchRequest r) { + int total = 0; + int matched = 0; + if (r.isMaAlignEnabled()) { + if (r.isPriceAboveMa()) { total++; if (m.priceAboveMa) matched++; } + if (r.isMaAlignment()) { total++; if (m.maAlignment) matched++; } + if (r.isMaConvergence()) { total++; if (m.maConvergence) matched++; } } - if (r.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.isTrendEnabled()) { + if (r.isMa20SlopeUp()) { total++; if (m.ma20SlopeUp) matched++; } + if (r.isNewHighBreakout()) { total++; if (m.newHighBreakout) matched++; } + if (r.isIchimokuAboveCloud()) { total++; if (m.ichimokuAboveCloud) matched++; } } - if (r.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"); + if (r.isVolumePowerEnabled()) { + if (r.isVolumeVsPrior200()) { total++; if (m.volumeVsPrior200) matched++; } + if (r.isMinTurnover5dAvg()) { total++; if (m.minTurnover5dAvg) matched++; } + if (r.isVolMa5Over20()) { total++; if (m.volMa5Over20) matched++; } } - return ids; + if (r.isIndicatorEnabled()) { + if (r.isMacdGoldenCross()) { total++; if (m.macdGoldenCross) matched++; } + if (r.isRsiBand()) { total++; if (m.rsiBand) matched++; } + if (r.isDmiBullish()) { total++; if (m.dmiBullish) matched++; } + } + if (total == 0) return 100; + return Math.round(matched * 100f / total); } - private String resolveSortKey(TrendSearchRequest r) { - String key = r.getSortBy() != null ? r.getSortBy() : "near52wHigh"; - if (enabledSortIds(r).contains(key)) return key; - List enabled = enabledSortIds(r); - return enabled.isEmpty() ? "near52wHigh" : enabled.get(0); + /** 정배열·상승세·거래량 복합 추세 강도 (0~100) */ + private int computeTrendStrength(MetricValues m) { + double score = 0; + if (m.priceAboveMa) score += 20; + if (m.maAlignment) score += 30; + if (m.ma20SlopeUp) score += 15; + if (m.newHighBreakout) score += 15; + if (m.ichimokuAboveCloud) score += 10; + if (m.volMa5Over20) score += 5; + if (m.macdGoldenCross) score += 3; + if (m.dmiBullish) score += 2; + return (int) Math.min(100, Math.round(score)); } - private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r, Map sortScores) { - List conditions = buildConditions(s.metrics, r, sortScores, s.market); + /** 상세 조회용 — 필요 시 페이징 (단일 종목) */ + private List loadHistory(String market, String tf, int wantBars) { + int page = Math.min(wantBars, SCAN_MAX_BARS); + List all = new ArrayList<>(historicalDataService.getHistory(market, tf, null, page)); + while (all.size() < wantBars && all.size() >= page && page >= SCAN_MAX_BARS) { + throttleUpbit(); + CandleBarDto oldest = all.get(0); + String to = Instant.ofEpochSecond(oldest.getTime()).toString(); + List older = historicalDataService.getHistory( + market, tf, to, Math.min(SCAN_MAX_BARS, wantBars - all.size())); + if (older.isEmpty()) break; + Set seen = all.stream().map(CandleBarDto::getTime).collect(Collectors.toSet()); + List fresh = older.stream() + .filter(c -> !seen.contains(c.getTime())) + .toList(); + if (fresh.isEmpty()) break; + List merged = new ArrayList<>(fresh.size() + all.size()); + merged.addAll(fresh); + merged.addAll(all); + merged.sort(Comparator.comparingLong(CandleBarDto::getTime)); + all = merged; + } + if (all.size() > wantBars) { + return new ArrayList<>(all.subList(all.size() - wantBars, all.size())); + } + return all; + } + + private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r) { + List 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) : 0; + int matchRate = total > 0 ? Math.round(matched * 100f / total) : (int) s.metrics.matchScore; + s.metrics.matchScore = matchRate; double lastClose = s.candles.get(s.candles.size() - 1).getClose(); return TrendSearchResultDto.builder() @@ -289,99 +385,134 @@ public class TrendSearchService { .build(); } - private List buildConditions(MetricValues m, TrendSearchRequest r, - Map sortScores, String market) { + private List buildConditions(MetricValues m, TrendSearchRequest r) { List out = new ArrayList<>(); + String catMa = "Ⅰ. 정배열"; + String catTrend = "Ⅱ. 상승세"; + String catVol = "Ⅲ. 돈과 힘"; + String catInd = "Ⅳ. 보조지표"; - 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.isMaAlignEnabled()) { + addFilter(out, "priceAboveMa", catMa, "주가 > 5·20·60 이평", r.isPriceAboveMa(), m.priceAboveMa); + addFilter(out, "maAlignment", catMa, "5 > 20 > 60 > 120 정배열", r.isMaAlignment(), m.maAlignment); + if (r.isMaConvergence()) { + boolean pass = m.maConvergence; + out.add(cond("maConvergence", catMa, "이평밀집도(5·20·60)", + round2(m.maSpreadPct), "≤ " + round2(r.getMaConvergencePct()) + "%", + pass ? 100 : 35, pass ? "match" : "mismatch")); + } } - if (r.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.isTrendEnabled()) { + addFilter(out, "ma20SlopeUp", catTrend, + r.getMa20SlopeBars() + "봉 연속 20일선 상승", r.isMa20SlopeUp(), m.ma20SlopeUp); + if (r.isNewHighBreakout()) { + double nearPct = Math.max(0, r.getNewHighNearPct()); + out.add(cond("newHighBreakout", catTrend, + r.getNewHighBreakoutDays() + "일 고가 돌파/근접", + round2(m.price), "≥ " + round2(m.periodHigh * (1 - nearPct / 100)), + m.newHighBreakout ? 100 : 40, m.newHighBreakout ? "match" : "mismatch")); + } + addFilter(out, "ichimokuAboveCloud", catTrend, "구름대 상단(선행스팬)", r.isIchimokuAboveCloud(), m.ichimokuAboveCloud); } - if (r.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.isVolumePowerEnabled()) { + if (r.isVolumeVsPrior200()) { + boolean pass = m.volumeVsPrior200; + out.add(cond("volumeVsPrior200", catVol, "직전 봉 대비 거래량", + round2(m.volPriorPct), "≥ " + round2(r.getVolumeVsPriorPct()) + "%", + pass ? 100 : 40, pass ? "match" : "mismatch")); + } + if (r.isMinTurnover5dAvg()) { + boolean pass = m.minTurnover5dAvg; + out.add(cond("minTurnover5dAvg", catVol, "5일 평균 거래대금", + round2(m.turnover5dAvg / 1e8), "≥ " + round2(r.getMinTurnover5dAvgKrw() / 1e8) + "억", + pass ? 100 : 40, pass ? "match" : "mismatch")); + } + addFilter(out, "volMa5Over20", catVol, "거래량 5일 이평 > 20일", r.isVolMa5Over20(), m.volMa5Over20); } - if (r.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()); + if (r.isIndicatorEnabled()) { + if (r.isMacdGoldenCross()) { + out.add(cond("macdGoldenCross", catInd, "MACD 골든크로스", + round2(m.macdVal), "MACD>Signal 또는 MACD>0", + m.macdGoldenCross ? 100 : 40, m.macdGoldenCross ? "match" : "mismatch")); + } + if (r.isRsiBand()) { + boolean pass = m.rsiBand; + out.add(cond("rsiBand", catInd, "RSI 구간", + round2(m.rsiVal), r.getRsiMin() + " ≤ RSI ≤ " + r.getRsiMax(), + pass ? 100 : 40, pass ? "match" : "mismatch")); + } + if (r.isDmiBullish()) { + boolean pass = m.dmiBullish; + out.add(cond("dmiBullish", catInd, "+DI > -DI · ADX", + round2(m.adxVal), "+DI>" + round2(m.mdi) + " ADX≥" + round2(r.getAdxMin()), + pass ? 100 : 40, pass ? "match" : "mismatch")); + } } return out; } - private void addSort(List 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 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 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 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 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 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 maSpreadPct(double m5, double m20, double m60) { + double avg = (m5 + m20 + m60) / 3.0; + if (avg <= 0) return 100; + double maxDev = Math.max(Math.abs(m5 - avg), Math.max(Math.abs(m20 - avg), Math.abs(m60 - avg))); + return (maxDev / avg) * 100; + } + + private static boolean ma20ConsecutiveRise(SMAIndicator ma20, int end, int bars) { + if (end < 20 + bars) return false; + for (int i = 0; i < bars; i++) { + double cur = safeVal(ma20, end - i); + double prev = safeVal(ma20, end - i - 1); + if (!validMa(cur) || !validMa(prev) || cur <= prev) return false; + } + return true; + } + + private static boolean ichimokuAboveCloud(BarSeries series, ClosePriceIndicator close, int end) { + if (end < 52 + 26) return false; + try { + int conv = 9, base = 26, span2 = 52, disp = 26; + IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(series, conv); + IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(series, base); + IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(series, tenkan, kijun, disp); + IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(series, span2, disp); + double price = close.getValue(end).doubleValue(); + double a = spanA.getValue(end).doubleValue(); + double b = spanB.getValue(end).doubleValue(); + if (!Double.isFinite(a) || !Double.isFinite(b)) return false; + double cloudTop = Math.max(a, b); + return price > cloudTop; + } catch (Exception e) { + return false; + } + } + + private static double volumeVsPriorPct(VolumeIndicator vol, int end) { + if (end < 1) return 0; + double prev = vol.getValue(end - 1).doubleValue(); + double cur = vol.getValue(end).doubleValue(); + return prev > 0 ? (cur / prev) * 100 : 0; + } + + private static double avgTurnover5d(List candles, TickerSnap tick) { + if (candles.size() >= 5) { + int end = candles.size() - 1; + double sum = 0; + for (int i = end - 4; i <= end; i++) { + CandleBarDto c = candles.get(i); + sum += c.getClose() * c.getVolume(); + } + return sum / 5; + } + return tick.accTradePrice24h > 0 ? tick.accTradePrice24h : 0; } private static double maxHigh(BarSeries series, int end, int lookback) { @@ -393,100 +524,29 @@ public class TrendSearchService { 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()); + private static double safeVal(SMAIndicator ma, int idx) { + if (idx < 0) return Double.NaN; + try { + double v = ma.getValue(idx).doubleValue(); + return Double.isFinite(v) ? v : Double.NaN; + } catch (Exception e) { + return Double.NaN; } - 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) { + private static boolean validMa(double v) { + return Double.isFinite(v) && v > 0; + } + + private static double safeVol(VolumeIndicator vol, int idx) { if (idx < 0) return 0; try { - return ma.getValue(idx).doubleValue(); + return vol.getValue(idx).doubleValue(); } catch (Exception e) { return 0; } } - private static double volVs5dAvg(List 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 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 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 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() @@ -569,36 +629,6 @@ public class TrendSearchService { } } - private Map fetchBidAskRatios(List markets) { - Map out = new HashMap<>(); - try { - WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build(); - for (int i = 0; i < markets.size(); i += 10) { - List 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) { @@ -610,17 +640,17 @@ public class TrendSearchService { } private BarSeries toSeries(List candles, String tf) { - Duration duration = Ta4jStorage.parseDuration(tf); + Duration duration = Ta4jStorage.parseDuration(normalizeTimeframe(tf)); BarSeries series = new BaseBarSeriesBuilder() .withName("trend") .withBarBuilderFactory(new TimeBarBuilderFactory()) .withNumFactory(DoubleNumFactory.getInstance()) .build(); - + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); for (CandleBarDto c : candles) { try { - Instant endInstant = Instant.ofEpochSecond(c.getTime()).plus(duration); - Bar bar = series.barBuilder() + Instant endInstant = Ta4jStorage.epochToInstant(c.getTime()).plus(duration); + factory.createBarBuilder(series) .timePeriod(duration) .endTime(endInstant) .openPrice(c.getOpen()) @@ -628,8 +658,7 @@ public class TrendSearchService { .lowPrice(c.getLow()) .closePrice(c.getClose()) .volume(c.getVolume()) - .build(); - series.addBar(bar, true); + .add(); } catch (Exception e) { log.trace("[TrendSearch] Bar 추가 스킵: {}", e.getMessage()); } @@ -647,58 +676,53 @@ public class TrendSearchService { private record ScoredMarket(String market, TickerSnap tick, List 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; + final boolean priceAboveMa, maAlignment, maConvergence; + final double maSpreadPct; + final boolean ma20SlopeUp, newHighBreakout, ichimokuAboveCloud; + final double periodHigh, price; + final double volPriorPct; + final boolean volumeVsPrior200, minTurnover5dAvg, volMa5Over20; + final double turnover5dAvg; + final double macdVal, signalVal; + final boolean macdGoldenCross; + final double rsiVal; + final boolean rsiBand; + final double pdi, mdi, adxVal; + final boolean dmiBullish; + int matchScore; + int trendStrength; - 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; - }; + MetricValues(boolean priceAboveMa, boolean maAlignment, boolean maConvergence, double maSpreadPct, + boolean ma20SlopeUp, boolean newHighBreakout, double periodHigh, double price, + boolean ichimokuAboveCloud, + double volPriorPct, boolean volumeVsPrior200, double turnover5dAvg, + boolean minTurnover5dAvg, boolean volMa5Over20, + double macdVal, double signalVal, boolean macdGoldenCross, + double rsiVal, boolean rsiBand, + double pdi, double mdi, double adxVal, boolean dmiBullish) { + this.priceAboveMa = priceAboveMa; + this.maAlignment = maAlignment; + this.maConvergence = maConvergence; + this.maSpreadPct = maSpreadPct; + this.ma20SlopeUp = ma20SlopeUp; + this.newHighBreakout = newHighBreakout; + this.periodHigh = periodHigh; + this.price = price; + this.ichimokuAboveCloud = ichimokuAboveCloud; + this.volPriorPct = volPriorPct; + this.volumeVsPrior200 = volumeVsPrior200; + this.turnover5dAvg = turnover5dAvg; + this.minTurnover5dAvg = minTurnover5dAvg; + this.volMa5Over20 = volMa5Over20; + this.macdVal = macdVal; + this.signalVal = signalVal; + this.macdGoldenCross = macdGoldenCross; + this.rsiVal = rsiVal; + this.rsiBand = rsiBand; + this.pdi = pdi; + this.mdi = mdi; + this.adxVal = adxVal; + this.dmiBullish = dmiBullish; } } } diff --git a/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx index beaa3a8..557cba4 100644 --- a/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx +++ b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx @@ -1,11 +1,12 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import type { TrendSearchRequest } from '../../utils/trendSearchApi'; import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi'; import { TREND_CATEGORY_ORDER, TREND_CONDITION_DEFS, - TREND_SORT_CONDITION_IDS, - type TrendConditionMode, + buildCategoryTogglePatch, + countActiveTrendFilters, + type TrendConditionDef, } from '../../utils/trendSearchConditions'; interface Props { @@ -24,36 +25,148 @@ function tfLabel(tf: string): string { return tf; } -function modeBadge(mode: TrendConditionMode): React.ReactNode { - if (mode === 'filter') return 필터; - if (mode === 'unsupported') return 미지원; - return 정렬; +function ConditionParams({ + item, + filters, + patch, +}: { + item: TrendConditionDef; + filters: TrendSearchRequest; + patch: (p: Partial) => void; +}) { + if (!item.params) return null; + + switch (item.params) { + case 'maConvergencePct': + return ( +
+ 밀집도 + patch({ maConvergencePct: Number(e.target.value) })} + /> + % 이내 +
+ ); + case 'ma20SlopeBars': + return ( +
+ 연속 + patch({ ma20SlopeBars: Number(e.target.value) })} + /> + 봉 상승 +
+ ); + case 'newHighBreakout': + return ( +
+ patch({ newHighBreakoutDays: Number(e.target.value) })} + /> + 일 고가 · - + patch({ newHighNearPct: Number(e.target.value) })} + /> + % 이내 +
+ ); + case 'volumeVsPrior': + return ( +
+ + patch({ volumeVsPriorPct: Number(e.target.value) })} + /> + % +
+ ); + case 'minTurnover': + return ( +
+ + patch({ minTurnover5dAvgKrw: Number(e.target.value) * 1e8 })} + /> + 억 원 +
+ ); + case 'rsiBand': + return ( +
+ patch({ rsiMin: Number(e.target.value) })} + /> + < RSI < + patch({ rsiMax: Number(e.target.value) })} + /> +
+ ); + case 'adxMin': + return ( +
+ ADX ≥ + patch({ adxMin: Number(e.target.value) })} + /> +
+ ); + default: + return null; + } } const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch, searching }) => { const patch = (p: Partial) => onChange({ ...filters, ...p }); - - const sortOptions = useMemo(() => { - return TREND_CONDITION_DEFS.filter(c => { - if (c.mode !== 'sort') return false; - if (!filters[c.categoryKey]) return false; - return Boolean(filters[c.requestKey]); - }); - }, [filters]); - - const handleToggleCondition = (key: keyof TrendSearchRequest, checked: boolean, id: string, mode: TrendConditionMode) => { - const next = { ...filters, [key]: checked }; - if (checked && mode === 'sort' && !TREND_SORT_CONDITION_IDS.includes(filters.sortBy)) { - next.sortBy = id; - } - if (!checked && filters.sortBy === id) { - const fallback = TREND_CONDITION_DEFS.find( - c => c.mode === 'sort' && c.id !== id && Boolean(next[c.requestKey]), - ); - if (fallback) next.sortBy = fallback.id; - } - onChange(next); - }; + const activeCount = countActiveTrendFilters(filters); return (
@@ -61,7 +174,10 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch,

추세검색 조건

-

20개 조건 · 필터 + 정렬

+

+ 활성 조건 일치율 높은 순 랭킹 + {activeCount > 0 ? ` · 조건 ${activeCount}개` : ' · 조건 없음(추세 점수 순)'} +

@@ -71,12 +187,15 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch, return (
- {cat.label} +
+ {cat.en} + {cat.label} +
@@ -85,39 +204,24 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch,
{items.map(item => { const checked = Boolean(filters[item.requestKey]); - const disabled = item.mode === 'unsupported'; return ( -
+
- {!disabled && ( - - {item.desc} - - )} + + {item.desc} +
- {item.id === 'newHighCount' && checked && ( -
- N= - patch({ newHighDays: Number(e.target.value) })} - /> - -
+ {checked && ( + )}
); @@ -128,20 +232,6 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch, ); })} -
-

주 정렬 기준

- -
-

캔들 주기

diff --git a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx index 804365a..c4971e3 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx @@ -48,7 +48,7 @@ const TrendSearchResultsCardGrid: React.FC = ({ return (
-

조건에 맞는 종목이 없습니다. 좌측 필터를 조정해 보세요.

+

검색 결과가 없습니다. 조건·캔들 주기를 조정하거나 잠시 후 다시 시도해 보세요.

); diff --git a/frontend/src/components/virtual/VirtualSignalEqualizer.tsx b/frontend/src/components/virtual/VirtualSignalEqualizer.tsx index 2fc3516..e468e38 100644 --- a/frontend/src/components/virtual/VirtualSignalEqualizer.tsx +++ b/frontend/src/components/virtual/VirtualSignalEqualizer.tsx @@ -42,18 +42,20 @@ const VirtualSignalEqualizer: React.FC = ({ matchRate }) => { {t}% ))}
-
-
- {segments.map((seg, i) => ( -
- ))} +
+
+
+ {segments.map((seg, i) => ( +
+ ))} +
{rate >= 100 && (
100% MATCH
diff --git a/frontend/src/styles/trendSearchDashboard.css b/frontend/src/styles/trendSearchDashboard.css index 3357f59..0adecdf 100644 --- a/frontend/src/styles/trendSearchDashboard.css +++ b/frontend/src/styles/trendSearchDashboard.css @@ -316,6 +316,10 @@ text-align: center; } +.tsd-num--wide { + width: 56px; +} + .tsd-param-sep { color: var(--tsd-muted); align-self: center; diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 5af31dd..16b8b76 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1443,8 +1443,8 @@ .vtd-sig-visual { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; - align-items: stretch; - gap: 10px 12px; + align-items: start; + gap: 12px 14px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border)); border-radius: 10px; @@ -1465,6 +1465,15 @@ flex-shrink: 0; } +.vtd-sig-eq-column { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + min-width: 40px; + max-width: 52px; +} + .vtd-sig-eq-scale { display: flex; flex-direction: column; @@ -1477,8 +1486,7 @@ .vtd-sig-eq-bar-wrap { position: relative; - flex: 1; - min-width: 36px; + width: 100%; } .vtd-sig-eq-bar { @@ -1516,18 +1524,18 @@ } .vtd-sig-eq-badge { - position: absolute; - right: -8px; - top: -4px; - transform: translateX(100%); - font-size: 9px; + margin-top: 6px; + font-size: 8px; font-weight: 800; + letter-spacing: 0.04em; color: #82b1ff; white-space: nowrap; + text-align: center; + line-height: 1.2; text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent); } -/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 (Cybernetic Command Desk) */ +/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */ .vtd-heat-list { list-style: none; margin: 0; @@ -1575,7 +1583,7 @@ top: 50%; transform: translateY(-50%); z-index: 2; - max-width: calc(100% - 48px); + max-width: calc(100% - 52px); font-size: 9px; font-weight: 600; color: var(--vtd-heat-label-color); @@ -1594,6 +1602,7 @@ min-width: 0; border-radius: 7px 0 0 7px; transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease; + z-index: 0; } .vtd-heat-pct { @@ -1601,7 +1610,7 @@ right: 8px; top: 50%; transform: translateY(-50%); - z-index: 2; + z-index: 3; font-size: 10px; font-weight: 800; font-variant-numeric: tabular-nums; diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index fd21bc4..c1a6c57 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1156,65 +1156,75 @@ export interface TrendSearchRequest { limit: number; scanLimit: number; sortBy: string; - newHighDays: number; - priceEnabled: boolean; - volumeEnabled: boolean; - flowEnabled: boolean; - fundamentalEnabled: boolean; - near52wHigh: boolean; - ret3m: boolean; - ret6m: boolean; - maDeviation: boolean; - maFullAlign: boolean; - stage2: boolean; - relativeStrength: boolean; - newHighCount: boolean; - tradeAmount: boolean; - volVs5dAvg: boolean; - turnover: boolean; - bidAskRatio: boolean; - smartMoney: boolean; - instConsecutive: boolean; - rsiHigh: boolean; - macdPeak: boolean; - lightCredit: boolean; - earningsGrowth: boolean; - roe: boolean; - opMargin: boolean; - pegBelow1: boolean; + minMatchRate?: number; + + maAlignEnabled: boolean; + trendEnabled: boolean; + volumePowerEnabled: boolean; + indicatorEnabled: boolean; + + priceAboveMa: boolean; + maAlignment: boolean; + maConvergence: boolean; + maConvergencePct: number; + + ma20SlopeUp: boolean; + ma20SlopeBars: number; + newHighBreakout: boolean; + newHighBreakoutDays: number; + newHighNearPct: number; + ichimokuAboveCloud: boolean; + + volumeVsPrior200: boolean; + volumeVsPriorPct: number; + minTurnover5dAvg: boolean; + minTurnover5dAvgKrw: number; + volMa5Over20: boolean; + + macdGoldenCross: boolean; + rsiBand: boolean; + rsiMin: number; + rsiMax: number; + dmiBullish: boolean; + adxMin: number; } export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = { timeframe: '1d', limit: 20, - scanLimit: 80, - sortBy: 'near52wHigh', - newHighDays: 20, - priceEnabled: true, - volumeEnabled: true, - flowEnabled: true, - fundamentalEnabled: false, - near52wHigh: true, - ret3m: false, - ret6m: false, - maDeviation: true, - maFullAlign: true, - stage2: false, - relativeStrength: true, - newHighCount: false, - tradeAmount: false, - volVs5dAvg: true, - turnover: false, - bidAskRatio: false, - smartMoney: false, - instConsecutive: false, - rsiHigh: true, - macdPeak: false, - lightCredit: false, - earningsGrowth: false, - roe: false, - opMargin: false, - pegBelow1: false, + scanLimit: 60, + sortBy: 'matchRate', + minMatchRate: 0, + + maAlignEnabled: true, + trendEnabled: true, + volumePowerEnabled: false, + indicatorEnabled: false, + + priceAboveMa: true, + maAlignment: true, + maConvergence: false, + maConvergencePct: 3, + + ma20SlopeUp: true, + ma20SlopeBars: 2, + newHighBreakout: true, + newHighBreakoutDays: 20, + newHighNearPct: 5, + ichimokuAboveCloud: false, + + volumeVsPrior200: false, + volumeVsPriorPct: 150, + minTurnover5dAvg: false, + minTurnover5dAvgKrw: 1_000_000_000, + volMa5Over20: false, + + macdGoldenCross: false, + rsiBand: false, + rsiMin: 50, + rsiMax: 75, + dmiBullish: false, + adxMin: 18, }; export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const; diff --git a/frontend/src/utils/trendSearchConditions.ts b/frontend/src/utils/trendSearchConditions.ts index af71020..83b8f06 100644 --- a/frontend/src/utils/trendSearchConditions.ts +++ b/frontend/src/utils/trendSearchConditions.ts @@ -1,240 +1,191 @@ -/** 추세검색 20개 조건 정의 */ -export type TrendConditionMode = 'sort' | 'filter' | 'unsupported'; +/** 추세검색 조건 정의 — 정배열 / 상승세 / 거래량 / 보조지표 */ +export type TrendConditionMode = 'filter'; + +export type TrendCategoryKey = + | 'maAlignEnabled' + | 'trendEnabled' + | 'volumePowerEnabled' + | 'indicatorEnabled'; export interface TrendConditionDef { id: string; - category: 'price' | 'volume' | 'flow' | 'fundamental'; + category: 'ma' | 'trend' | 'volume' | 'indicator'; categoryLabel: string; label: string; desc: string; mode: TrendConditionMode; requestKey: keyof import('./backendApi').TrendSearchRequest; - categoryKey: keyof Pick< - import('./backendApi').TrendSearchRequest, - 'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled' - >; + categoryKey: TrendCategoryKey; + /** 조건 파라미터 UI */ + params?: 'maConvergencePct' | 'ma20SlopeBars' | 'newHighBreakout' | 'volumeVsPrior' | 'minTurnover' | 'rsiBand' | 'adxMin'; } export const TREND_CONDITION_DEFS: TrendConditionDef[] = [ + // Ⅰ. 정배열 { - id: 'near52wHigh', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '52주 신고가 근접률', - desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)', - mode: 'sort', - requestKey: 'near52wHigh', - categoryKey: 'priceEnabled', - }, - { - id: 'ret3m', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '3개월 수익률', - desc: '최근 12주 누적 상승률', - mode: 'sort', - requestKey: 'ret3m', - categoryKey: 'priceEnabled', - }, - { - id: 'ret6m', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '6개월 수익률', - desc: '최근 24주 누적 상승률', - mode: 'sort', - requestKey: 'ret6m', - categoryKey: 'priceEnabled', - }, - { - id: 'maDeviation', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '이동평균 이격도 (20·60일)', - desc: '20·60일선 대비 주가 이격률', - mode: 'sort', - requestKey: 'maDeviation', - categoryKey: 'priceEnabled', - }, - { - id: 'maFullAlign', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '이동평균 완전 정배열', - desc: '주가 > 5 > 20 > 60 > 120 > 200일선', + id: 'priceAboveMa', + category: 'ma', + categoryLabel: 'Ⅰ. 정배열', + label: '주가 > 5·20·60 이평', + desc: '종가가 5·20·60일 이동평균선 위', mode: 'filter', - requestKey: 'maFullAlign', - categoryKey: 'priceEnabled', + requestKey: 'priceAboveMa', + categoryKey: 'maAlignEnabled', }, { - id: 'stage2', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: "마크 미너비니 Stage 2", - desc: '150·200일선 위 + 200일선 1개월↑', + id: 'maAlignment', + category: 'ma', + categoryLabel: 'Ⅰ. 정배열', + label: '5 > 20 > 60 > 120 정배열', + desc: '단기 이평이 장기 이평 위에 배열', mode: 'filter', - requestKey: 'stage2', - categoryKey: 'priceEnabled', + requestKey: 'maAlignment', + categoryKey: 'maAlignEnabled', }, { - id: 'relativeStrength', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '상대강도 (RS vs BTC)', - desc: 'BTC 대비 초과 수익률', - mode: 'sort', - requestKey: 'relativeStrength', - categoryKey: 'priceEnabled', - }, - { - id: 'newHighCount', - category: 'price', - categoryLabel: 'Ⅰ. 가격·탄력성', - label: '최근 N일 신고가 달성 횟수', - desc: '최근 구간 신고가 경신 일수', - mode: 'sort', - requestKey: 'newHighCount', - categoryKey: 'priceEnabled', - }, - { - id: 'tradeAmount', - category: 'volume', - categoryLabel: 'Ⅱ. 거래량·대금', - label: '당일 거래대금', - desc: '24h 누적 거래대금', - mode: 'sort', - requestKey: 'tradeAmount', - categoryKey: 'volumeEnabled', - }, - { - id: 'volVs5dAvg', - category: 'volume', - categoryLabel: 'Ⅱ. 거래량·대금', - label: '5일 평균 대비 거래대금 증가율', - desc: '5일 평균 대비 당일 거래대금', - mode: 'sort', - requestKey: 'volVs5dAvg', - categoryKey: 'volumeEnabled', - }, - { - id: 'turnover', - category: 'volume', - categoryLabel: 'Ⅱ. 거래량·대금', - label: '거래량 회전율', - desc: '20일 평균 대비 당일 거래량 비율', - mode: 'sort', - requestKey: 'turnover', - categoryKey: 'volumeEnabled', - }, - { - id: 'bidAskRatio', - category: 'volume', - categoryLabel: 'Ⅱ. 거래량·대금', - label: '매수 대기 잔량 비율', - desc: '호가 매수잔량 / 매도잔량', - mode: 'sort', - requestKey: 'bidAskRatio', - categoryKey: 'volumeEnabled', - }, - { - id: 'smartMoney', - category: 'flow', - categoryLabel: 'Ⅲ. 수급·심리', - label: '스마트머니 누적 (OBV)', - desc: 'OBV 20일 누적 상승폭 (기관·외국인 대용)', - mode: 'sort', - requestKey: 'smartMoney', - categoryKey: 'flowEnabled', - }, - { - id: 'instConsecutive', - category: 'flow', - categoryLabel: 'Ⅲ. 수급·심리', - label: '연속 순매수 일수', - desc: '양봉+거래량 증가 연속 일수 (연기금·투신 대용)', - mode: 'sort', - requestKey: 'instConsecutive', - categoryKey: 'flowEnabled', - }, - { - id: 'rsiHigh', - category: 'flow', - categoryLabel: 'Ⅲ. 수급·심리', - label: 'RSI (상대강도지수)', - desc: 'RSI 14 — 높을수록 강한 모멘텀', - mode: 'sort', - requestKey: 'rsiHigh', - categoryKey: 'flowEnabled', - }, - { - id: 'macdPeak', - category: 'flow', - categoryLabel: 'Ⅲ. 수급·심리', - label: 'MACD 오실레이터 최고치', - desc: '최근 20일 MACD 히스토그램 신고가', - mode: 'sort', - requestKey: 'macdPeak', - categoryKey: 'flowEnabled', - }, - { - id: 'lightCredit', - category: 'flow', - categoryLabel: 'Ⅲ. 수급·심리', - label: '거래량 감소 + 주가 상승', - desc: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)', + id: 'maConvergence', + category: 'ma', + categoryLabel: 'Ⅰ. 정배열', + label: '이평밀집도 수렴 (선택)', + desc: '5·20·60 이평 간격이 N% 이내 (확산 전 포착)', mode: 'filter', - requestKey: 'lightCredit', - categoryKey: 'flowEnabled', + requestKey: 'maConvergence', + categoryKey: 'maAlignEnabled', + params: 'maConvergencePct', + }, + // Ⅱ. 상승세 + { + id: 'ma20SlopeUp', + category: 'trend', + categoryLabel: 'Ⅱ. 상승세', + label: '20일 이평 연속 상승', + desc: '20일선(생명선) N봉 연속 우상향', + mode: 'filter', + requestKey: 'ma20SlopeUp', + categoryKey: 'trendEnabled', + params: 'ma20SlopeBars', }, { - id: 'earningsGrowth', - category: 'fundamental', - categoryLabel: 'Ⅳ. 펀더멘털', - label: '분기 영업이익 성장률 (YoY)', - desc: '주식 전용 — 코인 미지원', - mode: 'unsupported', - requestKey: 'earningsGrowth', - categoryKey: 'fundamentalEnabled', + id: 'newHighBreakout', + category: 'trend', + categoryLabel: 'Ⅱ. 상승세', + label: '신고가 돌파·근접', + desc: 'N일 고가 돌파 또는 고가 대비 -M% 이내', + mode: 'filter', + requestKey: 'newHighBreakout', + categoryKey: 'trendEnabled', + params: 'newHighBreakout', }, { - id: 'roe', - category: 'fundamental', - categoryLabel: 'Ⅳ. 펀더멘털', - label: 'ROE (자기자본이익률)', - desc: '주식 전용 — 코인 미지원', - mode: 'unsupported', - requestKey: 'roe', - categoryKey: 'fundamentalEnabled', + id: 'ichimokuAboveCloud', + category: 'trend', + categoryLabel: 'Ⅱ. 상승세', + label: '일목 구름대 상단', + desc: '주가 > 선행스팬1 · 선행스팬2', + mode: 'filter', + requestKey: 'ichimokuAboveCloud', + categoryKey: 'trendEnabled', + }, + // Ⅲ. 돈과 힘 + { + id: 'volumeVsPrior200', + category: 'volume', + categoryLabel: 'Ⅲ. 돈과 힘', + label: '직전 봉 대비 거래량', + desc: '일봉=전일, 분봉=직전 봉 대비 거래량 %', + mode: 'filter', + requestKey: 'volumeVsPrior200', + categoryKey: 'volumePowerEnabled', + params: 'volumeVsPrior', }, { - id: 'opMargin', - category: 'fundamental', - categoryLabel: 'Ⅳ. 펀더멘털', - label: '매출액 영업이익률', - desc: '주식 전용 — 코인 미지원', - mode: 'unsupported', - requestKey: 'opMargin', - categoryKey: 'fundamentalEnabled', + id: 'minTurnover5dAvg', + category: 'volume', + categoryLabel: 'Ⅲ. 돈과 힘', + label: '5일 평균 거래대금', + desc: '최근 5일 평균 거래대금 하한 (억 원)', + mode: 'filter', + requestKey: 'minTurnover5dAvg', + categoryKey: 'volumePowerEnabled', + params: 'minTurnover', }, { - id: 'pegBelow1', - category: 'fundamental', - categoryLabel: 'Ⅳ. 펀더멘털', - label: 'PEG 1.0 이하', - desc: '주식 전용 — 코인 미지원', - mode: 'unsupported', - requestKey: 'pegBelow1', - categoryKey: 'fundamentalEnabled', + id: 'volMa5Over20', + category: 'volume', + categoryLabel: 'Ⅲ. 돈과 힘', + label: '거래량 이평 정배열', + desc: '5일 거래량 이평 > 20일 거래량 이평', + mode: 'filter', + requestKey: 'volMa5Over20', + categoryKey: 'volumePowerEnabled', + }, + // Ⅳ. 보조지표 + { + id: 'macdGoldenCross', + category: 'indicator', + categoryLabel: 'Ⅳ. 보조지표', + label: 'MACD 골든크로스', + desc: 'MACD Line > Signal 또는 MACD > 0', + mode: 'filter', + requestKey: 'macdGoldenCross', + categoryKey: 'indicatorEnabled', + }, + { + id: 'rsiBand', + category: 'indicator', + categoryLabel: 'Ⅳ. 보조지표', + label: 'RSI 구간', + desc: '과열·침체 구간 제외 (기본 50~70)', + mode: 'filter', + requestKey: 'rsiBand', + categoryKey: 'indicatorEnabled', + params: 'rsiBand', + }, + { + id: 'dmiBullish', + category: 'indicator', + categoryLabel: 'Ⅳ. 보조지표', + label: '+DI > -DI · ADX', + desc: '추세 강도 ADX 하한 이상', + mode: 'filter', + requestKey: 'dmiBullish', + categoryKey: 'indicatorEnabled', + params: 'adxMin', }, ]; -export const TREND_SORT_CONDITION_IDS = TREND_CONDITION_DEFS - .filter(c => c.mode === 'sort') - .map(c => c.id); - -export const TREND_CATEGORY_ORDER = [ - { key: 'priceEnabled' as const, label: 'Ⅰ. 가격·탄력성', cat: 'price' as const }, - { key: 'volumeEnabled' as const, label: 'Ⅱ. 거래량·대금', cat: 'volume' as const }, - { key: 'flowEnabled' as const, label: 'Ⅲ. 수급·심리', cat: 'flow' as const }, - { key: 'fundamentalEnabled' as const, label: 'Ⅳ. 펀더멘털', cat: 'fundamental' as const }, +export const TREND_CATEGORY_ORDER: { + key: TrendCategoryKey; + label: string; + cat: TrendConditionDef['category']; + en: string; +}[] = [ + { key: 'maAlignEnabled', label: 'Ⅰ. 정배열', cat: 'ma', en: 'Moving Average Alignment' }, + { key: 'trendEnabled', label: 'Ⅱ. 상승세', cat: 'trend', en: 'Trend & Momentum' }, + { key: 'volumePowerEnabled', label: 'Ⅲ. 돈과 힘', cat: 'volume', en: 'Volume & Liquidity' }, + { key: 'indicatorEnabled', label: 'Ⅳ. 보조지표', cat: 'indicator', en: 'Indicators' }, ]; + +/** 카테고리 OFF 시 하위 조건까지 함께 끄기 위한 패치 */ +export function buildCategoryTogglePatch( + catKey: TrendCategoryKey, + on: boolean, +): Partial { + const patch: Partial = { [catKey]: on }; + if (!on) { + for (const def of TREND_CONDITION_DEFS.filter(d => d.categoryKey === catKey)) { + patch[def.requestKey] = false as never; + } + } + return patch; +} + +export function countActiveTrendFilters( + filters: import('./backendApi').TrendSearchRequest, +): number { + let n = 0; + for (const def of TREND_CONDITION_DEFS) { + if (filters[def.categoryKey] && filters[def.requestKey]) n++; + } + return n; +}