추세검색 수정
This commit is contained in:
@@ -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);
|
||||||
m.trendStrength = computeTrendStrength(m);
|
if (useBullishScore) {
|
||||||
m.matchScore = enabledFilters == 0
|
m.bullishTrendScore = computeBullishTrendScore(m, r);
|
||||||
? m.trendStrength
|
m.matchScore = m.bullishTrendScore;
|
||||||
: computeMatchScore(m, r);
|
if (minTrendScore > 0 && m.bullishTrendScore < minTrendScore) continue;
|
||||||
|
} else {
|
||||||
// minMatchRate>0 일 때만 하한 필터 (기본 0 = 일치율 순 전체 랭킹)
|
m.trendStrength = computeTrendStrength(m);
|
||||||
if (minMatchRate > 0 && m.matchScore < minMatchRate) continue;
|
m.matchScore = enabledFilters == 0
|
||||||
|
? m.trendStrength
|
||||||
|
: computeMatchScore(m, r);
|
||||||
|
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);
|
||||||
m.trendStrength = computeTrendStrength(m);
|
if (r.isBullishTrendEnabled()) {
|
||||||
int enabled = countEnabledFilters(r);
|
m.bullishTrendScore = computeBullishTrendScore(m, r);
|
||||||
m.matchScore = enabled > 0 ? computeMatchScore(m, r) : m.trendStrength;
|
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,57 +95,72 @@ 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
|
<>
|
||||||
theme={theme}
|
<BuilderPageShell
|
||||||
title="추세검색"
|
theme={theme}
|
||||||
subtitle="Golden Analysis · Trend Search"
|
title="추세검색"
|
||||||
pageClassName="bps-page--tsd bps-page--vtd"
|
subtitle="Golden Analysis · Trend Search"
|
||||||
headerActions={(
|
pageClassName="bps-page--tsd bps-page--vtd"
|
||||||
<TrendSearchViewHeaderControls
|
headerActions={(
|
||||||
displayMode={displayMode}
|
<TrendSearchViewHeaderControls
|
||||||
onDisplayModeChange={setDisplayMode}
|
displayMode={displayMode}
|
||||||
autoRefresh={autoRefresh}
|
onDisplayModeChange={setDisplayMode}
|
||||||
onAutoRefreshChange={setAutoRefresh}
|
autoRefresh={autoRefresh}
|
||||||
searching={searching}
|
onAutoRefreshChange={setAutoRefresh}
|
||||||
onRefresh={() => void runSearch()}
|
searching={searching}
|
||||||
resultCount={results.length}
|
onRefresh={() => void runSearch()}
|
||||||
/>
|
resultCount={results.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
leftStorageKey="tsd-left-width"
|
||||||
|
leftDefaultWidth={320}
|
||||||
|
leftCollapsedStorageKey="tsd-left-open"
|
||||||
|
collapsiblePanels
|
||||||
|
left={(
|
||||||
|
<TrendSearchFilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
onSearch={() => void runSearch()}
|
||||||
|
searching={searching}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
center={(
|
||||||
|
<TrendSearchResultsCardGrid
|
||||||
|
results={results}
|
||||||
|
timeframe={filters.timeframe}
|
||||||
|
displayMode={displayMode}
|
||||||
|
selectedMarket={selectedMarket}
|
||||||
|
onSelect={row => void handleSelect(row)}
|
||||||
|
flashMarkets={flashMarkets}
|
||||||
|
theme={theme}
|
||||||
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
|
tickers={tickers}
|
||||||
|
lastUpdatedAt={lastUpdatedAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
leftStorageKey="tsd-left-width"
|
</>
|
||||||
leftDefaultWidth={320}
|
|
||||||
leftCollapsedStorageKey="tsd-left-open"
|
|
||||||
collapsiblePanels
|
|
||||||
left={(
|
|
||||||
<TrendSearchFilterPanel
|
|
||||||
filters={filters}
|
|
||||||
onChange={setFilters}
|
|
||||||
onSearch={() => void runSearch()}
|
|
||||||
searching={searching}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
center={(
|
|
||||||
<TrendSearchResultsCardGrid
|
|
||||||
results={results}
|
|
||||||
timeframe={filters.timeframe}
|
|
||||||
displayMode={displayMode}
|
|
||||||
loading={searching}
|
|
||||||
selectedMarket={selectedMarket}
|
|
||||||
onSelect={row => void handleSelect(row)}
|
|
||||||
flashMarkets={flashMarkets}
|
|
||||||
theme={theme}
|
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
||||||
tickers={tickers}
|
|
||||||
lastUpdatedAt={lastUpdatedAt}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>< RSI <</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];
|
<div>
|
||||||
return (
|
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
|
||||||
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
|
<span className="tsd-bullish-card-ko">상승추세 검색그룹</span>
|
||||||
<div className="tsd-filter-cat-head">
|
|
||||||
<div>
|
|
||||||
<span className="tsd-filter-cat-en">{cat.en}</span>
|
|
||||||
<span className="tsd-filter-cat-ko">{cat.label}</span>
|
|
||||||
</div>
|
|
||||||
<label className="tsd-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={catOn}
|
|
||||||
onChange={e => patch(buildCategoryTogglePatch(cat.key, e.target.checked))}
|
|
||||||
/>
|
|
||||||
<span className="tsd-toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{catOn && (
|
|
||||||
<div className="tsd-filter-items">
|
|
||||||
{items.map(item => {
|
|
||||||
const checked = Boolean(filters[item.requestKey]);
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="tsd-filter-item">
|
|
||||||
<div className="tsd-filter-item-row">
|
|
||||||
<label className="tsd-filter-item-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={e => patch({ [item.requestKey]: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span className="tsd-filter-item-label">{item.label}</span>
|
|
||||||
<span className="tsd-cond-badge tsd-cond-badge--filter">조건</span>
|
|
||||||
</label>
|
|
||||||
<span className="tsd-filter-item-desc-inline" title={item.desc}>
|
|
||||||
{item.desc}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{checked && (
|
|
||||||
<ConditionParams item={item} filters={filters} patch={patch} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<span className="tsd-bullish-card-total">{weightTotal}점</span>
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
<div className="tsd-bullish-items">
|
||||||
|
{BULLISH_WEIGHT_ITEMS.map(item => {
|
||||||
|
const value = weights[item.key];
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="tsd-bullish-item">
|
||||||
|
<div className="tsd-bullish-item-head">
|
||||||
|
<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
|
||||||
|
type="range"
|
||||||
|
className="tsd-slider tsd-bullish-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
|
||||||
|
aria-label={`${item.label} 배점`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="tsd-num tsd-bullish-score"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={value}
|
||||||
|
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
|
||||||
|
aria-label={`${item.label} 배점 입력`}
|
||||||
|
/>
|
||||||
|
<span className="tsd-bullish-score-unit">점</span>
|
||||||
|
</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%); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user