추세검색 수정

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% 이상만 */ /** 결과 목록에 포함할 최소 일치율(%) — 0=전체 랭킹, 50=50% 이상만 */
private int minMatchRate = 0; private int minMatchRate = 0;
// ── 카테고리 ON/OFF ── // ── Ⅰ. 상승추세 검색그룹 (가중 점수화, 총 100점) ──
private boolean maAlignEnabled = true; private boolean bullishTrendEnabled = true;
private boolean trendEnabled = 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 volumePowerEnabled = false;
private boolean indicatorEnabled = false; private boolean indicatorEnabled = false;
// . 정배열 (Moving Average Alignment) // . 정배열 (Moving Average Alignment)
/** 주가(종가) > 5·20·60일 이평 */ /** 주가(종가) > 5·20·60일 이평 */
private boolean priceAboveMa = true; private boolean priceAboveMa = false;
/** 5 > 20 > 60 > 120 정배열 */ /** 5 > 20 > 60 > 120 정배열 */
private boolean maAlignment = true; private boolean maAlignment = false;
/** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */ /** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */
private boolean maConvergence = false; private boolean maConvergence = false;
/** 이평밀집도 허용 % (기본 3) */ /** 이평밀집도 허용 % (기본 3) */
@@ -41,10 +60,10 @@ public class TrendSearchRequest {
// Ⅱ. 상승세 (Trend & Momentum) // Ⅱ. 상승세 (Trend & Momentum)
/** 20일 이평 연속 상승 봉 수 */ /** 20일 이평 연속 상승 봉 수 */
private boolean ma20SlopeUp = true; private boolean ma20SlopeUp = false;
private int ma20SlopeBars = 2; private int ma20SlopeBars = 2;
/** N일 신고가 돌파 또는 고가 대비 -near% 이내 */ /** N일 신고가 돌파 또는 고가 대비 -near% 이내 */
private boolean newHighBreakout = true; private boolean newHighBreakout = false;
private int newHighBreakoutDays = 20; private int newHighBreakoutDays = 20;
private double newHighNearPct = 5.0; private double newHighNearPct = 5.0;
/** 일목 구름대(선행스팬) 상단 */ /** 일목 구름대(선행스팬) 상단 */
@@ -47,8 +47,8 @@ public class TrendSearchService {
private static final int MIN_BARS_BASE = 65; private static final int MIN_BARS_BASE = 65;
private static final int HIGH_MATCH_THRESHOLD = 90; private static final int HIGH_MATCH_THRESHOLD = 90;
private static final int HISTORY_BARS = 365; private static final int HISTORY_BARS = 365;
/** 스캔 1회당 최대 캔들 (업비트 rate limit·MA120 기준) */ /** 스캔 1회당 최대 캔들 (업비트 rate limit·EMA120 기준) */
private static final int SCAN_MAX_BARS = 120; private static final int SCAN_MAX_BARS = 150;
private static final long CANDLE_CACHE_TTL_MS = 10 * 60 * 1000L; private static final long CANDLE_CACHE_TTL_MS = 10 * 60 * 1000L;
/** market|tf → 일봉 캐시 (rate limit·재스캔 대응) */ /** market|tf → 일봉 캐시 (rate limit·재스캔 대응) */
@@ -70,6 +70,8 @@ public class TrendSearchService {
Map<String, TickerSnap> tickers = fetchTickers(markets); Map<String, TickerSnap> tickers = fetchTickers(markets);
int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate())); int minMatchRate = Math.min(100, Math.max(0, r.getMinMatchRate()));
int enabledFilters = countEnabledFilters(r); int enabledFilters = countEnabledFilters(r);
boolean useBullishScore = r.isBullishTrendEnabled();
int minTrendScore = Math.min(100, Math.max(0, r.getMinTrendScore()));
List<ScoredMarket> scored = new ArrayList<>(); List<ScoredMarket> scored = new ArrayList<>();
for (int i = 0; i < markets.size(); i++) { for (int i = 0; i < markets.size(); i++) {
@@ -84,13 +86,17 @@ public class TrendSearchService {
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
MetricValues m = computeMetrics(series, candles, tick, r); MetricValues m = computeMetrics(series, candles, tick, r);
if (useBullishScore) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore;
if (minTrendScore > 0 && m.bullishTrendScore < minTrendScore) continue;
} else {
m.trendStrength = computeTrendStrength(m); m.trendStrength = computeTrendStrength(m);
m.matchScore = enabledFilters == 0 m.matchScore = enabledFilters == 0
? m.trendStrength ? m.trendStrength
: computeMatchScore(m, r); : computeMatchScore(m, r);
// minMatchRate>0 일 때만 하한 필터 (기본 0 = 일치율 순 전체 랭킹)
if (minMatchRate > 0 && m.matchScore < minMatchRate) continue; if (minMatchRate > 0 && m.matchScore < minMatchRate) continue;
}
scored.add(new ScoredMarket(market, tick, candles, m)); scored.add(new ScoredMarket(market, tick, candles, m));
} catch (Exception e) { } catch (Exception e) {
@@ -99,15 +105,19 @@ public class TrendSearchService {
} }
if (scored.isEmpty()) { if (scored.isEmpty()) {
log.warn("[TrendSearch] scan empty tf={} markets={} enabledFilters={} (캔들·rate limit 확인)", log.warn("[TrendSearch] scan empty tf={} markets={} bullish={} (캔들·rate limit 확인)",
tf, markets.size(), enabledFilters); tf, markets.size(), useBullishScore);
return List.of(); 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() return scored.stream()
.sorted(Comparator.comparingDouble((ScoredMarket s) -> s.metrics.matchScore).reversed() .sorted(byTrendStrength
.thenComparingDouble(s -> s.metrics.trendStrength).reversed() .thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed()
.thenComparingDouble(s -> s.metrics.turnover5dAvg).reversed()) .thenComparing(ScoredMarket::market))
.limit(limit) .limit(limit)
.map(s -> toResult(s, r)) .map(s -> toResult(s, r))
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -127,9 +137,14 @@ public class TrendSearchService {
TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market)); TickerSnap tick = tickers.getOrDefault(market, TickerSnap.empty(market));
BarSeries series = toSeries(candles, tf); BarSeries series = toSeries(candles, tf);
MetricValues m = computeMetrics(series, candles, tick, r); MetricValues m = computeMetrics(series, candles, tick, r);
if (r.isBullishTrendEnabled()) {
m.bullishTrendScore = computeBullishTrendScore(m, r);
m.matchScore = m.bullishTrendScore;
} else {
m.trendStrength = computeTrendStrength(m); m.trendStrength = computeTrendStrength(m);
int enabled = countEnabledFilters(r); int enabled = countEnabledFilters(r);
m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength; m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength;
}
return toResult(new ScoredMarket(market, tick, candles, m), r); 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; double adxVal = end >= diLen + 5 ? adx.getValue(end).doubleValue() : 0;
boolean dmiBullish = pdi > mdi && adxVal >= r.getAdxMin(); 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( return new MetricValues(
priceAboveMa, maAlignment, maConvergence, maSpreadPct, priceAboveMa, maAlignment, maConvergence, maSpreadPct,
ma20SlopeUp, newHighBreakout, periodHigh, price, ma20SlopeUp, newHighBreakout, periodHigh, price,
@@ -210,7 +242,9 @@ public class TrendSearchService {
volPriorPct, volumeVsPrior200, turnover5dAvg, minTurnover5dAvg, volMa5Over20, volPriorPct, volumeVsPrior200, turnover5dAvg, minTurnover5dAvg, volMa5Over20,
macdVal, signalVal, macdGoldenCross, macdVal, signalVal, macdGoldenCross,
rsiVal, rsiBand, 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) { private int requiredMinBars(TrendSearchRequest r) {
int need = MIN_BARS_BASE; 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.isMaAlignEnabled() && r.isMaAlignment()) need = Math.max(need, 120);
if (r.isTrendEnabled() && r.isIchimokuAboveCloud()) need = Math.max(need, 80); if (r.isTrendEnabled() && r.isIchimokuAboveCloud()) need = Math.max(need, 80);
if (r.isTrendEnabled() && r.isNewHighBreakout()) { if (r.isTrendEnabled() && r.isNewHighBreakout()) {
@@ -322,6 +360,17 @@ public class TrendSearchService {
return Math.round(matched * 100f / total); 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) */ /** 정배열·상승세·거래량 복합 추세 강도 (0~100) */
private int computeTrendStrength(MetricValues m) { private int computeTrendStrength(MetricValues m) {
double score = 0; double score = 0;
@@ -366,9 +415,19 @@ public class TrendSearchService {
private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r) { private TrendSearchResultDto toResult(ScoredMarket s, TrendSearchRequest r) {
List<TrendSearchConditionDto> conditions = buildConditions(s.metrics, r); List<TrendSearchConditionDto> conditions = buildConditions(s.metrics, r);
int matched = (int) conditions.stream().filter(c -> "match".equals(c.getStatus())).count(); final int matched;
int total = conditions.size(); final int total;
int matchRate = total > 0 ? Math.round(matched * 100f / total) : (int) s.metrics.matchScore; 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; s.metrics.matchScore = matchRate;
double lastClose = s.candles.get(s.candles.size() - 1).getClose(); 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) { private List<TrendSearchConditionDto> buildConditions(MetricValues m, TrendSearchRequest r) {
List<TrendSearchConditionDto> out = new ArrayList<>(); List<TrendSearchConditionDto> out = new ArrayList<>();
String catBullish = "상승추세";
String catMa = ". 정배열"; String catMa = ". 정배열";
String catTrend = "Ⅱ. 상승세"; String catTrend = "Ⅱ. 상승세";
String catVol = "Ⅲ. 돈과 힘"; String catVol = "Ⅲ. 돈과 힘";
String catInd = "Ⅳ. 보조지표"; 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()) { if (r.isMaAlignEnabled()) {
addFilter(out, "priceAboveMa", catMa, "주가 > 5·20·60 이평", r.isPriceAboveMa(), m.priceAboveMa); addFilter(out, "priceAboveMa", catMa, "주가 > 5·20·60 이평", r.isPriceAboveMa(), m.priceAboveMa);
addFilter(out, "maAlignment", catMa, "5 > 20 > 60 > 120 정배열", r.isMaAlignment(), m.maAlignment); 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")); 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 ─────────────────────────────────────────────────── // ── Indicator helpers ───────────────────────────────────────────────────
private static double maSpreadPct(double m5, double m20, double m60) { private static double maSpreadPct(double m5, double m20, double m60) {
@@ -689,8 +798,12 @@ public class TrendSearchService {
final boolean rsiBand; final boolean rsiBand;
final double pdi, mdi, adxVal; final double pdi, mdi, adxVal;
final boolean dmiBullish; final boolean dmiBullish;
final double ema20, ema60, ema120, ema20Past;
final boolean bullishEmaAlignment, bullishEmaSlope, bullishAdxTrend;
final boolean bullishMacdMomentum, bullishPricePosition;
int matchScore; int matchScore;
int trendStrength; int trendStrength;
int bullishTrendScore;
MetricValues(boolean priceAboveMa, boolean maAlignment, boolean maConvergence, double maSpreadPct, MetricValues(boolean priceAboveMa, boolean maAlignment, boolean maConvergence, double maSpreadPct,
boolean ma20SlopeUp, boolean newHighBreakout, double periodHigh, double price, boolean ma20SlopeUp, boolean newHighBreakout, double periodHigh, double price,
@@ -699,7 +812,10 @@ public class TrendSearchService {
boolean minTurnover5dAvg, boolean volMa5Over20, boolean minTurnover5dAvg, boolean volMa5Over20,
double macdVal, double signalVal, boolean macdGoldenCross, double macdVal, double signalVal, boolean macdGoldenCross,
double rsiVal, boolean rsiBand, 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.priceAboveMa = priceAboveMa;
this.maAlignment = maAlignment; this.maAlignment = maAlignment;
this.maConvergence = maConvergence; this.maConvergence = maConvergence;
@@ -723,6 +839,15 @@ public class TrendSearchService {
this.mdi = mdi; this.mdi = mdi;
this.adxVal = adxVal; this.adxVal = adxVal;
this.dmiBullish = dmiBullish; 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;
} }
} }
} }
+19 -3
View File
@@ -17,6 +17,7 @@ import {
type TrendSearchRequest, type TrendSearchRequest,
type TrendSearchResultDto, type TrendSearchResultDto,
} from '../utils/trendSearchApi'; } from '../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import '../styles/trendSearchDashboard.css'; import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css'; import '../styles/virtualTradingDashboard.css';
@@ -59,7 +60,7 @@ const TrendSearchPage: React.FC<Props> = ({
if (!opts?.silent) setSearching(true); if (!opts?.silent) setSearching(true);
try { try {
const list = await scanTrendSearch(filtersRef.current); const list = await scanTrendSearch(filtersRef.current);
setResults(list); setResults(sortTrendSearchByStrength(list));
setLastUpdatedAt(Date.now()); setLastUpdatedAt(Date.now());
setSelectedMarket(prev => { setSelectedMarket(prev => {
if (prev && list.some(r => r.market === prev)) return prev; if (prev && list.some(r => r.market === prev)) return prev;
@@ -94,12 +95,15 @@ const TrendSearchPage: React.FC<Props> = ({
try { try {
const detail = await fetchTrendSearchDetail(row.market, filters.timeframe); const detail = await fetchTrendSearchDetail(row.market, filters.timeframe);
if (detail) { 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 */ } } catch { /* keep row */ }
}, [filters.timeframe]); }, [filters.timeframe]);
return ( return (
<>
<BuilderPageShell <BuilderPageShell
theme={theme} theme={theme}
title="추세검색" title="추세검색"
@@ -133,7 +137,6 @@ const TrendSearchPage: React.FC<Props> = ({
results={results} results={results}
timeframe={filters.timeframe} timeframe={filters.timeframe}
displayMode={displayMode} displayMode={displayMode}
loading={searching}
selectedMarket={selectedMarket} selectedMarket={selectedMarket}
onSelect={row => void handleSelect(row)} onSelect={row => void handleSelect(row)}
flashMarkets={flashMarkets} flashMarkets={flashMarkets}
@@ -145,6 +148,19 @@ const TrendSearchPage: React.FC<Props> = ({
/> />
)} )}
/> />
{searching && (
<div className="tsd-search-overlay" role="status" aria-live="polite" aria-busy="true">
<div className="tsd-search-overlay-card">
<div className="loading-spinner tsd-search-spinner" />
<p className="tsd-search-overlay-text"> </p>
<div className="tsd-search-progress" aria-hidden="true">
<div className="tsd-search-progress-indeterminate" />
</div>
</div>
</div>
)}
</>
); );
}; };
@@ -1,13 +1,12 @@
import React from 'react'; import React, { useMemo } from 'react';
import type { TrendSearchRequest } from '../../utils/trendSearchApi'; import type { TrendSearchRequest } from '../../utils/trendSearchApi';
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi'; import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
import { import {
TREND_CATEGORY_ORDER, BULLISH_WEIGHT_ITEMS,
TREND_CONDITION_DEFS, adjustBullishWeight,
buildCategoryTogglePatch, sumWeights,
countActiveTrendFilters, type BullishWeightKey,
type TrendConditionDef, } from '../../utils/trendSearchBullishWeights';
} from '../../utils/trendSearchConditions';
interface Props { interface Props {
filters: TrendSearchRequest; filters: TrendSearchRequest;
@@ -25,148 +24,29 @@ function tfLabel(tf: string): string {
return tf; return tf;
} }
function ConditionParams({
item,
filters,
patch,
}: {
item: TrendConditionDef;
filters: TrendSearchRequest;
patch: (p: Partial<TrendSearchRequest>) => void;
}) {
if (!item.params) return null;
switch (item.params) {
case 'maConvergencePct':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.maConvergencePct}
min={0.5}
max={10}
step={0.5}
onChange={e => patch({ maConvergencePct: Number(e.target.value) })}
/>
<span>% </span>
</div>
);
case 'ma20SlopeBars':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.ma20SlopeBars}
min={2}
max={5}
onChange={e => patch({ ma20SlopeBars: Number(e.target.value) })}
/>
<span> </span>
</div>
);
case 'newHighBreakout':
return (
<div className="tsd-filter-params">
<input
type="number"
className="tsd-num"
value={filters.newHighBreakoutDays}
min={5}
max={60}
onChange={e => patch({ newHighBreakoutDays: Number(e.target.value) })}
/>
<span> · -</span>
<input
type="number"
className="tsd-num"
value={filters.newHighNearPct}
min={0}
max={10}
step={0.5}
onChange={e => patch({ newHighNearPct: Number(e.target.value) })}
/>
<span>% </span>
</div>
);
case 'volumeVsPrior':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.volumeVsPriorPct}
min={100}
max={500}
step={10}
onChange={e => patch({ volumeVsPriorPct: Number(e.target.value) })}
/>
<span>%</span>
</div>
);
case 'minTurnover':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num tsd-num--wide"
value={Math.round(filters.minTurnover5dAvgKrw / 1e8)}
min={1}
max={10000}
onChange={e => patch({ minTurnover5dAvgKrw: Number(e.target.value) * 1e8 })}
/>
<span> </span>
</div>
);
case 'rsiBand':
return (
<div className="tsd-filter-params">
<input
type="number"
className="tsd-num"
value={filters.rsiMin}
min={30}
max={60}
onChange={e => patch({ rsiMin: Number(e.target.value) })}
/>
<span>&lt; RSI &lt;</span>
<input
type="number"
className="tsd-num"
value={filters.rsiMax}
min={60}
max={85}
onChange={e => patch({ rsiMax: Number(e.target.value) })}
/>
</div>
);
case 'adxMin':
return (
<div className="tsd-filter-params">
<span>ADX </span>
<input
type="number"
className="tsd-num"
value={filters.adxMin}
min={10}
max={50}
onChange={e => patch({ adxMin: Number(e.target.value) })}
/>
</div>
);
default:
return null;
}
}
const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => { const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p }); const patch = (p: Partial<TrendSearchRequest>) => 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 ( return (
<div className="tsd-filter-panel"> <div className="tsd-filter-panel">
@@ -175,62 +55,56 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
<div> <div>
<h3 className="tsd-panel-title"> </h3> <h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub"> <p className="tsd-panel-sub">
{filters.minTrendScore} · {weightTotal}
{activeCount > 0 ? ` · 조건 ${activeCount}` : ' · 조건 없음(추세 점수 순)'}
</p> </p>
</div> </div>
</div> </div>
{TREND_CATEGORY_ORDER.map(cat => { <div className="tsd-bullish-card">
const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat); <div className="tsd-bullish-card-head">
const catOn = filters[cat.key];
return (
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
<div className="tsd-filter-cat-head">
<div> <div>
<span className="tsd-filter-cat-en">{cat.en}</span> <span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-filter-cat-ko">{cat.label}</span> <span className="tsd-bullish-card-ko"> </span>
</div> </div>
<label className="tsd-toggle"> <span className="tsd-bullish-card-total">{weightTotal}</span>
<input
type="checkbox"
checked={catOn}
onChange={e => patch(buildCategoryTogglePatch(cat.key, e.target.checked))}
/>
<span className="tsd-toggle-track" />
</label>
</div> </div>
{catOn && (
<div className="tsd-filter-items"> <div className="tsd-bullish-items">
{items.map(item => { {BULLISH_WEIGHT_ITEMS.map(item => {
const checked = Boolean(filters[item.requestKey]); const value = weights[item.key];
return ( return (
<div key={item.id} className="tsd-filter-item"> <div key={item.key} className="tsd-bullish-item">
<div className="tsd-filter-item-row"> <div className="tsd-bullish-item-head">
<label className="tsd-filter-item-check"> <span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input <input
type="checkbox" type="range"
checked={checked} className="tsd-slider tsd-bullish-slider"
onChange={e => patch({ [item.requestKey]: e.target.checked })} min={0}
max={100}
step={1}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/> />
<span className="tsd-filter-item-label">{item.label}</span> <input
<span className="tsd-cond-badge tsd-cond-badge--filter"></span> type="number"
</label> className="tsd-num tsd-bullish-score"
<span className="tsd-filter-item-desc-inline" title={item.desc}> min={0}
{item.desc} max={100}
</span> value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div> </div>
{checked && (
<ConditionParams item={item} filters={filters} patch={patch} />
)}
</div> </div>
); );
})} })}
</div> </div>
)}
</div> </div>
);
})}
<div className="tsd-filter-section"> <div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4> <h4 className="tsd-filter-section-title"> </h4>
@@ -248,6 +122,22 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
</div> </div>
</div> </div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-limit-row">
<input
type="range"
className="tsd-slider"
min={0}
max={100}
step={5}
value={filters.minTrendScore}
onChange={e => patch({ minTrendScore: Number(e.target.value) })}
/>
<span className="tsd-limit-val">{filters.minTrendScore}</span>
</div>
</div>
<div className="tsd-filter-section"> <div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4> <h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-limit-row"> <div className="tsd-limit-row">
@@ -56,10 +56,10 @@ const TrendSearchResultCard: React.FC<Props> = ({
updatedAt, updatedAt,
flash = false, flash = false,
}) => { }) => {
const ko = result.koreanName || getKoreanName(result.market); const en = result.market.replace(/^KRW-/, '');
const sym = result.market.replace(/^KRW-/, '');
const isChart = displayMode === 'chart';
const quoteTicker = ticker ?? resultToTicker(result); const quoteTicker = ticker ?? resultToTicker(result);
const ko = quoteTicker.koreanName || getKoreanName(result.market) || en;
const isChart = displayMode === 'chart';
const flashCls = useMemo(() => { const flashCls = useMemo(() => {
if (!flash) return ''; if (!flash) return '';
@@ -91,9 +91,10 @@ const TrendSearchResultCard: React.FC<Props> = ({
<div className="vtd-card-head-main"> <div className="vtd-card-head-main">
<div className="vtd-card-title"> <div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span> <span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span> <span className="vtd-card-sym">{en}</span>
</div> </div>
<span className="vtd-card-tf"> {tfLabelShort(timeframe)}</span> <span className="vtd-card-tf"> {tfLabelShort(timeframe)}</span>
<span className="tsd-card-score">{result.matchRate}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>} {result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div> </div>
</div> </div>
@@ -102,7 +103,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
<VirtualTargetQuote market={result.market} ticker={quoteTicker} compact /> <VirtualTargetQuote market={result.market} ticker={quoteTicker} compact />
{!isChart && ( {!isChart && (
<span className="vtd-card-met-count"> <span className="vtd-card-met-count">
{result.matchedCount}/{result.totalConditions} {result.matchedCount}/{result.totalConditions}
</span> </span>
)} )}
<VirtualLiveBadge status="live" receiving={flash} /> <VirtualLiveBadge status="live" receiving={flash} />
@@ -1,5 +1,6 @@
import React from 'react'; import React, { useMemo } from 'react';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../../utils/trendSearchMetrics';
import type { Theme } from '../../types'; import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker'; import type { TickerData } from '../../hooks/useMarketTicker';
@@ -34,6 +35,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
tickers, tickers,
lastUpdatedAt, lastUpdatedAt,
}) => { }) => {
const sortedResults = useMemo(
() => sortTrendSearchByStrength(results),
[results],
);
if (loading && results.length === 0) { if (loading && results.length === 0) {
return ( return (
<div className="vtd-grid-wrap"> <div className="vtd-grid-wrap">
@@ -57,7 +63,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
return ( return (
<div className="vtd-grid-wrap"> <div className="vtd-grid-wrap">
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}> <div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
{results.map(row => ( {sortedResults.map(row => (
<TrendSearchResultCard <TrendSearchResultCard
key={row.market} key={row.market}
result={row} result={row}
@@ -123,6 +123,88 @@
color: var(--tsd-muted); color: var(--tsd-muted);
} }
/* 상승추세 검색그룹 카드 */
.tsd-bullish-card {
border: 1px solid color-mix(in srgb, var(--tsd-gold) 35%, var(--tsd-border));
border-radius: 10px;
background: var(--tsd-filter-cat-bg);
overflow: hidden;
}
.tsd-bullish-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: color-mix(in srgb, var(--tsd-gold) 8%, transparent);
border-bottom: 1px solid var(--tsd-border);
}
.tsd-bullish-card-en {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-bullish-card-ko {
display: block;
font-size: 10px;
color: var(--tsd-muted);
margin-top: 2px;
}
.tsd-bullish-card-total {
font-size: 13px;
font-weight: 800;
color: var(--tsd-gold);
white-space: nowrap;
}
.tsd-bullish-items {
display: flex;
flex-direction: column;
gap: 0;
padding: 6px 0;
}
.tsd-bullish-item {
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 70%, transparent);
}
.tsd-bullish-item:last-child {
border-bottom: none;
}
.tsd-bullish-item-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
}
.tsd-bullish-item-label {
font-size: 12px;
font-weight: 700;
color: var(--se-text, var(--text));
}
.tsd-bullish-item-desc {
font-size: 9px;
color: var(--tsd-muted);
line-height: 1.35;
}
.tsd-bullish-item-controls {
display: flex;
align-items: center;
gap: 8px;
}
.tsd-bullish-slider {
flex: 1;
min-width: 0;
}
.tsd-bullish-score {
width: 44px;
text-align: center;
flex-shrink: 0;
}
.tsd-bullish-score-unit {
font-size: 10px;
color: var(--tsd-muted);
flex-shrink: 0;
}
.tsd-filter-cat { .tsd-filter-cat {
border: 1px solid var(--tsd-border); border: 1px solid var(--tsd-border);
border-radius: 10px; border-radius: 10px;
@@ -825,6 +907,13 @@
display: none !important; display: none !important;
} }
.tsd-card-score {
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
margin-left: auto;
white-space: nowrap;
}
.tsd-card-high-badge { .tsd-card-high-badge {
font-size: 8px; font-size: 8px;
font-weight: 700; font-weight: 700;
@@ -861,3 +950,56 @@
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
/* 검색 실행 — 화면 중앙 백드롭 프로그레스 */
.tsd-search-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.tsd-search-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 28px 40px;
min-width: 240px;
border-radius: 12px;
background: var(--se-bg-elevated, var(--bg2));
border: 1px solid var(--tsd-border);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
}
.tsd-search-spinner {
width: 36px;
height: 36px;
}
.tsd-search-overlay-text {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--tsd-text);
}
.tsd-search-progress {
width: 200px;
height: 4px;
border-radius: 2px;
background: var(--tsd-track-bg);
overflow: hidden;
}
.tsd-search-progress-indeterminate {
width: 45%;
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--tsd-gold), var(--se-accent, var(--accent)));
animation: tsd-search-progress-slide 1.1s ease-in-out infinite;
}
@keyframes tsd-search-progress-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
+27 -6
View File
@@ -1158,6 +1158,17 @@ export interface TrendSearchRequest {
sortBy: string; sortBy: string;
minMatchRate?: number; minMatchRate?: number;
/** 상승추세 검색그룹 (가중 점수화) */
bullishTrendEnabled: boolean;
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
weightMacdMomentum: number;
weightPricePosition: number;
minTrendScore: number;
emaSlopeLookback: number;
adxTrendMin: number;
maAlignEnabled: boolean; maAlignEnabled: boolean;
trendEnabled: boolean; trendEnabled: boolean;
volumePowerEnabled: boolean; volumePowerEnabled: boolean;
@@ -1196,19 +1207,29 @@ export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
sortBy: 'matchRate', sortBy: 'matchRate',
minMatchRate: 0, minMatchRate: 0,
maAlignEnabled: true, bullishTrendEnabled: true,
trendEnabled: true, weightMaAlignment: 30,
weightMaSlope: 15,
weightAdxTrend: 25,
weightMacdMomentum: 15,
weightPricePosition: 15,
minTrendScore: 70,
emaSlopeLookback: 5,
adxTrendMin: 20,
maAlignEnabled: false,
trendEnabled: false,
volumePowerEnabled: false, volumePowerEnabled: false,
indicatorEnabled: false, indicatorEnabled: false,
priceAboveMa: true, priceAboveMa: false,
maAlignment: true, maAlignment: false,
maConvergence: false, maConvergence: false,
maConvergencePct: 3, maConvergencePct: 3,
ma20SlopeUp: true, ma20SlopeUp: false,
ma20SlopeBars: 2, ma20SlopeBars: 2,
newHighBreakout: true, newHighBreakout: false,
newHighBreakoutDays: 20, newHighBreakoutDays: 20,
newHighNearPct: 5, newHighNearPct: 5,
ichimokuAboveCloud: false, ichimokuAboveCloud: false,
@@ -0,0 +1,114 @@
/** 상승추세 검색그룹 — 5개 평가항목 배점 (합계 100 고정) */
export type BullishWeightKey =
| 'weightMaAlignment'
| 'weightMaSlope'
| 'weightAdxTrend'
| 'weightMacdMomentum'
| 'weightPricePosition';
export interface BullishWeightItem {
key: BullishWeightKey;
label: string;
desc: string;
}
export const BULLISH_WEIGHT_ITEMS: BullishWeightItem[] = [
{
key: 'weightMaAlignment',
label: '이평선 정배열',
desc: 'EMA(20) > 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<BullishWeightKey, number> = {
weightMaAlignment: 30,
weightMaSlope: 15,
weightAdxTrend: 25,
weightMacdMomentum: 15,
weightPricePosition: 15,
};
export function bullishWeightsFromRequest(
req: Pick<import('./backendApi').TrendSearchRequest, BullishWeightKey>,
): 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<BullishWeightKey, number>,
changedKey: BullishWeightKey,
newValue: number,
): Record<BullishWeightKey, number> {
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<BullishWeightKey, number> = { ...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;
}
+11 -1
View File
@@ -1,4 +1,4 @@
import type { TrendSearchConditionDto } from './trendSearchApi'; import type { TrendSearchConditionDto, TrendSearchResultDto } from './trendSearchApi';
import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics'; import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics';
import type { VirtualConditionRow } from './virtualStrategyConditions'; import type { VirtualConditionRow } from './virtualStrategyConditions';
@@ -42,3 +42,13 @@ export function tfLabelShort(tf: string): string {
if (tf.endsWith('w')) return `${tf.replace('w', '')}`; if (tf.endsWith('w')) return `${tf.replace('w', '')}`;
return tf; 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);
});
}