From c1bcf88c6c58f83cc098b3d5a011beca9bf1fd1b Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 27 May 2026 00:28:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EC=84=B8=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../goldenchart/dto/TrendSearchRequest.java | 33 +- .../service/TrendSearchService.java | 169 +++++++++-- frontend/src/components/TrendSearchPage.tsx | 108 ++++--- .../trendSearch/TrendSearchFilterPanel.tsx | 286 ++++++------------ .../trendSearch/TrendSearchResultCard.tsx | 11 +- .../TrendSearchResultsCardGrid.tsx | 10 +- frontend/src/styles/trendSearchDashboard.css | 142 +++++++++ frontend/src/utils/backendApi.ts | 33 +- .../src/utils/trendSearchBullishWeights.ts | 114 +++++++ frontend/src/utils/trendSearchMetrics.ts | 12 +- 10 files changed, 631 insertions(+), 287 deletions(-) create mode 100644 frontend/src/utils/trendSearchBullishWeights.ts diff --git a/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java index 9e41a01..3f57be9 100644 --- a/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/TrendSearchRequest.java @@ -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; /** 일목 구름대(선행스팬) 상단 */ diff --git a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java index 3252742..44a6788 100644 --- a/backend/src/main/java/com/goldenchart/service/TrendSearchService.java +++ b/backend/src/main/java/com/goldenchart/service/TrendSearchService.java @@ -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 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 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 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 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 buildConditions(MetricValues m, TrendSearchRequest r) { List 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 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; } } } diff --git a/frontend/src/components/TrendSearchPage.tsx b/frontend/src/components/TrendSearchPage.tsx index 785b5ad..e552349 100644 --- a/frontend/src/components/TrendSearchPage.tsx +++ b/frontend/src/components/TrendSearchPage.tsx @@ -17,6 +17,7 @@ import { type TrendSearchRequest, type TrendSearchResultDto, } from '../utils/trendSearchApi'; +import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics'; import '../styles/trendSearchDashboard.css'; import '../styles/virtualTradingDashboard.css'; @@ -59,7 +60,7 @@ const TrendSearchPage: React.FC = ({ if (!opts?.silent) setSearching(true); try { const list = await scanTrendSearch(filtersRef.current); - setResults(list); + setResults(sortTrendSearchByStrength(list)); setLastUpdatedAt(Date.now()); setSelectedMarket(prev => { if (prev && list.some(r => r.market === prev)) return prev; @@ -94,57 +95,72 @@ const TrendSearchPage: React.FC = ({ try { const detail = await fetchTrendSearchDetail(row.market, filters.timeframe); if (detail) { - setResults(prev => prev.map(r => (r.market === detail.market ? detail : r))); + setResults(prev => sortTrendSearchByStrength( + prev.map(r => (r.market === detail.market ? detail : r)), + )); } } catch { /* keep row */ } }, [filters.timeframe]); return ( - void runSearch()} - resultCount={results.length} - /> + <> + void runSearch()} + resultCount={results.length} + /> + )} + leftStorageKey="tsd-left-width" + leftDefaultWidth={320} + leftCollapsedStorageKey="tsd-left-open" + collapsiblePanels + left={( + void runSearch()} + searching={searching} + /> + )} + center={( + void handleSelect(row)} + flashMarkets={flashMarkets} + theme={theme} + chartRealtimeSource={chartRealtimeSource} + chartSeriesPriceLabels={chartSeriesPriceLabels} + tickers={tickers} + lastUpdatedAt={lastUpdatedAt} + /> + )} + /> + + {searching && ( +
+
+
+

추세검색 스캔 중…

+ +
)} - leftStorageKey="tsd-left-width" - leftDefaultWidth={320} - leftCollapsedStorageKey="tsd-left-open" - collapsiblePanels - left={( - void runSearch()} - searching={searching} - /> - )} - center={( - void handleSelect(row)} - flashMarkets={flashMarkets} - theme={theme} - chartRealtimeSource={chartRealtimeSource} - chartSeriesPriceLabels={chartSeriesPriceLabels} - tickers={tickers} - lastUpdatedAt={lastUpdatedAt} - /> - )} - /> + ); }; diff --git a/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx index 557cba4..3b39a1d 100644 --- a/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx +++ b/frontend/src/components/trendSearch/TrendSearchFilterPanel.tsx @@ -1,13 +1,12 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import type { TrendSearchRequest } from '../../utils/trendSearchApi'; import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi'; import { - TREND_CATEGORY_ORDER, - TREND_CONDITION_DEFS, - buildCategoryTogglePatch, - countActiveTrendFilters, - type TrendConditionDef, -} from '../../utils/trendSearchConditions'; + BULLISH_WEIGHT_ITEMS, + adjustBullishWeight, + sumWeights, + type BullishWeightKey, +} from '../../utils/trendSearchBullishWeights'; interface Props { filters: TrendSearchRequest; @@ -25,148 +24,29 @@ function tfLabel(tf: string): string { return tf; } -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 activeCount = countActiveTrendFilters(filters); + + const weights = useMemo(() => ({ + weightMaAlignment: filters.weightMaAlignment, + weightMaSlope: filters.weightMaSlope, + weightAdxTrend: filters.weightAdxTrend, + weightMacdMomentum: filters.weightMacdMomentum, + weightPricePosition: filters.weightPricePosition, + }), [ + filters.weightMaAlignment, + filters.weightMaSlope, + filters.weightAdxTrend, + filters.weightMacdMomentum, + filters.weightPricePosition, + ]); + + const weightTotal = sumWeights(Object.values(weights)); + + const handleWeightChange = (key: BullishWeightKey, value: number) => { + const next = adjustBullishWeight(weights, key, value); + patch(next); + }; return (
@@ -175,62 +55,56 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch,

추세검색 조건

- 활성 조건 일치율 높은 순 랭킹 - {activeCount > 0 ? ` · 조건 ${activeCount}개` : ' · 조건 없음(추세 점수 순)'} + 상승추세 점수 {filters.minTrendScore}점 이상 · 배점 합계 {weightTotal}점

- {TREND_CATEGORY_ORDER.map(cat => { - const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat); - const catOn = filters[cat.key]; - return ( -
-
-
- {cat.en} - {cat.label} -
- -
- {catOn && ( -
- {items.map(item => { - const checked = Boolean(filters[item.requestKey]); - return ( -
-
- - - {item.desc} - -
- {checked && ( - - )} -
- ); - })} -
- )} +
+
+
+ Bullish Trend Score + 상승추세 검색그룹
- ); - })} + {weightTotal}점 +
+ +
+ {BULLISH_WEIGHT_ITEMS.map(item => { + const value = weights[item.key]; + return ( +
+
+ {item.label} + {item.desc} +
+
+ handleWeightChange(item.key, Number(e.target.value))} + aria-label={`${item.label} 배점`} + /> + handleWeightChange(item.key, Number(e.target.value))} + aria-label={`${item.label} 배점 입력`} + /> + +
+
+ ); + })} +
+

캔들 주기

@@ -248,6 +122,22 @@ const TrendSearchFilterPanel: React.FC = ({ filters, onChange, onSearch,
+
+

최소 합격 점수

+
+ patch({ minTrendScore: Number(e.target.value) })} + /> + {filters.minTrendScore}점 +
+
+

결과 개수

diff --git a/frontend/src/components/trendSearch/TrendSearchResultCard.tsx b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx index f3fd6de..3a9160f 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultCard.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultCard.tsx @@ -56,10 +56,10 @@ const TrendSearchResultCard: React.FC = ({ updatedAt, flash = false, }) => { - const ko = result.koreanName || getKoreanName(result.market); - const sym = result.market.replace(/^KRW-/, ''); - const isChart = displayMode === 'chart'; + const en = result.market.replace(/^KRW-/, ''); const quoteTicker = ticker ?? resultToTicker(result); + const ko = quoteTicker.koreanName || getKoreanName(result.market) || en; + const isChart = displayMode === 'chart'; const flashCls = useMemo(() => { if (!flash) return ''; @@ -91,9 +91,10 @@ const TrendSearchResultCard: React.FC = ({
{ko} - {sym} + {en}
시간봉 {tfLabelShort(timeframe)} + {result.matchRate}점 {result.highMatch && HIGH MATCH}
@@ -102,7 +103,7 @@ const TrendSearchResultCard: React.FC = ({ {!isChart && ( - {result.matchedCount}/{result.totalConditions} 조건 충족 + 추세점수 {result.matchedCount}/{result.totalConditions} )} diff --git a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx index c4971e3..028db9c 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; +import { sortTrendSearchByStrength } from '../../utils/trendSearchMetrics'; import type { Theme } from '../../types'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { TickerData } from '../../hooks/useMarketTicker'; @@ -34,6 +35,11 @@ const TrendSearchResultsCardGrid: React.FC = ({ tickers, lastUpdatedAt, }) => { + const sortedResults = useMemo( + () => sortTrendSearchByStrength(results), + [results], + ); + if (loading && results.length === 0) { return (
@@ -57,7 +63,7 @@ const TrendSearchResultsCardGrid: React.FC = ({ return (
- {results.map(row => ( + {sortedResults.map(row => ( EMA(60) > EMA(120)', + }, + { + key: 'weightMaSlope', + label: '이평선 기울기', + desc: 'EMA(20) 현재 > 5봉 전', + }, + { + key: 'weightAdxTrend', + label: '추세강도 (ADX)', + desc: '+DI > -DI · ADX ≥ 20', + }, + { + key: 'weightMacdMomentum', + label: '모멘텀 (MACD)', + desc: 'MACD > Signal · MACD > 0', + }, + { + key: 'weightPricePosition', + label: '가격위치', + desc: '종가 > EMA(20)', + }, +]; + +export const DEFAULT_BULLISH_WEIGHTS: Record = { + weightMaAlignment: 30, + weightMaSlope: 15, + weightAdxTrend: 25, + weightMacdMomentum: 15, + weightPricePosition: 15, +}; + +export function bullishWeightsFromRequest( + req: Pick, +): number[] { + return BULLISH_WEIGHT_ITEMS.map(item => req[item.key] ?? DEFAULT_BULLISH_WEIGHTS[item.key]); +} + +export function sumWeights(weights: number[]): number { + return weights.reduce((a, b) => a + b, 0); +} + +/** + * 한 항목 배점 변경 시 나머지 항목에 차이를 균등 분배해 합계 100 유지. + */ +export function adjustBullishWeight( + current: Record, + changedKey: BullishWeightKey, + newValue: number, +): Record { + const keys = BULLISH_WEIGHT_ITEMS.map(i => i.key); + const idx = keys.indexOf(changedKey); + if (idx < 0) return current; + + const clamped = Math.max(0, Math.min(100, Math.round(newValue))); + const oldValue = current[changedKey]; + if (clamped === oldValue) return current; + + const result: Record = { ...current }; + result[changedKey] = clamped; + + const delta = clamped - oldValue; + const otherKeys = keys.filter(k => k !== changedKey); + + if (delta > 0) { + let remaining = delta; + for (let i = 0; i < otherKeys.length; i++) { + const k = otherKeys[i]; + const take = i === otherKeys.length - 1 + ? remaining + : Math.floor(delta / otherKeys.length); + result[k] = Math.max(0, result[k] - take); + remaining -= take; + } + } else { + const add = Math.abs(delta); + let remaining = add; + for (let i = 0; i < otherKeys.length; i++) { + const k = otherKeys[i]; + const give = i === otherKeys.length - 1 + ? remaining + : Math.floor(add / otherKeys.length); + result[k] += give; + remaining -= give; + } + } + + const total = keys.reduce((s, k) => s + result[k], 0); + if (total !== 100) { + const fixKey = otherKeys[0] ?? changedKey; + result[fixKey] = Math.max(0, result[fixKey] + (100 - total)); + } + + return result; +} diff --git a/frontend/src/utils/trendSearchMetrics.ts b/frontend/src/utils/trendSearchMetrics.ts index 1120b11..96e7c03 100644 --- a/frontend/src/utils/trendSearchMetrics.ts +++ b/frontend/src/utils/trendSearchMetrics.ts @@ -1,4 +1,4 @@ -import type { TrendSearchConditionDto } from './trendSearchApi'; +import type { TrendSearchConditionDto, TrendSearchResultDto } from './trendSearchApi'; import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics'; import type { VirtualConditionRow } from './virtualStrategyConditions'; @@ -42,3 +42,13 @@ export function tfLabelShort(tf: string): string { if (tf.endsWith('w')) return `${tf.replace('w', '')}주`; return tf; } + +/** 추세강도(점수) 내림차순 정렬 */ +export function sortTrendSearchByStrength( + results: TrendSearchResultDto[], +): TrendSearchResultDto[] { + return [...results].sort((a, b) => { + if (b.matchRate !== a.matchRate) return b.matchRate - a.matchRate; + return a.market.localeCompare(b.market); + }); +}